ucn 5.0.2 → 5.0.4

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.
@@ -30,12 +30,14 @@ The release board independently cross-checks overlapping stable-handle answers f
30
30
  Publish-blocking semantic gates also compare stratified samples from pinned real repositories with independent language-native oracles: ts-morph, Pyright, gopls, rust-analyzer, JDT LS, clangd, and Roslyn. This validates the measured static evidence classes; it does not turn dynamic dispatch, generated code, reflection, or external dependencies into complete runtime knowledge.
31
31
 
32
32
  MCP keeps its process and project index warm across calls. CLI and MCP share
33
- the same text budget: targeted commands default to 10K output characters,
34
- broad commands to 3K, with a 100K hard ceiling. Use CLI `--max-chars`, MCP
35
- `max_chars`, a narrower file/directory scope, or a smaller section projection
36
- when necessary. Truncation must retain the accounting/contract lines needed to
37
- interpret the answer, and the requested limit includes both the notice and the
38
- preserved metadata.
33
+ the same text budget: broad sweep commands (`repo`, `entrypoints`,
34
+ `endpoints`, `deadcode`, `deps`, `check`, `audit-async`) default to 3K output
35
+ characters, all other commands to 10K, with a 100K hard ceiling. Use CLI
36
+ `--max-chars`, MCP `max_chars`, a narrower file/directory scope, or a smaller
37
+ section projection when necessary. Truncated output keeps the head of the
38
+ answer, a notice stating the full size and limit, and the accounting/contract
39
+ lines needed to interpret what remains; the requested limit includes all
40
+ three. The text block is the whole response on every surface.
39
41
 
40
42
  Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use.
41
43
 
