ucn 5.3.7 → 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.
@@ -126,7 +126,7 @@ answers:
126
126
  ucn find handleRequest --lines # path:line:signature # kind
127
127
  ucn show handleRequest --lines # callers as path:line:text; unverified ones end in "\t# unverified: <reason>"
128
128
  ucn show handleRequest --lines --sections=callees
129
- ucn usages handleRequest --lines # every literal-name line; non-call kinds tagged "# import" / "# definition"
129
+ ucn usages handleRequest --lines # every literal-name occurrence; non-call kinds tagged "# import" / "# definition"
130
130
  ucn search 'retry(' --lines # grep -n output, code-aware scope
131
131
  ucn impact handleRequest --lines
132
132
  ucn source handleRequest --raw # the code and nothing else, ready for an exact-string edit
@@ -136,9 +136,17 @@ ucn source src/server.js:40-80 --raw
136
136
  Records go to stdout; the `ACCOUNT` / `CONTRACT` lines, notes, and the
137
137
  same-name disambiguation go to stderr prefixed `# ` (MCP keeps them in the one
138
138
  text block, and `--raw` appends its note as one trailing `# ` line there).
139
+ `usages` emits one record per occurrence, so a source line may repeat; deduplicate
140
+ `path:line` values for line counts. Definition handles start at decorators when
141
+ present, while usages point at token lines (`nameLine` identifies the declaration
142
+ token when it differs from `startLine`). Structural `search --unused` keeps its
143
+ safety note and decorator tags in shell output; runtime registrations can appear
144
+ and zero call edges do not prove a symbol is safe to delete.
139
145
  `--lines` lists the whole band without default row/character caps, so pipe through
140
- `grep -v '# unverified'` for the confirmed tier or `cut -d: -f1 | sort | uniq -c`
141
- 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
142
150
  contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
143
151
  omissions. `show --lines` accepts only callers/callees sections; target-less
144
152
  `impact --lines` lists Git-diff callers with per-target accounting. Closing a
@@ -202,6 +210,30 @@ The selection note discloses that approximation. `find`, text `search`,
202
210
  (structural `search`: 50). Use an explicit `--limit=N` to request more;
203
211
  `usages` and `--lines` have no default row cap.
204
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
+
205
237
  For `plan --rename-to`, the selected declaration is only the starting point.
206
238
  When the index proves the relationship, the rename unit closes over
207
239
  overload/signature groups, base and override declarations, Rust trait slots,
@@ -33,7 +33,7 @@ structural or code-only search.
33
33
  |---|---|
34
34
  | `repo` | Repository orientation. Select `summary,files,stats,health` with `--sections`; `--deep` includes readiness evidence. Skipped unsupported source is listed with a grep/language-tool handoff. |
35
35
  | `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. Cycles distinguish eager edges from function-local, Python typing-guarded, and TypeScript type-only edges. Complete cycle groups remain visible when enumeration is capped. |
36
- | `api [file]` | Static exported/public surface for a project or file. |
36
+ | `api [file]` | Static exported/public surface for a project or file. An exact file includes tests; broader scans exclude tests with a count. Use `--include-tests` to include them. |
37
37
  | `entrypoints` | Framework, route, task, test, and runtime entry points. |
38
38
  | `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. |
39
39
 
@@ -49,6 +49,20 @@ structural or code-only search.
49
49
 
50
50
  Symbol-listing commands emit handles such as `src/api.ts:42:handler`. Pass the full handle to symbol commands. `path:line` also works. Handles prevent same-named definitions from being silently combined.
51
51
 
52
+ Definition handles and source spans start at the first decorator or annotation when present; literal usages point at the actual token line. Use a symbol's `nameLine` (when present, otherwise `startLine`) to compare declaration tokens with usages.
53
+
54
+ Structural `search --param` matches parameter names, types, and defaults; `--returns` matches return annotations. Both exclude AST comments and preserve string contents. `--unused` lists callable symbols without call edges, not safe-delete candidates; decorated runtime registrations may still appear. Its safety note and decorator tags are retained in `--lines` output. Use `deadcode` and `usages` before deletion.
55
+
56
+ `repo` summary/stats `buildTime` is the duration of the last index build (discovery, parsing, and graphs), retained in the cache. It excludes cache loading/saving and query execution, so it is not command wall time; `buildTimeNote` states this boundary.
57
+
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
+
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
+
52
66
  ## Common flags
