diffninja 0.1.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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,710 @@
1
+ import { childByType, collapseWs, namedChildren } from "./types.js";
2
+ /**
3
+ * Every static expression is stored under the provenance of how it was read,
4
+ * so only expressions that mean the same thing can match:
5
+ * `s:` a string literal value, `m:` a dotted member path whose value is not
6
+ * known, `i:` a bare identifier whose value is not known. An identifier is
7
+ * dynamic by nature, so it never matches.
8
+ */
9
+ const LITERAL_TAG = "s:";
10
+ const MEMBER_TAG = "m:";
11
+ const IDENTIFIER_TAG = "i:";
12
+ /** Emitter methods that publish a keyed event. */
13
+ const EVENT_EMIT_METHODS = { emit: true, emitAsync: true };
14
+ /** Queue method that enqueues a keyed job. */
15
+ const QUEUE_ADD_METHOD = "add";
16
+ /** BullMQ job field the consumer branches on. */
17
+ const JOB_NAME_PROPERTY = "name";
18
+ /** Decorators registering a handler for an event key, e.g. `OnTypedEvent`. */
19
+ const EVENT_REGISTRATION_DECORATOR = /^On[A-Za-z]*Event$/u;
20
+ /** DI decorator naming the queue a field or constructor parameter is bound to. */
21
+ const INJECT_QUEUE_DECORATOR = "InjectQueue";
22
+ /** Class decorator naming the queue a consumer class processes. */
23
+ const PROCESSOR_DECORATOR = "Processor";
24
+ /** Method decorator naming one job key a consumer handles. */
25
+ const PROCESS_DECORATOR = "Process";
26
+ /** Nodes that declare a function body, whose first parameter is its own `job`. */
27
+ const FUNCTION_NODES = {
28
+ function_declaration: true,
29
+ function_expression: true,
30
+ generator_function: true,
31
+ generator_function_declaration: true,
32
+ arrow_function: true,
33
+ method_definition: true,
34
+ };
35
+ /** Nodes that declare a class body, the scope of `@InjectQueue` and `@Processor`. */
36
+ const CLASS_NODES = { class_declaration: true, class: true };
37
+ // ── Static key expressions ───────────────────────────────────────────────────
38
+ /** The unwrapped expression behind `(x)`, `x as T` and `x satisfies T`. */
39
+ function stripWrappers(node) {
40
+ let current = node;
41
+ for (;;) {
42
+ const inner = current.type === "parenthesized_expression"
43
+ ? namedChildren(current)[0]
44
+ : current.type === "as_expression" || current.type === "satisfies_expression"
45
+ ? current.childForFieldName("expression") ?? namedChildren(current)[0]
46
+ : undefined;
47
+ if (!inner || inner.id === current.id)
48
+ return current;
49
+ current = inner;
50
+ }
51
+ }
52
+ /**
53
+ * Literal text of a plain string, or `null` when it carries an escape, a line
54
+ * break, or a substitution. Escapes are never decoded, so an expression that
55
+ * needs decoding stays dynamic instead of being guessed.
56
+ */
57
+ function stringValue(node) {
58
+ const text = node.text;
59
+ if (node.type === "template_string" && namedChildren(node).length > 0)
60
+ return null;
61
+ if (text.length < 2)
62
+ return null;
63
+ const quote = text[0];
64
+ if (quote !== '"' && quote !== "'" && quote !== "`")
65
+ return null;
66
+ if (text.at(-1) !== quote)
67
+ return null;
68
+ const body = text.slice(1, -1);
69
+ if (body.includes("\\") || body.includes(quote) || body.includes("\n"))
70
+ return null;
71
+ return body;
72
+ }
73
+ /** Dotted path of a member expression, or `null` when a segment is computed. */
74
+ function memberPath(node) {
75
+ if (node.type === "identifier" || node.type === "property_identifier")
76
+ return node.text;
77
+ if (node.type !== "member_expression")
78
+ return null;
79
+ const object = node.childForFieldName("object");
80
+ const property = node.childForFieldName("property");
81
+ if (!property || property.type !== "property_identifier")
82
+ return null;
83
+ const prefix = object ? memberPath(object) : null;
84
+ return prefix === null ? null : `${prefix}.${property.text}`;
85
+ }
86
+ /** Read a key expression as written. `null` means dynamic: never recorded. */
87
+ function staticKeyOf(node) {
88
+ const expression = stripWrappers(node);
89
+ if (expression.type === "string" || expression.type === "template_string") {
90
+ const value = stringValue(expression);
91
+ return value === null ? null : { tag: LITERAL_TAG + value, text: collapseWs(expression.text) };
92
+ }
93
+ if (expression.type === "identifier") {
94
+ return { tag: IDENTIFIER_TAG + expression.text, text: expression.text };
95
+ }
96
+ if (expression.type === "member_expression") {
97
+ const path = memberPath(expression);
98
+ return path === null ? null : { tag: MEMBER_TAG + path, text: collapseWs(expression.text) };
99
+ }
100
+ return null;
101
+ }
102
+ /** Field name of a `this.<field>` receiver, or `null` for any other receiver. */
103
+ function thisFieldName(object) {
104
+ if (!object || object.type !== "member_expression")
105
+ return null;
106
+ if (object.childForFieldName("object")?.type !== "this")
107
+ return null;
108
+ const property = object.childForFieldName("property");
109
+ return property?.type === "property_identifier" ? property.text : null;
110
+ }
111
+ /** Constant tables travel with immutable extraction metadata, never a file-global latest snapshot. */
112
+ const constantsByReview = new WeakMap();
113
+ function addMember(constants, container, member, value) {
114
+ const table = constants.members.get(container) ?? new Map();
115
+ table.set(member, value);
116
+ constants.members.set(container, table);
117
+ }
118
+ /** The single value a table holds for a key, or `null` when absent/divergent. */
119
+ function oneValue(values) {
120
+ let found = null;
121
+ for (const value of values) {
122
+ if (found !== null && found !== value)
123
+ return null;
124
+ found = value;
125
+ }
126
+ return found;
127
+ }
128
+ function collectConstants(statement, constants) {
129
+ const declaration = statement.type === "export_statement" ? namedChildren(statement)[0] ?? statement : statement;
130
+ if (declaration.type === "enum_declaration")
131
+ return collectEnum(declaration, constants);
132
+ if (declaration.type !== "lexical_declaration" && declaration.type !== "variable_declaration") {
133
+ return;
134
+ }
135
+ for (const declarator of namedChildren(declaration)) {
136
+ if (declarator.type !== "variable_declarator")
137
+ continue;
138
+ const name = declarator.childForFieldName("name") ?? childByType(declarator, "identifier");
139
+ const value = declarator.childForFieldName("value");
140
+ if (!name || name.type !== "identifier" || !value)
141
+ continue;
142
+ collectDeclarator(name.text, value, constants);
143
+ }
144
+ }
145
+ /** Enum members with a plain string initialiser; a bare member has no value. */
146
+ function collectEnum(declaration, constants) {
147
+ const name = childByType(declaration, "identifier")?.text;
148
+ const body = childByType(declaration, "enum_body");
149
+ if (!name || !body)
150
+ return;
151
+ for (const member of namedChildren(body)) {
152
+ if (member.type !== "enum_assignment")
153
+ continue;
154
+ const key = childByType(member, "property_identifier")?.text;
155
+ const initialiser = childByType(member, "string");
156
+ if (!key || !initialiser)
157
+ continue;
158
+ const value = stringValue(initialiser);
159
+ if (value !== null)
160
+ addMember(constants, name, key, value);
161
+ }
162
+ }
163
+ /** One `const NAME = value`: a string constant, or an all-string key map. */
164
+ function collectDeclarator(name, value, constants) {
165
+ const expression = stripWrappers(value);
166
+ if (expression.type !== "object") {
167
+ const literal = stringValue(expression);
168
+ if (literal !== null)
169
+ constants.scalars.set(name, literal);
170
+ return;
171
+ }
172
+ const entries = [];
173
+ for (const pair of namedChildren(expression)) {
174
+ if (pair.type !== "pair")
175
+ return;
176
+ const key = pair.childForFieldName("key");
177
+ const entryValue = pair.childForFieldName("value");
178
+ if (!key || !entryValue)
179
+ return;
180
+ const member = key.type === "property_identifier"
181
+ ? key.text
182
+ : key.type === "string"
183
+ ? stringValue(key)
184
+ : null;
185
+ const literal = entryValue.type === "string" ? stringValue(entryValue) : null;
186
+ if (member === null || literal === null)
187
+ return;
188
+ entries.push([member, literal]);
189
+ }
190
+ for (const [member, literal] of entries)
191
+ addMember(constants, name, member, literal);
192
+ }
193
+ /** Member literal from this file first, then from any file of the active set. */
194
+ function lookupMember(container, member, file, files) {
195
+ const local = files.get(file)?.members.get(container)?.get(member);
196
+ if (local !== undefined)
197
+ return local;
198
+ const values = [];
199
+ for (const constants of files.values()) {
200
+ const value = constants.members.get(container)?.get(member);
201
+ if (value !== undefined)
202
+ values.push(value);
203
+ }
204
+ return oneValue(values);
205
+ }
206
+ /** Scalar literal from this file first, then from any file of the active set. */
207
+ function lookupScalar(name, file, files) {
208
+ const local = files.get(file)?.scalars.get(name);
209
+ if (local !== undefined)
210
+ return local;
211
+ const values = [];
212
+ for (const constants of files.values()) {
213
+ const value = constants.scalars.get(name);
214
+ if (value !== undefined)
215
+ values.push(value);
216
+ }
217
+ return oneValue(values);
218
+ }
219
+ const ROOT_SCOPE = {
220
+ injected: new Map(),
221
+ processorChannel: null,
222
+ jobParam: null,
223
+ };
224
+ /**
225
+ * Record this file's dispatch sites on their owning functions. Declarations are
226
+ * never added; only `review.dispatches` is appended, next to whatever the
227
+ * contract extractor already wrote there.
228
+ */
229
+ export function extractDispatchContext(file, tree, functions) {
230
+ const constants = { members: new Map(), scalars: new Map() };
231
+ for (const statement of namedChildren(tree.rootNode))
232
+ collectConstants(statement, constants);
233
+ const context = {
234
+ functions: functions.filter((fn) => fn.file === file),
235
+ };
236
+ visitNode(tree.rootNode, context, ROOT_SCOPE);
237
+ for (const fn of context.functions) {
238
+ fn.review ??= {};
239
+ constantsByReview.set(fn.review, constants);
240
+ }
241
+ }
242
+ /** Smallest extracted function whose span contains the node, or `null`. */
243
+ function ownerFor(node, context) {
244
+ let owner = null;
245
+ for (const fn of context.functions) {
246
+ if (fn.start > node.startIndex || fn.end < node.endIndex)
247
+ continue;
248
+ if (!owner || fn.end - fn.start < owner.end - owner.start)
249
+ owner = fn;
250
+ }
251
+ return owner;
252
+ }
253
+ /**
254
+ * Attach one record to the smallest function containing `node`. Repeating the
255
+ * walk over the same tree, or a cached extraction replayed, must not duplicate.
256
+ */
257
+ function pushDispatch(context, node, record) {
258
+ const owner = ownerFor(node, context);
259
+ if (!owner)
260
+ return;
261
+ const full = { ...record, line: node.startPosition.row + 1 };
262
+ const existing = owner.review?.dispatches;
263
+ const duplicate = existing?.some((prior) => prior.kind === full.kind &&
264
+ prior.direction === full.direction &&
265
+ prior.key === full.key &&
266
+ prior.channel === full.channel &&
267
+ prior.line === full.line);
268
+ if (duplicate)
269
+ return;
270
+ owner.review = { ...owner.review, dispatches: [...(existing ?? []), full] };
271
+ }
272
+ /** Callee name of a call or decorator: last identifier written. */
273
+ function calleeName(node) {
274
+ if (!node)
275
+ return null;
276
+ if (node.type === "identifier")
277
+ return node.text;
278
+ if (node.type === "member_expression") {
279
+ const property = node.childForFieldName("property");
280
+ return property?.type === "property_identifier" ? property.text : null;
281
+ }
282
+ return null;
283
+ }
284
+ /** Call arguments as written, in source order. */
285
+ function callArguments(call) {
286
+ const args = call.childForFieldName("arguments");
287
+ return args ? namedChildren(args) : [];
288
+ }
289
+ /** Decorator callee name plus its arguments, or `null` without a call. */
290
+ function decoratorCall(decorator) {
291
+ const expression = namedChildren(decorator)[0];
292
+ if (!expression || expression.type !== "call_expression")
293
+ return null;
294
+ const name = calleeName(expression.childForFieldName("function"));
295
+ if (name === null)
296
+ return null;
297
+ return { name, args: callArguments(expression) };
298
+ }
299
+ /** First parameter name of a function node, the object `job.name` reads. */
300
+ function firstParamName(node) {
301
+ const params = childByType(node, "formal_parameters");
302
+ if (params) {
303
+ const first = namedChildren(params)[0];
304
+ if (!first)
305
+ return null;
306
+ if (first.type === "identifier")
307
+ return first.text;
308
+ return childByType(first, "identifier")?.text ?? null;
309
+ }
310
+ const sole = namedChildren(node)[0];
311
+ return sole?.type === "identifier" ? sole.text : null;
312
+ }
313
+ /** A `@InjectQueue(...)` receiver: the bound name and its channel. */
314
+ function injectedBinding(node, decorators) {
315
+ let channel = null;
316
+ let found = false;
317
+ for (const decorator of decorators) {
318
+ const call = decoratorCall(decorator);
319
+ if (!call || call.name !== INJECT_QUEUE_DECORATOR)
320
+ continue;
321
+ found = true;
322
+ const argument = call.args[0];
323
+ if (argument)
324
+ channel = staticKeyOf(argument);
325
+ }
326
+ if (!found)
327
+ return null;
328
+ const name = childByType(node, "property_identifier")?.text ?? childByType(node, "identifier")?.text;
329
+ return name === undefined ? null : { name, channel };
330
+ }
331
+ function classMembers(body) {
332
+ const members = [];
333
+ let decorators = [];
334
+ for (const element of namedChildren(body)) {
335
+ if (element.type === "decorator") {
336
+ decorators.push(element);
337
+ continue;
338
+ }
339
+ if (element.type === "comment")
340
+ continue;
341
+ members.push({ node: element, decorators });
342
+ decorators = [];
343
+ }
344
+ return members;
345
+ }
346
+ /** Channel of the class `@Processor(...)`, from the class or its export. */
347
+ function processorChannel(node) {
348
+ const parent = node.parent;
349
+ const decorators = [
350
+ ...namedChildren(node).filter((child) => child.type === "decorator"),
351
+ ...(parent?.type === "export_statement"
352
+ ? namedChildren(parent).filter((child) => child.type === "decorator")
353
+ : []),
354
+ ];
355
+ let channel = null;
356
+ for (const decorator of decorators) {
357
+ const call = decoratorCall(decorator);
358
+ if (!call || call.name !== PROCESSOR_DECORATOR)
359
+ continue;
360
+ const argument = call.args[0];
361
+ if (argument)
362
+ channel = staticKeyOf(argument);
363
+ }
364
+ return channel;
365
+ }
366
+ /** Where a consumer's channel comes from, appended to handler evidence. */
367
+ function processorNote(channel) {
368
+ return channel === null ? " (no static @Processor channel)" : ` (@Processor(${channel.text}))`;
369
+ }
370
+ /** A class body: channels in effect, then every member's decorators and body. */
371
+ function visitClass(node, context, outer) {
372
+ const body = childByType(node, "class_body");
373
+ if (!body)
374
+ return;
375
+ const members = classMembers(body);
376
+ const injected = new Map();
377
+ for (const member of members) {
378
+ const field = injectedBinding(member.node, member.decorators);
379
+ if (field)
380
+ injected.set(field.name, field.channel);
381
+ if (member.node.type !== "method_definition")
382
+ continue;
383
+ if (childByType(member.node, "property_identifier")?.text !== "constructor")
384
+ continue;
385
+ const params = childByType(member.node, "formal_parameters");
386
+ if (!params)
387
+ continue;
388
+ for (const param of namedChildren(params)) {
389
+ const binding = injectedBinding(param, namedChildren(param).filter((child) => child.type === "decorator"));
390
+ if (binding)
391
+ injected.set(binding.name, binding.channel);
392
+ }
393
+ }
394
+ const scope = {
395
+ injected,
396
+ processorChannel: processorChannel(node),
397
+ jobParam: outer.jobParam,
398
+ };
399
+ for (const member of members) {
400
+ for (const decorator of member.decorators) {
401
+ registerDecorator(decorator, member.node, context, scope);
402
+ }
403
+ visitNode(member.node, context, scope);
404
+ }
405
+ }
406
+ /** `@On*Event(key)` and `@Process(key)` register a handler for a key. */
407
+ function registerDecorator(decorator, member, context, scope) {
408
+ const call = decoratorCall(decorator);
409
+ if (!call)
410
+ return;
411
+ const isEvent = EVENT_REGISTRATION_DECORATOR.test(call.name);
412
+ const isJob = call.name === PROCESS_DECORATOR;
413
+ if (!isEvent && !isJob)
414
+ return;
415
+ const argument = call.args[0];
416
+ const key = argument ? staticKeyOf(argument) : null;
417
+ if (!key)
418
+ return;
419
+ const owner = ownerFor(member, context);
420
+ const target = owner ? owner.key : collapseWs(member.text).slice(0, 60);
421
+ if (isEvent) {
422
+ pushDispatch(context, member, {
423
+ kind: "event",
424
+ direction: "handle",
425
+ key: key.tag,
426
+ evidence: `@${call.name}(${key.text}) on ${target}`,
427
+ });
428
+ return;
429
+ }
430
+ pushDispatch(context, member, {
431
+ kind: "queue",
432
+ direction: "handle",
433
+ key: key.tag,
434
+ channel: scope.processorChannel?.tag,
435
+ evidence: `@${call.name}(${key.text}) on ${target}${processorNote(scope.processorChannel)}`,
436
+ });
437
+ }
438
+ /** One function body: its own `job` parameter, its enclosing class channels. */
439
+ function visitFunction(node, context, outer) {
440
+ const scope = {
441
+ injected: outer.injected,
442
+ processorChannel: outer.processorChannel,
443
+ jobParam: firstParamName(node) ?? outer.jobParam,
444
+ };
445
+ for (const child of namedChildren(node)) {
446
+ if (child.type === "formal_parameters")
447
+ continue;
448
+ visitNode(child, context, scope);
449
+ }
450
+ }
451
+ /** `x.emit(key, …)` / `x.emitAsync(key, …)` publish an event key. */
452
+ function checkEventEmit(call, property, receiver, context) {
453
+ if (!Object.hasOwn(EVENT_EMIT_METHODS, property.text))
454
+ return false;
455
+ const argument = callArguments(call)[0];
456
+ const key = argument ? staticKeyOf(argument) : null;
457
+ // A bare identifier here may be a local, so only a literal or a member path
458
+ // is a key; `emit(name)` stays dynamic rather than resolving to a same-named
459
+ // module constant.
460
+ if (!key || key.tag.startsWith(IDENTIFIER_TAG))
461
+ return true;
462
+ const source = receiver ? collapseWs(receiver.text) : "?";
463
+ pushDispatch(context, call, {
464
+ kind: "event",
465
+ direction: "emit",
466
+ key: key.tag,
467
+ evidence: `${property.text}(${key.text}) on ${source}`,
468
+ });
469
+ return true;
470
+ }
471
+ /** `this.<injected queue>.add(key, …)` enqueues one job key. */
472
+ function checkQueueAdd(call, receiver, context, scope) {
473
+ const field = thisFieldName(receiver);
474
+ if (field === null)
475
+ return;
476
+ const injected = scope.injected.get(field);
477
+ if (injected === undefined)
478
+ return;
479
+ const argument = callArguments(call)[0];
480
+ const key = argument ? staticKeyOf(argument) : null;
481
+ // `add(name)` may compare a local against a module constant, which the
482
+ // constant table cannot separate; a queue name in a decorator cannot.
483
+ if (!key || key.tag.startsWith(IDENTIFIER_TAG))
484
+ return;
485
+ const binding = injected === null ? "@InjectQueue(...) with a non-static channel" : `@InjectQueue(${injected.text})`;
486
+ pushDispatch(context, call, {
487
+ kind: "queue",
488
+ direction: "emit",
489
+ key: key.tag,
490
+ channel: injected?.tag,
491
+ evidence: `queue.add(${key.text}) on this.${field} bound by ${binding}`,
492
+ });
493
+ }
494
+ function checkCall(call, context, scope) {
495
+ const callee = call.childForFieldName("function");
496
+ if (!callee || callee.type !== "member_expression")
497
+ return;
498
+ const property = callee.childForFieldName("property");
499
+ if (!property || property.type !== "property_identifier")
500
+ return;
501
+ const receiver = callee.childForFieldName("object");
502
+ if (checkEventEmit(call, property, receiver, context))
503
+ return;
504
+ if (property.text === QUEUE_ADD_METHOD)
505
+ checkQueueAdd(call, receiver, context, scope);
506
+ }
507
+ /** True for `<job>.name`, the BullMQ job name the consumer branches on. */
508
+ function isJobNameAccess(node, jobParam) {
509
+ if (!node || !jobParam || node.type !== "member_expression")
510
+ return false;
511
+ const object = node.childForFieldName("object");
512
+ const property = node.childForFieldName("property");
513
+ return object?.type === "identifier" && object.text === jobParam && property?.text === JOB_NAME_PROPERTY;
514
+ }
515
+ /** `switch (job.name) { case key: }` handles one job key per static case. */
516
+ function checkSwitch(node, context, scope) {
517
+ const discriminant = childByType(node, "parenthesized_expression");
518
+ const subject = discriminant ? namedChildren(discriminant)[0] ?? null : null;
519
+ if (!subject || !isJobNameAccess(subject, scope.jobParam))
520
+ return;
521
+ const body = childByType(node, "switch_body");
522
+ if (!body)
523
+ return;
524
+ const owner = ownerFor(node, context);
525
+ const target = owner ? owner.key : "?";
526
+ for (const clause of namedChildren(body)) {
527
+ if (clause.type !== "switch_case")
528
+ continue;
529
+ const written = namedChildren(clause)[0];
530
+ const key = written ? staticKeyOf(written) : null;
531
+ // A bare identifier may be a local, so only a literal or member path counts.
532
+ if (!key || key.tag.startsWith(IDENTIFIER_TAG))
533
+ continue;
534
+ pushDispatch(context, clause, {
535
+ kind: "queue",
536
+ direction: "handle",
537
+ key: key.tag,
538
+ channel: scope.processorChannel?.tag,
539
+ evidence: `case ${key.text} of switch (${subject.text}) in ${target}${processorNote(scope.processorChannel)}`,
540
+ });
541
+ }
542
+ }
543
+ /** `if (job.name === key)` handles one job key without a switch. */
544
+ function checkIf(node, context, scope) {
545
+ const condition = node.childForFieldName("condition") ?? childByType(node, "parenthesized_expression");
546
+ const comparison = condition ? stripWrappers(condition) : null;
547
+ if (!comparison || comparison.type !== "binary_expression")
548
+ return;
549
+ const equality = comparison.children.some((child) => child.type === "===" || child.type === "==");
550
+ if (!equality)
551
+ return;
552
+ const left = comparison.childForFieldName("left");
553
+ const right = comparison.childForFieldName("right");
554
+ const written = isJobNameAccess(left, scope.jobParam)
555
+ ? right
556
+ : isJobNameAccess(right, scope.jobParam)
557
+ ? left
558
+ : null;
559
+ const key = written ? staticKeyOf(written) : null;
560
+ // A bare identifier may be a local, so only a literal or member path counts.
561
+ if (!key || key.tag.startsWith(IDENTIFIER_TAG))
562
+ return;
563
+ const owner = ownerFor(node, context);
564
+ const target = owner ? owner.key : "?";
565
+ pushDispatch(context, node, {
566
+ kind: "queue",
567
+ direction: "handle",
568
+ key: key.tag,
569
+ channel: scope.processorChannel?.tag,
570
+ evidence: `if (${scope.jobParam}.${JOB_NAME_PROPERTY} === ${key.text}) in ${target}${processorNote(scope.processorChannel)}`,
571
+ });
572
+ }
573
+ function visitNode(node, context, scope) {
574
+ if (Object.hasOwn(CLASS_NODES, node.type))
575
+ return visitClass(node, context, scope);
576
+ if (Object.hasOwn(FUNCTION_NODES, node.type))
577
+ return visitFunction(node, context, scope);
578
+ if (node.type === "call_expression")
579
+ checkCall(node, context, scope);
580
+ else if (node.type === "switch_statement")
581
+ checkSwitch(node, context, scope);
582
+ else if (node.type === "if_statement")
583
+ checkIf(node, context, scope);
584
+ for (const child of namedChildren(node))
585
+ visitNode(child, context, scope);
586
+ }
587
+ /**
588
+ * Read a stored key against the constant tables. A member path or identifier
589
+ * becomes a literal when the indexed files agree on exactly one value; a
590
+ * divergent or unknown value stays as written, which is why it can only match
591
+ * the same written path.
592
+ */
593
+ function effectiveKey(tag, file, files) {
594
+ if (tag === undefined)
595
+ return null;
596
+ if (tag.startsWith(LITERAL_TAG)) {
597
+ const value = tag.slice(LITERAL_TAG.length);
598
+ return { kind: "value", key: value, text: value };
599
+ }
600
+ if (tag.startsWith(MEMBER_TAG)) {
601
+ const path = tag.slice(MEMBER_TAG.length);
602
+ const written = { kind: "member", key: path, text: path };
603
+ const dot = path.indexOf(".");
604
+ if (dot <= 0 || dot === path.length - 1)
605
+ return written;
606
+ const value = lookupMember(path.slice(0, dot), path.slice(dot + 1), file, files);
607
+ return value === null ? written : { kind: "value", key: value, text: path };
608
+ }
609
+ const name = tag.slice(IDENTIFIER_TAG.length);
610
+ const value = lookupScalar(name, file, files);
611
+ return value === null
612
+ ? { kind: "identifier", key: name, text: name }
613
+ : { kind: "value", key: value, text: name };
614
+ }
615
+ /** Two keys align only as the same literal value or the same written path. */
616
+ function keysAlign(a, b) {
617
+ if (!a || !b)
618
+ return false;
619
+ if (a.kind === "identifier" || b.kind === "identifier")
620
+ return false;
621
+ return a.kind === b.kind && a.key === b.key;
622
+ }
623
+ function display(key) {
624
+ if (key.kind === "value")
625
+ return `'${key.key}'`;
626
+ return `${key.key} (unresolved ${key.kind === "member" ? "member path" : "identifier"})`;
627
+ }
628
+ function edgeEvidence(emit, handle, key, channel) {
629
+ const written = [...new Set([emit.key, handle.key]
630
+ .filter((side) => side !== null && side.text !== key.key)
631
+ .map((side) => side.text))];
632
+ const keyPhrase = key.kind === "value"
633
+ ? `key value ${display(key)}${written.length === 0 ? "" : ` resolved from ${written.join(" and ")}`}`
634
+ : `identical static key path ${display(key)}`;
635
+ const channelPhrase = channel === null
636
+ ? "channel not required for events"
637
+ : `channel ${display(channel)}`;
638
+ return [
639
+ `${emit.record.kind} relation: ${keyPhrase}; ${channelPhrase};`,
640
+ `emit ${emit.fn.file}:${emit.record.line} ${emit.record.evidence} ->`,
641
+ `handle ${handle.fn.file}:${handle.record.line} ${handle.record.evidence};`,
642
+ "static syntax relation, not a runtime call or proof of delivery",
643
+ ].join(" ");
644
+ }
645
+ /**
646
+ * Pair every event/queue producer with the handlers registered for its key.
647
+ * Queue edges additionally require both sides to name the same channel, so two
648
+ * queues that reuse a job name never cross. One edge per (kind, owner, target,
649
+ * key): repeated sites in one function collapse onto the earliest line.
650
+ */
651
+ export function resolveDispatchContext(functions) {
652
+ const files = new Map();
653
+ for (const fn of functions) {
654
+ const constants = fn.review && constantsByReview.get(fn.review);
655
+ if (constants)
656
+ files.set(fn.file, constants);
657
+ }
658
+ const emits = [];
659
+ const handles = [];
660
+ for (const fn of functions) {
661
+ for (const record of fn.review?.dispatches ?? []) {
662
+ const site = {
663
+ fn,
664
+ record,
665
+ key: effectiveKey(record.key, fn.file, files),
666
+ channel: effectiveKey(record.channel, fn.file, files),
667
+ };
668
+ if (site.key === null)
669
+ continue;
670
+ (record.direction === "emit" ? emits : handles).push(site);
671
+ }
672
+ }
673
+ emits.sort((a, b) => a.record.line - b.record.line);
674
+ handles.sort((a, b) => a.record.line - b.record.line);
675
+ const edges = [];
676
+ const seen = new Set();
677
+ for (const emit of emits) {
678
+ for (const handle of handles) {
679
+ if (emit.fn === handle.fn)
680
+ continue;
681
+ if (emit.record.kind !== handle.record.kind)
682
+ continue;
683
+ if (emit.key === null || handle.key === null)
684
+ continue;
685
+ if (!keysAlign(emit.key, handle.key))
686
+ continue;
687
+ if (emit.record.kind === "queue" && !keysAlign(emit.channel, handle.channel))
688
+ continue;
689
+ const identity = [
690
+ emit.record.kind,
691
+ emit.fn.file,
692
+ emit.fn.key,
693
+ handle.fn.file,
694
+ handle.fn.key,
695
+ emit.key.key,
696
+ ].join("\u0000");
697
+ if (seen.has(identity))
698
+ continue;
699
+ seen.add(identity);
700
+ edges.push({
701
+ owner: emit.fn,
702
+ target: handle.fn,
703
+ kind: emit.record.kind,
704
+ line: emit.record.line,
705
+ evidence: edgeEvidence(emit, handle, emit.key, emit.record.kind === "queue" ? emit.channel : null),
706
+ });
707
+ }
708
+ }
709
+ return edges;
710
+ }