ucn 5.3.8 → 5.4.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.
@@ -143,8 +143,10 @@ token when it differs from `startLine`). Structural `search --unused` keeps its
143
143
  safety note and decorator tags in shell output; runtime registrations can appear
144
144
  and zero call edges do not prove a symbol is safe to delete.
145
145
  `--lines` lists the whole band without default row/character caps, so pipe through
146
- `grep -v '# unverified'` for the confirmed tier or `cut -d: -f1 | sort | uniq -c`
147
- for callers per file. Nothing to list prints nothing and exits 1, grep's
146
+ `grep -v '# unverified'` for the confirmed tier. To count distinct source lines
147
+ per file, use `cut -d: -f1,2 | sort -u | cut -d: -f1 | sort | uniq -c`;
148
+ counting raw usage records can count a source line more than once.
149
+ Nothing to list prints nothing and exits 1, grep's
148
150
  contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
149
151
  omissions. `show --lines` accepts only callers/callees sections; target-less
150
152
  `impact --lines` lists Git-diff callers with per-target accounting. Closing a
@@ -208,6 +210,30 @@ The selection note discloses that approximation. `find`, text `search`,
208
210
  (structural `search`: 50). Use an explicit `--limit=N` to request more;
209
211
  `usages` and `--lines` have no default row cap.
210
212
 
213
+ Automatic test filtering follows the language's conventions: Python `spec.py`
214
+ and `chart_spec.py` are production paths; `test_*.py`, `*_test.py`, and test
215
+ directories remain test paths. Structural search discloses hidden test files,
216
+ including empty results; `--include-tests` gives the full indexed inventory.
217
+ Explicit `--exclude=spec` still means the requested path exclusion.
218
+
219
+ Public JSON source `file` fields are relative to `meta.pathBase` (the absolute
220
+ project root). Dependency edge paths use the same base. Both absolute and
221
+ relative indexed-file handles are accepted. Definition handles retain their
222
+ decorator span; use `nameLine` for the name token rather than joining usages
223
+ to a handle's start line.
224
+
225
+ Callable references passed to another function remain visible when a project
226
+ method or function could be their target. An ordinary attribute read with no
227
+ callable member candidate stays a non-call reference in ACCOUNT and `usages`.
228
+ The caller model includes callback dependencies; it does not prove that the
229
+ receiving function invokes every passed callable.
230
+
231
+ `audit-async` checks recognized async producers. In JS/TS/HTML it also checks
232
+ captured promises used in arithmetic, conditions, or resolved-value member
233
+ access within the same lexical scope. Awaiting, returning, promise handlers,
234
+ reassignment, and shadowed bindings are distinguished. It is a bounded AST
235
+ audit, not a compiler-wide proof that every missing await has been found.
236
+
211
237
  For `plan --rename-to`, the selected declaration is only the starting point.
212
238
  When the index proves the relationship, the rename unit closes over
213
239
  overload/signature groups, base and override declarations, Rust trait slots,
