ucn 5.4.1 → 5.5.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.
Files changed (87) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +42 -16
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis-ext.js +6 -6
  38. package/core/output/analysis.js +18 -2
  39. package/core/output/brief.js +3 -0
  40. package/core/output/doctor.js +4 -0
  41. package/core/output/endpoints.js +15 -6
  42. package/core/output/find.js +11 -2
  43. package/core/output/lines.js +8 -2
  44. package/core/output/refactoring.js +121 -14
  45. package/core/output/reporting.js +31 -3
  46. package/core/output/shared.js +14 -0
  47. package/core/output-budget.js +316 -122
  48. package/core/parallel-build.js +124 -47
  49. package/core/parser.js +3 -2
  50. package/core/project.js +457 -60
  51. package/core/provenance-facts.js +156 -12
  52. package/core/provenance-overload.js +30 -3
  53. package/core/provenance.js +104 -30
  54. package/core/pytest-fixtures.js +434 -0
  55. package/core/python-fixture-flow.js +17 -3
  56. package/core/python-modules.js +173 -0
  57. package/core/reflection.js +877 -0
  58. package/core/registry.js +1 -1
  59. package/core/reporting.js +234 -52
  60. package/core/request-helpers.js +561 -0
  61. package/core/route-graph.js +1922 -0
  62. package/core/route-lists.js +2414 -0
  63. package/core/rust-macro-expansion.js +2113 -0
  64. package/core/rust-modules.js +185 -0
  65. package/core/search.js +121 -29
  66. package/core/shared.js +19 -1
  67. package/core/test-name-bindings.js +256 -0
  68. package/core/type-denotation.js +1161 -0
  69. package/core/type-references.js +3229 -0
  70. package/core/verify.js +3360 -672
  71. package/languages/adapter.js +31 -5
  72. package/languages/c-family.js +4061 -212
  73. package/languages/c-macro-invocations.js +380 -0
  74. package/languages/c-preprocessor.js +254 -0
  75. package/languages/csharp.js +1406 -49
  76. package/languages/go.js +1243 -70
  77. package/languages/index.js +728 -6
  78. package/languages/java.js +473 -61
  79. package/languages/javascript.js +1270 -197
  80. package/languages/lexical-scope.js +1280 -0
  81. package/languages/python.js +1557 -72
  82. package/languages/rust-macro-rules.js +1020 -0
  83. package/languages/rust-value-flow.js +416 -0
  84. package/languages/rust.js +2237 -115
  85. package/languages/utils.js +298 -4
  86. package/mcp/server.js +25 -11
  87. package/package.json +1 -1
@@ -10,18 +10,58 @@
10
10
  * @param {function} [options.onLeave] - Called when leaving each node (after children processed)
11
11
  */
12
12
  function traverseTree(node, callback, options) {
13
+ // The flat node list an extractor pass already built for this tree
14
+ // (fix #388): the same nodes in the same order, without a native
15
+ // children read per node. Never built here, so a walk of an uncached
16
+ // tree cannot evict a list another pass still uses.
17
+ const range = cachedNodeRange(node);
18
+ if (range) {
19
+ traverseNodeRange(range, callback, options?.onLeave);
20
+ return;
21
+ }
22
+ traverseTreeWalk(node, callback, options);
23
+ }
24
+
25
+ function traverseTreeWalk(node, callback, options) {
13
26
  if (callback(node) === false) return;
14
27
  // Single batched native call per node — namedChildCount + N × namedChild(i)
15
28
  // costs N+1 native round-trips for the same children.
16
29
  const children = node.namedChildren;
17
30
  for (let i = 0; i < children.length; i++) {
18
- traverseTree(children[i], callback, options);
31
+ traverseTreeWalk(children[i], callback, options);
19
32
  }
20
33
  if (options?.onLeave) {
21
34
  options.onLeave(node);
22
35
  }
23
36
  }
24
37
 
