chiasmus 0.1.14 → 0.1.15

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.
@@ -74,10 +74,52 @@ function extractFromTree(tree, filePath, lang, defines, calls, imports, exports,
74
74
  }
75
75
  else {
76
76
  const scopeStack = [];
77
- walkNode(tree.rootNode, filePath, lang, scopeStack, defines, calls, imports, exports, contains, callSet);
77
+ // Per-file alias map: local name imported name. Populated as
78
+ // `import_statement` nodes are encountered during the walk, read by
79
+ // `resolveCallee` so a call to `bar()` from `import { foo as bar }`
80
+ // records an edge to `foo`, matching the exported name on the other
81
+ // side of the module boundary. ES imports are syntactically required
82
+ // to appear before any executable code, so building the map lazily
83
+ // during the walk is safe.
84
+ const aliasMap = new Map();
85
+ // Function-reference queue: every `foo` identifier passed as a direct
86
+ // call argument is recorded here during the walk, then resolved after
87
+ // the walk against the file's final defines + imports. Deferred
88
+ // resolution is necessary because the reference can appear *before*
89
+ // the callee is defined in source order (e.g. `names.map(quoteIfNeeded)`
90
+ // above a `function quoteIfNeeded(...)` declaration).
91
+ const pendingRefs = [];
92
+ walkNode(tree.rootNode, filePath, lang, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
93
+ // Resolve pending references. A ref becomes a real edge iff the target
94
+ // name is a function/method defined in this file or an import binding
95
+ // this file owns. Non-matching names are dropped so local variable
96
+ // identifiers (req, opts, config, etc.) don't pollute the graph.
97
+ const knownNames = new Set();
98
+ for (const d of defines) {
99
+ if (d.file !== filePath)
100
+ continue;
101
+ if (d.kind === "function" || d.kind === "method" || d.kind === "class") {
102
+ knownNames.add(d.name);
103
+ }
104
+ }
105
+ for (const imp of imports) {
106
+ if (imp.file === filePath)
107
+ knownNames.add(imp.name);
108
+ }
109
+ for (const ref of pendingRefs) {
110
+ if (!knownNames.has(ref.callee))
111
+ continue;
112
+ if (ref.caller === ref.callee)
113
+ continue;
114
+ const key = `${ref.caller}->${ref.callee}`;
115
+ if (callSet.has(key))
116
+ continue;
117
+ callSet.add(key);
118
+ calls.push({ caller: ref.caller, callee: ref.callee });
119
+ }
78
120
  }
79
121
  }