package/core/analysis.js CHANGED
@@ -1768,6 +1768,8 @@ function diffImpact(index, options = {}) {
1768
1768
  if (!diffText || !diffText.trim()) {
1769
1769
  return {
1770
1770
  base: staged ? '(staged)' : base,
1771
+ changedPaths: 0,
1772
+ nonSourcePaths: 0,
1771
1773
  functions: [],
1772
1774
  moduleLevelChanges: [],
1773
1775
  newFunctions: [],
@@ -1803,9 +1805,13 @@ function diffImpact(index, options = {}) {
1803
1805
  let totalCallSites = 0;
1804
1806
  let totalUnverifiedSites = 0;
1805
1807
 
1808
+ // fix #283: count changed paths the analysis can't see (docs, config,
1809
+ // unsupported languages) so "no changes to analyze" can say why.
1810
+ let nonSourcePaths = 0;
1811
+
1806
1812
  for (const change of changes) {
1807
1813
  const lang = detectLanguage(change.filePath);
1808
- if (!lang) continue;
1814
+ if (!lang) { nonSourcePaths++; continue; }
1809
1815
 
1810
1816
  const fileEntry = index.files.get(change.filePath);
1811
1817
 
@@ -2172,6 +2178,8 @@ function diffImpact(index, options = {}) {
2172
2178
 
2173
2179
  return {
2174
2180
  base: staged ? '(staged)' : base,
2181
+ changedPaths: changes.length,
2182
+ nonSourcePaths,
2175
2183
  functions,
2176
2184
  moduleLevelChanges,
2177
2185
  newFunctions,
package/core/bridge.js CHANGED
@@ -8,10 +8,13 @@
8
8
  * server route — across language boundaries.
9
9
  *
10
10
  * REUSES the call cache (getCachedCalls) and AST-derived symbol metadata
11
- * (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs). The only file
12
- * I/O is index-driven; we never re-parse files. Extraction results are cached
13
- * lazily on `index._endpointsCache` and invalidated on rebuild via the same
14
- * mechanism as `_reachableSymbols`.
11
+ * (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs). File I/O is
12
+ * index-driven; the one re-parse is the router-mount scan (fix #282), which
13
+ * AST-parses only the files whose call cache mentions a router factory or an
14
+ * include/mount call — keyword arguments (`prefix=`) and mount references
15
+ * aren't in the call cache. Extraction results are cached lazily on
16
+ * `index._endpointsCache` and invalidated on rebuild via the same mechanism
17
+ * as `_reachableSymbols`.
15
18
  *
16
19
  * Output shape:
17
20
  * serverRoutes: [{ method, path, normalizedPath, handler, file, line, framework, raw }]
@@ -26,6 +29,7 @@ const fs = require('fs');
26
29
  const { codeUnitCompare } = require('./shared');
27
30
  const path = require('path');
28
31
  const { getCachedCalls } = require('./callers');
32
+ const { getParser, safeParse } = require('../languages');
29
33
 
30
34
  // ============================================================================
31
35
  // HTTP METHOD CONSTANTS
@@ -86,6 +90,57 @@ function joinRoutePath(prefix, sub) {
86
90
  return p + '/' + s;
87
91
  }
88
92
 
93
+ /** Join two PREFIX fragments (fix #282). Unlike joinRoutePath, two empty
94
+ * fragments compose to '' — a router with no mount prefix anywhere must not
95
+ * gain a spurious '/'. */
96
+ function joinPrefixes(a, b) {
97
+ if (!a) return b || '';
98
+ if (!b) return a;
99
+ return a.replace(/\/+$/, '') + '/' + b.replace(/^\/+/, '');
100
+ }
101
+
102
+ /**
103
+ * Compose mount-prefix chains transitively (fix #282). A router's full
104
+ * prefix = (each mounter's full prefix + the mount-call prefix) + its own
105
+ * constructor prefix — FastAPI/Flask include semantics; Express has no
106
+ * constructor prefix so `ctorPrefixes` is simply empty there.
107
+ *
108
+ * @param {Map<string, Array<{mounterKey: string|null, prefix: string, overridesCtor?: boolean}>>} edges
109
+ * targetKey -> incoming mounts (mounterKey null = unresolvable mounter,
110
+ * treated as a root). `overridesCtor` models Flask's register_blueprint
111
+ * semantics: an explicit url_prefix REPLACES the blueprint's own prefix
112
+ * (FastAPI's include_router prefix composes with it instead).
113
+ * @param {Map<string, string>} ctorPrefixes - key -> constructor prefix
114
+ * @returns {Map<string, string[]>} key -> composed full prefixes (sorted)
115
+ */
116
+ function composeMountPrefixes(edges, ctorPrefixes) {
117
+ const memo = new Map();
118
+ const resolve = (key, depth, stack) => {
119
+ if (key == null) return [''];
120
+ if (memo.has(key)) return memo.get(key);
121
+ if (depth > 8 || stack.has(key)) return [''];
122
+ stack.add(key);
123
+ const own = ctorPrefixes.get(key) || '';
124
+ const incoming = edges.get(key) || [];
125
+ const full = incoming.length === 0 ? [own]
126
+ : incoming.flatMap(e => resolve(e.mounterKey, depth + 1, stack)
127
+ .map(parentFull => joinPrefixes(joinPrefixes(parentFull, e.prefix),
128
+ e.overridesCtor ? '' : own)));
129
+ const out = [...new Set(full.length ? full : [own])].sort(codeUnitCompare);
130
+ stack.delete(key);
131
+ memo.set(key, out);
132
+ return out;
133
+ };
134
+ // Only router keys are exposed — mounter-only keys (the app object) are
135
+ // resolved for composition but would shadow group-prefix fallbacks if kept.
136
+ const routerKeys = new Set([...edges.keys(), ...ctorPrefixes.keys()]);
137
+ const out = new Map();
138
+ for (const key of [...routerKeys].sort(codeUnitCompare)) {
139
+ out.set(key, resolve(key, 0, new Set()));
140
+ }
141
+ return out;
142
+ }
143
+
89
144
  // ============================================================================
90
145
  // FRAMEWORK PATTERNS
91
146
  // ============================================================================
@@ -281,6 +336,7 @@ function extractServerRoutes(index) {
281
336
 
282
337
  const routes = [];
283
338
  const mountedPrefixes = collectProjectRouterMounts(index);
339
+ const pythonMounts = collectPythonRouterMounts(index);
284
340
  const pythonReceiverFrameworks = new Map();
285
341
  for (const [filePath, entry] of index.files) {
286
342
  if (entry.language !== 'python') continue;
@@ -332,18 +388,26 @@ function extractServerRoutes(index) {
332
388
  const declRoutes = collectMethodRoutes(sym, lang, classPrefix, fileEntry,
333
389
  pythonReceiverFrameworks.get(sym.file));
334
390
  for (const r of declRoutes) {
335
- routes.push({
336
- method: r.method,
337
- path: r.path,
338
- normalizedPath: normalizePath(r.path),
339
- handler: sym.name,
340
- file: sym.relativePath || sym.file,
341
- absoluteFile: sym.file,
342
- line: sym.startLine,
343
- framework: r.framework,
344
- classPrefix: classPrefix || undefined,
345
- raw: r.raw || `${r.method} ${r.path}`,
346
- });
391
+ // fix #282: FastAPI/Flask routers serve under their composed
392
+ // mount prefixes (APIRouter(prefix=) + include_router(prefix=)).
393
+ const prefixes = (lang === 'python' && r.receiver &&
394
+ pythonMounts.get(`${sym.file}:${r.receiver}`)) || [''];
395
+ for (const prefix of prefixes) {
396
+ const fullPath = prefix ? joinRoutePath(prefix, r.path) : r.path;
397
+ routes.push({
398
+ method: r.method,
399
+ path: fullPath,
400
+ normalizedPath: normalizePath(fullPath),
401
+ handler: sym.name,
402
+ file: sym.relativePath || sym.file,
403
+ absoluteFile: sym.file,
404
+ line: sym.startLine,
405
+ framework: r.framework,
406
+ classPrefix: classPrefix || undefined,
407
+ ...(prefix && { mountPrefix: prefix }),
408
+ raw: r.raw || `${r.method} ${fullPath}`,
409
+ });
410
+ }
347
411
  }
348
412
  }
349
413
  }
@@ -476,6 +540,9 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
476
540
  method: r.method,
477
541
  path: r.path,
478
542
  framework: r.framework,
543
+ // fix #282: the decorator's receiver variable keys the
544
+ // composed mount-prefix lookup in extractServerRoutes.
545
+ receiver: r.receiver,
479
546
  });
480
547
  }
481
548
  }
@@ -587,10 +654,10 @@ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = nul
587
654
  const methods = methodsMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '').toUpperCase()).filter(Boolean);
588
655
  // Caller will receive ONE entry; we return GET if methods empty, else first.
589
656
  if (methods.length > 0) {
590
- return { method: methods[0], path: pathStr, framework: 'flask' };
657
+ return { method: methods[0], path: pathStr, framework: 'flask', receiver: m[1] };
591
658
  }
592
659
  }
593
- return { method: 'GET', path: pathStr, framework: 'flask' };
660
+ return { method: 'GET', path: pathStr, framework: 'flask', receiver: m[1] };
594
661
  }
595
662
  if (['get','post','put','delete','patch','options','head'].includes(verb)) {
596
663
  const modules = (fileEntry?.imports || []).map(value =>
@@ -601,43 +668,193 @@ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = nul
601
668
  ? 'flask' : modules.some(module => /^fastapi\b/.test(module)) &&
602
669
  !modules.some(module => /^flask\b/.test(module))
603
670
  ? 'fastapi' : 'unknown-python');
604
- return { method: verb.toUpperCase(), path: pathStr, framework };
671
+ return { method: verb.toUpperCase(), path: pathStr, framework, receiver: m[1] };
672
+ }
673
+ return null;
674
+ }
675
+
676
+ // ── Python router mounts (fix #282) ─────────────────────────────────────────
677
+ // FastAPI: `router = APIRouter(prefix="/api")` + `app.include_router(r, prefix="/v2")`.
678
+ // Flask: `bp = Blueprint(..., url_prefix="/api")` + `app.register_blueprint(bp, url_prefix="/v2")`.
679
+ // Neither prefix mechanism is in the call cache (both are keyword arguments,
680
+ // and the mounted router is a non-string argument), so every route rendered
681
+ // with its bare decorator path — /list for the real /api/things/list — and
682
+ // --bridge matched nothing on a prefixed application.
683
+ const PY_ROUTER_FACTORIES = new Set(['APIRouter', 'Blueprint']);
684
+ const PY_INCLUDE_METHODS = new Set(['include_router', 'register_blueprint']);
685
+ const PY_PREFIX_KWARGS = new Set(['prefix', 'url_prefix']);
686
+
687
+ /** Unquote a Python string literal node ("/api", '/api', r"/api"). */
688
+ function pyStringText(node) {
689
+ if (!node || node.type !== 'string') return null;
690
+ const m = node.text.match(/^[rbuf]{0,2}(['"])([\s\S]*)\1$/i);
691
+ return m ? m[2] : null;
692
+ }
693
+
694
+ /** First `prefix=`/`url_prefix=` string keyword argument of a call node. */
695
+ function pyPrefixKwarg(argsNode) {
696
+ if (!argsNode) return null;
697
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
698
+ const arg = argsNode.namedChild(i);
699
+ if (arg.type !== 'keyword_argument') continue;
700
+ const nameNode = arg.childForFieldName('name') || arg.namedChild(0);
701
+ if (!nameNode || !PY_PREFIX_KWARGS.has(nameNode.text)) continue;
702
+ const valueNode = arg.childForFieldName('value') || arg.namedChild(1);
703
+ return pyStringText(valueNode);
605
704
  }
606
705
  return null;
607
706
  }
608
707
 
609
- /** Map exported router receiver variables to their literal project mounts. */
708
+ /**
709
+ * Map Python router variables to their composed mount prefixes.
710
+ * Returns Map `${absFile}:${routerVar}` -> [full prefix strings].
711
+ * Only files whose call cache mentions a router factory or include call are
712
+ * AST-parsed; unresolvable mount references contribute no edge (the route
713
+ * keeps its bare path — conservative under the advisory contract).
714
+ */
715
+ function collectPythonRouterMounts(index) {
716
+ const relevant = [];
717
+ for (const [filePath, entry] of index.files) {
718
+ if (entry.language !== 'python') continue;
719
+ const calls = getCachedCalls(index, filePath) || [];
720
+ if (calls.some(c => (PY_ROUTER_FACTORIES.has(c.name) && c.assignedTo) ||
721
+ PY_INCLUDE_METHODS.has(c.name))) {
722
+ relevant.push([filePath, entry]);
723
+ }
724
+ }
725
+ if (relevant.length === 0) return new Map();
726
+ let parser;
727
+ try { parser = getParser('python'); } catch (e) { return new Map(); }
728
+ if (!parser) return new Map();
729
+
730
+ const ctorPrefixes = new Map();
731
+ const edges = new Map();
732
+ for (const [filePath, entry] of relevant) {
733
+ let tree = null;
734
+ try {
735
+ tree = safeParse(parser, fs.readFileSync(filePath, 'utf8'));
736
+ } catch (e) { /* unreadable/unparseable → no prefixes from this file */ }
737
+ if (!tree) continue;
738
+ const visit = (node) => {
739
+ if (node.type === 'call') visitPyRouterCall(node, filePath, entry, index, ctorPrefixes, edges);
740
+ for (let i = 0; i < node.namedChildCount; i++) visit(node.namedChild(i));
741
+ };
742
+ visit(tree.rootNode);
743
+ }
744
+ return composeMountPrefixes(edges, ctorPrefixes);
745
+ }
746
+
747
+ function visitPyRouterCall(callNode, filePath, entry, index, ctorPrefixes, edges) {
748
+ const fn = callNode.childForFieldName('function');
749
+ if (!fn) return;
750
+
751
+ // `<var> = APIRouter(prefix="/api")` — constructor prefix.
752
+ if (fn.type === 'identifier' && PY_ROUTER_FACTORIES.has(fn.text)) {
753
+ const parent = callNode.parent;
754
+ if (!parent || parent.type !== 'assignment') return;
755
+ const left = parent.childForFieldName('left');
756
+ if (!left || left.type !== 'identifier') return;
757
+ const prefix = pyPrefixKwarg(callNode.childForFieldName('arguments'));
758
+ if (prefix) ctorPrefixes.set(`${filePath}:${left.text}`, prefix);
759
+ return;
760
+ }
761
+
762
+ // `<recv>.include_router(<ref>, prefix="/v2")` — mount edge.
763
+ if (fn.type !== 'attribute') return;
764
+ const attrNode = fn.childForFieldName('attribute');
765
+ if (!attrNode || !PY_INCLUDE_METHODS.has(attrNode.text)) return;
766
+ const recvNode = fn.childForFieldName('object');
767
+ // Non-identifier receivers (self.app, factories) can't be keyed — treat
768
+ // as a root mounter so the edge prefix still applies.
769
+ const mounterKey = recvNode && recvNode.type === 'identifier'
770
+ ? `${filePath}:${recvNode.text}` : null;
771
+ const argsNode = callNode.childForFieldName('arguments');
772
+ if (!argsNode) return;
773
+ let refNode = null;
774
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
775
+ const arg = argsNode.namedChild(i);
776
+ if (arg.type === 'keyword_argument' || arg.type.includes('comment')) continue;
777
+ refNode = arg;
778
+ break;
779
+ }
780
+ if (!refNode) return;
781
+ const explicitPrefix = pyPrefixKwarg(argsNode);
782
+ const prefix = explicitPrefix || '';
783
+ // Flask: register_blueprint's url_prefix REPLACES the blueprint's own
784
+ // url_prefix; FastAPI's include_router prefix composes with it.
785
+ const overridesCtor = explicitPrefix != null && attrNode.text === 'register_blueprint';
786
+
787
+ let targetKey = null;
788
+ if (refNode.type === 'attribute') {
789
+ // `app.include_router(mod.router)` — module attribute.
790
+ const obj = refNode.childForFieldName('object');
791
+ const attr = refNode.childForFieldName('attribute');
792
+ if (obj && obj.type === 'identifier' && attr) {
793
+ const binding = (entry.importBindings || []).find(b => b.name === obj.text);
794
+ const rel = binding && entry.moduleResolved?.[binding.module];
795
+ if (rel) targetKey = `${path.join(index.root, rel)}:${attr.text}`;
796
+ }
797
+ } else if (refNode.type === 'identifier') {
798
+ // Bare name: alias-aware from-import first, then same-file variable.
799
+ const aliasEntry = (entry.importAliases || []).find(a => a.local === refNode.text);
800
+ const original = aliasEntry ? aliasEntry.original : refNode.text;
801
+ const binding = (entry.importBindings || []).find(b =>
802
+ b.name === original && entry.moduleResolved?.[b.module]);
803
+ targetKey = binding
804
+ ? `${path.join(index.root, entry.moduleResolved[binding.module])}:${original}`
805
+ : `${filePath}:${refNode.text}`;
806
+ }
807
+ if (!targetKey) return;
808
+ const list = edges.get(targetKey) || [];
809
+ list.push({ mounterKey, prefix, ...(overridesCtor && { overridesCtor }) });
810
+ edges.set(targetKey, list);
811
+ }
812
+
813
+ /** Map exported router receiver variables to their literal project mounts.
814
+ * fix #282: same-file mounts (`app.use('/api', localRouter)`), NAMED-export
815
+ * routers, and transitive composition (`app.use('/api', parent)` +
816
+ * `parent.use('/sub', child)` → child serves under /api/sub). */
610
817
  function collectProjectRouterMounts(index) {
611
- const mounts = new Map();
818
+ const edges = new Map();
612
819
  for (const [filePath, fileEntry] of index.files) {
613
820
  if (!['javascript', 'typescript', 'tsx'].includes(fileEntry.language)) continue;
614
821
  const calls = getCachedCalls(index, filePath) || [];
822
+ const localRouters = collectRouterReceivers(calls, fileEntry.language);
615
823
  for (const call of calls) {
616
824
  if (call.name !== 'use' || !call.receiver || !call.firstStringArg ||
617
825
  (call.argCount != null && call.argCount < 2)) continue;
826
+ const mounterKey = `${filePath}:${call.receiver}`;
618
827
  const refs = calls.filter(candidate => candidate.line === call.line &&
619
828
  candidate !== call && (candidate.isFunctionReference || candidate.isPotentialCallback));
620
829
  for (const ref of refs) {
830
+ const targetKeys = [];
621
831
  const binding = (fileEntry.importBindings || []).find(item => item.name === ref.name);
622
832
  const rel = binding && fileEntry.moduleResolved?.[binding.module];
623
- if (!rel) continue;
624
- const targetFile = path.join(index.root, rel);
625
- const targetEntry = index.files.get(targetFile);
626
- if (!targetEntry) continue;
627
- const exportedReceivers = (targetEntry.exportDetails || [])
628
- .filter(exp => exp.type === 'module.exports' || exp.isDefault ||
629
- exp.kind === 'default' || exp.type === 'export-default')
630
- .map(exp => exp.localName || exp.name).filter(Boolean);
631
- for (const receiver of exportedReceivers) {
632
- const key = `${targetFile}:${receiver}`;
633
- const list = mounts.get(key) || [];
634
- if (!list.includes(call.firstStringArg)) list.push(call.firstStringArg);
635
- mounts.set(key, list);
833
+ if (rel) {
834
+ const targetFile = path.join(index.root, rel);
835
+ const targetEntry = index.files.get(targetFile);
836
+ if (targetEntry) {
837
+ const exportedReceivers = (targetEntry.exportDetails || [])
838
+ .filter(exp => exp.type === 'module.exports' || exp.isDefault ||
839
+ exp.kind === 'default' || exp.type === 'export-default' ||
840
+ exp.name === ref.name)
841
+ .map(exp => exp.localName || exp.name).filter(Boolean);
842
+ for (const receiver of exportedReceivers) {
843
+ targetKeys.push(`${targetFile}:${receiver}`);
844
+ }
845
+ }
846
+ } else if (localRouters.has(ref.name)) {
847
+ targetKeys.push(`${filePath}:${ref.name}`);
848
+ }
849
+ for (const key of targetKeys) {
850
+ const list = edges.get(key) || [];
851
+ list.push({ mounterKey, prefix: call.firstStringArg });
852
+ edges.set(key, list);
636
853
  }
637
854
  }
638
855
  }
639
856
  }
640
- return mounts;
857
+ return composeMountPrefixes(edges, new Map());
641
858
  }
642
859
 
643
860
  /**
package/core/cache.js CHANGED
@@ -599,7 +599,7 @@ function clearAllCaches() {
599
599
  // v166: C++ nested aliases persist their lexical owner ranges, and `auto`
600
600
  // return functions persist a unanimously inferred local concrete type. v165
601
601
  // was used during prerelease development before both fields were complete.
602
- const CACHE_FORMAT_VERSION = 166;
602
+ const CACHE_FORMAT_VERSION = 167;
603
603
 
604
604
  /**
605
605
  * Save index to cache file
package/core/check.js CHANGED
@@ -80,13 +80,25 @@ function check(index, options = {}) {
80
80
  ];
81
81
 
82
82
  if (!dr || (modified.length === 0 && added.length === 0 && deleted.length === 0)) {
83
+ // fix #283: say what the diff actually contained. "no changes
84
+ // detected" with three changed files reads as a false negative in a
85
+ // pre-commit hook — the truth is the changes are outside what the
86
+ // symbol analysis can see.
87
+ const changedPaths = dr?.changedPaths || 0;
88
+ const nonSourcePaths = dr?.nonSourcePaths || 0;
89
+ let reason = 'no changes detected';
90
+ if (changedPaths > 0 && nonSourcePaths === changedPaths) {
91
+ reason = `${changedPaths} changed path(s), all outside supported source files`;
92
+ } else if (changedPaths > 0) {
93
+ reason = 'no callable-symbol changes in the diff';
94
+ }
83
95
  return {
84
96
  base: options.base || 'HEAD',
85
97
  staged: !!options.staged,
86
98
  ok: true,
87
99
  status: 'clean',
88
100
  empty: true,
89
- reason: 'no changes detected',
101
+ reason,
90
102
  };
91
103
  }
92
104
 
package/core/execute.js CHANGED
@@ -65,6 +65,15 @@ function editDistance(a, b) {
65
65
 
66
66
  function symbolNotFound(index, name, label = 'Symbol') {
67
67
  const query = String(name || '');
68
+ // Malformed handle (fix #283): `path:name` is a handle missing its line
69
+ // segment, not a symbol whose name contains a colon (identifiers can't).
70
+ // Saying "not found" here steered users into concluding the index missed
71
+ // the symbol. Single colon only — Rust `Type::method` paths never match.
72
+ const handleish = query.match(/^([^:]+):([A-Za-z_$][\w$]*)$/);
73
+ if (handleish && /[/\\]|\.[A-Za-z0-9]{1,8}$/.test(handleish[1])) {
74
+ return `Malformed handle "${query}": expected file:line:name. ` +
75
+ `Run find "${handleish[2]}" to get the exact handle.`;
76
+ }
68
77
  const lower = query.toLowerCase();
69
78
  let best = null;
70
79
  for (const candidate of index.symbols.keys()) {
@@ -822,7 +831,13 @@ const HANDLERS = {
822
831
  impact: (index, p) => {
823
832
  // Public v5 overload: no symbol means Git-diff impact. This replaces
824
833
  // the separate diff-impact command without changing the symbol path.
825
- if (!p.name || (typeof p.name === 'string' && !p.name.trim())) {
834
+ // An EXPLICIT empty string is an error, not diff mode (fix #283): a
835
+ // pipeline whose handle extraction produced "" would otherwise get a
836
+ // diff answer indistinguishable from "this symbol has no callers".
837
+ if (typeof p.name === 'string' && p.name.trim() === '') {
838
+ return { ok: false, error: 'Empty symbol target. Omit the argument entirely for Git-diff impact, or pass a symbol name/handle.' };
839
+ }
840
+ if (p.name == null) {
826
841
  const response = HANDLERS.diffImpact(index, { ...p });
827
842
  if (response.ok) addMode(response.result, 'diff');
828
843
  return response;
@@ -1078,6 +1093,10 @@ const HANDLERS = {
1078
1093
  check: (index, p) => {
1079
1094
  // Public v5 overload: a symbol target performs the former verify
1080
1095
  // operation; a target-less invocation remains the diff/precommit check.
1096
+ // An EXPLICIT empty string is an error, not diff mode (fix #283).
1097
+ if (typeof p.name === 'string' && p.name.trim() === '') {
1098
+ return { ok: false, error: 'Empty symbol target. Omit the argument entirely for the pre-commit diff check, or pass a symbol name/handle.' };
1099
+ }
1081
1100
  if (p.name && String(p.name).trim()) {
1082
1101
  const response = HANDLERS.verify(index, { ...p });
1083
1102
  if (response.ok) addMode(response.result, 'symbol');
@@ -210,6 +210,11 @@ function formatDiffImpact(result, options = {}) {
210
210
  parts.push(`${s.totalCallSites || 0} call sites across ${s.affectedFiles || 0} files`);
211
211
  if (s.unverifiedCallSites > 0) parts.push(`${s.unverifiedCallSites} unverified`);
212
212
  lines.push(parts.join(', '));
213
+ // fix #283: changed paths outside supported source are invisible to the
214
+ // symbol analysis — disclose instead of silently narrowing the diff.
215
+ if (result.nonSourcePaths > 0) {
216
+ lines.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
217
+ }
213
218
  lines.push('');
214
219
 
215
220
  // Modified functions
@@ -274,7 +274,13 @@ function formatVerify(result, options = {}) {
274
274
  const flags = _formatPatternFlags(m.patterns);
275
275
  lines.push(` ${m.file}:${m.line}${flags}`);
276
276
  lines.push(` ${m.expression}`);
277
- lines.push(` Expected ${m.expected}, got ${m.actual}: [${m.args?.join(', ') || ''}]`);
277
+ // fix #281: keyword-binding problems carry their own sentence —
278
+ // the raw count fits, so "Expected N, got N" would be misleading.
279
+ if (m.problem) {
280
+ lines.push(` ${m.problem}: [${m.args?.join(', ') || ''}]`);
281
+ } else {
282
+ lines.push(` Expected ${m.expected}, got ${m.actual}: [${m.args?.join(', ') || ''}]`);
283
+ }
278
284
  }
279
285
  }
280
286
 
@@ -343,6 +349,7 @@ function formatVerifyJson(result) {
343
349
  expression: m.expression,
344
350
  expected: m.expected,
345
351
  actual: m.actual,
352
+ ...(m.problem && { problem: m.problem }),
346
353
  args: m.args || [],
347
354
  patterns: m.patterns,
348
355
  })),
@@ -31,10 +31,13 @@ function buildPublicParams(command, arg, params = {}) {
31
31
  switch (command) {
32
32
  case 'search': return { ...clean, term: arg || clean.term };
33
33
  case 'source': return parseSourceTarget(arg || clean.name, clean);
34
- case 'impact': return { ...clean, ...(arg ? { name: arg } : {}) };
34
+ // fix #283: an EXPLICIT empty-string arg must reach the handler (which
35
+ // rejects it) instead of silently selecting diff mode — `arg ? ...`
36
+ // made `impact ""` indistinguishable from `impact`.
37
+ case 'impact': return { ...clean, ...(arg != null ? { name: arg } : {}) };
35
38
  case 'deps': return { ...clean, file: arg || clean.file };
36
39
  case 'api': return { ...clean, file: arg || clean.file };
37
- case 'check': return { ...clean, ...(arg ? { name: arg } : {}) };
40
+ case 'check': return { ...clean, ...(arg != null ? { name: arg } : {}) };
38
41
  case 'stacktrace': return { ...clean, stack: clean.stack || arg };
39
42
  default: return clean;
40
43
  }
package/core/registry.js CHANGED
@@ -153,10 +153,12 @@ const FLAG_APPLICABILITY = {
153
153
  auditAsync: ['file', 'exclude', 'limit'],
154
154
  };
155
155
 
156
- // Commands whose output is project-wide — truncation means you need a filter, not more text.
157
- // Used by MCP server for tighter default output limits.
156
+ // Project/change-wide sweep commands with no required symbol argument —
157
+ // truncation means you need a filter, not more text. Drives the shared
158
+ // CLI/MCP output budget: broad commands default to 3K chars, everything
159
+ // else (symbol-targeted commands like usages/tests included) to 10K.
158
160
  const BROAD_COMMANDS = new Set([
159
- 'repo', 'entrypoints', 'endpoints', 'tests', 'deadcode', 'usages',
161
+ 'repo', 'entrypoints', 'endpoints', 'deadcode',
160
162
  'deps', 'check', 'auditAsync',
161
163
  ]);
162
164
 
package/core/verify.js CHANGED
@@ -413,13 +413,28 @@ function formatTypedSignature(def, overrides = {}) {
413
413
  const ps = overrides.paramsStructured != null ? overrides.paramsStructured : def.paramsStructured;
414
414
  if (Array.isArray(ps)) {
415
415
  const paramTypes = def.paramTypes || {};
416
- const parts2 = ps.map(p => {
416
+ // Python binding-position markers (fix #281): re-render the bare `*`
417
+ // before the first keyword-only param (unless `*args` already plays
418
+ // that role) and the `/` after the last positional-only param, so the
419
+ // displayed signature matches the source contract.
420
+ const lastPosOnly = ps.reduce(
421
+ (acc, p, i) => (p && p.positionalOnly ? i : acc), -1);
422
+ let starShown = false;
423
+ const parts2 = [];
424
+ ps.forEach((p, i) => {
417
425
  // Apply paramTypes mapping when paramsStructured doesn't carry types
418
426
  const merged = { ...p };
419
427
  if (!merged.type && paramTypes[p.name]) merged.type = paramTypes[p.name];
420
- return formatTypedParam(merged);
428
+ if (p && p.rest && /^\*(?!\*)/.test(String(p.name))) starShown = true;
429
+ if (!starShown && p && p.keywordOnly) {
430
+ parts2.push('*');
431
+ starShown = true;
432
+ }
433
+ const tok = formatTypedParam(merged);
434
+ if (tok) parts2.push(tok);
435
+ if (i === lastPosOnly) parts2.push('/');
421
436
  });
422
- parts.push(`(${parts2.filter(Boolean).join(', ')})`);
437
+ parts.push(`(${parts2.join(', ')})`);
423
438
  } else if (def.params !== undefined) {
424
439
  parts.push(`(${def.params})`);
425
440
  }
@@ -874,6 +889,28 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
874
889
  args.push(argNode.text.trim());
875
890
  }
876
891
 
892
+ // Python argument structure (fix #281): keyword arguments bind by
893
+ // NAME, and `*seq` / `**map` unpacking makes the argument count
894
+ // non-static. Both were invisible before — keyword args counted as
895
+ // positional slots and unpacking fell through to a hard mismatch.
896
+ const keywordArgNames = [];
897
+ let unpackingArgs = 0;
898
+ let pyPositional = 0;
899
+ if (language === 'python') {
900
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
901
+ const argNode = argsNode.namedChild(i);
902
+ if (argNode.type.includes('comment')) continue;
903
+ if (argNode.type === 'keyword_argument') {
904
+ const nameNode = argNode.childForFieldName('name') || argNode.namedChild(0);
905
+ if (nameNode) keywordArgNames.push(nameNode.text);
906
+ } else if (argNode.type === 'list_splat' || argNode.type === 'dictionary_splat') {
907
+ unpackingArgs++;
908
+ } else {
909
+ pyPositional++;
910
+ }
911
+ }
912
+ }
913
+
877
914
  // Function.prototype indirection has a precise, AST-visible argument
878
915
  // mapping. `fn.call(thisArg, a, b)` invokes fn(a, b). `fn.apply`
879
916
  // is countable only when its argument array is a literal. `bind`
@@ -922,7 +959,9 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
922
959
  return {
923
960
  args,
924
961
  argCount: args.length,
925
- hasSpread: args.some(a => a.startsWith('...')),
962
+ hasSpread: args.some(a => a.startsWith('...')) || unpackingArgs > 0,
963
+ ...(unpackingArgs > 0 && { unpackingSpread: true }),
964
+ ...(language === 'python' && { positionalCount: pyPositional, keywordArgNames }),
926
965
  hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a)),
927
966
  isMethodCall,
928
967
  ...(indirectKind && { indirectKind }),
@@ -1129,6 +1168,70 @@ function identifyCallPatterns(callSites, funcName) {
1129
1168
  return patterns;
1130
1169
  }
1131
1170
 
1171
+ // Decorators that provably keep the declared call interface. Any OTHER
1172
+ // decorator may reshape the signature (click/celery/functools partials), so
1173
+ // keyword-binding violations against the declared parameters route to the
1174
+ // UNCERTAIN band instead of hard mismatches (fix #281 — the #205 "decorators
1175
+ // reshape signatures" rule applied to verify's claim).
1176
+ const SIGNATURE_PRESERVING_DECORATORS = new Set([
1177
+ 'staticmethod', 'classmethod', 'abstractmethod', 'override', 'final',
1178
+ ]);
1179
+
1180
+ /**
1181
+ * Bind a keyword-argument call against structured parameters (fix #281).
1182
+ * Mirrors the interpreter's rules: positional args fill non-keyword-only
1183
+ * slots in order, each keyword arg must name a known non-positional-only
1184
+ * parameter (unless `**kwargs` absorbs it), and every required parameter
1185
+ * must end up bound. Returns problem strings (empty = binds cleanly).
1186
+ * Callers gate on the `keywordArguments` trait — languages without named
1187
+ * arguments allow short calls, so required-coverage would false-flag.
1188
+ */
1189
+ function bindKeywordCall(params, analysis) {
1190
+ const problems = [];
1191
+ const isListRest = p => p.rest && /^\*(?!\*)/.test(String(p.name));
1192
+ const isDictRest = p => p.rest && /^\*\*/.test(String(p.name));
1193
+ const slots = params.filter(p => !p.rest && !p.keywordOnly);
1194
+ const hasListRest = params.some(isListRest);
1195
+ const hasDictRest = params.some(isDictRest);
1196
+ const positional = analysis.positionalCount != null
1197
+ ? analysis.positionalCount : analysis.argCount;
1198
+ const tooManyPositional = positional > slots.length && !hasListRest;
1199
+ if (tooManyPositional) {
1200
+ problems.push(`takes ${slots.length} positional argument(s) but ` +
1201
+ `${positional} ${positional === 1 ? 'was' : 'were'} given`);
1202
+ }
1203
+ const bound = new Set(
1204
+ slots.slice(0, Math.min(positional, slots.length)).map(p => p.name));
1205
+ for (const kw of analysis.keywordArgNames || []) {
1206
+ const param = params.find(p => !p.rest && p.name === kw);
1207
+ if (!param) {
1208
+ if (!hasDictRest) problems.push(`unexpected keyword argument '${kw}'`);
1209
+ } else if (param.positionalOnly) {
1210
+ // With **kwargs the name is absorbed there — but then the
1211
+ // positional-only parameter itself stays unbound (missing check).
1212
+ if (!hasDictRest) {
1213
+ problems.push(`'${kw}' is positional-only and cannot be passed by keyword`);
1214
+ }
1215
+ } else if (bound.has(kw)) {
1216
+ problems.push(`got multiple values for argument '${kw}'`);
1217
+ } else {
1218
+ bound.add(kw);
1219
+ }
1220
+ }
1221
+ // Skip required-coverage when positionals already overflowed — the extra
1222
+ // positionals were almost certainly aimed at the unbound keyword-only
1223
+ // params, and the interpreter reports only the positional error too.
1224
+ if (!tooManyPositional) {
1225
+ const missing = params
1226
+ .filter(p => !p.rest && !p.optional && p.default === undefined && !bound.has(p.name))
1227
+ .map(p => `'${p.name}'`);
1228
+ if (missing.length > 0) {
1229
+ problems.push(`missing required argument(s): ${missing.join(', ')}`);
1230
+ }
1231
+ }
1232
+ return problems;
1233
+ }
1234
+
1132
1235
  /**
1133
1236
  * Verify that all call sites match a function's signature
1134
1237
  * @param {object} index - ProjectIndex instance
@@ -1216,6 +1319,18 @@ function verify(index, name, options = {}) {
1216
1319
 
1217
1320
  const defIsMethod = !!(def.isMethod || def.type === 'method' || def.className);
1218
1321
 
1322
+ // fix #281: keyword-argument binding validation. Applies only where the
1323
+ // language binds by name (Python), the target has ONE parameter list
1324
+ // (overload groups keep the count-range check), and the arity isn't an
1325
+ // inherited-constructor unknown.
1326
+ const keywordBindable = langTraits(lang)?.keywordArguments === true &&
1327
+ rawParamLists.length === 1 && !inheritedCtorOnly;
1328
+ const decoratorReshapes = keywordBindable && Array.isArray(def.decorators) &&
1329
+ def.decorators.some(d => {
1330
+ const dName = String(d).replace(/^@/, '').split('(')[0].trim();
1331
+ return !SIGNATURE_PRESERVING_DECORATORS.has(dName.split('.').pop());
1332
+ });
1333
+
1219
1334
  // Helper: extract pattern flags (Feature A/B) from analyzeCallSite result.
1220
1335
  // Reused so each valid/mismatch/uncertain entry carries the same shape.
1221
1336
  function patternFlagsFrom(a) {
@@ -1261,7 +1376,9 @@ function verify(index, name, options = {}) {
1261
1376
  file: call.relativePath,
1262
1377
  line: call.line,
1263
1378
  expression: call.content.trim(),
1264
- reason: 'Uses spread operator',
1379
+ reason: analysis.unpackingSpread
1380
+ ? 'Uses argument unpacking (*/**) — argument count is not static'
1381
+ : 'Uses spread operator',
1265
1382
  patterns: patternFlagsFrom(analysis),
1266
1383
  ...carry,
1267
1384
  });
