ucn 5.5.1 → 5.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -9
  2. package/.claude/skills/ucn/references/commands.md +40 -18
  3. package/.claude/skills/ucn/references/resolution-rules.md +126 -20
  4. package/.claude/skills/ucn/references/trust-contract.md +5 -3
  5. package/README.md +32 -21
  6. package/cli/index.js +2 -1
  7. package/core/accessors.js +341 -54
  8. package/core/account.js +74 -20
  9. package/core/analysis.js +257 -125
  10. package/core/ast-analysis.js +12 -3
  11. package/core/bridge.js +15 -65
  12. package/core/brief.js +8 -4
  13. package/core/build-worker.js +2 -1
  14. package/core/cache.js +137 -6
  15. package/core/call-name-cache.js +134 -0
  16. package/core/callable-scope.js +21 -0
  17. package/core/callers.js +4726 -940
  18. package/core/check.js +22 -5
  19. package/core/class-identity.js +399 -12
  20. package/core/client-receivers.js +189 -0
  21. package/core/constructor-calls.js +831 -0
  22. package/core/constructor-hierarchy.js +147 -0
  23. package/core/constructor-protocol.js +196 -0
  24. package/core/contract-membership.js +346 -140
  25. package/core/cpp-conversion-constraints.js +123 -0
  26. package/core/cpp-conversion-syntax.js +216 -0
  27. package/core/cpp-conversions.js +414 -0
  28. package/core/cpp-scope.js +245 -44
  29. package/core/deadcode.js +61 -16
  30. package/core/decorator-binding.js +137 -3
  31. package/core/enum-reference-syntax.js +134 -0
  32. package/core/execute.js +17 -1
  33. package/core/external-macros.js +42 -10
  34. package/core/go-packages.js +171 -0
  35. package/core/graph-build.js +2 -0
  36. package/core/graph.js +75 -59
  37. package/core/ignore-rules.js +2 -1
  38. package/core/imports.js +145 -19
  39. package/core/index-ir.js +41 -4
  40. package/core/inprocess-clients.js +41 -15
  41. package/core/ir.js +27 -6
  42. package/core/macro-expansion.js +38 -2
  43. package/core/out-of-class-members.js +182 -0
  44. package/core/output/analysis-ext.js +4 -2
  45. package/core/output/analysis.js +89 -20
  46. package/core/output/graph.js +12 -3
  47. package/core/output/lines.js +4 -3
  48. package/core/output/public.js +2 -0
  49. package/core/output/reporting.js +12 -1
  50. package/core/output/shared.js +2 -0
  51. package/core/output/tracing.js +28 -22
  52. package/core/output-budget.js +2 -2
  53. package/core/parallel-build.js +52 -1
  54. package/core/project.js +217 -112
  55. package/core/provenance-constructor-base.js +161 -0
  56. package/core/provenance-facts.js +257 -27
  57. package/core/provenance-overload.js +458 -54
  58. package/core/provenance-type.js +487 -0
  59. package/core/provenance.js +234 -15
  60. package/core/public-command.js +17 -2
  61. package/core/query-bodies.js +159 -0
  62. package/core/reflection.js +270 -2
  63. package/core/rename-token-syntax.js +111 -0
  64. package/core/reporting.js +13 -7
  65. package/core/request-helpers.js +63 -7
  66. package/core/rust-macro-expansion.js +151 -19
  67. package/core/rust-modules.js +1 -1
  68. package/core/rust-names.js +607 -0
  69. package/core/search.js +70 -17
  70. package/core/shared.js +98 -1
  71. package/core/source-memo.js +201 -0
  72. package/core/text-usage-syntax.js +38 -0
  73. package/core/tracing.js +7 -1
  74. package/core/type-denotation.js +124 -37
  75. package/core/type-references.js +615 -246
  76. package/core/value-references.js +2777 -0
  77. package/core/verify.js +1097 -281
  78. package/languages/adapter.js +28 -4
  79. package/languages/c-family.js +1118 -164
  80. package/languages/constructor-bases.js +105 -0
  81. package/languages/cpp-platform.js +26 -0
  82. package/languages/cpp-site-syntax.js +83 -0
  83. package/languages/csharp-scope.js +439 -0
  84. package/languages/csharp.js +428 -68
  85. package/languages/go.js +385 -207
  86. package/languages/html.js +4 -2
  87. package/languages/index.js +397 -2
  88. package/languages/java-platform.js +226 -0
  89. package/languages/java-unicode.js +269 -0
  90. package/languages/java.js +461 -117
  91. package/languages/javascript.js +418 -66
  92. package/languages/lexical-scope.js +190 -23
  93. package/languages/python.js +404 -54
  94. package/languages/rust.js +510 -82
  95. package/languages/utils.js +67 -8
  96. package/mcp/server.js +8 -2
  97. package/package.json +3 -3
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  const { langTraits } = require('../languages');
28
+ const { sameNode } = require('../languages/utils');
28
29
 
29
30
  const WILD = '\u0000*';
30
31
  const MODULE_RECEIVER = Object.freeze({ kind: 'module' });
@@ -501,6 +502,224 @@ function classifyReceiver(node, language) {
501
502
  return null;
502
503
  }
503
504
 