@@ -57,6 +57,12 @@ Structural `search --param` matches parameter names, types, and defaults; `--ret
57
57
 
58
58
  `--lines` writes one record per output line. `usages` records occurrences, so multiple tokens on the same source line can produce repeated `path:line` values. Deduplicate those values when counting source lines.
59
59
 
60
+ Public JSON source paths (`file`, caller files, dependency roots and edges) are project-relative, with the absolute base in `meta.pathBase`. Project roots and external paths remain absolute. Indexed absolute handles are accepted as well as relative handles.
61
+
62
+ Default test exclusions follow language conventions. Python `spec.py` and `*_spec.py` are included; `test_*.py`, `*_test.py`, and test directories are excluded. Structural search reports hidden test-file counts, including on empty results. `--include-tests` disables these defaults; explicit `--exclude` patterns still apply.
63
+
64
+ `audit-async` checks recognized async producers, including captured JS/TS/HTML promises used as resolved values in the same lexical scope. Promise returns and handlers are valid; alias flow and unknown receivers require compiler/type-checker review.
65
+
60
66
  ## Common flags
61
67
 
62
68
  | Flag | Meaning |
package/core/account.js CHANGED
@@ -301,6 +301,10 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
301
301
  const callLines = new Set();
302
302
  if (Array.isArray(cachedCalls)) {
303
303
  for (const c of cachedCalls) {
304
+ // Unclaimed callback/type references have no invocation
305
+ // syntax. Let the usage AST classify them as references;
306
+ // merely entering the candidate cache is not a call fact.
307
+ if (c.isFunctionReference || c.isTypeReference) continue;
304
308
  if (c.name === name || c.resolvedName === name ||
305
309
  (c.resolvedNames && c.resolvedNames.includes(name))) {
306
310
  callLines.add(c.line);
package/core/analysis.js CHANGED
@@ -2656,6 +2656,78 @@ const _ASYNCIO_CONSUMER_FNS = new Set([
2656
2656
  'gather', 'create_task', 'ensure_future', 'wait', 'as_completed',
2657
2657
  ]);
2658
2658
 
2659
+ // Follow a captured JS promise within its lexical scope, looking only for
2660
+ // operations that require its resolved value. Passing/returning/awaiting the
2661
+ // promise and its own methods are valid. Reassignment stops the inference;
2662
+ // nested functions and shadowing blocks cannot borrow the outer binding.
2663
+ function storedPromiseMisuse(call, functionNodes) {
2664
+ const { sameNode } = require('../languages/utils');
2665
+ let value = call;
2666
+ while (value.parent?.type === 'parenthesized_expression') value = value.parent;
2667
+ const assignment = value.parent;
2668
+ if (!assignment || !['variable_declarator', 'assignment_expression'].includes(assignment.type)) return null;
2669
+ const binding = assignment.childForFieldName(assignment.type === 'variable_declarator' ? 'name' : 'left');
2670
+ if (binding?.type !== 'identifier') return null;
2671
+ let scope = assignment.parent;
2672
+ while (scope && scope.type !== 'statement_block' && !functionNodes.has(scope.type)) scope = scope.parent;
2673
+ if (!scope) return null;
2674
+ const name = binding.text;
2675
+ const promiseMembers = new Set(['then', 'catch', 'finally', 'constructor',
2676
+ 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable']);
2677
+ let stopped = false;
2678
+ let misuse = null;
2679
+ const namesBinding = node => !!node && ((['identifier', 'shorthand_property_identifier_pattern'].includes(node.type) && node.text === name) ||
2680
+ node.namedChildren.some(namesBinding));
2681
+ const shadows = block => (block.namedChildren || []).some(statement =>
2682
+ ['lexical_declaration', 'variable_declaration'].includes(statement.type) &&
2683
+ statement.namedChildren.some(decl => namesBinding(decl.childForFieldName('name'))));
2684
+ const visit = node => {
2685
+ if (stopped || misuse || node.endIndex <= call.endIndex) return;
2686
+ if (functionNodes.has(node.type)) return;
2687
+ if (!sameNode(node, scope) && node.type === 'statement_block' && shadows(node)) return;
2688
+ if (node.type === 'catch_clause' && namesBinding(node.childForFieldName('parameter'))) return;
2689
+ if (node.type === 'for_statement' && shadows(node)) return;
2690
+ if (node.type === 'for_in_statement' && namesBinding(node.childForFieldName('left'))) {
2691
+ // A declared loop variable shadows; an undeclared one overwrites
2692
+ // the promise, so later uses cannot inherit its earlier type.
2693
+ if (!node.children.some(child => ['let', 'const'].includes(child.type))) stopped = true;
2694
+ return;
2695
+ }
2696
+ if (node.type === 'assignment_expression' && !sameNode(node, assignment) && node.childForFieldName('left')?.text === name) {
2697
+ const right = node.childForFieldName('right');
2698
+ if (right) visit(right);
2699
+ stopped = true;
2700
+ return;
2701
+ }
2702
+ if (node.type === 'identifier' && node.text === name && node.startIndex >= call.endIndex) {
2703
+ let use = node;
2704
+ while (use.parent?.type === 'parenthesized_expression') use = use.parent;
2705
+ const parent = use.parent;
2706
+ if (parent?.type === 'member_expression' && sameNode(parent.childForFieldName('object'), use)) {
2707
+ const property = parent.childForFieldName('property');
2708
+ if (property && !promiseMembers.has(property.text)) misuse = node;
2709
+ } else if (parent?.type === 'subscript_expression' && sameNode(parent.childForFieldName('object'), use)) {
2710
+ misuse = node;
2711
+ } else if (parent?.type === 'binary_expression') {
2712
+ const operator = parent.childForFieldName('operator')?.text;
2713
+ if (['+', '-', '*', '/', '%', '**', '<', '>', '<=', '>=', '|', '&', '^', '<<', '>>', '>>>'].includes(operator)) misuse = node;
2714
+ } else if (parent?.type === 'unary_expression' && ['+', '-', '~'].includes(parent.childForFieldName('operator')?.text)) {
2715
+ misuse = node;
2716
+ } else if (parent && ['update_expression', 'augmented_assignment_expression'].includes(parent.type)) {
2717
+ misuse = node;
2718
+ } else if (parent && ['if_statement', 'while_statement', 'do_statement', 'ternary_expression'].includes(parent.type) &&
2719
+ sameNode(parent.childForFieldName('condition'), use)) {
2720
+ misuse = node;
2721
+ }
2722
+ }
2723
+ for (const child of node.namedChildren || []) visit(child);
2724
+ };
2725
+ // A function root's body is the scan scope, never its parameters.
2726
+ visit(scope.type === 'statement_block' ? scope : scope.childForFieldName('body') || scope);
2727
+ return misuse ? { line: misuse.startPosition.row + 1, variable: name,
2728
+ originLine: call.startPosition.row + 1, reason: 'stored-promise-used-as-value' } : null;
2729
+ }
2730
+
2659
2731
  /**
2660
2732
  * Run an async/await audit across the project.
2661
2733
  *
@@ -3012,6 +3084,10 @@ function auditAsync(index, options = {}) {
3012
3084
  let current = node.parent;
3013
3085
  let awaitDepth = 0;
3014
3086
  while (current && awaitDepth++ < 5) {
3087
+ if (current.type === 'parenthesized_expression') {
3088
+ current = current.parent;
3089
+ continue;
3090
+ }
3015
3091
  if (current.type === 'await_expression' ||
3016
3092
  current.type === 'await') {
3017
3093
  awaited = true;
@@ -3034,12 +3110,15 @@ function auditAsync(index, options = {}) {
3034
3110
  }
3035
3111
  break;
3036
3112
  }
3037
- if (!awaited && !isFireAndForget(node, language)) {
3113
+ const storedMisuse = !awaited && langTraits(language)?.storedPromises
3114
+ ? storedPromiseMisuse(node, FN_NODE_TYPES) : null;
3115
+ if (storedMisuse || (!awaited && !isFireAndForget(node, language))) {
3038
3116
  issues.push({
3039
3117
  file: fileEntry.relativePath || filePath,
3040
3118
  line,
3041
3119
  callerName: enclosing.name,
3042
3120
  calleeName,
3121
+ ...(storedMisuse || {}),
3043
3122
  });
3044
3123
  }
3045
3124
  }
package/core/cache.js CHANGED
@@ -713,7 +713,8 @@ function clearAllCaches() {
713
713
  // v225 (fix #357): Rust `use path::name as local` bindings record the original name with a paired `renames` alias.
714
714
  // v226: bundled/minified filename exclusions are disclosed in discoveryIssues.
715
715
  // v227: signature parameter/return text excludes AST comments in every language.
716
- const CACHE_FORMAT_VERSION = 227;
716
+ // v228: Python keyword arguments retain the same callable-reference facts as positional arguments.
717
+ const CACHE_FORMAT_VERSION = 228;
717
718
  const USAGE_CACHE_FILE = 'usage-results.json';
718
719
 
719
720
  /**
package/core/callers.js CHANGED
@@ -291,6 +291,22 @@ function _javaConstructorDisposition(index, filePath, fileEntry, call, targetDef
291
291
  return 'unknown';
292
292
  }
293
293
 
294
+ // A structural member passed as a value is not invocation syntax. Keep it
295
+ // in the callback model only when a project member can actually be callable;
296
+ // an unrelated standalone function's spelling is not such evidence. Module
297
+ // members remain eligible because modules can export standalone functions.
298
+ function isDataMemberReference(fileEntry, call, definitions) {
299
+ if (!call.isFunctionReference || !call.isMethod || call.receiverIsModule ||
300
+ call.receiverModuleSpecifier || langTraits(fileEntry?.language)?.typeSystem !== 'structural') return false;
301
+ if (_structuralModuleBindings(fileEntry, call).length > 0) return false;
302
+ return !definitions.some(def => !NON_CALLABLE_TYPES.has(def.type) &&
303
+ // A typed Python descriptor read really invokes its getter; retain
304
+ // that existing proof path. An untyped property spelling belongs to
305
+ // the separate property-access inventory, never the call band.
306
+ (!require('./accessors').isAccessorDefinition(def) || call.receiverType) &&
307
+ (def.className || def.receiver));
308
+ }
309
+
294
310
  /**
295
311
  * Find all call sites that invoke the named symbol.
296
312
  *
@@ -697,6 +713,7 @@ function findCallers(index, name, options = {}) {
697
713
  langTraits(fileEntry.language)?.typeSystem === 'structural';
698
714
 
699
715
  for (let call of calls) {
716
+ if (isDataMemberReference(fileEntry, call, options.targetDefinitions || definitions)) continue;
700
717
  // fix #353: C# `Beta.Helper.Widget()` — the parser records a
701
718
  // field hop rooted at `this` (Beta is no local). When the
702
719
  // prefix names a project NAMESPACE that declares the last
@@ -5444,7 +5461,8 @@ function findCallees(index, definition, options = {}) {
5444
5461
  // to this definition's source range. Nested closures deliberately
5445
5462
  // remain in the slice and retain the existing inner-symbol rules.
5446
5463
  const calls = _callsInDefinitionRange(index, def.file, allCalls,
5447
- def.startLine, def.endLine);
5464
+ def.startLine, def.endLine).filter(call => !isDataMemberReference(
5465
+ index.files.get(def.file), call, index.symbols.get(call.name) || []));
5448
5466
  // The reachability walk uses the legacy (non-accounting) path and most
5449
5467
  // entry/test symbols contain no calls. Avoid constructing receiver,
5450
5468
  // overload, and flow machinery for an empty source range. Contract
@@ -5545,7 +5563,8 @@ function findCallees(index, definition, options = {}) {
5545
5563
  if (!entry) {
5546
5564
  const defs = index.symbols.get(call.name) || [];
5547
5565
  const owners = defs.filter(s => !NON_CALLABLE_TYPES.has(s.type)).length;
5548
- entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners, ...meta };
5566
+ entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners,
5567
+ ...(call.isFunctionReference && { functionReference: true }), ...meta };
5549
5568
  unverifiedCallees.set(key, entry);
5550
5569
  }
5551
5570
  entry.callCount++;
package/core/execute.js CHANGED
@@ -1462,6 +1462,7 @@ const HANDLERS = {
1462
1462
  unused: p.unused || false,
1463
1463
  caseSensitive: p.caseSensitive || false,
1464
1464
  exclude,
1465
+ testExclude: p.includeTests ? undefined : ['test files'],
1465
1466
  in: p.in,
1466
1467
  file: p.file,
1467
1468
  top: topVal || (p.lines ? undefined : 50),
@@ -1470,13 +1471,15 @@ const HANDLERS = {
1470
1471
  const unsupported = (!p.regex && (p.term || p.name))
1471
1472
  ? require('./account').scanUnsupportedFiles(index, p.term || p.name)
1472
1473
  : null;
1473
- let note;
1474
+ let note = result.meta.filesSkipped > 0
1475
+ ? `${result.meta.filesSkipped} test file(s) hidden by default (--include-tests).`
1476
+ : undefined;
1474
1477
  if (unsupported?.lines > 0) {
1475
1478
  Object.defineProperty(result, 'unsupportedMatches', {
1476
1479
  value: unsupported,
1477
1480
  enumerable: false, writable: true, configurable: true,
1478
1481
  });
1479
- note = `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep.`;
1482
+ note = combineNotes([note, `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep.`]);
1480
1483
  }
1481
1484
  return { ok: true, result, structural: true, note };
1482
1485
  }
@@ -2506,6 +2509,18 @@ function execute(index, command, params = {}) {
2506
2509
  const validationError = validatePublicParams(command, params);
2507
2510
  if (validationError) return { ok: false, error: validationError };
2508
2511
  }
2512
+ // Public JSON paths and pasted stack frames may be absolute. Resolve
2513
+ // only indexed files, then use the same relative scope as our handles.
2514
+ const relativeIndexedFile = file => {
2515
+ if (!file || !path.isAbsolute(file)) return file;
2516
+ const resolved = index.resolveFilePathForQuery(file);
2517
+ return typeof resolved === 'string' ? index.files.get(resolved).relativePath : file;
2518
+ };
2519
+ if (params.file) params.file = relativeIndexedFile(params.file);
2520
+ const absoluteHandle = params.name && parseSymbolHandle(params.name);
2521
+ if (absoluteHandle && path.isAbsolute(absoluteHandle.file)) {
2522
+ params.name = relativeIndexedFile(absoluteHandle.file) + params.name.slice(absoluteHandle.file.length);
2523
+ }
2509
2524
  // Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
2510
2525
  // Handles WITH a name suffix are handled later by applyClassMethodSyntax.
2511
2526
  if (params && params.name && looksLikeHandle(params.name)) {
@@ -2520,6 +2535,7 @@ function execute(index, command, params = {}) {
2520
2535
  }
2521
2536
  }
2522
2537
  const response = handler(index, params);
2538
+ response.projectRoot = index.root;
2523
2539
  const bundled = (index.discoveryIssues || []).filter(issue => issue.reason === 'bundled');
2524
2540
  if (bundled.length > 0) {
2525
2541
  const files = bundled.slice(0, 5).map(issue => issue.relativePath).join(', ');
@@ -205,7 +205,7 @@ function formatCalleeAccountLine(acct) {
205
205
  function unverifiedCalleeLines(entries, compact) {
206
206
  if (!entries || entries.length === 0) return [];
207
207
  const lines = [];
208
- lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call syntax, receiver/binding unresolved:`);
208
+ lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call or callable-reference syntax, receiver/binding unresolved:`);
209
209
  for (const u of entries) {
210
210
  const owners = u.ownerCount > 1 ? ` (${u.ownerCount} owners)` : '';
211
211
  const sites = u.sites && u.sites.length > 0 ? ` L${u.sites.join(',L')}` : '';
@@ -461,7 +461,7 @@ function formatContext(ctx, options = {}) {
461
461
 
462
462
  const typeUnverified = ctx.unverifiedCallers || [];
463
463
  if (typeUnverified.length > 0) {
464
- lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call syntax, no binding/receiver evidence:`);
464
+ lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
465
465
  formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