53
67
 
54
68
  | Flag | Meaning |
package/cli/index.js CHANGED
@@ -917,7 +917,8 @@ Commands:
917
917
  deps <file> File graph; --direction=imports|importers|both
918
918
  --detailed Include import declarations
919
919
  deps --cycles Report circular dependencies (no file target)
920
- api [file] Project or file public API
920
+ api [file] Project or file public API (exact files include tests;
921
+ broader scans exclude tests unless --include-tests)
921
922
  check [symbol] Signature check; without symbol, precommit check
922
923
  plan <symbol> Preview rename or parameter edits
923
924
  entrypoints Runtime and framework entry points
@@ -933,7 +934,8 @@ Common flags:
933
934
  --base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
934
935
  --max-chars=N (text output; default 10K targeted / 3K broad, ceiling 100K)
935
936
  --lines find/usages/search/show/impact: grep -n shape, one path:line:text
936
- record per line (tags after a tab: # unverified: <reason>, # import,
937
+ record per output line; usages may repeat a source line per occurrence
938
+ (tags after a tab: # unverified: <reason>, # import,
937
939
  # callee); accounting and notes go to stderr as "# " lines; exit 1
938
940
  when nothing matched; exit 2 on errors. No default result cap.
939
941
  show defaults to callers; --sections=callers,callees selects bands.
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
@@ -712,7 +712,9 @@ function clearAllCaches() {
712
712
  // copied bindings and qualified macro receivers; TS indexed array evidence.
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
- const CACHE_FORMAT_VERSION = 226;
715
+ // v227: signature parameter/return text excludes AST comments in every language.
716
+ // v228: Python keyword arguments retain the same callable-reference facts as positional arguments.
717
+ const CACHE_FORMAT_VERSION = 228;
716
718
  const USAGE_CACHE_FILE = 'usage-results.json';
717
719
 
718
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
  }
@@ -2330,7 +2333,7 @@ const HANDLERS = {
2330
2333
  },
2331
2334
 
2332
2335
  api: (index, p) => {
2333
- if (p.file) {
2336
+ if (p.file && typeof index.resolveFilePathForQuery(p.file) !== 'string') {
2334
2337
  const fileErr = checkFilePatternMatch(index, p.file);
2335
2338
  if (fileErr) return { ok: false, error: fileErr };
2336
2339
  }
@@ -2346,7 +2349,7 @@ const HANDLERS = {
2346
2349
  return { ok: false, error: `No files matched the 'in' directory filter '${p.in}'.` };
2347
2350
  }
2348
2351
  }
2349
- let result = index.api(p.file, { in: p.in });
2352
+ let result = index.api(p.file, { in: p.in, includeTests: p.includeTests });
2350
2353
  if (p.file) {
2351
2354
  const fileErr = checkFileError(result, p.file, index);
2352
2355
  if (fileErr) return { ok: false, error: fileErr };
@@ -2374,6 +2377,10 @@ const HANDLERS = {
2374
2377
  }
2375
2378
  result = items;
2376
2379
  }
2380
+ if (result.apiInfo?.excludedTestFiles > 0) {
2381
+ const excluded = `${result.apiInfo.excludedTestFiles} test file(s) excluded from API; use --include-tests to include them, or name an exact file.`;
2382
+ note = note ? `${note}\n${excluded}` : excluded;
2383
+ }
2377
2384
  return { ok: true, result, note };
2378
2385
  },
2379
2386
 
@@ -2502,6 +2509,18 @@ function execute(index, command, params = {}) {
2502
2509
  const validationError = validatePublicParams(command, params);
2503
2510
  if (validationError) return { ok: false, error: validationError };
2504
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
+ }
2505
2524
  // Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
2506
2525
  // Handles WITH a name suffix are handled later by applyClassMethodSyntax.
2507
2526
  if (params && params.name && looksLikeHandle(params.name)) {
@@ -2516,6 +2535,7 @@ function execute(index, command, params = {}) {
2516
2535
  }
2517
2536
  }
2518
2537
  const response = handler(index, params);
2538
+ response.projectRoot = index.root;
2519
2539
  const bundled = (index.discoveryIssues || []).filter(issue => issue.reason === 'bundled');
2520
2540
  if (bundled.length > 0) {
2521
2541
  const files = bundled.slice(0, 5).map(issue => issue.relativePath).join(', ');
package/core/graph.js CHANGED
@@ -564,6 +564,8 @@ function api(index, filePath, options = {}) {
564
564
  const results = [];
565
565
  let scopedFiles = 0;
566
566
  let pythonImplicitFiles = 0;
567
+ let excludedTestFiles = 0;
568
+ let explicitFile = false;
567
569
 
568
570
  let fileIterator;
569
571
  if (filePath) {
@@ -573,6 +575,7 @@ function api(index, filePath, options = {}) {
573
575
  const fileEntry = index.files.get(resolved);
574
576
  if (!fileEntry) return { error: 'file-not-found', filePath };
575
577
  fileIterator = [[resolved, fileEntry]];
578
+ explicitFile = true;
576
579
  } else {
577
580
  // Fall back to pattern filter (substring match on relative path)
578
581
  const matches = [];
@@ -596,7 +599,8 @@ function api(index, filePath, options = {}) {
596
599
  }
597
600
 
598
601
  // Skip test files by default (test classes aren't part of public API)
599
- if (!options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
602
+ if (!explicitFile && !options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
603
+ excludedTestFiles++;
600
604
  continue;
601
605
  }
602
606
  scopedFiles++;
@@ -612,7 +616,7 @@ function api(index, filePath, options = {}) {
612
616
  results.sort((a, b) => codeUnitCompare(a.file, b.file) ||
613
617
  (a.startLine - b.startLine) || codeUnitCompare(a.name, b.name));
614
618
  Object.defineProperty(results, 'apiInfo', {
615
- value: { scopedFiles, pythonImplicitFiles },
619
+ value: { scopedFiles, pythonImplicitFiles, excludedTestFiles },
616
620
  enumerable: false, writable: true, configurable: true,
617
621
  });
618
622
  return results;
@@ -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)})` : '';
@@ -33,7 +33,7 @@ function formatDoctor(result, options = {}) {
33
33
  // Cache state
34
34
  if (result.cache) {
35
35
  const state = result.cache.fresh === true ? 'fresh' : result.cache.fresh === false ? 'stale' : 'unknown';
36
- const buildHint = result.cache.buildMs ? `, ${result.cache.buildMs}ms build` : '';
36
+ const buildHint = result.cache.buildMs ? `, ${result.cache.buildMs}ms last index build (excludes cache I/O and query execution)` : '';
37
37
  lines.push(`Cache: ${state}${buildHint}`);
38
38
  }
39
39
  if (result.commandTrust) {
@@ -29,7 +29,8 @@ function record(pathLike, line, text, tag = '') {
29
29
  // Keep unusual filenames from becoming notes or extra physical records.
30
30
  let file = String(pathLike).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
31
31
  if (file.startsWith('# ')) file = './' + file;
32
- return `${file}:${line == null ? 0 : line}:${body}${tag ? `\t# ${tag}` : ''}`;
32
+ const tagText = String(tag || '').replace(/\s+/g, ' ').trim();
33
+ return `${file}:${line == null ? 0 : line}:${body}${tagText ? `\t# ${tagText}` : ''}`;
33
34
  }
34
35
 
35
36
  function commentLines(text) {
@@ -95,9 +96,14 @@ function searchRecords(result) {
95
96
  if (result && !Array.isArray(result) && Array.isArray(result.results)) {
96
97
  for (const item of result.results) {
97
98
  const text = item.params != null ? `${item.name}(${item.params})` : item.name;
98
- out.push(record(item.file, item.line, text, item.kind || item.type));
99
+ const decorators = (item.decorators || []).map(d => `@${String(d).replace(/^@/, '')}`).join(', ');
100
+ const tag = [item.kind || item.type, decorators].filter(Boolean).join('; ');
101
+ out.push(record(item.file, item.line, text, tag));
99
102
  }
100
103
  const meta = result.meta;
104
+ if (meta?.query?.unused) {
105
+ notes.push(...commentLines(require('./search').unusedSearchNote()));
106
+ }
101
107
  if (meta && meta.totalMatched > meta.shown) {
102
108
  notes.push(`# ${meta.totalMatched - meta.shown} more match(es) (--limit=N / --all)`);
103
109
  }
@@ -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
  }
@@ -424,6 +432,9 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
424
432
  if (result.meta.truncatedMatches > 0) commandMeta.truncated = true;
425
433
  if (result.unsupportedMatches) commandMeta.unsupportedMatches = result.unsupportedMatches;
426
434
  }
435
+ if (command === 'api' && result?.apiInfo) {
436
+ commandMeta.apiInfo = result.apiInfo;
437
+ }
427
438
  if (command === 'entrypoints' && result?.filterInfo) {
428
439
  commandMeta.hiddenTestEntrypoints = result.filterInfo.hiddenTests;
429
440
  commandMeta.testsIncluded = result.filterInfo.testsIncluded;
@@ -471,12 +482,13 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
471
482
  ...(data && data.ok === false && { ok: false }),
472
483
  ...(modeOf(command, result) && { mode: modeOf(command, result) }),
473
484
  contract: contractMeta(command),
485
+ ...(execution.projectRoot && { pathBase: execution.projectRoot }),
474
486
  ...commandMeta,
475
487
  ...(execution.note && { note: execution.note }),
476
488
  },
477
489
  data,
478
490
  };
479
- return JSON.stringify(canonicalJsonValue(envelope), null, 2);
491
+ return JSON.stringify(canonicalJsonValue(envelope, execution.projectRoot), null, 2);
480
492
  }
