ucn 5.3.8 → 5.4.1

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.
@@ -55,7 +55,7 @@ function formatUncertainRequests(result, options) {
55
55
  lines.push(`Possible client requests (${list.length}) — request-shaped call with a path literal, receiver not recognized as an HTTP client:`);
56
56
  const cap = options.all ? Infinity : 10;
57
57
  for (const r of list.slice(0, cap)) {
58
- lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}`);
58
+ lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}${r.isTest ? ' [test]' : ''}`);
59
59
  }
60
60
  if (list.length > cap) lines.push(` (+${list.length - cap} more — use --all)`);
61
61
  return lines;
@@ -88,7 +88,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
88
88
  for (const r of list) {
89
89
  const handler = r.handler || '<anonymous>';
90
90
  const fw = r.framework ? `[${r.framework}]` : '';
91
- lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw} :${r.line}`);
91
+ lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw}${r.isTest ? ' [test]' : ''} :${r.line}`);
92
92
  }
93
93
  lines.push('');
94
94
  }
@@ -115,7 +115,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
115
115
  const inferred = r.methodInferred ? '?' : '';
116
116
  const interp = r.interp ? ' (interp)' : '';
117
117
  const fw = r.framework ? `[${r.framework}]` : '';
118
- lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} ${fw} :${r.line}`);
118
+ lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} ${fw}${r.isTest ? ' [test]' : ''} :${r.line}`);
119
119
  }
120
120
  lines.push('');
121
121
  }
@@ -166,12 +166,12 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
166
166
 
167
167
  lines.push(`Matched (${sorted.length} routes):`);
168
168
  for (const { route, clients } of sorted) {
169
- lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}] ${route.file}:${route.line}`);
169
+ lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}]${route.isTest ? ' [test]' : ''} ${route.file}:${route.line}`);
170
170
  for (const b of clients) {
171
171
  const conf = b.confidence.toFixed(2);
172
172
  const tier = b.matchType.toUpperCase();
173
173
  const inf = b.methodInferred ? ' method?' : '';
174
- lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf}) from ${b.request.callerName} ${b.request.file}:${b.request.line}`);
174
+ lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf}) from ${b.request.callerName}${b.request.isTest ? ' [test]' : ''} ${b.request.file}:${b.request.line}`);
175
175
  }
176
176
  lines.push('');
177
177
  }
@@ -180,7 +180,7 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
180
180
  if (unmatchedRoutes.length > 0) {
181
181
  lines.push(`Unmatched server routes (${unmatchedRoutes.length}):`);
182
182
  for (const r of unmatchedRoutes) {
183
- lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}] ${r.file}:${r.line}`);
183
+ lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}]${r.isTest ? ' [test]' : ''} ${r.file}:${r.line}`);
184
184
  }
185
185
  lines.push('');
186
186
  }
@@ -190,7 +190,7 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
190
190
  for (const r of unmatchedRequests) {
191
191
  const inferred = r.methodInferred ? '?' : '';
192
192
  const interp = r.interp ? ' (interp)' : '';
193
- lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} [${r.framework}] ${r.file}:${r.line}`);
193
+ lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} [${r.framework}]${r.isTest ? ' [test]' : ''} ${r.file}:${r.line}`);
194
194
  }
195
195
  }
196
196
 
@@ -213,6 +213,7 @@ function formatEndpointsJson(result, options = {}) {
213
213
  file: r.file,
214
214
  line: r.line,
215
215
  framework: r.framework,
216
+ isTest: !!r.isTest,
216
217
  ...(r.classPrefix && { classPrefix: r.classPrefix }),
217
218
  });
