ucn 5.4.1 → 5.5.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.
Files changed (87) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +42 -16
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis-ext.js +6 -6
  38. package/core/output/analysis.js +18 -2
  39. package/core/output/brief.js +3 -0
  40. package/core/output/doctor.js +4 -0
  41. package/core/output/endpoints.js +15 -6
  42. package/core/output/find.js +11 -2
  43. package/core/output/lines.js +8 -2
  44. package/core/output/refactoring.js +121 -14
  45. package/core/output/reporting.js +31 -3
  46. package/core/output/shared.js +14 -0
  47. package/core/output-budget.js +316 -122
  48. package/core/parallel-build.js +124 -47
  49. package/core/parser.js +3 -2
  50. package/core/project.js +457 -60
  51. package/core/provenance-facts.js +156 -12
  52. package/core/provenance-overload.js +30 -3
  53. package/core/provenance.js +104 -30
  54. package/core/pytest-fixtures.js +434 -0
  55. package/core/python-fixture-flow.js +17 -3
  56. package/core/python-modules.js +173 -0
  57. package/core/reflection.js +877 -0
  58. package/core/registry.js +1 -1
  59. package/core/reporting.js +234 -52
  60. package/core/request-helpers.js +561 -0
  61. package/core/route-graph.js +1922 -0
  62. package/core/route-lists.js +2414 -0
  63. package/core/rust-macro-expansion.js +2113 -0
  64. package/core/rust-modules.js +185 -0
  65. package/core/search.js +121 -29
  66. package/core/shared.js +19 -1
  67. package/core/test-name-bindings.js +256 -0
  68. package/core/type-denotation.js +1161 -0
  69. package/core/type-references.js +3229 -0
  70. package/core/verify.js +3360 -672
  71. package/languages/adapter.js +31 -5
  72. package/languages/c-family.js +4061 -212
  73. package/languages/c-macro-invocations.js +380 -0
  74. package/languages/c-preprocessor.js +254 -0
  75. package/languages/csharp.js +1406 -49
  76. package/languages/go.js +1243 -70
  77. package/languages/index.js +728 -6
  78. package/languages/java.js +473 -61
  79. package/languages/javascript.js +1270 -197
  80. package/languages/lexical-scope.js +1280 -0
  81. package/languages/python.js +1557 -72
  82. package/languages/rust-macro-rules.js +1020 -0
  83. package/languages/rust-value-flow.js +416 -0
  84. package/languages/rust.js +2237 -115
  85. package/languages/utils.js +298 -4
  86. package/mcp/server.js +25 -11
  87. package/package.json +1 -1
package/core/deadcode.js CHANGED
@@ -13,7 +13,9 @@ const { isTestFile } = require('./discovery');
13
13
  const { isFrameworkEntrypoint } = require('./entrypoints');
14
14
  const { splitParentList } = require('./graph-build');
15
15
  const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments, escapeRegExp } = require('./shared');
16
- const { projectComputedDispatch, reflectionSites } = require('./ast-analysis');
16
+ const { projectComputedDispatch, projectReflectionSites } = require('./ast-analysis');
17
+ const { reflectionPatternReferences } = require('./reflection');
18
+ const { protocolMemberOf, protocolTypeOf, typedLiteralContract } = require('./contract-membership');
17
19
 
18
20
  const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
19
21
 
@@ -215,12 +217,27 @@ function overridesOutOfTreeBase(index, symbol) {
215
217
  if (owners.length <= 1) return true;
216
218
  }
217
219
  const mods = symbol.modifiers || [];
218
- const publicByShape = !mods.includes('private') &&
219
- !symbol.name.startsWith('#') && !symbol.name.startsWith('_');
220
+ // Language-private spellings (`#x`, Python `__x`) never override a base
221
+ // member; a leading `_` is only a convention in languages that say so
222
+ // (fix #397: Node stream `_transform` hooks).
223
+ const privateName = langTraits(index.files.get(symbol.file)?.language)?.privateMemberName;
224
+ const publicByShape = !mods.includes('private') && !symbol.name.startsWith('#') &&
225
+ (typeof privateName === 'function' ? !privateName(symbol.name) : !symbol.name.startsWith('_'));
220
226
  if (publicByShape && _classHasExternalBase(index, symbol)) return true;