505
+ // ---------------------------------------------------------------------------
506
+ // Names one call away (fix #428)
507
+ // ---------------------------------------------------------------------------
508
+
509
+ // Callables whose parameters a reflective name can be (grammar node types).
510
+ const PARAMETER_OWNERS = new Set([
511
+ 'function_declaration', 'generator_function_declaration', 'function_expression', 'generator_function',
512
+ 'arrow_function', 'method_definition', 'function_definition', 'method_declaration',
513
+ 'constructor_declaration', 'local_function_statement', 'func_literal',
514
+ ]);
515
+ const PARAMETER_LISTS = new Set(['formal_parameters', 'parameters', 'parameter_list']);
516
+ const VARIADIC_PARAMETERS = new Set(['rest_pattern', 'list_splat_pattern', 'dictionary_splat_pattern',
517
+ 'spread_parameter', 'variadic_parameter_declaration', 'variadic_parameter']);
518
+
519
+ /** The parameter names of a callable in declaration order (null if unknown). */
520
+ function _parameterNames(fn) {
521
+ const list = fn.childForFieldName('parameters') ||
522
+ (fn.namedChildren || []).find(child => PARAMETER_LISTS.has(child.type));
523
+ if (!list) {
524
+ // `x => ..`: a single bare parameter.
525
+ const single = fn.childForFieldName('parameter');
526
+ return single?.type === 'identifier' ? [single.text] : null;
527
+ }
528
+ const names = [];
529
+ for (const param of list.namedChildren) {
530
+ if (param.type === 'comment') continue;
531
+ if (param.type === 'identifier') { names.push(param.text); continue; }
532
+ // A Go declaration may name several parameters of one type.
533
+ if (param.type === 'parameter_declaration' || param.type === 'variadic_parameter_declaration') {
534
+ const ids = param.namedChildren.filter(child => child.type === 'identifier');
535
+ if (ids.length === 0) names.push(null);
536
+ for (const id of ids) names.push(id.text);
537
+ continue;
538
+ }
539
+ const named = param.childForFieldName('name') || param.childForFieldName('pattern') ||
540
+ param.childForFieldName('left') ||
541
+ (param.type === 'typed_parameter' ? param.namedChild(0) : null);
542
+ names.push(named?.type === 'identifier' ? named.text : null);
543
+ }
544
+ return names;
545
+ }
546
+
547
+ /** The name a callable is called by: its own, or the binding it is assigned to. */
548
+ function _callableName(fn) {
549
+ const own = fn.childForFieldName('name');
550
+ if (own) return own.text;
551
+ const parent = fn.parent;
552
+ if (parent?.type === 'variable_declarator') return parent.childForFieldName('name')?.text || null;
553
+ if (parent?.type === 'pair') return parent.childForFieldName('key')?.text?.replace(/^["']|["']$/g, '') || null;
554
+ if (parent?.type === 'assignment_expression') {
555
+ const left = parent.childForFieldName('left');
556
+ if (left?.type === 'identifier') return left.text;
557
+ if (left?.type === 'member_expression') return left.childForFieldName('property')?.text || null;
558
+ }
559
+ return null;
560
+ }
561
+
562
+ /** Does `body` assign the name anywhere (nested callables included)? */
563
+ function _assignsName(body, name) {
564
+ const stack = [body];
565
+ while (stack.length > 0) {
566
+ const node = stack.pop();
567
+ if (_bindingPairs(node).some(([bound]) => bound === name)) return true;
568
+ for (const child of node.namedChildren || []) stack.push(child);
569
+ }
570
+ return false;
571
+ }
572
+
573
+ /**
574
+ * A reflective name that is a parameter of an enclosing callable, never
575
+ * rebound: { name, line, index } (the call-site argument position), else
576
+ * null. Its value is the argument the callable's callers pass.
577
+ */
578
+ function _keyParameter(ident, language) {
579
+ if (ident?.type !== 'identifier') return null;
580
+ for (let fn = ident.parent; fn; fn = fn.parent) {
581
+ if (CLASS_NODES.has(fn.type)) return null;
582
+ if (!PARAMETER_OWNERS.has(fn.type)) continue;
583
+ const names = _parameterNames(fn);
584
+ if (!names) return null;
585
+ let position = names.indexOf(ident.text);
586
+ if (position < 0) continue; // captured from an outer callable
587
+ const name = _callableName(fn);
588
+ if (!name || _assignsName(fn.childForFieldName('body') || fn, ident.text)) return null;
589
+ // A method's receiver parameter (`self`, `cls`) is not a call
590
+ // argument of a bound call.
591
+ const selfNames = langTraits(language)?.selfParam || [];
592
+ let owner = fn.parent?.type === 'decorated_definition' ? fn.parent.parent : fn.parent;
593
+ if (fn.type === 'function_definition' && owner?.type === 'block' &&
594
+ owner.parent?.type === 'class_definition' && selfNames.includes(names[0])) {
595
+ if (position === 0) return null;
596
+ position--;
597
+ }
598
+ // The class a method belongs to: a call naming another class as its
599
+ // qualifier is no call of it. A C# extension's `this` parameter is
600
+ // the receiver of an extension call (`t.M(x)`).
601
+ const ownerClass = _enclosingClass(fn, false);
602
+ const ownerName = ownerClass ? _classInfo(ownerClass)?.name || null : null;
603
+ const params = (fn.childForFieldName('parameters')?.namedChildren || []).filter(child => child.type !== 'comment');
604
+ const firstParam = params.find(child => child.type === 'parameter');
605
+ const extension = !!firstParam?.children?.some(child => child.type === 'modifier' && child.text === 'this');
606
+ // A call passing more arguments than a fixed parameter list takes
607
+ // is not a call of it.
608
+ const variadic = params.some(param => VARIADIC_PARAMETERS.has(param.type) ||
609
+ param.children?.some(child => child.type === 'modifier' && child.text === 'params') ||
610
+ param.namedChildren?.some(child => VARIADIC_PARAMETERS.has(child.type)));
611
+ const maxArgs = variadic ? null : names.length - (names.length > 0 && selfNames.includes(names[0]) &&
612
+ fn.type === 'function_definition' && owner?.type === 'block' && owner.parent?.type === 'class_definition' ? 1 : 0);
613
+ return { name, line: fn.startPosition.row + 1, index: position,
614
+ ...(ownerName && { owner: ownerName }), ...(extension && { extension: true }),
615
+ ...(Number.isInteger(maxArgs) && { maxArgs }) };
616
+ }
617
+ return null;
618
+ }
619
+
620
+ const MAX_KEY_CALLS = 256;
621
+ // Characters after a call's name its arguments are looked for in, when the
622
+ // record keeps only the name's position.
623
+ const KEY_CALL_WINDOW = 4096;
624
+
625
+ /**
626
+ * The names a parameter-keyed site receives (fix #428): the string-shaped
627
+ * arguments at the parameter's position in the calls naming its callable,
628
+ * read from each call's own AST (one hop). Null when no call passes one.
629
+ */
630
+ function resolveKeyParameter(index, site, language, onlyFiles = null, onlyName = null) {
631
+ const key = site.keyParameter;
632
+ const { getCachedCalls } = require('./callers');
633
+ const { getParser, safeParse } = require('../languages');
634
+ const files = [...(onlyFiles || index.getCalleeFiles?.(key.name) || [])].sort();
635
+ const exact = new Set();
636
+ let seen = 0;
637
+ for (const file of files) {
638
+ const entry = index.files.get(file);
639
+ if (!entry || _family(entry.language) !== _family(language)) continue;
640
+ // A call qualified by another class's name never calls the method;
641
+ // an extension call passes the `this` argument as its receiver.
642
+ const shift = call => key.extension && call.isMethod && call.receiver && call.receiver !== key.owner ? 1 : 0;
643
+ const position = call => key.index - shift(call);
644
+ let calls = (getCachedCalls(index, file) || []).filter(call => call.name === key.name &&
645
+ !call.isFunctionReference && position(call) >= 0 &&
646
+ !(Number.isInteger(key.maxArgs) && Number.isInteger(call.argCount) && call.argCount > key.maxArgs - shift(call)) &&
647
+ !(Number.isInteger(call.argCount) && call.argCount <= position(call)) &&
648
+ !(key.owner && call.receiverIsTypeQualified && call.receiver && call.receiver !== key.owner));
649
+ if (calls.length === 0) continue;
650
+ seen += calls.length;
651
+ if (seen > MAX_KEY_CALLS) return null;
652
+ let content;
653
+ try { content = index._readFile(file); } catch { continue; }
654
+ // A literal argument lies in the call's text: a call whose text
655
+ // spells no quoted `onlyName` cannot pass it, and needs no parse.
656
+ if (onlyName) {
657
+ const quoted = ['"', "'", '`'].map(quote => `${quote}${onlyName}${quote}`);
658
+ calls = calls.filter(call => {
659
+ const start = call.callStart ?? call.callSite?.start;
660
+ if (!Number.isInteger(start)) return true;
661
+ const text = content.slice(start, call.callEnd ?? start + KEY_CALL_WINDOW);
662
+ return quoted.some(spelling => text.includes(spelling));
663
+ });
664
+ if (calls.length === 0) continue;
665
+ }
666
+ const tree = index._getParsedTree?.(file, content, entry.language) || safeParse(getParser(entry.language), content);
667
+ if (!tree) continue;
668
+ const api = langTraits(entry.language)?.reflectionApi || {};
669
+ const ctx = { formatCalls: api.formatCalls || null, resolve: makeResolver() };
670
+ for (const call of calls) {
671
+ const offset = call.callStart ?? call.callSite?.start;
672
+ if (!Number.isInteger(offset)) continue;
673
+ let node = tree.rootNode.descendantForIndex(offset);
674
+ for (let depth = 0; node && depth < 6 && !CALL_NODE_TYPES.includes(node.type); depth++) node = node.parent;
675
+ const args = node ? _callArgs(node) : null;
676
+ // Keyword, spread or missing arguments do not bind by position.
677
+ if (!args || args.slice(0, position(call) + 1).some(arg =>
678
+ /keyword_argument|spread|splat|named_argument/.test(arg.type) ||
679
+ (arg.type === 'argument' && arg.childForFieldName('name')))) continue;
680
+ let arg = args[position(call)];
681
+ if (arg?.type === 'argument') arg = arg.namedChildren.find(child => child.type !== 'comment') || arg;
682
+ if (!arg) continue;
683
+ const cls = classifyNameExpression(arg, ctx);
684
+ if (cls.name) exact.add(cls.name);
685
+ for (const pattern of cls.patterns || []) if (!pattern.includes('*')) exact.add(pattern);
686
+ }
687
+ }
688
+ return exact.size > 0 ? [...exact].sort() : null;
689
+ }
690
+
691
+ const QUOTED_IDENTIFIER = /(["'`])([A-Za-z_$][\w$]*)\1/g;
692
+
693
+ /**
694
+ * Could a parameter-keyed site receive `name` (fix #428)? Only when a file
695
+ * holding a call of its callable spells `name` as a string literal: the
696
+ * files' quoted identifiers are read once, and the AST resolution runs only
697
+ * for a name they hold.
698
+ */
699
+ function _keyParameterPasses(index, pidx, entry, name) {
700
+ const files = index.getCalleeFiles?.(entry.site.keyParameter.name);
701
+ if (!files || files.size === 0) return false;
702
+ const spelled = [];
703
+ for (const file of files) {
704
+ let quoted = pidx.quoted.get(file);
705
+ if (!quoted) {
706
+ quoted = new Set();
707
+ try {
708
+ for (const match of index._readFile(file).matchAll(QUOTED_IDENTIFIER)) quoted.add(match[2]);
709
+ } catch { /* unreadable: no literal */ }
710
+ pidx.quoted.set(file, quoted);
711
+ }
712
+ if (quoted.has(name)) spelled.push(file);
713
+ }
714
+ if (spelled.length === 0) return false;
715
+ let byName = pidx.keyNames.get(entry);
716
+ if (!byName) pidx.keyNames.set(entry, byName = new Map());
717
+ if (!byName.has(name)) {
718
+ byName.set(name, !!resolveKeyParameter(index, entry.site, entry.language, spelled, name)?.includes(name));
719
+ }
720
+ return byName.get(name);
721
+ }
722
+
504
723
  // ---------------------------------------------------------------------------
505
724
  // Site extraction
506
725
  // ---------------------------------------------------------------------------
@@ -558,7 +777,12 @@ function reflectionSitesInTree(tree, language, code = null) {
558
777
  const push = (node, kind, nameNode, receiverNode, receiverFact) => {
559
778
  const cls = classifyNameExpression(nameNode, ctx);
560
779
  const expression = String(node.text || '');
780
+ // A name that is the enclosing callable's parameter is decided one
781
+ // call away, by what its callers pass (fix #428).
782
+ const keyParameter = cls.dynamic && !cls.patterns && _unwrap(nameNode)?.type === 'identifier'
783
+ ? _keyParameter(_unwrap(nameNode), language) : null;
561
784
  sites.push({
785
+ ...(keyParameter && { keyParameter }),
562
786
  kind,
563
787
  line: node.startPosition.row + 1,
564
788
  expression: expression.length > EXPRESSION_CAP
@@ -595,6 +819,14 @@ function reflectionSitesInTree(tree, language, code = null) {
595
819
  // counts; opaque keys stay the computed-dispatch disclosure.
596
820
  const index = node.childForFieldName('index');
597
821
  const inner = _unwrap(index);
822
+ // `registry[type](..)` with `type` a parameter of the enclosing
823
+ // callable dispatches by the name its callers pass (fix #428).
824
+ if (inner?.type === 'identifier' && node.parent?.type === 'call_expression' &&
825
+ sameNode(node.parent.childForFieldName('function'), node) &&
826
+ _keyParameter(inner, language)) {
827
+ push(node, 'computed', index, node.childForFieldName('object'));
828
+ return;
829
+ }
598
830
  if (!inner || !COMPUTED_PATTERN_KEYS.has(inner.type)) return;
599
831
  const cls = classifyNameExpression(index, ctx);
600
832
  if (cls.patterns && cls.patterns.length > 0) {
@@ -696,6 +928,14 @@ function _longestFragment(pattern) {
696
928
  return best;
697
929
  }
698
930
 
931
+ /** Whether a pattern entry names members by some literal text (fix #433):
932
+ * a fully dynamic name (`eval(code)`, `globals()[key]` with no literal
933
+ * part) reaches every top-level name of its module alike and is attributed
934
+ * to none of them; deadcode still withholds what it may reach. */
935
+ function patternIsAttributable(pattern) {
936
+ return _longestFragment(String(pattern || '').split('\u0000').join('')) !== '';
937
+ }
938
+
699
939
  function _family(language) {
700
940
  return langTraits(language)?.reflectionApi?.family || language;
701
941
  }
@@ -713,6 +953,7 @@ function reflectionPatternIndex(index) {
713
953
  const lengths = new Set();
714
954
  let entryCount = 0;
715
955
  const moduleScoped = new Map();
956
+ const keyParameterSites = [];
716
957
  for (const [file, sites] of byFile) {
717
958
  const fe = index.files.get(file);
718
959
  if (!fe) continue;
@@ -728,6 +969,11 @@ function reflectionPatternIndex(index) {
728
969
  }
729
970
  continue;
730
971
  }
972
+ if (site.keyParameter && !site.patterns) {
973
+ keyParameterSites.push({ file, relativePath: fe.relativePath,
974
+ language: fe.language, family: _family(fe.language), site });
975
+ continue;
976
+ }
731
977
  if (!site.patterns) continue;
732
978
  for (const pattern of site.patterns) {
733
979
  const frag = _longestFragment(pattern);
@@ -740,7 +986,8 @@ function reflectionPatternIndex(index) {
740
986
  }
741
987
  }
742
988
  }
743
- const result = { byFragment, lengths: [...lengths].sort((a, b) => a - b), entryCount, moduleScoped };
989
+ const result = { byFragment, lengths: [...lengths].sort((a, b) => a - b), entryCount, moduleScoped,
990
+ keyParameterSites, quoted: new Map(), keyNames: new Map() };
744
991
  index._reflectionPatternIndex = result;
745
992
  return result;
746
993
  }
@@ -755,8 +1002,12 @@ function patternEntriesFor(index, name, language, file = null) {
755
1002
  if (globMatches(entry.pattern, name)) out.push(entry);
756
1003
  }
757
1004
  }
758
- if (pidx.entryCount === 0) return out;
759
1005
  const family = language ? _family(language) : null;
1006
+ for (const entry of pidx.keyParameterSites) {
1007
+ if (family && entry.family !== family) continue;
1008
+ if (_keyParameterPasses(index, pidx, entry, name)) out.push({ ...entry, pattern: name });
1009
+ }
1010
+ if (pidx.entryCount === 0) return out;
760
1011
  const seenFrag = new Set();
761
1012
  for (const len of pidx.lengths) {
762
1013
  if (len > name.length) break;
@@ -862,6 +1113,20 @@ function resetReflectionIndex(index) {
862
1113
  index._reflectionPatternIndex = null;
863
1114
  }
864
1115
 
1116
+ /** Exact AST string literals, for conservative same-file dynamic dispatch.
1117
+ * This does not prove that a literal flows to a reflection call. */
1118
+ function reflectionLiteralNames(tree) {
1119
+ const names = new Map();
1120
+ if (!tree) return names;
1121
+ for (const node of tree.rootNode.descendantsOfType([...STRING_NODES])) {
1122
+ const value = classifyNameExpression(node);
1123
+ if (value.name && !value.dynamic && !names.has(value.name)) {
1124
+ names.set(value.name, node.startPosition.row + 1);
1125
+ }
1126
+ }
1127
+ return names;
1128
+ }
1129
+
865
1130
  module.exports = {
866
1131
  WILD,
867
1132
  stringAlternatives,
@@ -873,5 +1138,8 @@ module.exports = {
873
1138
  reflectionEntryReaches,
874
1139
  reflectionPatternReferences,
875
1140
  reflectionPatternIndex,
1141
+ patternIsAttributable,
876
1142
  resetReflectionIndex,
1143
+ reflectionLiteralNames,
1144
+ resolveKeyParameter,
877
1145
  };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const cache = require('./cache');
5
+ const { indexedContent, parseViewParts, syntaxMode, SYNTAX_FAMILIES } = require('./source-memo');
6
+ const { langTraits } = require('../languages');
7
+
8
+ /** Cache AST-proved positions only; the caller resolves each binding anew. */
9
+ function callTokenSyntax(index, file, entry, content, range, name, line, derive) {
10
+ if (index.isProvenanceFactIndex || !entry?.hash ||
11
+ !langTraits(entry.language)?.physicalCallTokenSyntax ||
12
+ !Number.isInteger(range.start) || !Number.isInteger(range.end) ||
13
+ range.start < 0 || range.end <= range.start || !Number.isInteger(line) || line <= 0) return derive();
14
+ const tree = index._peekParsedTree?.(file, entry?.language);
15
+ const state = sourceState(index, file, entry, content, tree);
16
+ if (!state?.mode) return derive();
17
+ let facts = state.names.get(name);
18
+ if (!facts) {
19
+ const value = cache.getUsageResult(index, file, name, state.mode);
20
+ const rows = Array.isArray(value) && value.every(row => Array.isArray(row) &&
21
+ row.length === 4 && row.every(Number.isInteger) &&
22
+ row[0] >= 0 && row[1] > row[0] && row[2] > 0 && row[3] >= 0)
23
+ ? value.slice() : [];
24
+ const positions = new Map();
25
+ let duplicate = false;
26
+ for (const row of rows) {
27
+ const key = `${row[0]}:${row[1]}:${row[2]}`;
28
+ if (positions.has(key)) duplicate = true;
29
+ positions.set(key, row[3]);
30
+ }
31
+ facts = duplicate ? { rows: [], positions: new Map() } : { rows, positions };
32
+ state.names.set(name, facts);
33
+ }
34
+ const key = `${range.start}:${range.end}:${line}`;
35
+ const stored = facts.positions.get(key);
36
+ state.lines ||= content.split('\n');
37
+ const lineText = state.lines[line - 1] || '';
38
+ if (!state.lineStarts) {
39
+ let at = 0;
40
+ state.lineStarts = state.lines.map(text => { const start = at; at += text.length + 1; return start; });
41
+ }
42
+ const lineStart = state.lineStarts[line - 1];
43
+ const inRange = column => Number.isInteger(lineStart) && lineStart + column >= range.start &&
44
+ lineStart + column + name.length <= range.end;
45
+ if (!tree && stored !== undefined && inRange(stored) &&
46
+ lineText.slice(stored, stored + name.length) === name) return stored;
47
+ const column = derive();
48
+ if (column === stored) return column;
49
+ if (Number.isInteger(column) && column >= 0 && inRange(column) && Number.isInteger(range.start) &&
50
+ Number.isInteger(range.end) && range.end > range.start && Number.isInteger(line) &&
51
+ line > 0 && lineText.slice(column, column + name.length) === name) {
52
+ const rows = facts.rows.filter(row =>
53
+ row[0] !== range.start || row[1] !== range.end || row[2] !== line);
54
+ rows.push([range.start, range.end, line, column]);
55
+ facts.rows = rows;
56
+ facts.positions.set(key, column);
57
+ cache.storeUsageResult(index, file, name, state.mode, rows);
58
+ }
59
+ return column;
60
+ }
61
+
62
+ /** The selected declaration is a fresh input; only its physical name column is saved. */
63
+ function declarationTokenSyntax(index, file, entry, content, line, name, definition, allIdentifiers = true) {
64
+ if (index.isProvenanceFactIndex || !entry?.hash ||
65
+ !langTraits(entry.language)?.physicalCallTokenSyntax) return null;
66
+ const tree = index._peekParsedTree?.(file, entry?.language);
67
+ const state = sourceState(index, file, entry, content, tree);
68
+ if (!state?.mode) return null;
69
+ let identity;
70
+ try {
71
+ const description = JSON.stringify([allIdentifiers, definition || null], (_key, value) =>
72
+ value && typeof value === 'object' && !Array.isArray(value)
73
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value);
74
+ identity = crypto.createHash('sha256').update(description).digest('hex');
75
+ }
76
+ catch { return null; }
77
+ const mode = state.mode.replace(`${SYNTAX_FAMILIES.callToken.prefix}:`, `${SYNTAX_FAMILIES.declarationToken.prefix}:`) +
78
+ `:${line}:${identity}`;
79
+ const saved = cache.getUsageResult(index, file, name, mode);
80
+ state.lines ||= content.split('\n');
81
+ const text = state.lines[line - 1] || '';
82
+ const valid = value => Array.isArray(value) && value.length === 1 &&
83
+ Number.isInteger(value[0]) && value[0] >= 0 &&
84
+ text.slice(value[0], value[0] + name.length) === name;
85
+ return {
86
+ columns: !tree && valid(saved) ? saved.slice() : null,
87
+ save(columns) {
88
+ if (valid(columns) && (!valid(saved) || saved[0] !== columns[0])) {
89
+ cache.storeUsageResult(index, file, name, mode, columns.slice());
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ function sourceState(index, file, entry, content, tree) {
96
+ if (index.isProvenanceFactIndex || !entry?.hash || !index._usageResultCache ||
97
+ !langTraits(entry.language)?.physicalCallTokenSyntax) return null;
98
+ const memo = index._opMemo?.('callTokenSyntax', () => new Map());
99
+ let state = memo?.get(file);
100
+ // Preserve the existing query-tree LRU and avoid rehashing a hot tree.
101
+ if (!state && tree) return null;
102
+ if (!state || state.content !== content || state.hash !== entry.hash) {
103
+ const eligible = indexedContent(index, file, entry, content);
104
+ const mode = eligible ? syntaxMode('callToken', parseViewParts(entry)) : null;
105
+ state = { content, hash: entry.hash, mode, names: new Map(), lines: null };
106
+ memo?.set(file, state);
107
+ }
108
+ return state;
109
+ }
110
+
111
+ module.exports = { callTokenSyntax, declarationTokenSyntax };
package/core/reporting.js CHANGED
@@ -15,6 +15,11 @@ const { summarizeCommandTrust } = require('./trust-matrix');
15
15
  const { projectComputedDispatch } = require('./ast-analysis');
16
16
  const { langTraits } = require('../languages');
17
17
 
18
+ // Modules read lazily (load cycles), each resolved once: a require() per
19
+ // call in hot paths was measurable (fix #436).
20
+ let lazySharedModuleCache = null;
21
+ const lazySharedModule = () => lazySharedModuleCache || (lazySharedModuleCache = require('./shared'));
22
+
18
23
  function matchesReportingScope(index, relativePath, options = {}) {
19
24
  if (!relativePath) return false;
20
25
  if (options.file && !relativePath.includes(options.file)) return false;
@@ -124,6 +129,7 @@ function getStats(index, options = {}) {
124
129
  // Ensure the calls cache is fully populated before counting.
125
130
  // First-time stats --hot may need to parse files to extract calls;
126
131
  // subsequent runs use the persisted calls cache.
132
+ require('./cache').ensureCallsCacheLoaded(index);
127
133
  if (typeof index.buildCalleeIndex === 'function' && !index.calleeIndex) {
128
134
  index.buildCalleeIndex();
129
135
  }
@@ -209,7 +215,7 @@ function getStats(index, options = {}) {
209
215
  // candidate set, the exact count, AND the upper bound (a bound that
210
216
  // still counted them stopped the early exit from firing, so the
211
217
  // orientation fell back to its approximate refinement budget).
212
- const { inlineTestRanges, lineInRanges } = require('./shared');
218
+ const { inlineTestRanges, lineInRanges } = lazySharedModule();
213
219
  const inlineTestRangesByFile = new Map();
214
220
  const inInlineTest = (file, line) => {
215
221
  if (!file || !line) return false;
@@ -227,7 +233,7 @@ function getStats(index, options = {}) {
227
233
  // the exact passes never scan them (every per-site verdict is
228
234
  // file-local). Orientation on django spent most of its time
229
235
  // resolving the test suite's calls, which it then discarded.
230
- const { isTestPath } = require('./shared');
236
+ const { isTestPath } = lazySharedModule();
231
237
  const countableFile = (filePath) => {
232
238
  if (!scopedPaths.has(filePath)) return false;
233
239
  const fileEntry = index.files.get(filePath);
@@ -307,7 +313,7 @@ function getStats(index, options = {}) {
307
313
  if (upper === 0) continue;
308
314
  if (index.files.get(symbol.file)?.isBundled) continue;
309
315
  if (options.productionCallsOnly &&
310
- (require('./shared').isTestPath(symbol.relativePath) ||
316
+ (lazySharedModule().isTestPath(symbol.relativePath) ||
311
317
  inInlineTest(symbol.file, symbol.startLine))) continue;
312
318
  const identity = `${symbol.file}:${symbol.startLine}:${name}:` +
313
319
  `${symbol.className || symbol.receiver || ''}:${symbol.params || ''}`;
@@ -418,7 +424,7 @@ function getStats(index, options = {}) {
418
424
  const testPath = (file) => {
419
425
  let verdict = testPathMemo.get(file);
420
426
  if (verdict === undefined) {
421
- verdict = require('./shared').isTestPath(file);
427
+ verdict = lazySharedModule().isTestPath(file);
422
428
  testPathMemo.set(file, verdict);
423
429
  }
424
430
  return verdict;
@@ -744,7 +750,7 @@ function doctor(index, options = {}) {
744
750
  // Reflection/eval signals come from the shared text-blind-spot counter
745
751
  // (core/shared.js) — the SAME routine detectCompleteness uses for the about
746
752
  // footer, so the two never drift (field-report #2). Occurrence counts.
747
- const { hasTextBlindspots, countTextBlindspots } = require('./shared');
753
+ const { hasTextBlindspots, countTextBlindspots } = lazySharedModule();
748
754
 
749
755
  const computedDispatch = projectComputedDispatch(index);
750
756
  for (const [filePath, fe] of index.files) {
@@ -1130,7 +1136,7 @@ function orient(index, options = {}) {
1130
1136
  in: options.in,
1131
1137
  exclude: options.exclude,
1132
1138
  };
1133
- const { isTestPath } = require('./shared');
1139
+ const { isTestPath } = lazySharedModule();
1134
1140
  const hasProductionFiles = [...index.files.values()].some(fileEntry => {
1135
1141
  const relativePath = fileEntry.relativePath;
1136
1142
  return relativePath &&
@@ -1172,7 +1178,7 @@ function orient(index, options = {}) {
1172
1178
  let entrypoints = null;
1173
1179
  try {
1174
1180
  const { detectEntrypoints } = require('./entrypoints');
1175
- const { addTestExclusions } = require('./shared');
1181
+ const { addTestExclusions } = lazySharedModule();
1176
1182
  const orientExclude = Array.isArray(options.exclude)
1177
1183
  ? options.exclude
1178
1184
  : (options.exclude
@@ -43,6 +43,8 @@ const JS_HTTP_PACKAGES = [
43
43
  ];
44
44
  const JS_HTTP_GLOBAL_CALLS = new Set(['fetch']);
45
45
  const JS_HTTP_GLOBAL_CTORS = new Set(['XMLHttpRequest', 'EventSource']);
46
+ const JS_HTTP_CLIENT_TYPES = new Set(['AxiosInstance', 'AxiosStatic', 'KyInstance',
47
+ 'Got', 'GotInstance', 'HttpClient', 'Dispatcher', 'Agent']);
46
48
  const PY_HTTP_MODULES = [
47
49
  'requests', 'httpx', 'aiohttp', 'urllib3', 'urllib.request', 'http.client', 'niquests', 'treq',
48
50
  ];
@@ -173,7 +175,7 @@ class RequestHelperProof {
173
175
  }
174
176
 
175
177
  /** Proof for the function expression being called. */
176
- jsCallee(file, fn, depth, factory = false) {
178
+ jsCallee(file, fn, depth) {
177
179
  if (!fn || depth > MAX_RESOLVE) return unknown('depth');
178
180
  if (fn.type === 'identifier') {
179
181
  const decl = findDecl(this.sess, file, fn.text, fn);
@@ -201,9 +203,7 @@ class RequestHelperProof {
201
203
  // the method reads as one (`client.post`, `instance.request`);
202
204
  // a server built on an HTTP stack (`app.route(...)`) is not a
203
205
  // client.
204
- // A factory call on a client value (`axios.create()`) yields a
205
- // client value too.
206
- if (!factory && (!prop || !CLIENT_METHOD.test(prop))) return unknown('method-not-request');
206
+ if (!prop || !CLIENT_METHOD.test(prop)) return unknown('method-not-request');
207
207
  return this.jsValue(file, obj, depth + 1);
208
208
  }
209
209
  if (fn.type === 'call_expression') return this.jsValue(file, fn, depth + 1);
@@ -296,7 +296,7 @@ class RequestHelperProof {
296
296
  const spec = this.requireSpecOf({ valueNode: e });
297
297
  return isHttpPackage(spec, JS_HTTP_PACKAGES) ? PROVEN : unknown('receiver-unproven');
298
298
  }
299
- return this.jsCallee(file, callee, depth + 1, true);
299
+ return this.jsFactory(file, callee, depth + 1);
300
300
  }
301
301
  default:
302
302
  return unknown('receiver-unproven');
@@ -314,6 +314,59 @@ class RequestHelperProof {
314
314
  return last;
315
315
  }
316
316
 
317
+ /** A request result or server is not a client just because the function
318
+ * that produced it uses an HTTP package. Follow factory return values. */
319
+ jsFactory(file, callee, depth) {
320
+ if (!callee || depth > MAX_RESOLVE) return unknown('receiver-unproven');
321
+ if (callee.type === 'member_expression') {
322
+ const name = field(callee, 'property')?.text;
323
+ if (name === 'create' || name === 'extend') {
324
+ return this.jsValue(file, unwrap(field(callee, 'object')), depth + 1);
325
+ }
326
+ return unknown('factory-unproven');
327
+ }
328
+ if (callee.type !== 'identifier') return unknown('factory-unproven');
329
+ const decl = findDecl(this.sess, file, callee.text, callee);
330
+ let target = decl ? { file, value: decl.count === 1 ? decl.valueNode : null } : null;
331
+ if (!decl) {
332
+ const binding = this.jsBinding(file, callee.text);
333
+ if (binding) {
334
+ if (binding.binding.module?.startsWith('@hey-api/client-') ||
335
+ binding.binding.module === 'openapi-fetch') return PROVEN;
336
+ const rec = this.jsImportRecord(file, binding.binding, binding.original);
337
+ if (rec) target = { file: rec.file, value: rec.decl?.valueNode || rec.valueNode };
338
+ }
339
+ }
340
+ const fn = unwrap(target?.value);
341
+ if (!fn || !JS_FUNCTION_TYPES.has(fn.type)) return unknown('factory-unproven');
342
+ const body = field(fn, 'body');
343
+ if (!body) return unknown('factory-unproven');
344
+ const values = [];
345
+ const visit = node => {
346
+ if (node.type === 'return_statement') {
347
+ values.push(node.namedChild(0));
348
+ return;
349
+ }
350
+ for (const child of namedChildren(node)) {
351
+ if (!JS_FUNCTION_TYPES.has(child.type) && child.type !== 'class_declaration') visit(child);
352
+ }
353
+ };
354
+ if (body.type === 'statement_block') visit(body);
355
+ else values.push(body);
356
+ if (!values.length) return unknown('factory-unproven');
357
+ const proven = values.every(value => {
358
+ const v = unwrap(value);
359
+ if (v?.type !== 'object') return this.jsValue(target.file, v, depth + 1).proven;
360
+ const methods = namedChildren(v).filter(member =>
361
+ CLIENT_METHOD.test((field(member, 'key') || field(member, 'name'))?.text || ''));
362
+ return methods.length > 0 && methods.every(member => {
363
+ const impl = member.type === 'method_definition' ? member : unwrap(field(member, 'value'));
364
+ return impl && JS_FUNCTION_TYPES.has(impl.type) && this.jsFunction(target.file, impl, 0).proven;
365
+ });
366
+ });
367
+ return proven ? PROVEN : unknown('factory-unproven');
368
+ }
369
+
317
370
  /** A parameter proves a client only through its declared type or default. */
318
371
  jsParam(file, decl, depth) {
319
372
  const holder = decl.nameNode.parent;
@@ -321,7 +374,8 @@ class RequestHelperProof {
321
374
  if (typeNode) {
322
375
  const m = typeNode.text.replace(/^:\s*/, '').match(/^([A-Za-z_$][\w$]*)/);
323
376
  const b = m ? this.jsBinding(file, m[1]) : null;
324
- if (b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES)) return PROVEN;
377
+ if (b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES) &&
378
+ JS_HTTP_CLIENT_TYPES.has(b.original)) return PROVEN;
325
379
  }
326
380
  const dflt = holder ? (field(holder, 'value') || field(holder, 'right')) : null;
327
381
  if (dflt) return this.jsValue(file, dflt, depth + 1);
@@ -374,6 +428,8 @@ class RequestHelperProof {
374
428
  return !!b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES);
375
429
  }
376
430
  if (callee.type === 'member_expression') {
431
+ if (CLIENT_METHOD.test(field(callee, 'property')?.text || '') &&
432
+ this.jsValue(file, unwrap(field(callee, 'object')), 0).proven) return true;
377
433
  let root = unwrap(field(callee, 'object'));
378
434
  while (root && root.type === 'member_expression') root = unwrap(field(root, 'object'));
379
435
  if (!root || root.type !== 'identifier') return false;
@@ -558,4 +614,4 @@ function locateCall(root, start, end, type) {
558
614
  return node;
559
615
  }
560
616
 
561
- module.exports = { RequestHelperProof, JS_HTTP_PACKAGES, PY_HTTP_MODULES };
617
+ module.exports = { RequestHelperProof, JS_HTTP_PACKAGES, PY_HTTP_MODULES, pyImportedFunction };