218
219
  const trimReq = (r) => ({
@@ -225,6 +226,7 @@ function formatEndpointsJson(result, options = {}) {
225
226
  callerName: r.callerName,
226
227
  ...(r.callerStartLine && { callerStartLine: r.callerStartLine }),
227
228
  framework: r.framework,
229
+ isTest: !!r.isTest,
228
230
  ...(r.methodInferred && { methodInferred: true }),
229
231
  });
230
232
  const trimBridge = (b) => ({
@@ -250,6 +252,7 @@ function formatEndpointsJson(result, options = {}) {
250
252
  uncertainRequests: (result.uncertainRequests || []).map(r => ({
251
253
  receiver: r.receiver, method: r.method, path: r.path,
252
254
  file: r.file, line: r.line, callerName: r.callerName, reason: r.reason,
255
+ isTest: !!r.isTest,
253
256
  })),
254
257
  // In unmatched-only mode, the matched bridges array is suppressed
255
258
  // — consumers that want both should not pass --unmatched.
@@ -77,9 +77,20 @@ function findRecords(result) {
77
77
  function usagesRecords(result) {
78
78
  const out = [];
79
79
  const notes = [];
80
+ const byLine = new Map();
80
81
  for (const usage of Array.isArray(result) ? result : []) {
81
82
  const kind = usage.isDefinition ? 'definition' : (usage.usageType || 'reference');
82
- out.push(record(pathOf(usage), usage.line, usage.content, kind === 'call' ? '' : kind));
83
+ const key = `${pathOf(usage)}\0${usage.line}`;
84
+ if (!byLine.has(key)) byLine.set(key, { usage, kinds: new Set(), count: 0 });
85
+ const row = byLine.get(key);
86
+ row.kinds.add(kind);
87
+ row.count++;
88
+ }
89
+ for (const { usage, kinds, count } of byLine.values()) {
90
+ const tags = [...kinds].filter(kind => kind !== 'call');
91
+ if (kinds.has('call') && tags.length) tags.unshift('call');
92
+ if (count > 1) tags.push(`${count} occurrences`);
93
+ out.push(record(pathOf(usage), usage.line, usage.content, tags.join('; ')));
83
94
  }
84
95
  const counts = result && result.summaryCounts;
85
96
  if (counts && counts.hiddenTestUsages > 0) {
@@ -187,6 +198,17 @@ function impactRecords(result) {
187
198
  out.push(record(pathOf(site), site.line, site.content, 'unverified: deleted-target-name-match'));
188
199
  }
189
200
  }
201
+ for (const symbol of [...(result.symbols || []), ...(result.newSymbols || []), ...(result.deletedSymbols || [])]) {
202
+ out.push(record(symbol.relativePath, symbol.startLine, symbol.name, `${symbol.type} declaration change`));
203
+ if (symbol.impact) {
204
+ const nested = impactRecords(symbol.impact);
205
+ out.push(...nested.records);
206
+ notes.push(...nested.notes);
207
+ }
208
+ for (const site of symbol.remainingReferences || []) {
209
+ out.push(record(site.file, site.line, site.expression, 'unverified: deleted-target-name-match'));
210
+ }
211
+ }
190
212
  const summary = result.summary || {};
191
213
  notes.push(`# Diff: ${summary.modifiedFunctions || 0} modified, ${summary.newFunctions || 0} new, ${summary.deletedFunctions || 0} deleted functions; ${(result.moduleLevelChanges || []).length} file(s) with module-level changes.`);
192
214
  if (result.nonSourcePaths) notes.push(`# ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
@@ -206,11 +228,11 @@ function impactRecords(result) {
206
228
  const accesses = result.propertyAccesses;
207
229
  for (const group of accesses.byFile || []) {
208
230
  for (const access of group.sites || []) {
209
- out.push(record(group.file, access.line, access.expression, 'property-access'));
231
+ out.push(record(group.file, access.line, access.expression, `property-access: ${access.accessKind || 'access'}${Number.isInteger(access.column) ? `, column ${access.column + 1}` : ''}`));
210
232
  }
211
233
  }
212
234
  for (const access of accesses.unverifiedSites || []) {
213
- out.push(record(pathOf(access), access.line, access.expression, `${unverifiedTag(access)}; property-access`));
235
+ out.push(record(pathOf(access), access.line, access.expression, `${unverifiedTag(access)}; property-access: ${access.accessKind || 'access'}${Number.isInteger(access.column) ? `, column ${access.column + 1}` : ''}`));
214
236
  }
215
237
  notes.push(`# PROPERTY ACCESS SITES: ${accesses.confirmedCount} confirmed, ${accesses.unverifiedCount} unverified, ${accesses.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
216
238
  }
@@ -40,12 +40,20 @@ function appendNote(text, note) {
40
40
  }
41
41
 
42
42
  /** Canonicalize object keys so JSON bytes do not depend on index provenance. */
43
- function canonicalJsonValue(value) {
44
- if (Array.isArray(value)) return value.map(canonicalJsonValue);
43
+ function canonicalJsonValue(value, root, field = undefined) {
44
+ if (Array.isArray(value)) return value.map(item => canonicalJsonValue(item, root, field));
45
+ if (root && typeof value === 'string' &&
46
+ ['file', 'filePath', 'callerFile', 'definitionFile', 'resolved', 'path', 'targetFile', 'from', 'to', 'root', 'files'].includes(field)) {
47
+ const path = require('path');
48
+ if (path.isAbsolute(value)) {
49
+ const relative = path.relative(root, value);
50
+ if (relative && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) return relative;
51
+ }
52
+ }
45
53
  if (!value || typeof value !== 'object') return value;
46
54
  const canonical = {};
47
55
  for (const key of Object.keys(value).sort()) {
48
- canonical[key] = canonicalJsonValue(provenanceReplacer(key, value[key]));
56
+ canonical[key] = canonicalJsonValue(provenanceReplacer(key, value[key]), root, key);
49
57
  }
50
58
  return canonical;
51
59
  }
@@ -474,12 +482,13 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
474
482
  ...(data && data.ok === false && { ok: false }),
475
483
  ...(modeOf(command, result) && { mode: modeOf(command, result) }),
476
484
  contract: contractMeta(command),
485
+ ...(execution.projectRoot && { pathBase: execution.projectRoot }),
477
486
  ...commandMeta,
478
487
  ...(execution.note && { note: execution.note }),
479
488
  },
480
489
  data,
481
490
  };
482
- return JSON.stringify(canonicalJsonValue(envelope), null, 2);
491
+ return JSON.stringify(canonicalJsonValue(envelope, execution.projectRoot), null, 2);
483
492
  }
484
493
 
485
494
  module.exports = {
@@ -522,7 +522,10 @@ function formatAuditAsync(result) {
522
522
  lines.push(`${file} (${fileIssues.length})`);
523
523
  for (const issue of fileIssues) {
524
524
  const caller = issue.callerName ? ` [${issue.callerName}]` : '';
525
- lines.push(` :${issue.line}${caller} ${issue.calleeName}() — async, not awaited`);
525
+ const detail = issue.reason === 'stored-promise-used-as-value'
526
+ ? `${issue.variable} used as a resolved value; promise from ${issue.calleeName}() at line ${issue.originLine}`
527
+ : `${issue.calleeName}() — async, not awaited`;
528
+ lines.push(` :${issue.line}${caller} ${detail}`);
526
529
  }
527
530
  }
528
531
  return lines.join('\n');
@@ -284,7 +284,7 @@ function formatDeadcode(results, options = {}) {
284
284
  lines.push(`\n${extHint}`);
285
285
  }
286
286
  if (results.excludedRuntimeContract > 0) {
287
- lines.push(`\n${results.excludedRuntimeContract} Java serialization callback(s) hidden (JVM runtime contract, not dead).`);
287
+ lines.push(`\n${results.excludedRuntimeContract} runtime callback(s) hidden (language/runtime registrations, not dead).`);
288
288
  }
289
289
  if (results.pythonImplicitExportFiles > 0) {
290
290
  lines.push(`\nPython public-surface rule active in ${results.pythonImplicitExportFiles} file(s) without __all__: top-level non-underscore names are treated as externally reachable.`);
@@ -132,6 +132,7 @@ function applyOutputBudget(text, {
132
132
  all = false,
133
133
  surface = 'cli',
134
134
  params = {},
135
+ trailingChars = 0,
135
136
  } = {}) {
136
137
  if (!text) {
137
138
  return {
@@ -148,13 +149,14 @@ function applyOutputBudget(text, {
148
149
  ? BROAD_OUTPUT_CHARS
149
150
  : DEFAULT_OUTPUT_CHARS;
150
151
  const requested = maxChars || (all ? MAX_OUTPUT_CHARS : defaultLimit);
151
- const limit = Math.min(requested, MAX_OUTPUT_CHARS);
152
+ const hardLimit = Math.min(requested, MAX_OUTPUT_CHARS);
153
+ const limit = Math.max(0, hardLimit - trailingChars);
152
154
  if (text.length <= limit) {
153
155
  return {
154
156
  text,
155
157
  truncated: false,
156
158
  fullChars: text.length,
157
- requestedLimit: limit,
159
+ requestedLimit: hardLimit,
158
160
  contractMetadata: [],
159
161
  contractMetadataComplete: true,
160
162
  };
@@ -178,8 +180,8 @@ function applyOutputBudget(text, {
178
180
  ? `Raise ${raiseHint}.`
179
181
  : `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
180
182
  let notice = compactBudget
181
- ? `... OUTPUT TRUNCATED (${text.length}→${limit}). ${compactGuidance}`
182
- : `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${limit}. ` +
183
+ ? `... OUTPUT TRUNCATED (${text.length}→${hardLimit}). ${compactGuidance}`
184
+ : `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${hardLimit}. ` +
183
185
  `${narrowingHint(command, surface, params)} ${allHint}`;
184
186
  if (compactBudget && notice.length > limit) {
185
187
  const emergency = supportsAll
@@ -225,7 +227,7 @@ function applyOutputBudget(text, {
225
227
  text: pieces.join('\n').slice(0, limit),
226
228
  truncated: true,
227
229
  fullChars: text.length,
228
- requestedLimit: limit,
230
+ requestedLimit: hardLimit,
229
231
  contractMetadata: contractMetadata.lines,
230
232
  contractMetadataComplete: contractMetadata.complete,
231
233
  };
@@ -283,7 +285,7 @@ function applyOutputBudget(text, {
283
285
  text: rendered,
284
286
  truncated: true,
285
287
  fullChars: text.length,
286
- requestedLimit: limit,
288
+ requestedLimit: hardLimit,
287
289
  contractMetadata: contractMetadata.lines,
288
290
  contractMetadataComplete: contractMetadata.complete,
289
291
  };
package/core/project.js CHANGED
@@ -1105,6 +1105,11 @@ class ProjectIndex {
1105
1105
  const lowerPath = filePath.toLowerCase();
1106
1106
  for (const pattern of filters.exclude) {
1107
1107
  const lowerPattern = pattern.toLowerCase();
1108
+ if (lowerPattern === 'test files') {
1109
+ const rp = path.isAbsolute(filePath) ? path.relative(this.root, filePath) : filePath;
1110
+ if (require('./shared').isTestPath(rp)) return false;
1111
+ continue;
1112
+ }
1108
1113
  let regex = this._excludeRegexCache?.get(lowerPattern);
1109
1114
  if (!regex) {
1110
1115
  const escaped = lowerPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
package/core/registry.js CHANGED
@@ -149,7 +149,7 @@ const FLAG_APPLICABILITY = {
149
149
  repo: ['file', 'exclude', 'top', 'limit', 'all', 'detailed', 'topLevel', 'in', 'functions', 'hot', 'deep', 'sections'],
150
150
  deadcode: ['file', 'exclude', 'includeTests', 'includeExported', 'includeDecorated', 'limit', 'in'],
151
151
  entrypoints: ['file', 'exclude', 'includeTests', 'excludeTests', 'limit', 'type', 'framework'],
152
- endpoints: ['file', 'exclude', 'limit', 'framework', 'bridge', 'serverOnly', 'clientOnly', 'unmatched', 'method', 'prefix', 'hideUncertain'],
152
+ endpoints: ['file', 'in', 'exclude', 'excludeTests', 'limit', 'framework', 'bridge', 'serverOnly', 'clientOnly', 'unmatched', 'method', 'prefix', 'hideUncertain'],
153
153
  stacktrace: ['stack'],
154
154
  auditAsync: ['file', 'exclude', 'limit'],
155
155
  };
package/core/search.js CHANGED
@@ -727,6 +727,7 @@ function structuralSearch(index, options = {}) {
727
727
  // Auto-infer type: --receiver implies type=call
728
728
  const type = options.type || (receiver ? 'call' : undefined);
729
729
  const results = [];
730
+ const skippedTestFiles = new Set();
730
731
 
731
732
  // Validate type if provided
732
733
  if (type && !STRUCTURAL_TYPES.has(type)) {
@@ -753,7 +754,13 @@ function structuralSearch(index, options = {}) {
753
754
  if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
754
755
  }
755
756
  if ((options.exclude && options.exclude.length > 0) || options.in) {
756
- if (!index.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) return false;
757
+ if (!index.matchesFilters(fileEntry.relativePath, { in: options.in })) return false;
758
+ if (!index.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) {
759
+ if (options.testExclude && !index.matchesFilters(fileEntry.relativePath, { exclude: options.testExclude })) {
760
+ skippedTestFiles.add(fileEntry.relativePath);
761
+ }
762
+ return false;
763
+ }
757
764
  }
758
765
  return true;
759
766
  };
@@ -978,6 +985,7 @@ function structuralSearch(index, options = {}) {
978
985
  }).filter(([, v]) => v !== undefined && v !== null)),
979
986
  totalMatched: total,
980
987
  shown: results.length,
988
+ filesSkipped: skippedTestFiles.size,
981
989
  ...(unused && {
982
990
  unusedScope: 'callable-symbols-only',
983
991
  unusedSafety: 'candidate-only; use deadcode before deletion',
package/core/shared.js CHANGED
@@ -50,15 +50,22 @@ function codeUnitCompare(a, b) {
50
50
  * Path-based test heuristic — matches the same patterns as `find`'s exclusion
51
51
  * logic so that `about` and `find` agree on which files are de-emphasized.
52
52
  *
53
- * Triggers when any of `test|tests|spec|__tests__|__mocks__|fixture|mock`
54
- * appears as a path segment (with word boundaries on both sides).
53
+ * Uses language-specific filename conventions plus `test|tests|__tests__|__mocks__|fixture|mock`
54
+ * as path segments (with word boundaries on both sides). `spec` is a test
55
+ * marker only for languages whose test-file conventions include it.
55
56
  *
56
57
  * Complement to `isTestFile` (filename pattern check) — together they catch
57
58
  * both `foo.test.js` (filename) AND `test/agent-benchmark.js` (directory).
58
59
  */
59
60
  function isTestPath(rp) {
60
61
  if (!rp) return false;
61
- return /(^|[/._-])(test|tests|spec|__tests__|__mocks__|fixture|mock)s?([/._-]|$)/i.test(rp);
62
+ const language = detectLanguage(rp);
63
+ const { langTraits } = require('../languages');
64
+ const specConvention = langTraits(language)?.testFileCandidates?.('sample', '')
65
+ .some(candidate => candidate.includes('.spec'));
66
+ return isTestFile(rp, language) ||
67
+ /(^|[/._-])(test|tests|__tests__|__mocks__|fixture|mock)s?([/._-]|$)/i.test(rp) ||
68
+ (!!specConvention && /(^|[/._-])specs?([/._-]|$)/i.test(rp));
62
69
  }
63
70
 
64
71
  /**
@@ -109,7 +116,9 @@ function pickBestDefinition(matches, opts = {}) {
109
116
  * Returns a new array with test patterns appended (deduplicating).
110
117
  */
111
118
  function addTestExclusions(exclude) {
112
- const testPatterns = ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'];
119
+ // Keep automatic test classification distinct from explicit substring
120
+ // exclusions: --exclude=spec deliberately excludes Python specs too.
121
+ const testPatterns = ['test files'];
113
122
  const existing = new Set((exclude || []).map(e => e.toLowerCase()));
114
123
  const additions = testPatterns.filter(p => !existing.has(p));
115
124
  return [...(exclude || []), ...additions];
package/core/verify.js CHANGED
@@ -247,14 +247,26 @@ function classifyCallContext(callNode, language) {
247
247
  /**
248
248
  * Find a call expression node at the target line matching funcName
249
249
  */
250
- function findCallNode(node, callTypes, targetRow, funcName, occurrence = 0) {
250
+ function findCallNode(node, callTypes, targetRow, funcName, occurrence = 0, site = null) {
251
251
  // Several same-name calls can share one line (`greet("a") + greet("b")`,
252
252
  // f-strings) — fix #231: callers pass the site's per-line ordinal so each
253
253
  // record is arg-checked against ITS OWN node, not the line's first.
254
254
  // Records and this walk are both pre-order, so ordinals align; an
255
255
  // out-of-range ordinal falls back to the first match (never worse than
256
256
  // the pre-fix behavior when a parse shape hides a node).
257
- const matches = _collectCallNodes(node, callTypes, targetRow, funcName, occurrence + 1);
257
+ const matches = _collectCallNodes(node, callTypes, targetRow, funcName, site ? Infinity : occurrence + 1);
258
+ if (Number.isInteger(site?.start)) {
259
+ // The resolver already identified the callee token. Preserve that
260
+ // identity through argument extraction, including aliases and several
261
+ // calls on one line (some of which may target another declaration).
262
+ const exact = matches.filter(candidate => {
263
+ const callee = candidate.childForFieldName('function') ||
264
+ candidate.childForFieldName('name') ||
265
+ candidate.childForFieldName('constructor') || candidate.childForFieldName('type');
266
+ return callee && callee.startIndex <= site.start && callee.endIndex >= site.end;
267
+ });
268
+ if (exact.length === 1) return exact[0];
269
+ }
258
270
  return matches[occurrence] || matches[0] || null;
259
271
  }
260
272
 
@@ -795,8 +807,10 @@ function computePlanCallSites(index, name, def) {
795
807
  content: c.content,
796
808
  usageType: 'call',
797
809
  receiver: c.receiver,
810
+ calledAs: c.calledAs,
811
+ callSite: c.provenance?.facts?.site,
798
812
  };
799
- const siteKey = `${c.file}:${c.line}`;
813
+ const siteKey = `${c.file}:${c.line}:${c.calledAs || name}`;
800
814
  const occurrence = planLineSeen.get(siteKey) || 0;
801
815
  planLineSeen.set(siteKey, occurrence + 1);
802
816
  const analysis = analyzeCallSite(index, call, name, occurrence);
@@ -808,6 +822,8 @@ function computePlanCallSites(index, name, def) {
808
822
  expression: (call.content || '').trim(),
809
823
  args: analysis.args,
810
824
  argCount: analysis.argCount,
825
+ ...(analysis.keywordArgNames && { keywordArgNames: analysis.keywordArgNames }),
826
+ ...(analysis.positionalCount != null && { positionalCount: analysis.positionalCount }),
811
827
  ...(c.calledAs && { calledAs: c.calledAs }),
812
828
  });
813
829
  }
@@ -896,7 +912,9 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
896
912
  const targetRow = call.line - 1; // tree-sitter is 0-indexed
897
913
 
898
914
  // Find the call expression at the target line matching funcName
899
- const callNode = findCallNode(tree.rootNode, callTypes, targetRow, funcName, occurrence);
915
+ const spelling = call.calledAs && call.calledAs !== 'bound' ? call.calledAs : funcName;
916
+ const callNode = findCallNode(tree.rootNode, callTypes, targetRow, spelling, occurrence,
917
+ call.callSite || call.provenance?.facts?.site);
900
918
  if (!callNode) return { args: null, argCount: 0 };
901
919
 
902
920
  // Check if this is a method call (obj.func()) vs a direct call (func())
@@ -1351,6 +1369,8 @@ function verify(index, name, options = {}) {
1351
1369
  content: c.content,
1352
1370
  usageType: 'call',
1353
1371
  receiver: c.receiver,
1372
+ calledAs: c.calledAs,
1373
+ callSite: c.provenance?.facts?.site,
1354
1374
  // Preserve receiver identity through the usage-shaped adapter. Go
1355
1375
  // permits a local value to have the same spelling as its type; only
1356
1376
  // a type-qualified call is a method expression with an explicit
@@ -1392,7 +1412,7 @@ function verify(index, name, options = {}) {
1392
1412
 
1393
1413
  const verifyLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
1394
1414
  for (const call of calls) {
1395
- const siteKey = `${call.file}:${call.line}`;
1415
+ const siteKey = `${call.file}:${call.line}:${call.calledAs || name}`;
1396
1416
  const occurrence = verifyLineSeen.get(siteKey) || 0;
1397
1417
  verifyLineSeen.set(siteKey, occurrence + 1);
1398
1418
  const analysis = analyzeCallSite(index, call, name, occurrence);
@@ -2158,7 +2178,10 @@ function plan(index, name, options = {}) {
2158
2178
  } else if (options.defaultValue) {
2159
2179
  suggestion = `Add argument: ${options.defaultValue} (no default parameter values in ${planFileEntry?.language || 'this language'})`;
2160
2180
  } else {
2161
- suggestion = `Add argument: ${options.addParam}`;
2181
+ const keywordCall = langTraits(planLang)?.keywordArguments && site.keywordArgNames?.length > 0;
2182
+ suggestion = keywordCall
2183
+ ? `Add keyword argument: ${options.addParam}=${options.addParam} (replace the right-hand value with the intended expression; keep existing keyword arguments)`
2184
+ : `Add argument: ${options.addParam}`;
2162
2185
  }
2163
2186
  changes.push({
2164
2187
  file: site.file,
@@ -144,6 +144,7 @@ const LANGUAGES = {
144
144
  traits: {
145
145
  ...STRUCTURAL_TRAITS,
146
146
  selfParam: ['this'],
147
+ storedPromises: true,
147
148
  testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`, `${base}.test.ts`, `${base}.test.js`, `${base}.spec.ts`, `${base}.spec.js`],
148
149
  testDirs: ['__tests__'],
149
150
  },
@@ -157,6 +158,7 @@ const LANGUAGES = {
157
158
  traits: {
158
159
  ...STRUCTURAL_TRAITS,
159
160
  selfParam: ['this'],
161
+ storedPromises: true,
160
162
  testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`, `${base}.test.ts`, `${base}.test.js`, `${base}.spec.ts`, `${base}.spec.js`],
161
163
  testDirs: ['__tests__'],
162
164
  },
@@ -170,6 +172,7 @@ const LANGUAGES = {
170
172
  traits: {
171
173
  ...STRUCTURAL_TRAITS,
172
174
  selfParam: ['this'],
175
+ storedPromises: true,
173
176
  testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`, `${base}.test.ts`, `${base}.test.js`, `${base}.spec.ts`, `${base}.spec.js`],
174
177
  testDirs: ['__tests__'],
175
178
  },
@@ -324,6 +327,7 @@ const LANGUAGES = {
324
327
  traits: {
325
328
  ...STRUCTURAL_TRAITS,
326
329
  selfParam: ['this'],
330
+ storedPromises: true,
327
331
  testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`],
328
332
  },
329
333
  }
@@ -2805,7 +2805,14 @@ function findCallsInCode(code, parser) {
2805
2805
  const argsNode = node.childForFieldName('arguments');
2806
2806
  if (argsNode) {
2807
2807
  for (let i = 0; i < argsNode.namedChildCount; i++) {
2808
- const arg = argsNode.namedChild(i);
2808
+ const rawArg = argsNode.namedChild(i);
2809
+ const arg = rawArg.type === 'keyword_argument'
2810
+ ? rawArg.childForFieldName('value') : rawArg;
2811
+ if (!arg) continue;
2812
+ // Bare keyword values already belong to the reference
2813
+ // inventory/rename path; only extend the member-value
2814
+ // callback model to match its positional counterpart.
2815
+ if (rawArg.type === 'keyword_argument' && arg.type !== 'attribute') continue;
2809
2816
  if (arg.type === 'identifier' && !PYTHON_SKIP.has(arg.text) && !nonCallableNames.has(arg.text)) {
2810
2817
  calls.push({
2811
2818
  name: arg.text,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.8",
3
+ "version": "5.4.1",
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",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "version": "node scripts/sync-server-version.js && git add server.json",
13
- "test": "node --test test/audit-5.3.7.test.js test/audit-5.3.6.test.js test/evidence-provenance.test.js test/provenance-unit.test.js test/shell-output.test.js test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-c-family.test.js test/regression-cross.test.js test/regression-mcp.test.js test/mcp-protocol.test.js test/mcp-sdk-compat.test.js test/dependency-security.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/release-surface-regressions.test.js test/prerelease-audit.test.js test/release-readiness-audit.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/perf-optimizations.test.js test/performance-gate-policy.test.js test/oracle-gate-policy.test.js test/outcome-policy.test.js test/consistency-eval.test.js test/agent-public-surface-benchmark.test.js test/command-contracts.test.js test/language-adapter.test.js test/systematic-test.js test/mcp-edge-cases.js test/conservation.test.js test/parity-test.js test/trust-matrix.test.js",
13
+ "test": "node --test test/audit-5.4.0.test.js test/audit-5.3.8.test.js test/audit-5.3.7.test.js test/audit-5.3.6.test.js test/evidence-provenance.test.js test/provenance-unit.test.js test/shell-output.test.js test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-c-family.test.js test/regression-cross.test.js test/regression-mcp.test.js test/mcp-protocol.test.js test/mcp-sdk-compat.test.js test/dependency-security.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/release-surface-regressions.test.js test/prerelease-audit.test.js test/release-readiness-audit.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/perf-optimizations.test.js test/performance-gate-policy.test.js test/oracle-gate-policy.test.js test/outcome-policy.test.js test/consistency-eval.test.js test/agent-public-surface-benchmark.test.js test/command-contracts.test.js test/language-adapter.test.js test/systematic-test.js test/mcp-edge-cases.js test/conservation.test.js test/parity-test.js test/trust-matrix.test.js",
14
14
  "benchmark:agent": "node test/agent-public-surface-benchmark.js",
15
15
  "benchmark:agent:gate": "node test/agent-public-surface-benchmark.js --gate",
16
16
  "benchmark:agent:legacy": "node test/agent-understanding-benchmark.js",