38
+ /**
39
+ * Depth-first visit of one subtree of a cached flat node list: `callback`
40
+ * on entry (false skips the node's descendants and its `onLeave`), then
41
+ * `onLeave` after its descendants, exactly as the recursive walk orders them.
42
+ */
43
+ function traverseNodeRange({ nodes, subtreeEnds, index }, callback, onLeave) {
44
+ const end = subtreeEnds[index];
45
+ if (!onLeave) {
46
+ for (let i = index; i < end; ) {
47
+ if (callback(nodes[i]) === false) i = subtreeEnds[i];
48
+ else i++;
49
+ }
50
+ return;
51
+ }
52
+ const open = [];
53
+ for (let i = index; i < end; ) {
54
+ while (open.length > 0 && subtreeEnds[open[open.length - 1]] <= i) onLeave(nodes[open.pop()]);
55
+ if (callback(nodes[i]) === false) {
56
+ i = subtreeEnds[i];
57
+ } else {
58
+ open.push(i);
59
+ i++;
60
+ }
61
+ }
62
+ while (open.length > 0) onLeave(nodes[open.pop()]);
63
+ }
64
+
25
65
  /**
26
66
  * Get line locations from tree-sitter node
27
67
  * Returns 1-indexed lines to match UCN output format
@@ -776,8 +816,29 @@ function findMatchesWithASTFilter(content, term, parser, options = {}) {
776
816
  * traversal overhead (namedChild object creation, function call overhead).
777
817
  */
778
818
  let _cachedRootNode = null;
819
+ let _cachedRootId = null;
779
820
  let _cachedNodeList = null;
780
821
  let _cachedSubtreeEnds = null;
822
+ let _previousRootNode = null;
823
+ let _previousRootId = null;
824
+ let _previousNodeList = null;
825
+ let _previousSubtreeEnds = null;
826
+
827
+ // The same syntax node can reach a caller through a different wrapper: the
828
+ // binding keeps one wrapper per node only while the last one is alive, so a
829
+ // collected wrapper makes every later `tree.rootNode` a fresh object (fix
830
+ // #387). A node is identified by its tree plus the native node id.
831
+ function nodeIdOf(node) {
832
+ const id = node?.tree ? node.id : undefined;
833
+ return typeof id === 'number' ? id : null;
834
+ }
835
+
836
+ function sameCachedNode(node, cached, cachedId) {
837
+ if (!cached || cachedId === null || node.tree !== cached.tree) return false;
838
+ return nodeIdOf(node) === cachedId;
839
+ }
840
+
841
+ const NAMED_CHILDREN_LIST = Symbol.for('ucn.namedChildrenList');
781
842
 