221
227
  return false;
222
228
  }
223
229
 
230
+ /**
231
+ * A member of an object literal declared with an out-of-project type
232
+ * (`const traps: ProxyHandler<T> = { get() {} }`, fix #397): the type's
233
+ * consumer (the runtime, a library) calls it by name, so a zero in-project
234
+ * usage count is not evidence of deadness.
235
+ */
236
+ function typedLiteralExternalMember(index, symbol) {
237
+ if (!symbol.registryContainerType || symbol.className) return false;
238
+ return !!typedLiteralContract(index, symbol)?.external;
239
+ }
240
+
224
241
  // Go satisfies interfaces implicitly, so no `implements` edge exists for
225
242
  // compiler/stdlib callbacks. A zero textual usage for one of these exact
226
243
  // method shapes is not evidence of deadness (sort.Interface, http.Handler,
@@ -626,6 +643,15 @@ function symbolIsExported(index, symbol, fileEntry) {
626
643
  if (fileEntry.exports.includes(name) || mods.includes('export') || mods.includes('public')) {
627
644
  return true;
628
645
  }
646
+ // fix #379: a C++ member that follows a macro invocation whose access
647
+ // effect the parser could not read (the macro is defined in another
648
+ // file) takes the access its project definitions spell; an external or
649
+ // configuration-dependent macro leaves it unknown, and an unknown-access
650
+ // member is treated as public API.
651
+ if (symbol.accessAfterMacro && symbol.className) {
652
+ const access = memberAccessAfterMacro(index, symbol.accessAfterMacro);
653
+ if (access === null || access === 'public') return true;
654
+ }
629
655
  const traits = langTraits(fileEntry.language);
630
656
  // Python's language-level public surface is every top-level non-underscore
631
657
  // name when __all__ is absent. Apply the same rule here as `api`: deadcode
@@ -645,6 +671,22 @@ function symbolIsExported(index, symbol, fileEntry) {
645
671
  if (traits?.exportVisibility === 'capitalization') {
646
672
  return /^[A-Z]/.test(name);
647
673
  }
674
+ // fix #367f: members of an interface/trait carry the container's
675
+ // visibility (Rust trait items cannot be marked `pub`; Java and C#
676
+ // interface members are implicitly public), so the members of an
677
+ // exported interface/trait are public API exactly as `api` lists them.
678
+ if (symbol.className && !mods.includes('private')) {
679
+ const container = (index.symbols.get(symbol.className) || []).find(c =>
680
+ c.file === symbol.file && (c.type === 'interface' || c.type === 'trait') &&
681
+ c.startLine <= symbol.startLine && symbol.startLine <= (c.endLine ?? c.startLine));
682
+ if (container) {
683
+ const cmods = container.modifiers || [];
684
+ if (fileEntry.exports.includes(container.name) || cmods.includes('export') ||
685
+ cmods.includes('public') || cmods.includes('pub')) {
686
+ return true;
687
+ }
688
+ }
689
+ }
648
690
  if (traits?.implicitlyPublicMembers && symbol.className &&
649
691
  !mods.includes('private') && !name.startsWith('#') && !name.startsWith('_')) {
650
692
  const classSyms = index.symbols.get(symbol.className) || [];
@@ -661,6 +703,21 @@ function symbolIsExported(index, symbol, fileEntry) {
661
703
  return false;
662
704
  }
663
705
 
706
+ /**
707
+ * Access in effect after `macro` for a member whose lexical access before the
708
+ * invocation was `before`: the last access specifier every project
709
+ * definition of the macro spells (or `before` when none spells one). Null
710
+ * when the macro has no readable project definition or its definitions
711
+ * disagree.
712
+ */
713
+ function memberAccessAfterMacro(index, { macro, before } = {}) {
714
+ const { macroAccessEffect } = require('../languages/c-family');
715
+ const definitions = (index.symbols.get(macro) || []).filter(d => d.type === 'macro');
716
+ if (definitions.length === 0 || definitions.some(d => typeof d.ppBody !== 'string')) return null;
717
+ const effects = new Set(definitions.map(d => macroAccessEffect(d.ppBody) || before || null));
718
+ return effects.size === 1 ? [...effects][0] : null;
719
+ }
720
+
664
721
  function isPythonPackageFile(index, fileEntry) {
665
722
  if (fileEntry?.language !== 'python' || !fileEntry.relativePath) return false;
666
723
  let dir = path.dirname(path.join(index.root, fileEntry.relativePath));
@@ -674,40 +731,6 @@ function isPythonPackageFile(index, fileEntry) {
674
731
  return false;
675
732
  }
676
733
 
677
- const JAVA_SERIALIZATION_CALLBACKS = new Set([
678
- 'readResolve', 'writeReplace', 'readObject', 'writeObject',
679
- 'readObjectNoData',
680
- ]);
681
-
682
- /**
683
- * Java serialization invokes these callbacks reflectively, so there is no
684
- * text call edge. Require a Serializable/Externalizable heritage path before
685
- * suppressing a dead-code claim; name alone is not enough.
686
- */
687
- function isJavaSerializationCallback(index, symbol, fileEntry) {
688
- if (fileEntry?.language !== 'java' || !symbol.className ||
689
- !JAVA_SERIALIZATION_CALLBACKS.has(symbol.name)) return false;
690
- const head = value => String(value || '')
691
- .replace(/<.*$/, '').split(/[.$]/).pop().trim();
692
- const seen = new Set();
693
- const serializable = className => {
694
- const normalized = head(className);
695
- if (!normalized || seen.has(normalized)) return false;
696
- if (normalized === 'Serializable' || normalized === 'Externalizable') return true;
697
- seen.add(normalized);
698
- const defs = (index.symbols.get(normalized) || []).filter(candidate =>
699
- ['class', 'interface', 'record', 'enum'].includes(candidate.type));
700
- return defs.some(definition => {
701
- const implemented = Array.isArray(definition.implements)
702
- ? definition.implements : String(definition.implements || '').split(',');
703
- const parents = [definition.extends, ...implemented]
704
- .filter(Boolean).map(head);
705
- return parents.some(serializable);
706
- });
707
- };
708
- return serializable(symbol.className);
709
- }
710
-
711
734
  /**
712
735
  * Find dead code (unused functions/classes)
713
736
  * @param {object} index - ProjectIndex instance
@@ -742,29 +765,48 @@ function deadcode(index, options = {}) {
742
765
  const reflectionInfo = {
743
766
  count: 0,
744
767
  literalCount: 0,
768
+ patternCount: 0,
745
769
  dynamicCount: 0,
746
770
  fileCount: 0,
747
771
  files: [],
748
772
  names: [],
773
+ patterns: [],
774
+ withheld: 0,
775
+ withheldByPattern: [],
749
776
  };
750
- for (const [reflectionFile, fe] of index.files) {
777
+ // Reflection is liveness evidence wherever it sits (a test or script
778
+ // outside --in still reaches the member); the disclosure counts cover the
779
+ // audited scope.
780
+ const reflectionPatterns = new Set();
781
+ for (const [reflectionFile, sites] of projectReflectionSites(index)) {
782
+ const fe = index.files.get(reflectionFile);
783
+ if (!fe || sites.length === 0) continue;
784
+ for (const site of sites) {
785
+ if (site.name) literalReflectionNames.add(site.name);
786
+ if (site.patterns && site.patterns.length > 0 && !site.name) {
787
+ // Exact alternatives of a multi-spelling site are literals.
788
+ for (const pattern of site.patterns) {
789
+ if (!pattern.includes('*')) literalReflectionNames.add(pattern);
790
+ }
791
+ }
792
+ }
751
793
  if (!index.matchesFilters(fe.relativePath, options)) continue;
752
- let sites = [];
753
- try {
754
- sites = reflectionSites(index._readFile(reflectionFile), fe.language);
755
- } catch { /* coverage diagnostics own unreadable-file reporting */ }
756
- if (sites.length === 0) continue;
757
794
  reflectionInfo.count += sites.length;
758
- reflectionInfo.literalCount += sites.filter(site => !site.dynamic).length;
759
- reflectionInfo.dynamicCount += sites.filter(site => site.dynamic).length;
795
+ for (const site of sites) {
796
+ if (site.name) reflectionInfo.literalCount++;
797
+ else if (site.patterns && site.patterns.length > 0) reflectionInfo.patternCount++;
798
+ if (site.dynamic) reflectionInfo.dynamicCount++;
799
+ for (const pattern of site.patterns || []) {
800
+ if (pattern.includes('*')) reflectionPatterns.add(pattern);
801
+ }
802
+ }
760
803
  reflectionInfo.fileCount++;
761
804
  if (reflectionInfo.files.length < 10) {
762
805
  reflectionInfo.files.push(fe.relativePath);
763
806
  }
764
- for (const site of sites) {
765
- if (site.name) literalReflectionNames.add(site.name);
766
- }
767
807
  }
808
+ reflectionInfo.patterns = [...reflectionPatterns].sort(codeUnitCompare);
809
+ const reflectionWithheldByPattern = new Map();
768
810
  reflectionInfo.names = [...literalReflectionNames].sort(codeUnitCompare);
769
811
 
770
812
  // Ensure callee index is built (lazy, reused across operations)
@@ -845,10 +887,48 @@ function deadcode(index, options = {}) {
845
887
  potentiallyDeadNames.delete(reflectedName);
846
888
  }
847
889
 
890
+ // C/C++ token-pasting macros (fix #362): names an expanded invocation
891
+ // produces in value position (`{ #name, handle_##name }` tables) are
892
+ // uses. A paste pattern whose produced names cannot be enumerated (a
893
+ // pasting macro with a blind invocation, or one never seen expanded)
894
+ // withholds every candidate the pattern could spell.
895
+ const macroPaste = { sites: 0, blind: null, withheld: 0, withheldNames: [], patterns: 0 };
896
+ const macroSuppressedNames = new Set();
897
+ if ([...index.files.values()].some(fe => langTraits(fe.language)?.textualIncludes)) {
898
+ const { macroExpansionSummary } = require('./macro-expansion');
899
+ const { matchesPastePattern } = require('../languages/c-preprocessor');
900
+ const summary = macroExpansionSummary(index);
901
+ macroPaste.sites = summary.sites;
902
+ macroPaste.blind = summary.blind;
903
+ for (const name of summary.refNames) {
904
+ potentiallyDeadNames.delete(name);
905
+ macroSuppressedNames.add(name);
906
+ }
907
+ const patterns = summary.patterns.filter(p => p.pattern.some(piece => piece && piece.length > 0));
908
+ macroPaste.patterns = patterns.length;
909
+ if (patterns.length > 0) {
910
+ for (const name of [...potentiallyDeadNames].sort(codeUnitCompare)) {
911
+ const cFamily = (index.symbols.get(name) || []).some(def =>
912
+ langTraits(index.files.get(def.file)?.language)?.textualIncludes);
913
+ if (!cFamily) continue;
914
+ const pattern = patterns.find(p => matchesPastePattern(p.pattern, name));
915
+ if (!pattern) continue;
916
+ potentiallyDeadNames.delete(name);
917
+ macroSuppressedNames.add(name);
918
+ macroPaste.withheld++;
919
+ if (macroPaste.withheldNames.length < 10) {
920
+ macroPaste.withheldNames.push({ name, macro: pattern.macro,
921
+ file: pattern.file, line: pattern.line });
922
+ }
923
+ }
924
+ }
925
+ }
926
+
848
927
  const coverage = scanDeadcodeCoverage(index, potentiallyDeadNames);
849
928
  const coverageSuppressedNames = new Set([
850
929
  ...coverage.matchedNames,
851
930
  ...literalReflectionNames,
931
+ ...macroSuppressedNames,
852
932
  ]);
853
933
  if (coverage.claimsWithdrawn) {
854
934
  // An unreadable file/directory can contain a use of any candidate.
@@ -1205,6 +1285,12 @@ function deadcode(index, options = {}) {
1205
1285
  if (symbol.bodyScopedName) {
1206
1286
  continue;
1207
1287
  }
1288
+ // Generated by a project macro_rules! invocation (fix #374): the
1289
+ // definition is the macro's template or the invocation's
1290
+ // arguments, never a deletable declaration of its own.
1291
+ if (symbol.macroExpansion) {
1292
+ continue;
1293
+ }
1208
1294
  const fileEntry = index.files.get(symbol.file);
1209
1295
  const lang = fileEntry?.language;
1210
1296
 
@@ -1267,7 +1353,11 @@ function deadcode(index, options = {}) {
1267
1353
  if (langModule.isEntryPoint?.(symbol)) {
1268
1354
  continue;
1269
1355
  }
1270
- if (isJavaSerializationCallback(index, symbol, fileEntry)) {
1356
+ // Members the runtime/compiler invokes by name (trait
1357
+ // languageProtocolMember): Java serialization callbacks on
1358
+ // Serializable types, Python Enum hooks on Enum subclasses, C#
1359
+ // pattern-bound members, JS toJSON/then/[Symbol.*] (fix #363).
1360
+ if (protocolMemberOf(index, symbol) || protocolTypeOf(index, symbol)) {
1271
1361
  excludedRuntimeContract++;
1272
1362
  continue;
1273
1363
  }
@@ -1422,6 +1512,7 @@ function deadcode(index, options = {}) {
1422
1512
  const isExternalContract = classAuditSet.has(symbol.type)
1423
1513
  ? _heritageReachesExternalBase(index, symbol, lang, false)
1424
1514
  : overridesOutOfTreeBase(index, symbol) ||
1515
+ typedLiteralExternalMember(index, symbol) ||
1425
1516
  (lang === 'go' && goImplicitExternalContract(symbol));
1426
1517
  if (isExternalContract && !options.includeExported) {
1427
1518
  excludedExternalContract++;
@@ -1472,6 +1563,22 @@ function deadcode(index, options = {}) {
1472
1563
  continue;
1473
1564
  }
1474
1565
 
1566
+ // Reflection by name pattern (fix #363): a reflective access
1567
+ // whose name is built from literal fragments around a runtime
1568
+ // part (`getattr(self, "_get_%s_permissions" % src)`) can
1569
+ // reach every member the pattern spells on a receiver that may
1570
+ // hold this class. That is a runtime use, not deadness.
1571
+ const reflected = reflectionPatternReferences(index, symbol);
1572
+ if (reflected.length > 0) {
1573
+ const first = reflected[0];
1574
+ const pattern = first.site.receiver?.kind === 'module'
1575
+ ? `${first.pattern}\u0000${first.relativePath}` : first.pattern;
1576
+ reflectionWithheldByPattern.set(pattern,
1577
+ (reflectionWithheldByPattern.get(pattern) || 0) + 1);
1578
+ reflectionInfo.withheld++;
1579
+ continue;
1580
+ }
1581
+
1475
1582
  results.push({
1476
1583
  name: symbol.name,
1477
1584
  type: symbol.type,
@@ -1508,7 +1615,22 @@ function deadcode(index, options = {}) {
1508
1615
  results.excludedRuntimeContract = excludedRuntimeContract;
1509
1616
  results.excludedDynamicDispatch = excludedDynamicDispatch;
1510
1617
  results.computedDispatch = computedDispatchInfo;
1618
+ reflectionInfo.withheldByPattern = [...reflectionWithheldByPattern]
1619
+ .map(([key, count]) => {
1620
+ const [pattern, namespaceOf] = key.split('\u0000');
1621
+ return { pattern, count, ...(namespaceOf && { namespaceOf }) };
1622
+ })
1623
+ .sort((a, b) => b.count - a.count || codeUnitCompare(a.pattern, b.pattern));
1511
1624
  results.reflection = reflectionInfo;
1625
+ if (macroPaste.sites > 0 || macroPaste.blind?.count > 0 || macroPaste.withheld > 0) {
1626
+ results.macroPaste = macroPaste;
1627
+ }
1628
+ // Project macro_rules! invocations that could not be expanded (fix #374).
1629
+ if ([...index.files.values()].some(fe => fe.rustMacroExpansion?.blind)) {
1630
+ const { rustMacroExpansionSummary } = require('./rust-macro-expansion');
1631
+ const summary = rustMacroExpansionSummary(index);
1632
+ if (summary.blind.count > 0) results.macroRulesBlind = summary.blind;
1633
+ }
1512
1634
  results.coverage = {
1513
1635
  complete: coverage.complete,
1514
1636
  claimsWithdrawn: coverage.claimsWithdrawn,
@@ -0,0 +1,260 @@
1
+ /**
2
+ * core/decorator-binding.js - Decorators that may bind a callable by its name
3
+ * (fix #392).
4
+ *
5
+ * Where a decorator replaces the callable (`decoratorsWrapCallables`: Python,
6
+ * JS/TS), it receives the function object, and with it the function's NAME:
7
+ * a pytest fixture is requested by it, a click command and a Flask endpoint
8
+ * are named after it, a registry may key on `fn.__name__`, a TS member
9
+ * decorator receives the property key. Renaming the definition and its
10
+ * references is then not the whole rename. A decorator is known to keep the
11
+ * name out of it when:
12
+ * - it resolves (through the file's import bindings or builtins) to one of
13
+ * the language's name-preserving decorators (trait `callableDecorators`:
14
+ * `transparent`, `asyncContextManagers`, `namePreserving`);
15
+ * - it is the member's own property accessor (`@size.setter` on `size`);
16
+ * - it resolves to a project function whose decorated-callable parameter is
17
+ * only called, returned, passed to `functools.wraps`/`update_wrapper`, or
18
+ * tested with `callable`/`isinstance` (Python), or whose name/context
19
+ * parameters are never read (JS/TS); a decorator factory `@f(args)` is
20
+ * proven through the decorator it returns.
21
+ * Any other decorator is reported, so the plan asks for review.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const { langTraits, getParser, safeParse } = require('../languages');
27
+ const { sameNode, traverseTree } = require('../languages/utils');
28
+ const { resolveDecorator } = require('./async-producers');
29
+
30
+ function namedChildrenOf(node) {
31
+ return node ? node.namedChildren || [] : [];
32
+ }
33
+
34
+ function treeOf(index, filePath) {
35
+ const entry = index.files.get(filePath);
36
+ if (!entry) return null;
37
+ let content;
38
+ try { content = index._readFile(filePath); } catch { return null; }
39
+ const tree = index._getParsedTree?.(filePath, content, entry.language);
40
+ if (tree) return tree;
41
+ const parser = getParser(entry.language);
42
+ return parser ? safeParse(parser, content) : null;
43
+ }
44
+
45
+ /** The function node declared at `line` (1-based) with `name`. */
46
+ function functionNodeAt(tree, line, name) {
47
+ let found = null;
48
+ const row = line - 1;
49
+ traverseTree(tree.rootNode, node => {
50
+ if (found) return false;
51
+ if (node.endPosition.row < row || node.startPosition.row > row) return false;
52
+ const nameNode = node.childForFieldName?.('name');
53
+ if (nameNode && nameNode.text === name && nameNode.startPosition.row === row &&
54
+ /function|method|arrow/.test(node.type)) {
55
+ found = node;
56
+ return false;
57
+ }
58
+ if (node.type === 'variable_declarator' && nameNode?.text === name &&
59
+ nameNode.startPosition.row === row) {
60
+ const value = node.childForFieldName('value');
61
+ if (value && /function|arrow/.test(value.type)) {
62
+ found = value;
63
+ return false;
64
+ }
65
+ }
66
+ return true;
67
+ });
68
+ return found;
69
+ }
70
+
71
+ function firstParamName(fnNode, family) {
72
+ const params = fnNode.childForFieldName('parameters') || fnNode.childForFieldName('parameter');
73
+ if (!params) return null;
74
+ if (params.type === 'identifier') return params.text;
75
+ for (const param of namedChildrenOf(params)) {
76
+ if (param.type === 'identifier') return param.text;
77
+ const nameNode = param.childForFieldName?.('name') || param.childForFieldName?.('pattern');
78
+ if (nameNode?.type === 'identifier') return nameNode.text;
79
+ if (family === 'python' && param.type === 'typed_parameter' &&
80
+ param.namedChild(0)?.type === 'identifier') return param.namedChild(0).text;
81
+ return null;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function paramNames(fnNode) {
87
+ const params = fnNode.childForFieldName('parameters') || fnNode.childForFieldName('parameter');
88
+ if (!params) return [];
89
+ if (params.type === 'identifier') return [params.text];
90
+ const names = [];
91
+ for (const param of namedChildrenOf(params)) {
92
+ if (param.type === 'identifier') { names.push(param.text); continue; }
93
+ const nameNode = param.childForFieldName?.('name') || param.childForFieldName?.('pattern');
94
+ names.push(nameNode?.type === 'identifier' ? nameNode.text : null);
95
+ }
96
+ return names;
97
+ }
98
+
99
+ /**
100
+ * Python: the decorated-callable parameter `p` of decorator `fnNode` is only
101
+ * called, returned, passed to functools.wraps/update_wrapper, or tested.
102
+ */
103
+ function pythonParamKeepsName(fnNode, p, resolveName) {
104
+ const body = fnNode.childForFieldName('body');
105
+ if (!body) return false;
106
+ let proven = true;
107
+ traverseTree(body, node => {
108
+ if (!proven) return false;
109
+ if (node.type !== 'identifier' || node.text !== p) return true;
110
+ const parent = node.parent;
111
+ if (!parent) { proven = false; return false; }
112
+ // `return p`
113
+ if (parent.type === 'return_statement') return true;
114
+ // `p(...)`
115
+ if (parent.type === 'call' && sameNode(parent.childForFieldName('function'), node)) return true;
116
+ // `wraps(p)`, `update_wrapper(w, p)`, `callable(p)`, `isinstance(p, T)`
117
+ if (parent.type === 'argument_list' && parent.parent?.type === 'call') {
118
+ const callee = parent.parent.childForFieldName('function');
119
+ const key = callee ? resolveName(callee.text) : null;
120
+ if (key === 'functools.wraps' || key === 'functools.update_wrapper' ||
121
+ key === 'builtins.callable' || key === 'builtins.isinstance') return true;
122
+ }
123
+ proven = false;
124
+ return false;
125
+ });
126
+ return proven;
127
+ }
128
+
129
+ /** JS/TS: every parameter after the decorated value is unread. */
130
+ function jsExtraParamsUnread(fnNode) {
131
+ const names = paramNames(fnNode).slice(1);
132
+ if (names.some(n => n === null)) return false;
133
+ if (names.length === 0) return true;
134
+ const body = fnNode.childForFieldName('body');
135
+ if (!body) return true;
136
+ let read = false;
137
+ traverseTree(body, node => {
138
+ if (read) return false;
139
+ if (node.type === 'identifier' && names.includes(node.text)) read = true;
140
+ return !read;
141
+ });
142
+ return !read;
143
+ }
144
+
145
+ /** The nested decorator a factory returns (`return inner` / `return lambda..`). */
146
+ function returnedDecorator(fnNode, family) {
147
+ const body = fnNode.childForFieldName('body');
148
+ if (!body) return null;
149
+ const nested = new Map();
150
+ const returns = [];
151
+ traverseTree(body, node => {
152
+ if (sameNode(node, body)) return true;
153
+ if (/^(function_definition|function_declaration)$/.test(node.type)) {
154
+ const n = node.childForFieldName('name');
155
+ if (n) nested.set(n.text, node);
156
+ return false;
157
+ }
158
+ if (/function|arrow|lambda/.test(node.type)) return false;
159
+ if (node.type === 'variable_declarator') {
160
+ const n = node.childForFieldName('name');
161
+ const v = node.childForFieldName('value');
162
+ if (n && v && /function|arrow/.test(v.type)) nested.set(n.text, v);
163
+ }
164
+ if (node.type === 'return_statement') returns.push(node);
165
+ return true;
166
+ });
167
+ // JS arrow shorthand: `(x) => (target, key) => {}`
168
+ if (family === 'js' && body.type !== 'statement_block' && /function|arrow/.test(body.type)) return body;
169
+ if (returns.length !== 1) return null;
170
+ const value = returns[0].namedChild(0);
171
+ if (!value) return null;
172
+ if (value.type === 'identifier') return nested.get(value.text) || null;
173
+ if (/function|arrow/.test(value.type)) return value;
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Resolve a decorator's head name to one project callable definition, or null.
179
+ */
180
+ function projectDecoratorDef(index, def, head, rest) {
181
+ const { _nameBindingReaches, _moduleAttributeBindingReaches } = require('./callers');
182
+ const target = rest ? rest.slice(1) : head;
183
+ if (rest && rest.includes('.', 1)) return null;
184
+ const candidates = (index.symbols.get(target) || []).filter(candidate =>
185
+ !candidate.className && /function|method/.test(candidate.type || 'function'));
186
+ const reaching = candidates.filter(candidate => {
187
+ const targets = new Set([candidate.file]);
188
+ if (!rest) {
189
+ if (candidate.file === def.file) return true;
190
+ return _nameBindingReaches(index, def.file, head, targets) === 'yes';
191
+ }
192
+ return _moduleAttributeBindingReaches(index, def.file, head, target, targets) === 'yes';
193
+ });
194
+ return reaching.length === 1 ? reaching[0] : null;
195
+ }
196
+
197
+ function provenProjectDecorator(index, def, text, family, resolveName, memo) {
198
+ const call = text.indexOf('(');
199
+ const expr = (call >= 0 ? text.slice(0, call) : text).trim();
200
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/.test(expr)) return false;
201
+ const dot = expr.indexOf('.');
202
+ const head = dot < 0 ? expr : expr.slice(0, dot);
203
+ const rest = dot < 0 ? '' : expr.slice(dot);
204
+ const target = projectDecoratorDef(index, def, head, rest);
205
+ if (!target) return false;
206
+ const key = `${target.file}\0${target.startLine}\0${call >= 0 ? 1 : 0}`;
207
+ if (memo.has(key)) return memo.get(key);
208
+ memo.set(key, false); // recursion guard
209
+ const tree = treeOf(index, target.file);
210
+ let fnNode = tree ? functionNodeAt(tree, target.nameLine || target.startLine, target.name) : null;
211
+ if (fnNode && call >= 0) fnNode = returnedDecorator(fnNode, family);
212
+ let proven = false;
213
+ if (fnNode) {
214
+ if (family === 'python') {
215
+ const p = firstParamName(fnNode, family);
216
+ const targetEntry = index.files.get(target.file);
217
+ const vocab = langTraits(targetEntry?.language)?.callableDecorators || {};
218
+ const resolveInTarget = name => resolveDecorator(name, targetEntry, vocab)?.name || null;
219
+ proven = !!p && pythonParamKeepsName(fnNode, p, resolveInTarget);
220
+ } else {
221
+ proven = jsExtraParamsUnread(fnNode);
222
+ }
223
+ }
224
+ memo.set(key, proven);
225
+ return proven;
226
+ }
227
+
228
+ /**
229
+ * Decorators of `def` not proven to keep its name out of what they do.
230
+ * Returns [] when the language's decorators never replace the callable.
231
+ */
232
+ function nameBindingDecorators(index, def, memo = new Map()) {
233
+ const decorators = Array.isArray(def.decorators) ? def.decorators : [];
234
+ if (decorators.length === 0) return [];
235
+ const fileEntry = index.files.get(def.file);
236
+ const traits = fileEntry ? langTraits(fileEntry.language) : null;
237
+ if (!traits?.decoratorsWrapCallables) return [];
238
+ const family = fileEntry.language === 'python' ? 'python' : 'js';
239
+ const vocab = traits.callableDecorators || {};
240
+ const preserving = new Set([...(vocab.transparent || []), ...(vocab.asyncContextManagers || []),
241
+ ...(vocab.namePreserving || [])]);
242
+ const resolveName = name => resolveDecorator(name, fileEntry, vocab)?.name || null;
243
+ const unproven = [];
244
+ for (const raw of decorators) {
245
+ const text = String(typeof raw === 'string' ? raw : raw?.name || '').replace(/^@/, '').trim();
246
+ if (!text) continue;
247
+ const resolved = resolveDecorator(text, fileEntry, vocab);
248
+ if (resolved) {
249
+ const key = resolved.name + (resolved.called ? '()' : '');
250
+ if (preserving.has(key) || preserving.has(resolved.name)) continue;
251
+ }
252
+ // The member's own property accessors keep its name by definition.
253
+ if (new RegExp(`^${def.name.replace(/[$]/g, '\\$')}\\.(setter|getter|deleter)$`).test(text)) continue;
254
+ if (provenProjectDecorator(index, def, text, family, resolveName, memo)) continue;
255
+ unproven.push(text);
256
+ }
257
+ return unproven;
258
+ }
259
+
260
+ module.exports = { nameBindingDecorators };