80
- function walkNode(node, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet) {
122
+ function walkNode(node, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet) {
81
123
  const type = node.type;
82
124
  switch (type) {
83
125
  case "function_declaration": {
@@ -85,7 +127,7 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
85
127
  if (name) {
86
128
  defines.push({ file: filePath, name, kind: "function", line: node.startPosition.row + 1 });
87
129
  scopeStack.push(name);
88
- walkChildren(node, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
130
+ walkChildren(node, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
89
131
  scopeStack.pop();
90
132
  return; // already walked children
91
133
  }
@@ -101,7 +143,7 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
101
143
  contains.push({ parent: className, child: name });
102
144
  }
103
145
  scopeStack.push(name);
104
- walkChildren(node, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
146
+ walkChildren(node, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
105
147
  scopeStack.pop();
106
148
  return;
107
149
  }
@@ -112,7 +154,7 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
112
154
  if (name) {
113
155
  defines.push({ file: filePath, name, kind: "class", line: node.startPosition.row + 1 });
114
156
  scopeStack.push(name);
115
- walkChildren(node, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
157
+ walkChildren(node, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
116
158
  scopeStack.pop();
117
159
  return;
118
160
  }
@@ -130,12 +172,12 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
130
172
  const name = nameNode.text;
131
173
  defines.push({ file: filePath, name, kind: "function", line: node.startPosition.row + 1 });
132
174
  scopeStack.push(name);
133
- walkChildren(valueNode, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
175
+ walkChildren(valueNode, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
134
176
  scopeStack.pop();
135
177
  foundArrow = true;
136
178
  }
137
179
  else if (valueNode) {
138
- walkChildren(child, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
180
+ walkChildren(child, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
139
181
  }
140
182
  }
141
183
  }
@@ -144,7 +186,7 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
144
186
  break;
145
187
  }
146
188
  case "call_expression": {
147
- const callee = resolveCallee(node);
189
+ const callee = resolveCallee(node, aliasMap);
148
190
  const caller = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : null;
149
191
  if (callee && caller) {
150
192
  const key = `${caller}->${callee}`;
@@ -153,6 +195,29 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
153
195
  calls.push({ caller, callee });
154
196
  }
155
197
  }
198
+ // Record identifier arguments as potential function references.
199
+ // Passing a fn by reference (arr.map(fn), emitter.on("sig", fn))
200
+ // doesn't generate a call_expression for `fn` itself, so without
201
+ // this pass the target looks unused and dead-code analysis flags
202
+ // it as dead. We collect the (caller, argIdentifier) pairs here
203
+ // and resolve them against the file's known function names after
204
+ // the walk finishes.
205
+ if (caller) {
206
+ const argsNode = node.childForFieldName("arguments");
207
+ if (argsNode) {
208
+ for (let i = 0; i < argsNode.childCount; i++) {
209
+ const arg = argsNode.child(i);
210
+ if (arg.type !== "identifier")
211
+ continue;
212
+ const argName = arg.text;
213
+ // Rewrite through aliasMap so a reference to an aliased
214
+ // import resolves to the canonical exported name, matching
215
+ // what resolveCallee does for direct calls.
216
+ const canonical = aliasMap.get(argName) ?? argName;
217
+ pendingRefs.push({ caller, callee: canonical });
218
+ }
219
+ }
220
+ }
156
221
  break; // fall through to walk children (nested calls)
157
222
  }
158
223
  case "import_statement": {
@@ -161,7 +226,7 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
161
226
  if (source) {
162
227
  const importClause = node.children.find((c) => c.type === "import_clause");
163
228
  if (importClause) {
164
- extractImportNames(importClause, filePath, source, imports);
229
+ extractImportNames(importClause, filePath, source, imports, aliasMap);
165
230
  }
166
231
  }
167
232
  return; // no need to walk deeper
@@ -222,21 +287,29 @@ function walkNode(node, filePath, language, scopeStack, defines, calls, imports,
222
287
  break; // fall through to walk children (may contain function_declaration etc.)
223
288
  }
224
289
  }
225
- walkChildren(node, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
290
+ walkChildren(node, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
226
291
  }
227
- function walkChildren(node, filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet) {
292
+ function walkChildren(node, filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet) {
228
293
  for (let i = 0; i < node.childCount; i++) {
229
- walkNode(node.child(i), filePath, language, scopeStack, defines, calls, imports, exports, contains, callSet);
294
+ walkNode(node.child(i), filePath, language, scopeStack, aliasMap, pendingRefs, defines, calls, imports, exports, contains, callSet);
230
295
  }
231
296
  }
232
- /** Resolve the callee name from a call_expression node */
233
- function resolveCallee(callNode) {
297
+ /**
298
+ * Resolve the callee name from a call_expression node. If the identifier
299
+ * matches a local alias from `import { foo as bar }`, the call is
300
+ * rewritten to the original export name (`foo`) so cross-file analyses
301
+ * see the link. Member-expression callees aren't rewritten — they reach
302
+ * into an object, not a top-level binding.
303
+ */
304
+ function resolveCallee(callNode, aliasMap) {
234
305
  const fnNode = callNode.childForFieldName("function");
235
306
  if (!fnNode)
236
307
  return null;
237
308
  switch (fnNode.type) {
238
- case "identifier":
239
- return fnNode.text;
309
+ case "identifier": {
310
+ const name = fnNode.text;
311
+ return aliasMap.get(name) ?? name;
312
+ }
240
313
  case "member_expression": {
241
314
  // obj.method() → method, this.method() → method
242
315
  const property = fnNode.childForFieldName("property");
@@ -265,23 +338,36 @@ function findEnclosingClassName(node) {
265
338
  }
266
339
  return null;
267
340
  }
268
- /** Extract import names from an import_clause */
269
- function extractImportNames(clause, filePath, source, imports) {
341
+ /**
342
+ * Extract import names from an import_clause. For each named specifier
343
+ * we push an ImportsFact keyed by the *imported* name (so the imports
344
+ * list reflects the exported identifier as seen by the module being
345
+ * imported), and — when the local binding differs (an `as` alias) —
346
+ * register a `local → imported` entry in `aliasMap` so call-site
347
+ * resolution can rewrite calls through the alias back to the canonical
348
+ * name used by the call graph.
349
+ */
350
+ function extractImportNames(clause, filePath, source, imports, aliasMap) {
270
351
  for (let i = 0; i < clause.childCount; i++) {
271
352
  const child = clause.child(i);
272
353
  // Default import: import foo from './bar'
273
354
  if (child.type === "identifier") {
274
355
  imports.push({ file: filePath, name: child.text, source });
275
356
  }
276
- // Named imports: import { foo, bar } from './baz'
357
+ // Named imports: import { foo, bar, baz as qux } from './mod'
277
358
  if (child.type === "named_imports") {
278
359
  for (let j = 0; j < child.childCount; j++) {
279
360
  const spec = child.child(j);
280
- if (spec.type === "import_specifier") {
281
- const name = spec.childForFieldName("name")?.text;
282
- if (name) {
283
- imports.push({ file: filePath, name, source });
284
- }
361
+ if (spec.type !== "import_specifier")
362
+ continue;
363
+ const name = spec.childForFieldName("name")?.text;
364
+ if (!name)
365
+ continue;
366
+ imports.push({ file: filePath, name, source });
367
+ // `alias` field only exists when the specifier has an `as` clause.
368
+ const alias = spec.childForFieldName("alias")?.text;
369
+ if (alias && alias !== name) {
370
+ aliasMap.set(alias, name);
285
371
  }
286
372
  }
287
373
  }
@@ -588,20 +674,70 @@ function extractGoReceiverType(receiver) {
588
674
  return null;
589
675
  }
590
676
  // ── Clojure extraction ──────────────────────────────────────────────
591
- /** Get the text of the first sym_name child (direct or nested in sym_lit) */
677
+ /**
678
+ * Get the textual name of a Clojure symbol, preserving any namespace
679
+ * prefix. tree-sitter-clojure parses a qualified symbol like `db/query`
680
+ * into a sym_lit containing a `sym_ns` child ("db") and a `sym_name`
681
+ * child ("query"); returning just the `sym_name` loses the ns info and
682
+ * conflates cross-namespace calls. Reconstruct `ns/name` when both are
683
+ * present, otherwise fall back to the bare `sym_name`.
684
+ */
592
685
  function cljSymName(node) {
593
686
  if (node.type === "sym_name")
594
687
  return node.text;
595
688
  if (node.type === "sym_lit") {
689
+ let ns = null;
690
+ let name = null;
596
691
  for (let i = 0; i < node.childCount; i++) {
597
- if (node.child(i).type === "sym_name")
598
- return node.child(i).text;
692
+ const c = node.child(i);
693
+ if (c.type === "sym_ns")
694
+ ns = c.text;
695
+ else if (c.type === "sym_name" && name === null)
696
+ name = c.text;
599
697
  }
698
+ if (name === null)
699
+ return null;
700
+ return ns ? `${ns}/${name}` : name;
600
701
  }
601
702
  return null;
602
703
  }
603
- /** Extract ns form: (ns foo.bar (:require [baz.qux :as q] [x.y :refer [z]])) */
604
- function cljExtractNs(listNode, filePath, imports) {
704
+ /**
705
+ * Resolve a Clojure call target to its canonical form. Rules:
706
+ * - `alias/name` where alias is in aliasMap → `<full-ns>/name`
707
+ * - `prefix/name` where prefix is unknown → returned as-is (already fully
708
+ * qualified, or an alias we couldn't resolve — either way, leave it)
709
+ * - bare `name` where `<currentNs>/name` is defined in this file → qualify
710
+ * to the current ns (same-file reference)
711
+ * - bare `name` otherwise → returned as-is (external, clojure.core, etc.)
712
+ *
713
+ * When `currentNs` is null (file has no ns form) the bare path just returns
714
+ * the input, preserving the legacy "bare names everywhere" behavior for
715
+ * script-style files.
716
+ */
717
+ function qualifyCljName(name, ctx) {
718
+ const slashIdx = name.indexOf("/");
719
+ if (slashIdx >= 0) {
720
+ const prefix = name.slice(0, slashIdx);
721
+ const local = name.slice(slashIdx + 1);
722
+ const fullNs = ctx.aliasMap.get(prefix);
723
+ if (fullNs)
724
+ return `${fullNs}/${local}`;
725
+ return name;
726
+ }
727
+ if (ctx.currentNs === null)
728
+ return name;
729
+ const candidate = `${ctx.currentNs}/${name}`;
730
+ if (ctx.inFileDefns.has(candidate))
731
+ return candidate;
732
+ return name;
733
+ }
734
+ /**
735
+ * Extract ns form: (ns foo.bar (:require [baz.qux :as q] [x.y :refer [z]])).
736
+ * Populates `imports` with the required namespaces and `aliasMap` with any
737
+ * `:as` bindings so the call extractor can resolve `q/some-fn` to
738
+ * `baz.qux/some-fn`.
739
+ */
740
+ function cljExtractNs(listNode, filePath, imports, aliasMap) {
605
741
  let symIdx = -1;
606
742
  for (let i = 0; i < listNode.childCount; i++) {
607
743
  if (listNode.child(i).type === "sym_lit") {
@@ -638,16 +774,38 @@ function cljExtractNs(listNode, filePath, imports) {
638
774
  for (let k = j + 1; k < child.childCount; k++) {
639
775
  const vec = child.child(k);
640
776
  if (vec.type === "vec_lit") {
641
- // First sym_lit in vector is the required namespace
777
+ // First sym_lit in vector is the required namespace; scan for
778
+ // a trailing `:as alias` pair so alias → full-ns can be
779
+ // resolved at call sites.
780
+ let reqNs = null;
642
781
  for (let l = 0; l < vec.childCount; l++) {
643
- if (vec.child(l).type === "sym_lit") {
644
- const reqNs = cljSymName(vec.child(l));
645
- if (reqNs) {
646
- imports.push({ file: filePath, name: reqNs, source: reqNs });
647
- }
782
+ const vc = vec.child(l);
783
+ if (vc.type === "sym_lit") {
784
+ reqNs = cljSymName(vc);
648
785
  break;
649
786
  }
650
787
  }
788
+ if (!reqNs)
789
+ continue;
790
+ imports.push({ file: filePath, name: reqNs, source: reqNs });
791
+ for (let l = 0; l < vec.childCount; l++) {
792
+ const vc = vec.child(l);
793
+ if (vc.type !== "kwd_lit")
794
+ continue;
795
+ const kname = vc.children?.find((c) => c.type === "kwd_name")?.text;
796
+ if (kname !== "as")
797
+ continue;
798
+ for (let m = l + 1; m < vec.childCount; m++) {
799
+ const asSym = vec.child(m);
800
+ if (asSym.type === "sym_lit") {
801
+ const alias = cljSymName(asSym);
802
+ if (alias)
803
+ aliasMap.set(alias, reqNs);
804
+ break;
805
+ }
806
+ }
807
+ break;
808
+ }
651
809
  }
652
810
  }
653
811
  }
@@ -736,14 +894,17 @@ function cljMethodImplName(listNode) {
736
894
  * extend-protocol form, extracting call edges from each method impl body
737
895
  * using the method name as the caller.
738
896
  */
739
- function cljWalkDispatchMethods(parentList, inFileDefns, calls, callSet) {
897
+ function cljWalkDispatchMethods(parentList, ctx, calls, callSet) {
740
898
  for (let i = 0; i < parentList.childCount; i++) {
741
899
  const child = parentList.child(i);
742
900
  if (child.type !== "list_lit")
743
901
  continue;
744
902
  const methodName = cljMethodImplName(child);
745
903
  if (methodName) {
746
- cljExtractCalls(child, methodName, inFileDefns, calls, callSet);
904
+ // Method implementations are attributed to the bare method name
905
+ // (not qualified): dispatch method names are looked up by unqualified
906
+ // identifier and usually match a defprotocol entry elsewhere.
907
+ cljExtractCalls(child, methodName, ctx, calls, callSet);
747
908
  }
748
909
  }
749
910
  }
@@ -763,7 +924,12 @@ const CLJ_RECOGNIZED_TOPLEVEL = new Set([
763
924
  /** Walk a Clojure AST and extract defines, calls, imports */
764
925
  function walkClojure(rootNode, filePath, defines, calls, imports, exports, callSet) {
765
926
  let nsName = null;
927
+ const aliasMap = new Map();
766
928
  const definesBeforePhase1 = defines.length;
929
+ // Phase 1 pushes bare names and records the boundaries; after phase 1
930
+ // finishes we'll rewrite them in-place to qualified form once we know the
931
+ // final ns. Deferring the rewrite avoids having to know the ns before
932
+ // seeing the (ns ...) form, which can appear anywhere in the file.
767
933
  // ── Phase 1: collect top-level definitions ────────────────────────
768
934
  // In addition to defn/defn-, we also register defmulti, defprotocol
769
935
  // (and its declared methods), defrecord, deftype, definterface,
@@ -773,9 +939,9 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
773
939
  const child = rootNode.child(i);
774
940
  if (child.type !== "list_lit")
775
941
  continue;
776
- // ns form — harvest requires and capture the namespace name for use
777
- // as the top-level caller in phase 2.
778
- const ns = cljExtractNs(child, filePath, imports);
942
+ // ns form — harvest requires, :as aliases, and the namespace name for
943
+ // use as the top-level caller in phase 2.
944
+ const ns = cljExtractNs(child, filePath, imports, aliasMap);
779
945
  if (ns) {
780
946
  nsName = ns;
781
947
  continue;
@@ -848,21 +1014,53 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
848
1014
  }
849
1015
  }
850
1016
  }
1017
+ // Post-phase-1 qualification: now that the (ns ...) form has been seen
1018
+ // (or confirmed absent), rewrite each define pushed during phase 1 to
1019
+ // its namespace-qualified form. `defprotocol`/`definterface` method rows
1020
+ // (kind === "function" nested inside a class define) are qualified too —
1021
+ // they look identical to defn from the graph's perspective. Classes
1022
+ // (defrecord/deftype/definterface) are also qualified.
1023
+ //
1024
+ // If nsName is null the file is in legacy "bare" mode and names stay as
1025
+ // they were pushed — this keeps script-style .clj fixtures working.
1026
+ if (nsName !== null) {
1027
+ for (let k = definesBeforePhase1; k < defines.length; k++) {
1028
+ defines[k].name = `${nsName}/${defines[k].name}`;
1029
+ }
1030
+ // Exports were pushed alongside defines in phase 1. They were appended
1031
+ // after definesBeforePhase1 as we went, but exports and defines are
1032
+ // separate arrays — rewrite exports for this file by walking from the
1033
+ // pre-phase-1 length of the exports array.
1034
+ // We don't have a snapshot of the export length, so scan the tail for
1035
+ // entries belonging to this file and qualify those whose name still
1036
+ // looks bare.
1037
+ for (let k = exports.length - 1; k >= 0 && exports[k].file === filePath; k--) {
1038
+ if (!exports[k].name.includes("/")) {
1039
+ exports[k].name = `${nsName}/${exports[k].name}`;
1040
+ }
1041
+ }
1042
+ }
851
1043
  // Snapshot the set of names defined in *this file* during phase 1.
852
1044
  // Phase 2's cljExtractCalls uses this to recognize in-file references:
853
1045
  // whenever it encounters a sym_lit whose name matches one of these,
854
- // it emits a reference edge. This covers user-defined HOFs, map-value
855
- // registrations (`{:home home-handler}`), `(def h my-fn)`, and similar
856
- // patterns where a fn is passed by value rather than called directly.
1046
+ // it emits a reference edge. Names here are already qualified if the
1047
+ // file had an ns form.
857
1048
  const inFileDefns = new Set();
858
1049
  for (let k = definesBeforePhase1; k < defines.length; k++) {
859
1050
  inFileDefns.add(defines[k].name);
860
1051
  }
1052
+ const ctx = { currentNs: nsName, aliasMap, inFileDefns };
861
1053
  // Synthetic caller for top-level side-effecting forms. If the file has
862
1054
  // no ns declaration, fall back to the file path — it's still a unique
863
1055
  // identifier that downstream analyses can treat as "always live".
864
1056
  const topLevelCaller = nsName ?? `<toplevel:${filePath}>`;
865
1057
  // ── Phase 2: walk bodies for call edges ──────────────────────────
1058
+ //
1059
+ // Phase-2 callers are qualified whenever the file has an ns form: `defn
1060
+ // foo` in `ns myapp.core` becomes the caller `myapp.core/foo`. This
1061
+ // matches the (already rewritten) entries in `defines` so cross-file
1062
+ // analyses see a consistent graph.
1063
+ const qualifyCaller = (bare) => nsName !== null ? `${nsName}/${bare}` : bare;
866
1064
  for (let i = 0; i < rootNode.childCount; i++) {
867
1065
  const child = rootNode.child(i);
868
1066
  if (child.type !== "list_lit")
@@ -871,7 +1069,7 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
871
1069
  if (!head) {
872
1070
  // Non-symbolic head (keyword-first, map-first, etc.) at top level.
873
1071
  // Still walk it as file-level init — unusual but possible.
874
- cljExtractCalls(child, topLevelCaller, inFileDefns, calls, callSet);
1072
+ cljExtractCalls(child, topLevelCaller, ctx, calls, callSet);
875
1073
  continue;
876
1074
  }
877
1075
  switch (head.name) {
@@ -883,7 +1081,7 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
883
1081
  case "deftest": {
884
1082
  const name = cljNextSymNameAfter(child, head.symIdx);
885
1083
  if (name)
886
- cljExtractCalls(child, name, inFileDefns, calls, callSet);
1084
+ cljExtractCalls(child, qualifyCaller(name), ctx, calls, callSet);
887
1085
  break;
888
1086
  }
889
1087
  case "defmulti": {
@@ -893,7 +1091,7 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
893
1091
  // named. Attribute to the multi name.
894
1092
  const name = cljNextSymNameAfter(child, head.symIdx);
895
1093
  if (name)
896
- cljExtractCalls(child, name, inFileDefns, calls, callSet);
1094
+ cljExtractCalls(child, qualifyCaller(name), ctx, calls, callSet);
897
1095
  break;
898
1096
  }
899
1097
  case "defmethod": {
@@ -902,14 +1100,14 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
902
1100
  // is recorded as "called by" the multi and won't look dead.
903
1101
  const multiName = cljNextSymNameAfter(child, head.symIdx);
904
1102
  if (multiName)
905
- cljExtractCalls(child, multiName, inFileDefns, calls, callSet);
1103
+ cljExtractCalls(child, qualifyCaller(multiName), ctx, calls, callSet);
906
1104
  break;
907
1105
  }
908
1106
  case "defrecord":
909
1107
  case "deftype":
910
1108
  case "extend-type":
911
1109
  case "extend-protocol": {
912
- cljWalkDispatchMethods(child, inFileDefns, calls, callSet);
1110
+ cljWalkDispatchMethods(child, ctx, calls, callSet);
913
1111
  break;
914
1112
  }
915
1113
  default: {
@@ -917,7 +1115,7 @@ function walkClojure(rootNode, filePath, defines, calls, imports, exports, callS
917
1115
  // ns name as caller. Catches use-fixtures, (def x (compute)),
918
1116
  // (require '[...]), raw println calls, etc.
919
1117
  if (!CLJ_RECOGNIZED_TOPLEVEL.has(head.name)) {
920
- cljExtractCalls(child, topLevelCaller, inFileDefns, calls, callSet);
1118
+ cljExtractCalls(child, topLevelCaller, ctx, calls, callSet);
921
1119
  }
922
1120
  break;
923
1121
  }
@@ -1017,12 +1215,20 @@ const CLJ_RECURSE_TYPES = new Set([
1017
1215
  * site: emit its head as a callee, then apply HOF / threading-macro
1018
1216
  * reference rules to its arguments.
1019
1217
  */
1020
- function cljProcessCallSite(listLike, enclosingFn, calls, callSet) {
1218
+ function cljProcessCallSite(listLike, enclosingFn, ctx, calls, callSet) {
1021
1219
  const emit = (calleeRaw) => {
1022
- const callee = calleeRaw.includes("/") ? calleeRaw.split("/").pop() : calleeRaw;
1023
- if (!callee || callee === enclosingFn)
1220
+ if (!calleeRaw)
1024
1221
  return;
1025
- if (CLJ_SPECIAL_FORMS.has(callee))
1222
+ // Special-form filtering happens on the bare tail: `let` is a special
1223
+ // form whether written as `let` or `some.ns/let` (the latter is
1224
+ // degenerate but the filter shouldn't care). The aliased test lets
1225
+ // us shed def/let/if/etc. before paying for resolution.
1226
+ const slashIdx = calleeRaw.indexOf("/");
1227
+ const bareTail = slashIdx >= 0 ? calleeRaw.slice(slashIdx + 1) : calleeRaw;
1228
+ if (slashIdx < 0 && CLJ_SPECIAL_FORMS.has(bareTail))
1229
+ return;
1230
+ const callee = qualifyCljName(calleeRaw, ctx);
1231
+ if (!callee || callee === enclosingFn)
1026
1232
  return;
1027
1233
  const key = `${enclosingFn}->${callee}`;
1028
1234
  if (callSet.has(key))
@@ -1094,33 +1300,33 @@ function cljProcessCallSite(listLike, enclosingFn, calls, callSet) {
1094
1300
  * as its own call site via the same top-of-function check.
1095
1301
  *
1096
1302
  * Every sym_lit encountered at any depth is also checked against
1097
- * `inFileDefns`: if its name matches a definition in the current file,
1098
- * an edge is emitted. This covers in-file references that aren't at
1099
- * list-head position — e.g. fns passed to user-defined HOFs, fn values
1100
- * in map literals, `(def h my-fn)`, and registration-style calls like
1101
- * `(reg-event-fx :k handler)`.
1303
+ * `ctx.inFileDefns`: if its qualified form matches a definition in the
1304
+ * current file, an edge is emitted. This covers in-file references that
1305
+ * aren't at list-head position — e.g. fns passed to user-defined HOFs,
1306
+ * fn values in map literals, `(def h my-fn)`, and registration-style
1307
+ * calls like `(reg-event-fx :k handler)`.
1102
1308
  */
1103
- function cljExtractCalls(node, enclosingFn, inFileDefns, calls, callSet) {
1309
+ function cljExtractCalls(node, enclosingFn, ctx, calls, callSet) {
1104
1310
  if (node.type === "list_lit" || node.type === "anon_fn_lit") {
1105
- cljProcessCallSite(node, enclosingFn, calls, callSet);
1311
+ cljProcessCallSite(node, enclosingFn, ctx, calls, callSet);
1106
1312
  }
1107
1313
  for (let i = 0; i < node.childCount; i++) {
1108
1314
  const child = node.child(i);
1109
1315
  if (child.type === "sym_lit") {
1110
1316
  const name = cljSymName(child);
1111
1317
  if (name) {
1112
- const bare = name.includes("/") ? name.split("/").pop() : name;
1113
- if (bare && bare !== enclosingFn && inFileDefns.has(bare)) {
1114
- const key = `${enclosingFn}->${bare}`;
1318
+ const qualified = qualifyCljName(name, ctx);
1319
+ if (qualified && qualified !== enclosingFn && ctx.inFileDefns.has(qualified)) {
1320
+ const key = `${enclosingFn}->${qualified}`;
1115
1321
  if (!callSet.has(key)) {
1116
1322
  callSet.add(key);
1117
- calls.push({ caller: enclosingFn, callee: bare });
1323
+ calls.push({ caller: enclosingFn, callee: qualified });
1118
1324
  }
1119
1325
  }
1120
1326
  }
1121
1327
  }
1122
1328
  if (CLJ_RECURSE_TYPES.has(child.type)) {
1123
- cljExtractCalls(child, enclosingFn, inFileDefns, calls, callSet);
1329
+ cljExtractCalls(child, enclosingFn, ctx, calls, callSet);
1124
1330
  }
1125
1331
  }
1126
1332
  }