466
466
  const cap = 10;
467
467
  let shown = 0;
@@ -688,7 +688,7 @@ function formatContext(ctx, options = {}) {
688
688
  // Actionable ambiguity: call syntax without enough identity evidence.
689
689
  // Always visible and capped at 10 one-liners unless --all.
690
690
  if (actionableUnverified.length > 0) {
691
- lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call syntax, no binding/receiver evidence:`);
691
+ lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
692
692
  formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
693
693
  const cap = (ctx.meta && ctx.meta.all) ? Infinity : 10;
694
694
  let shown = 0;
@@ -927,7 +927,7 @@ function formatImpact(impact, options = {}) {
927
927
 
928
928
  // Unverified tier: visible, capped at 10 one-liners
929
929
  if (impactUnverified.length > 0) {
930
- lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
930
+ lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
931
931
  const cap = 10;
932
932
  for (const site of impactUnverified.slice(0, cap)) {
933
933
  const caller = site.callerName ? ` [${site.callerName}]` : '';
@@ -1085,7 +1085,7 @@ function formatAbout(about, options = {}) {
1085
1085
  const aboutUnverified = about.callers.unverified;
1086
1086
  if (aboutUnverified && aboutUnverified.total > 0) {
1087
1087
  lines.push('');
1088
- lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call syntax, no binding/receiver evidence:`);
1088
+ lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call or callable-reference syntax, no binding/receiver evidence:`);
1089
1089
  for (const u of aboutUnverified.top) {
1090
1090
  const caller = u.callerName ? ` [${u.callerName}]` : '';
1091
1091
  const reason = u.reason ? ` (${unverifiedReasonLabel(u)})` : '';
@@ -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');
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/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
@@ -808,6 +808,8 @@ function computePlanCallSites(index, name, def) {
808
808
  expression: (call.content || '').trim(),
809
809
  args: analysis.args,
810
810
  argCount: analysis.argCount,
811
+ ...(analysis.keywordArgNames && { keywordArgNames: analysis.keywordArgNames }),
812
+ ...(analysis.positionalCount != null && { positionalCount: analysis.positionalCount }),
811
813
  ...(c.calledAs && { calledAs: c.calledAs }),
812
814
  });
813
815
  }
@@ -2158,7 +2160,10 @@ function plan(index, name, options = {}) {
2158
2160
  } else if (options.defaultValue) {
2159
2161
  suggestion = `Add argument: ${options.defaultValue} (no default parameter values in ${planFileEntry?.language || 'this language'})`;
2160
2162
  } else {
2161
- suggestion = `Add argument: ${options.addParam}`;
2163
+ const keywordCall = langTraits(planLang)?.keywordArguments && site.keywordArgNames?.length > 0;
2164
+ suggestion = keywordCall
2165
+ ? `Add keyword argument: ${options.addParam}=${options.addParam} (replace the right-hand value with the intended expression; keep existing keyword arguments)`
2166
+ : `Add argument: ${options.addParam}`;
2162
2167
  }
2163
2168
  changes.push({
2164
2169
  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.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",
@@ -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.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",