481
493
 
482
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');
@@ -125,7 +125,7 @@ function formatStats(stats, options = {}) {
125
125
  lines.push(`Files: ${stats.files}`);
126
126
  }
127
127
  lines.push(`Symbols: ${stats.symbols}`);
128
- lines.push(`Build time: ${stats.buildTime}ms`);
128
+ lines.push(`Last index build: ${stats.buildTime}ms (excludes cache I/O and query execution; reused from cache)`);
129
129
 
130
130
  lines.push('\nBy Language:');
131
131
  for (const [lang, info] of Object.entries(stats.byLanguage)) {
@@ -87,6 +87,10 @@ function formatSearchJson(results, term) {
87
87
  /**
88
88
  * Format structural search results (index-based queries)
89
89
  */
90
+ function unusedSearchNote(flag = '--unused') {
91
+ return `${flag} lists callable symbols with no resolved call edge; it does not assess type/field/reference liveness and is not safe-delete proof. Confirm with deadcode and usages.`;
92
+ }
93
+
90
94
  function formatStructuralSearch(result, options = {}) {
91
95
  const { results, meta } = result;
92
96
  const lines = [];
@@ -106,7 +110,7 @@ function formatStructuralSearch(result, options = {}) {
106
110
  lines.push(`Structural search: ${queryStr}`);
107
111
  lines.push('═'.repeat(60));
108
112
  if (meta.query.unused) {
109
- lines.push(`NOTE: ${options.unusedFlag || '--unused'} lists callable symbols with no resolved call edge; it does not assess type/field/reference liveness and is not safe-delete proof. Confirm with deadcode and usages.`);
113
+ lines.push(`NOTE: ${unusedSearchNote(options.unusedFlag)}`);
110
114
  lines.push('');
111
115
  }
112
116
 
@@ -447,6 +451,7 @@ function formatTestsJson(tests, name) {
447
451
  }
448
452
 
449
453
  module.exports = {
454
+ unusedSearchNote,
450
455
  formatSearch,
451
456
  formatSearchJson,
452
457
  formatStructuralSearch,
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
@@ -143,7 +143,7 @@ const FLAG_APPLICABILITY = {
143
143
  impact: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'top', 'unreachableOnly', 'compact', 'base', 'staged', 'limit', 'all', 'lines'],
144
144
  tests: ['name', 'file', 'exclude', 'className', 'line', 'callsOnly', 'depth', 'includeMethods', 'all'],
145
145
  deps: ['file', 'exclude', 'depth', 'direction', 'all', 'detailed', 'cycles'],
146
- api: ['file', 'in', 'limit'],
146
+ api: ['file', 'in', 'limit', 'includeTests'],
147
147
  check: ['name', 'file', 'className', 'line', 'includeMethods', 'base', 'staged', 'limit'],
148
148
  plan: ['name', 'file', 'className', 'line', 'addParam', 'removeParam', 'renameTo', 'defaultValue'],
149
149
  repo: ['file', 'exclude', 'top', 'limit', 'all', 'detailed', 'topLevel', 'in', 'functions', 'hot', 'deep', 'sections'],