782
843
  function _buildNodeList(rootNode) {
783
844
  const nodes = [];
@@ -788,8 +849,9 @@ function _buildNodeList(rootNode) {
788
849
  const idx = nodes.length;
789
850
  nodes.push(node);
790
851
  subtreeEnds.push(0);
791
- // Batched children read — one native call instead of N+1 (see traverseTree)
792
- const children = node.namedChildren;
852
+ // Batched children read — one native call instead of N+1 (see
853
+ // traverseTree); the array stays on the node for later reads.
854
+ const children = node[NAMED_CHILDREN_LIST] ? node[NAMED_CHILDREN_LIST]() : node.namedChildren;
793
855
  for (let i = 0; i < children.length; i++) {
794
856
  collect(children[i]);
795
857
  }
@@ -806,16 +868,87 @@ function _buildNodeList(rootNode) {
806
868
  * enabling O(1) subtree skipping (for 'return false' semantics).
807
869
  */
808
870
  function getCachedNodeList(rootNode) {
809
- if (rootNode === _cachedRootNode && _cachedNodeList) {
871
+ if (_cachedNodeList && (rootNode === _cachedRootNode ||
872
+ sameCachedNode(rootNode, _cachedRootNode, _cachedRootId))) {
810
873
  return { nodes: _cachedNodeList, subtreeEnds: _cachedSubtreeEnds };
811
874
  }
875
+ // Two slots (fix #365): C/C++ indexing alternates between a selected
876
+ // recovery tree and its literal all-source tree, which evicted a single
877
+ // slot on every extractor pass.
878
+ if (_previousNodeList && (rootNode === _previousRootNode ||
879
+ sameCachedNode(rootNode, _previousRootNode, _previousRootId))) {
880
+ const nodes = _previousNodeList;
881
+ const subtreeEnds = _previousSubtreeEnds;
882
+ const id = _previousRootId;
883
+ _previousRootNode = _cachedRootNode;
884
+ _previousRootId = _cachedRootId;
885
+ _previousNodeList = _cachedNodeList;
886
+ _previousSubtreeEnds = _cachedSubtreeEnds;
887
+ _cachedRootNode = rootNode;
888
+ _cachedRootId = id;
889
+ _cachedNodeList = nodes;
890
+ _cachedSubtreeEnds = subtreeEnds;
891
+ return { nodes, subtreeEnds };
892
+ }
812
893
  const { nodes, subtreeEnds } = _buildNodeList(rootNode);
894
+ _previousRootNode = _cachedRootNode;
895
+ _previousRootId = _cachedRootId;
896
+ _previousNodeList = _cachedNodeList;
897
+ _previousSubtreeEnds = _cachedSubtreeEnds;
813
898
  _cachedRootNode = rootNode;
899
+ _cachedRootId = nodeIdOf(rootNode);
814
900
  _cachedNodeList = nodes;
815
901
  _cachedSubtreeEnds = subtreeEnds;
816
902
  return { nodes, subtreeEnds };
817
903
  }
818
904
 
905
+ /** Index of `node` in a flat preorder list (sorted by start offset), or -1. */
906
+ function nodeListIndex(nodes, node) {
907
+ const start = node.startIndex;
908
+ let lo = 0;
909
+ let hi = nodes.length;
910
+ while (lo < hi) {
911
+ const mid = (lo + hi) >> 1;
912
+ if (nodes[mid].startIndex < start) lo = mid + 1;
913
+ else hi = mid;
914
+ }
915
+ for (let i = lo; i < nodes.length && nodes[i].startIndex === start; i++) {
916
+ if (nodes[i] === node) return i;
917
+ }
918
+ const id = nodeIdOf(node);
919
+ if (id === null) return -1;
920
+ for (let i = lo; i < nodes.length && nodes[i].startIndex === start; i++) {
921
+ if (nodeIdOf(nodes[i]) === id) return i;
922
+ }
923
+ return -1;
924
+ }
925
+
926
+ /**
927
+ * The already-built flat node list holding `node` (fix #388), as
928
+ * { nodes, subtreeEnds, index } with nodes[index] the node, or null when no
929
+ * cached list covers it. Named nodes only, like `namedChildren`.
930
+ */
931
+ function cachedNodeRange(node) {
932
+ if (!node) return null;
933
+ const tree = node.tree;
934
+ if (!tree) return null;
935
+ if (_cachedNodeList && _cachedRootNode?.tree === tree) {
936
+ if (node === _cachedRootNode || sameCachedNode(node, _cachedRootNode, _cachedRootId)) {
937
+ return { nodes: _cachedNodeList, subtreeEnds: _cachedSubtreeEnds, index: 0 };
938
+ }
939
+ const index = nodeListIndex(_cachedNodeList, node);
940
+ if (index >= 0) return { nodes: _cachedNodeList, subtreeEnds: _cachedSubtreeEnds, index };
941
+ }
942
+ if (_previousNodeList && _previousRootNode?.tree === tree) {
943
+ if (node === _previousRootNode || sameCachedNode(node, _previousRootNode, _previousRootId)) {
944
+ return { nodes: _previousNodeList, subtreeEnds: _previousSubtreeEnds, index: 0 };
945
+ }
946
+ const index = nodeListIndex(_previousNodeList, node);
947
+ if (index >= 0) return { nodes: _previousNodeList, subtreeEnds: _previousSubtreeEnds, index };
948
+ }
949
+ return null;
950
+ }
951
+
819
952
  /**
820
953
  * Traverse a tree-sitter AST using a cached flat node list.
821
954
  * Semantically equivalent to traverseTree() but ~3x faster when the same
@@ -873,8 +1006,13 @@ function visitNameNodes(tree, code, name, callback) {
873
1006
  */
874
1007
  function clearNodeListCache() {
875
1008
  _cachedRootNode = null;
1009
+ _cachedRootId = null;
876
1010
  _cachedNodeList = null;
877
1011
  _cachedSubtreeEnds = null;
1012
+ _previousRootNode = null;
1013
+ _previousRootId = null;
1014
+ _previousNodeList = null;
1015
+ _previousSubtreeEnds = null;
878
1016
  }
879
1017
 
880
1018
  /**
@@ -1071,13 +1209,168 @@ function sameNode(a, b) {
1071
1209
  return !!a && !!b && (a === b || a.id === b.id);
1072
1210
  }
1073
1211
 
1212
+ /**
1213
+ * Whether a function body contains a node of `targetTypes` in its OWN scope:
1214
+ * nested scopes listed in `boundaryTypes` (inner functions, lambdas, classes)
1215
+ * are not descended. Used to recognize generator/iterator functions by their
1216
+ * `yield` (a yield inside a nested def makes the NESTED def the generator).
1217
+ */
1218
+ function containsOwnNode(body, targetTypes, boundaryTypes) {
1219
+ if (!body) return false;
1220
+ const stack = [body];
1221
+ while (stack.length > 0) {
1222
+ const node = stack.pop();
1223
+ const count = node.namedChildCount;
1224
+ for (let i = 0; i < count; i++) {
1225
+ const child = node.namedChild(i);
1226
+ if (targetTypes.has(child.type)) return true;
1227
+ if (!boundaryTypes.has(child.type)) stack.push(child);
1228
+ }
1229
+ }
1230
+ return false;
1231
+ }
1232
+
1233
+ /**
1234
+ * fix #367e: line ranges the parser could only recover, not parse. Inside an
1235
+ * outermost ERROR node, a damaged child (loose token, erroneous or MISSING
1236
+ * content) opens a region; clean statements/expressions that follow extend
1237
+ * it (they sit in unparsed context: `ok();` after a broken signature); a
1238
+ * clean complete declaration/definition/import/directive closes it, and
1239
+ * comments are neutral. A MISSING token outside any ERROR marks its own line.
1240
+ * Only subtrees with `hasError` are visited. Returns merged, sorted
1241
+ * [startLine, endLine] pairs (1-based), or [] for a clean tree.
1242
+ */
1243
+ const COMPLETE_CONSTRUCT_RE = /declaration|definition|_item$|^import|^preproc_|^package_clause$|^using_directive$|^module$/;
1244
+
1245
+ function parseErrorRegions(rootNode) {
1246
+ const regions = [];
1247
+ if (!rootNode?.hasError) return regions;
1248
+ const lineOf = position => position.row + 1;
1249
+ // Children are read in one batch per node (fix #388).
1250
+ const errorRegions = node => {
1251
+ let open = null;
1252
+ const close = () => { if (open) { regions.push(open); open = null; } };
1253
+ const children = node.children;
1254
+ for (let i = 0; i < children.length; i++) {
1255
+ const child = children[i];
1256
+ const type = child.type;
1257
+ if (type.includes('comment')) continue;
1258
+ const damaged = child.hasError || child.isMissing || type === 'ERROR' ||
1259
+ !child.isNamed || child.childCount === 0;
1260
+ const start = lineOf(child.startPosition);
1261
+ const end = lineOf(child.endPosition);
1262
+ if (damaged) {
1263
+ if (open) open[1] = Math.max(open[1], end);
1264
+ else open = [start, end];
1265
+ } else if (COMPLETE_CONSTRUCT_RE.test(type)) {
1266
+ close();
1267
+ } else if (open) {
1268
+ open[1] = Math.max(open[1], end);
1269
+ }
1270
+ }
1271
+ close();
1272
+ };
1273
+ const visit = node => {
1274
+ if (node.isMissing) {
1275
+ const line = lineOf(node.startPosition);
1276
+ regions.push([line, line]);
1277
+ return;
1278
+ }
1279
+ if (!node.hasError) return;
1280
+ if (node.type === 'ERROR') {
1281
+ errorRegions(node);
1282
+ return;
1283
+ }
1284
+ const children = node.children;
1285
+ for (let i = 0; i < children.length; i++) visit(children[i]);
1286
+ };
1287
+ visit(rootNode);
1288
+ regions.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
1289
+ const merged = [];
1290
+ for (const region of regions) {
1291
+ const last = merged[merged.length - 1];
1292
+ if (last && region[0] <= last[1]) last[1] = Math.max(last[1], region[1]);
1293
+ else merged.push([...region]);
1294
+ }
1295
+ return merged;
1296
+ }
1297
+
1298
+ /**
1299
+ * fix #380: generic arity of the LAST segment of a written type name
1300
+ * (`Outcome` 0, `Outcome<int>` 1, `Dictionary<string, List<int>>` 2,
1301
+ * `Outcome<,>` 2, `A<int>.B` 0, `Outcome<int>?` 1). Null for text that is not
1302
+ * a type-name shape.
1303
+ */
1304
+ function genericArityOf(text) {
1305
+ if (text == null) return null;
1306
+ let value = String(text).trim().replace(/\?$/, '');
1307
+ while (value.endsWith('[]')) value = value.slice(0, -2).trim();
1308
+ if (!value) return null;
1309
+ if (!value.endsWith('>')) {
1310
+ return /^[A-Za-z_@][\w@]*$/.test(value.split(/::|\./).pop() || '') ? 0 : null;
1311
+ }
1312
+ let depth = 0;
1313
+ let commas = 0;
1314
+ for (let i = value.length - 1; i >= 0; i--) {
1315
+ const ch = value[i];
1316
+ if (ch === '>') depth++;
1317
+ else if (ch === '<') {
1318
+ depth--;
1319
+ if (depth === 0) return commas + 1;
1320
+ } else if (ch === ',' && depth === 1) commas++;
1321
+ }
1322
+ return null;
1323
+ }
1324
+
1325
+ // Exclusive clauses of one compound statement (fix #398F): `if`/`elif`/
1326
+ // `else` and `case` arms never both run; a `try` body and its handlers, two
1327
+ // handlers, a handler and the `try` `else` are alternatives. A `try` body and
1328
+ // its `else` run in sequence, and `finally` always runs.
1329
+ function branchClausesExclusive(a, b) {
1330
+ if (a === b) return false;
1331
+ const kind = tag => tag.split(':')[0];
1332
+ const ka = kind(a), kb = kind(b);
1333
+ const branch = new Set(['if', 'elif', 'else']);
1334
+ if (branch.has(ka) && branch.has(kb)) return true;
1335
+ if (ka === 'case' && kb === 'case') return true;
1336
+ if (ka === 'finally' || kb === 'finally') return false;
1337
+ const pair = new Set([ka, kb]);
1338
+ if (pair.has('except') && (pair.has('try') || pair.has('try-else') || ka === kb)) return true;
1339
+ return false;
1340
+ }
1341
+
1342
+ /**
1343
+ * The package name a Go import path suggests when the import writes none,
1344
+ * by the Go tools' convention: the last element, or the one before a major
1345
+ * version suffix (v2 and up: `example.com/mod/v3` is package `mod`; a module
1346
+ * path never ends in /v0 or /v1, so such an element is a directory, and
1347
+ * `k8s.io/api/core/v1` declares package `v1`), without a `go-` prefix and up
1348
+ * to the first character no identifier holds (`github.com/goccy/go-json` is
1349
+ * `json`, `gopkg.in/yaml.v3` `yaml`, `k8s.io/mount-utils` `mount`). Only a
1350
+ * suggestion: the local name is the imported package's own clause (fix #400).
1351
+ */
1352
+ function goPathPackageName(modulePath) {
1353
+ const parts = String(modulePath).split('/');
1354
+ let base = parts[parts.length - 1];
1355
+ if (/^v([2-9]|[1-9]\d+)$/.test(base) && parts.length > 1) base = parts[parts.length - 2];
1356
+ if (base.startsWith('go-')) base = base.slice(3);
1357
+ const cut = base.search(/[^\p{L}\p{N}_]/u);
1358
+ return cut > 0 ? base.slice(0, cut) : base;
1359
+ }
1360
+
1074
1361
  module.exports = {
1362
+ genericArityOf,
1363
+ goPathPackageName,
1364
+ parseErrorRegions,
1365
+ containsOwnNode,
1075
1366
  nodeTextWithoutComments,
1076
1367
  sameNode,
1077
1368
  traverseTree,
1078
1369
  traverseTreeCached,
1079
1370
  visitNameNodes,
1080
1371
  getCachedNodeList,
1372
+ cachedNodeRange,
1373
+ traverseNodeRange,
1081
1374
  clearNodeListCache,
1082
1375
  nodeToLocation,
1083
1376
  extractParams,
@@ -1096,4 +1389,5 @@ module.exports = {
1096
1389
  extractStringArg,
1097
1390
  stripQuotes,
1098
1391
  extractSprintfPrefix,
1392
+ branchClausesExclusive,
1099
1393
  };
package/mcp/server.js CHANGED
@@ -7,6 +7,11 @@
7
7
  * Keeps a per-project index cache for fast repeat queries.
8
8
  */
9
9
 
10
+ // Reuse V8 code caches across processes (Node >= 22.1; fix #365): compiling
11
+ // the engine's modules is a fixed cost of every command. Node validates each
12
+ // entry against the source, and NODE_DISABLE_COMPILE_CACHE turns it off.
13
+ try { require('module').enableCompileCache?.(); } catch (_) { /* optional */ }
14
+
10
15
  const fs = require('fs');
11
16
  const path = require('path');
12
17
  const { StdioMcpServer } = require('./stdio-server');
@@ -30,6 +35,7 @@ const {
30
35
  formatSurfaceMessage,
31
36
  } = require('../core/registry');
32
37
  const { execute } = require('../core/execute');
38
+ const { describeError, UcnError } = require('../core/errors');
33
39
  const { applyOutputBudget, MAX_OUTPUT_CHARS } = require('../core/output-budget');
34
40
 
35
41
  // ============================================================================
@@ -41,14 +47,14 @@ const MAX_CACHE_SIZE = 10;
41
47
 
42
48
  function getIndex(projectDir, options) {
43
49
  if (typeof projectDir !== 'string' || projectDir.trim().length === 0) {
44
- throw new Error('project_dir is required and must be a non-empty path.');
50
+ throw new UcnError('project_dir is required and must be a non-empty path.');
45
51
  }
46
52
  const maxFiles = options && options.maxFiles;
47
53
  const followSymlinks = options && options.followSymlinks;
48
54
  const includeBundled = options?.includeBundled === true;
49
55
  const absDir = path.resolve(projectDir);
50
56
  if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory()) {
51
- throw new Error(`Project directory not found: ${absDir}`);
57
+ throw new UcnError(`Project directory not found: ${absDir}`);
52
58
  }
53
59
  const root = findProjectRoot(absDir);
54
60
  const cached = indexCache.get(root);
@@ -119,7 +125,7 @@ const server = new StdioMcpServer({
119
125
 
120
126
  function toolResult(text, command, maxChars, suffixNote, params = {}) {
121
127
  const suffix = suffixNote || '';
122
- const budget = applyOutputBudget(text, {
128
+ const budget = applyOutputBudget(text + suffix, {
123
129
  command,
124
130
  maxChars,
125
131
  surface: 'mcp',
@@ -130,13 +136,18 @@ function toolResult(text, command, maxChars, suffixNote, params = {}) {
130
136
  // narrowing hints; preserved contract lines follow). A structuredContent
131
137
  // side-channel is rendered INSTEAD of content by MCP clients that prefer
132
138
  // structured results, which discards the entire answer (fix #284).
133
- return { content: [{ type: 'text', text: budget.text + suffix }] };
139
+ return { content: [{ type: 'text', text: budget.text }] };
134
140
  }
135
141
 
136
142
  function toolError(message) {
137
143
  return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
138
144
  }
139
145
 
146
+ /** An exception inside UCN: marked internal, never rendered as a refusal (fix #394). */
147
+ function internalToolError(message) {
148
+ return { content: [{ type: 'text', text: message }], isError: true };
149
+ }
150
+
140
151
  function paramEditDistance(a, b) {
141
152
  if (Math.abs(a.length - b.length) > 2) return 3;
142
153
  const prev = new Array(b.length + 1);
@@ -260,7 +271,7 @@ const INPUT_SHAPE = {
260
271
  sections: stringParam('Comma-separated projection. show: summary,callers,callees,source,dependencies,tests,types,example,related. repo: summary,files,stats,health.'),
261
272
  exclude: stringParam('Comma-separated patterns to exclude (e.g. "test,mock,vendor")'),
262
273
  include_tests: booleanParam('Include test files in results (excluded by default)'),
263
- exclude_tests: booleanParam('Explicit spelling of the default test-file exclusion (entrypoints). Use include_tests=true to include test files.'),
274
+ exclude_tests: booleanParam('Exclude test files: the default for entrypoints (include_tests=true lists them); usages and endpoints list test files unless exclude_tests=true.'),
264
275
  include_methods: booleanParam('Include method callees where receiver evidence permits. Caller-bearing views always tier method sites.'),
265
276
  expand_unverified: booleanParam('trace callers: follow unverified edges; downstream nodes remain marked possible, never confirmed.'),
266
277
  min_confidence: numberParam('Minimum ordinal evidence weight (legacy name; not a probability) for caller/callee edges', { minimum: 0, maximum: 1 }),
@@ -292,11 +303,11 @@ const INPUT_SHAPE = {
292
303
  git: booleanParam('show summary: attach last-modified, author, and recent-change metadata.'),
293
304
  add_param: stringParam('Parameter name to add (plan command)'),
294
305
  remove_param: stringParam('Parameter name to remove (plan command)'),
295
- rename_to: stringParam('New function name (plan command)'),
306
+ rename_to: stringParam('New name for the function, method, or type (plan command)'),
296
307
  default_value: stringParam('Default value for added parameter (plan command)'),
297
308
  stack: stringParam('The stack trace text to parse (stacktrace command)'),
298
309
  range: stringParam('source line range, e.g. "10-20" or "15"; requires file.'),
299
- base: stringParam('Git ref to diff against (default: HEAD). E.g. "HEAD~3", "main", a commit SHA'),
310
+ base: stringParam('Git ref (default: HEAD). impact/check without a symbol: diff against it; check with a symbol: compare the declaration with its version there. E.g. "HEAD~3", "main", a commit SHA'),
300
311
  staged: booleanParam('impact/check without a symbol: analyze staged changes.'),
301
312
  deep: booleanParam('repo: include health and sample the ordinal resolution-evidence profile, not accuracy.'),
302
313
  compact: booleanParam('Compact output defaults to true for show/impact and false for usages; set the opposite value to change that command\'s presentation.'),
@@ -305,9 +316,9 @@ const INPUT_SHAPE = {
305
316
  top_level: booleanParam('repo files: show only top-level functions.'),
306
317
  class_name: stringParam('Class name to scope method analysis (e.g. "MarketDataFetcher" for close)'),
307
318
  line: integerParam('Definition line pin. Resolves the symbol defined at this exact line (the middle component of a file:line:name handle). Disambiguates same-file same-name definitions.', { exclusiveMinimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
308
- limit: integerParam('Max results to return (default: 500; structural search: 50; usages and lines: uncapped). Caps find, usages, search, deadcode, api, and repo files. Must be a positive integer.', { exclusiveMinimum: 0, maximum: 1000000 }),
319
+ limit: integerParam('Max results to return (default: 500; structural search: 50; usages and lines: uncapped). Caps find, usages, search, deadcode, api, and repo files. find text shows the top 5 in detail and counts the rest; limit raises both. Must be a positive integer.', { exclusiveMinimum: 0, maximum: 1000000 }),
309
320
  max_files: integerParam('Max files to index (default: 10000). Use for very large codebases. Must be a positive integer.', { exclusiveMinimum: 0, maximum: 10000000 }),
310
- max_chars: integerParam('Max output chars before truncation. Broad sweep commands (repo, entrypoints, endpoints, deadcode, deps, check, audit_async) default to 3K; all other commands default to 10K. Maximum: 100K. all=true lifts formatter caps but keeps the 100K transport ceiling.', { exclusiveMinimum: 0, maximum: 100000 }),
321
+ max_chars: integerParam('Max output characters, not UTF-8 bytes, including notes and preserved metadata. Broad sweep commands (repo, entrypoints, endpoints, deadcode, deps, check, audit_async) default to 3K; all other commands default to 10K. Maximum: 100K. all=true lifts formatter caps but keeps the 100K transport ceiling.', { exclusiveMinimum: 0, maximum: 100000 }),
311
322
  type: stringParam('Symbol type filter for structural search: function, class, call, method, type, state, field, constant, macro. Triggers index-based search.'),
312
323
  param: stringParam('Filter by parameter name or type (structural search). E.g. "Request", "ctx".'),
313
324
  receiver: stringParam('Filter calls by receiver (structural search, type=call). E.g. "db", "http".'),
@@ -469,6 +480,7 @@ server.registerTool(
469
480
  }
470
481
  const execution = execute(index, canonicalCommand, ep);
471
482
  if (!execution.ok) {
483
+ if (execution.internalError) return internalToolError(execution.error + strippedNote);
472
484
  return te(formatSurfaceMessage(execution.error, 'mcp'));
473
485
  }
474
486
 
@@ -507,7 +519,9 @@ server.registerTool(
507
519
  ));
508
520
 
509
521
  } catch (e) {
510
- return te(e.message);
522
+ const described = describeError(e);
523
+ if (described.internalError) return internalToolError(described.error + strippedNote);
524
+ return te(described.error);
511
525
  } finally {
512
526
  // Persist calls cache after command execution.
513
527
  // getIndex() only saves after build (when callsCache is empty).
@@ -516,7 +530,7 @@ server.registerTool(
516
530
  // MED-1: also persist when reachability was computed in-process so
517
531
  // long-lived MCP servers carry the BFS result forward to disk.
518
532
  if (index && !ep.includeBundled && !ep.maxFiles &&
519
- (index.callsCacheDirty || index.reachabilityDirty || index.computedDispatchDirty)) {
533
+ (index.callsCacheDirty || index.reachabilityDirty || index.computedDispatchDirty || index.macroExpansionDirty)) {
520
534
  try { index.saveCache(); } catch (_) { /* best-effort */ }
521
535
  index.callsCacheDirty = false;
522
536
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.4.1",
3
+ "version": "5.5.0",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
6
6
  "main": "index.js",