@@ -1285,33 +1402,40 @@ function verify(index, name, options = {}) {
1285
1402
  }
1286
1403
 
1287
1404
  // Check if arg count is valid
1288
- if (hasRest) {
1289
- // With rest param, need at least minArgs
1290
- if (argCount >= minArgs) {
1291
- valid.push({
1292
- file: call.relativePath,
1293
- line: call.line,
1294
- patterns: patternFlagsFrom(analysis),
1295
- ...carry,
1296
- });
1297
- } else {
1298
- mismatches.push({
1405
+ const countOk = hasRest
1406
+ ? argCount >= minArgs
1407
+ : (argCount >= minArgs && argCount <= expectedParamCount);
1408
+ if (!countOk) {
1409
+ mismatches.push({
1410
+ file: call.relativePath,
1411
+ line: call.line,
1412
+ expression: call.content.trim(),
1413
+ expected: hasRest
1414
+ ? `at least ${minArgs} arg(s)`
1415
+ : (minArgs === expectedParamCount
1416
+ ? `${expectedParamCount} arg(s)`
1417
+ : `${minArgs}-${expectedParamCount} arg(s)`),
1418
+ actual: argCount,
1419
+ args: analysis.args,
1420
+ patterns: patternFlagsFrom(analysis),
1421
+ ...carry,
1422
+ });
1423
+ continue;
1424
+ }
1425
+
1426
+ // fix #281: the count fits — for keyword-binding languages, check the
1427
+ // NAME-level contract too (keyword-only slots, unknown keyword names,
1428
+ // required coverage). A reshaping decorator demotes violations to
1429
+ // UNCERTAIN: the declared parameters may not be the call interface.
1430
+ const bindingProblems = keywordBindable ? bindKeywordCall(params, analysis) : [];
1431
+ if (bindingProblems.length > 0) {
1432
+ if (decoratorReshapes) {
1433
+ uncertain.push({
1299
1434
  file: call.relativePath,
1300
1435
  line: call.line,
1301
1436
  expression: call.content.trim(),
1302
- expected: `at least ${minArgs} arg(s)`,
1303
- actual: argCount,
1304
- args: analysis.args,
1305
- patterns: patternFlagsFrom(analysis),
1306
- ...carry,
1307
- });
1308
- }
1309
- } else {
1310
- // Without rest, need between minArgs and expectedParamCount
1311
- if (argCount >= minArgs && argCount <= expectedParamCount) {
1312
- valid.push({
1313
- file: call.relativePath,
1314
- line: call.line,
1437
+ reason: `Against the declared parameters: ${bindingProblems.join('; ')}. ` +
1438
+ 'The definition is decorated, and the decorator may reshape the call interface.',
1315
1439
  patterns: patternFlagsFrom(analysis),
1316
1440
  ...carry,
1317
1441
  });
@@ -1320,16 +1444,27 @@ function verify(index, name, options = {}) {
1320
1444
  file: call.relativePath,
1321
1445
  line: call.line,
1322
1446
  expression: call.content.trim(),
1323
- expected: minArgs === expectedParamCount
1324
- ? `${expectedParamCount} arg(s)`
1325
- : `${minArgs}-${expectedParamCount} arg(s)`,
1447
+ expected: hasRest
1448
+ ? `at least ${minArgs} arg(s)`
1449
+ : (minArgs === expectedParamCount
1450
+ ? `${expectedParamCount} arg(s)`
1451
+ : `${minArgs}-${expectedParamCount} arg(s)`),
1326
1452
  actual: argCount,
1327
1453
  args: analysis.args,
1454
+ problem: bindingProblems.join('; '),
1328
1455
  patterns: patternFlagsFrom(analysis),
1329
1456
  ...carry,
1330
1457
  });
1331
1458
  }
1459
+ continue;
1332
1460
  }
1461
+
1462
+ valid.push({
1463
+ file: call.relativePath,
1464
+ line: call.line,
1465
+ patterns: patternFlagsFrom(analysis),
1466
+ ...carry,
1467
+ });
1333
1468
  }
1334
1469
  clearTreeCache(index);
1335
1470
 
@@ -1415,7 +1550,10 @@ function verify(index, name, options = {}) {
1415
1550
  params: params.map(p => ({
1416
1551
  name: p.name,
1417
1552
  optional: p.optional || p.default !== undefined,
1418
- hasDefault: p.default !== undefined
1553
+ hasDefault: p.default !== undefined,
1554
+ // fix #281: binding-position markers (Python `*` / `/`)
1555
+ ...(p.keywordOnly && { keywordOnly: true }),
1556
+ ...(p.positionalOnly && { positionalOnly: true }),
1419
1557
  })),
1420
1558
  // max: null = unbounded (rest param) — typed for JSON consumers;
1421
1559
  // the text formatter renders it as `${min}+` (fix #230, was the
@@ -55,6 +55,13 @@ const STRUCTURAL_TRAITS = {
55
55
  // Whether `Type(...)` constructs a class without a `new` token. Python
56
56
  // classes are ordinary callable objects; JS/TS classes require `new`.
57
57
  classesCallableWithoutNew: false,
58
+ // Whether call sites bind arguments by parameter NAME (Python keyword
59
+ // arguments). Drives verify/check keyword binding validation (fix #281):
60
+ // keyword names checked against the signature, keyword-only/positional-only
61
+ // markers honored, required coverage enforced. Languages without named
62
+ // arguments must stay false — a JS call with fewer args than params is
63
+ // legal, so required-coverage checks would false-flag.
64
+ keywordArguments: false,
58
65
  };
59
66
  const NOMINAL_TRAITS = {
60
67
  typeSystem: 'nominal',
@@ -64,6 +71,11 @@ const NOMINAL_TRAITS = {
64
71
  exportVisibility: 'keyword',
65
72
  hasDynamicImports: true,
66
73
  testDirs: [],
74
+ // Call sites don't bind arguments by parameter name here (see
75
+ // STRUCTURAL_TRAITS.keywordArguments — Python overrides true). C# HAS
76
+ // named arguments but its parser doesn't record them yet; the trait stays
77
+ // false there until that family is measured (fix #281, classified-deferred).
78
+ keywordArguments: false,
67
79
  // Go/Java/Rust have no default parameter values — see STRUCTURAL_TRAITS.
68
80
  hasDefaultParams: false,
69
81
  // Whether ANY instance method call can dynamically dispatch to a subtype
@@ -177,6 +189,9 @@ const LANGUAGES = {
177
189
  // from-imports bind values only (`import * as ns` is parser-marked).
178
190
  submoduleImports: true,
179
191
  classesCallableWithoutNew: true,
192
+ // Call sites bind by parameter name (f(x=1)); signatures carry
193
+ // keyword-only (`*`) and positional-only (`/`) markers (fix #281).
194
+ keywordArguments: true,
180
195
  testFileCandidates: (base, ext) => [`test_${base}.py`, `${base}_test.py`],
181
196
  testDirs: ['tests'],
182
197
  },
@@ -80,10 +80,29 @@ function parseStructuredParams(paramsNode, language) {
80
80
  return params;
81
81
  }
82
82
 
83
+ // Python binding-position markers (fix #281): a bare `*` makes every later
84
+ // param keyword-only, a bare `/` makes every earlier param positional-only,
85
+ // and `*args` plays the same separator role as `*`. Dropping them collapsed
86
+ // `f(a, *, b)` to `f(a, b)` and verify green-lit guaranteed TypeErrors.
87
+ let pyKeywordOnly = false;
88
+
83
89
  for (let i = 0; i < paramsNode.namedChildCount; i++) {
84
90
  const param = paramsNode.namedChild(i);
85
91
  const paramInfo = {};
86
92
 
93
+ if (language === 'python') {
94
+ if (param.type === 'keyword_separator') {
95
+ pyKeywordOnly = true;
96
+ continue;
97
+ }
98
+ if (param.type === 'positional_separator') {
99
+ for (const prev of params) {
100
+ if (!prev.rest) prev.positionalOnly = true;
101
+ }
102
+ continue;
103
+ }
104
+ }
105
+
87
106
  // Different handling per language
88
107
  if (language === 'javascript' || language === 'typescript' || language === 'tsx') {
89
108
  parseJSParam(param, paramInfo);
@@ -98,6 +117,13 @@ function parseStructuredParams(paramsNode, language) {
98
117
  }
99
118
 
100
119
  if (paramInfo.name) {
120
+ if (language === 'python') {
121
+ if (pyKeywordOnly && !paramInfo.rest) paramInfo.keywordOnly = true;
122
+ // `*args` ends the positional section exactly like a bare `*`.
123
+ if (paramInfo.rest && /^\*(?!\*)/.test(String(paramInfo.name))) {
124
+ pyKeywordOnly = true;
125
+ }
126
+ }
101
127
  params.push(paramInfo);
102
128
  // Go multi-name declarations: `a, b int` → expand additional params
103
129
  if (paramInfo._additionalNames) {
package/mcp/server.js CHANGED
@@ -133,17 +133,12 @@ function toolResult(text, command, maxChars, suffixNote, params = {}) {
133
133
  surface: 'mcp',
134
134
  params,
135
135
  });
136
- const response = { content: [{ type: 'text', text: budget.text + suffix }] };
137
- if (budget.truncated) {
138
- response.structuredContent = {
139
- truncated: true,
140
- fullChars: budget.fullChars,
141
- requestedLimit: budget.requestedLimit,
142
- contractMetadata: budget.contractMetadata,
143
- contractMetadataComplete: budget.contractMetadataComplete,
144
- };
145
- }
146
- return response;
136
+ // The text block is the ONLY payload channel. Truncation facts live in the
137
+ // text itself (the OUTPUT TRUNCATED notice carries full size, limit, and
138
+ // narrowing hints; preserved contract lines follow). A structuredContent
139
+ // side-channel is rendered INSTEAD of content by MCP clients that prefer
140
+ // structured results, which discards the entire answer (fix #284).
141
+ return { content: [{ type: 'text', text: budget.text + suffix }] };
147
142
  }
148
143
 
149
144
  function toolError(message) {
@@ -324,7 +319,7 @@ server.registerTool(
324
319
  line: z.number().int().positive().optional().describe('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.'),
325
320
  limit: z.number().int().positive().max(1000000).optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, and repo files. Must be a positive integer.'),
326
321
  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.'),
327
- max_chars: z.number().int().positive().max(100000).optional().describe('Max output chars before truncation. Targeted commands default to 10K; broad commands default to 3K. Maximum: 100K. all=true lifts formatter caps but keeps the 100K transport ceiling.'),
322
+ max_chars: z.number().int().positive().max(100000).optional().describe('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.'),
328
323
  // Structural search flags (search command)
329
324
  type: z.string().optional().describe('Symbol type filter for structural search: function, class, call, method, type, state, field, constant, macro. Triggers index-based search.'),
330
325
  param: z.string().optional().describe('Filter by parameter name or type (structural search). E.g. "Request", "ctx".'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
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",