@filipebraida/adonis-function-points 0.1.0 → 0.3.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 (57) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/README.md +103 -2
  3. package/build/calibration-8eV8CEix.js +403 -0
  4. package/build/commands/fp_metrics.d.ts +10 -0
  5. package/build/commands/main.d.ts +6 -5
  6. package/build/commands/main.js +48 -114
  7. package/build/decorate-D6enDn9D.js +24 -0
  8. package/build/fp_calibrate-iFAec0tA.js +25 -0
  9. package/build/fp_count-D21tQ_pv.js +22 -0
  10. package/build/fp_diff-D0pHGMgi.js +25 -0
  11. package/build/fp_explain-BwFs-LW-.js +24 -0
  12. package/build/fp_inventory-Bu6O1Nn0.js +18 -0
  13. package/build/fp_metrics-MGDppfSa.js +20 -0
  14. package/build/index.d.ts +30 -1
  15. package/build/index.js +5 -3
  16. package/build/{pipeline-BzP-ITGN.js → pipeline-CIAydCcT.js} +452 -54
  17. package/build/{resolvers-CU9HKYpn.js → resolvers-CRB6lXoo.js} +474 -207
  18. package/build/{runners-Bt8tbISi.js → runners-CmxNHuuq.js} +146 -342
  19. package/build/src/albrecht/counter.d.ts +21 -1
  20. package/build/src/albrecht/data_functions.d.ts +6 -0
  21. package/build/src/albrecht/diff.d.ts +19 -1
  22. package/build/src/cli/runners.d.ts +12 -0
  23. package/build/src/cli.js +11 -2
  24. package/build/src/define_config.d.ts +35 -1
  25. package/build/src/inventory/detectors/lucid.d.ts +8 -0
  26. package/build/src/inventory/graph/call_graph.d.ts +29 -0
  27. package/build/src/inventory/graph/noise.d.ts +13 -0
  28. package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
  29. package/build/src/inventory/resolvers/index.js +1 -1
  30. package/build/src/inventory/resolvers/types.d.ts +35 -0
  31. package/build/src/inventory/sources/event_bindings.d.ts +44 -0
  32. package/build/src/metrics/structure.d.ts +16 -2
  33. package/build/src/pipeline.js +1 -1
  34. package/build/src/reporters/table.d.ts +11 -1
  35. package/build/src/types.d.ts +17 -0
  36. package/build/stubs/config.stub +27 -1
  37. package/package.json +2 -1
  38. package/build/define_config-DOqWyPwV.js +0 -19
  39. package/build/scripts/smoke_package.d.ts +0 -1
  40. package/build/tmp/probe.d.ts +0 -1
  41. package/build/tmp/probe_cli.d.ts +0 -1
  42. package/build/tmp/probe_cmp.d.ts +0 -1
  43. package/build/tmp/probe_count.d.ts +0 -1
  44. package/build/tmp/probe_data.d.ts +0 -1
  45. package/build/tmp/probe_diff.d.ts +0 -1
  46. package/build/tmp/probe_gap.d.ts +0 -1
  47. package/build/tmp/probe_graph.d.ts +0 -1
  48. package/build/tmp/probe_metrics.d.ts +0 -1
  49. package/build/tmp/probe_miss.d.ts +0 -1
  50. package/build/tmp/probe_names.d.ts +0 -1
  51. package/build/tmp/probe_nodata.d.ts +0 -1
  52. package/build/tmp/probe_one.d.ts +0 -1
  53. package/build/tmp/probe_perf.d.ts +0 -1
  54. package/build/tmp/probe_routes.d.ts +0 -1
  55. package/build/tmp/probe_unres.d.ts +0 -1
  56. package/build/tmp/probe_vazquez.d.ts +0 -1
  57. package/build/tsdown.config.d.ts +0 -2
@@ -1,228 +1,150 @@
1
- import { Node, SyntaxKind } from "ts-morph";
2
- //#region src/inventory/resolvers/action_object.ts
1
+ import { Node, Project, SyntaxKind } from "ts-morph";
2
+ //#region src/inventory/paths.ts
3
3
  /**
4
- * "Action object" pattern: the transaction delegates to an action instantiated
5
- * at the call site.
4
+ * One canonical spelling for every path the inventory emits.
6
5
  *
7
- * await new ExpireInvite().handle({ invite })
6
+ * Two path styles meet in this package. ts-morph always returns forward
7
+ * slashes, including on Windows; node's `path.join` returns backslashes there.
8
+ * Both end up in `HandlerRef.file`, and the call graph uses that string as a
9
+ * cache key:
8
10
  *
9
- * const mark = new MarkContentChanged()
10
- * await mark.handle({ documentId })
11
+ * const key = `${ref.file}#${ref.member ?? ref.line ?? '*'}`
11
12
  *
12
- * The second form keeps the instance in a local variable, so the declaration
13
- * has to be followed back to the `new` — that is what `classOfReceiver` does.
14
- */
15
- const actionObjectResolver = {
16
- name: "action-object",
17
- order: 10,
18
- resolve(call, ctx) {
19
- const expr = call.getExpression();
20
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
21
- const member = expr.getName();
22
- const className = classOfReceiver(expr.getExpression());
23
- if (!className) return [];
24
- const file = ctx.imports.get(className);
25
- if (!file) return [];
26
- return [{
27
- file,
28
- member
29
- }];
30
- }
31
- };
32
- /**
33
- * Finds the class behind a call receiver.
13
+ * Two spellings of the same file are two keys, so the same body would be
14
+ * analysed twice and pushed twice into the trace and the implementation scope
15
+ * — and a repeated scope entry changes the hash `fp:diff` compares.
34
16
  *
35
- * new Foo().handle() -> 'Foo'
36
- * foo.handle() where const foo = new Foo() -> 'Foo'
17
+ * Forward slashes win because ts-morph cannot be told otherwise, node's `fs`
18
+ * accepts them on Windows, and `path.relative` normalises mixed input anyway.
19
+ * Normalising at the boundary where a path is created costs one call; leaving
20
+ * it to each comparison costs vigilance forever.
37
21
  */
38
- function classOfReceiver(receiver) {
39
- if (receiver.isKind(SyntaxKind.NewExpression)) {
40
- const target = receiver.getExpression();
41
- return target.isKind(SyntaxKind.Identifier) ? target.getText() : null;
42
- }
43
- if (receiver.isKind(SyntaxKind.Identifier)) {
44
- const init = (receiver.getSymbol()?.getDeclarations().find((d) => d.isKind(SyntaxKind.VariableDeclaration)))?.asKind(SyntaxKind.VariableDeclaration)?.getInitializer();
45
- if (init?.isKind(SyntaxKind.NewExpression)) {
46
- const target = init.getExpression();
47
- return target.isKind(SyntaxKind.Identifier) ? target.getText() : null;
48
- }
22
+ const toPosix = (value) => value.split("\\").join("/");
23
+ /** Compares two paths that may have come from different sources. */
24
+ const samePath = (a, b) => a !== void 0 && b !== void 0 && toPosix(a) === toPosix(b);
25
+ //#endregion
26
+ //#region src/inventory/sources/event_bindings.ts
27
+ /** the method a listener declares; AdonisJS calls `handle` unless told otherwise */
28
+ const LISTENER_METHOD = "handle";
29
+ function collectEventBindings(app) {
30
+ const project = new Project({
31
+ skipAddingFilesFromTsConfig: true,
32
+ skipFileDependencyResolution: true,
33
+ compilerOptions: { allowJs: false }
34
+ });
35
+ for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
36
+ const bindings = /* @__PURE__ */ new Map();
37
+ for (const file of project.getSourceFiles()) for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
38
+ const expression = call.getExpression();
39
+ if (!Node.isPropertyAccessExpression(expression)) continue;
40
+ if (expression.getName() !== "on") continue;
41
+ const [event, handlers] = call.getArguments();
42
+ if (!event || !handlers) continue;
43
+ const eventFile = resolveEventClass(event, file, app);
44
+ if (!eventFile) continue;
45
+ const refs = listenersOf(handlers, file, app);
46
+ if (refs.length === 0) continue;
47
+ bindings.set(eventFile, [...bindings.get(eventFile) ?? [], ...refs]);
49
48
  }
50
- return null;
49
+ return bindings;
51
50
  }
52
- //#endregion
53
- //#region src/inventory/resolvers/job_dispatch.ts
54
- const DISPATCH_METHODS = new Set([
55
- "dispatch",
56
- "dispatchLater",
57
- "enqueue",
58
- "later"
59
- ]);
60
51
  /**
61
- * "Job" pattern: the write happens asynchronously.
62
- *
63
- * await CreateUserJob.dispatch({ userId })
64
- *
65
- * Runs before `static-service` on purpose: the syntactic shape is identical
66
- * (`Identifier.method(args)`) and the generic strategy would swallow the job.
67
- * The distinction is semantic, and it matters because the counting decision
68
- * differs.
52
+ * The event class a dispatch or a binding names.
69
53
  *
70
- * COUNTING DECISION: a job dispatched by a handler is followed as part of the
71
- * SAME transactional function, because IFPUG counts what the user recognises —
72
- * they click and the effect happens, even if execution is asynchronous. A
73
- * SCHEDULED job, which nobody dispatches, is a different thing: it is an entry
74
- * point of its own, and out of v1 scope — only HTTP routes are collected.
54
+ * Two shapes reach here: the class imported directly, and the generated
55
+ * registry (`events.OrderPlaced`), which is what `node ace make:event` produces
56
+ * and therefore the common one. Exported because the resolver has to ask the
57
+ * same question of a call site, and two implementations of "which event is
58
+ * this" would drift.
75
59
  */
76
- const jobDispatchResolver = {
77
- name: "job-dispatch",
78
- order: 15,
79
- resolve(call, ctx) {
80
- const expr = call.getExpression();
81
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
82
- if (!DISPATCH_METHODS.has(expr.getName())) return [];
83
- const receiver = expr.getExpression();
84
- if (!receiver.isKind(SyntaxKind.Identifier)) return [];
85
- const symbol = receiver.getText();
86
- if (ctx.dataStoresBySymbol.has(symbol)) return [];
87
- const file = ctx.imports.get(symbol);
88
- if (!file) return [];
89
- return [{
90
- file,
91
- member: ctx.sourceFile(file)?.getClasses().some((c) => c.getMethod("handle")) ? "handle" : expr.getName()
92
- }];
60
+ function resolveEventClass(expression, from, app) {
61
+ if (Node.isIdentifier(expression)) {
62
+ const target = importedFrom(expression.getText(), from, app);
63
+ return target ? toPosix(target) : null;
93
64
  }
94
- };
95
- //#endregion
96
- //#region src/inventory/resolvers/module_function.ts
97
- /**
98
- * "Module function" pattern: no class at all.
99
- *
100
- * await createUser(payload)
101
- * await syncWithProvider(order)
102
- *
103
- * Runs last: it matches any call to an imported identifier and would otherwise
104
- * swallow the more precise patterns.
105
- */
106
- const moduleFunctionResolver = {
107
- name: "module-function",
108
- order: 50,
109
- resolve(call, ctx) {
110
- const expr = call.getExpression();
111
- if (!expr.isKind(SyntaxKind.Identifier)) return [];
112
- const file = ctx.imports.get(expr.getText());
113
- if (!file) return [];
114
- return [{
65
+ if (!Node.isPropertyAccessExpression(expression)) return null;
66
+ const root = expression.getExpression();
67
+ if (!Node.isIdentifier(root)) return null;
68
+ const registry = importedFrom(root.getText(), from, app);
69
+ if (!registry) return null;
70
+ return registryEntry(registry, expression.getName(), from.getProject(), app);
71
+ }
72
+ /** listener bodies named by the second argument of `emitter.on` */
73
+ function listenersOf(handlers, from, app) {
74
+ const entries = handlers.isKind(SyntaxKind.ArrayLiteralExpression) ? handlers.getElements() : [handlers];
75
+ const refs = [];
76
+ for (const entry of entries) {
77
+ /**
78
+ * `[SomeListener, 'method']`: AdonisJS lets the binding name the method,
79
+ * and taking `handle` on faith there would look for a body that is not
80
+ * the one bound.
81
+ */
82
+ if (entry.isKind(SyntaxKind.ArrayLiteralExpression)) {
83
+ const [target, member] = entry.getElements();
84
+ const file = target ? listenerFile(target, from, app) : null;
85
+ if (!file) continue;
86
+ const named = member?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
87
+ refs.push({
88
+ file,
89
+ member: named ?? LISTENER_METHOD
90
+ });
91
+ continue;
92
+ }
93
+ const file = listenerFile(entry, from, app);
94
+ if (file) refs.push({
115
95
  file,
116
- member: expr.getText()
117
- }];
96
+ member: LISTENER_METHOD
97
+ });
118
98
  }
119
- };
120
- //#endregion
121
- //#region src/inventory/resolvers/property_service.ts
122
- /**
123
- * "Injected dependency" pattern: the call leaves through a class property.
124
- *
125
- * @inject()
126
- * class InvoiceController {
127
- * constructor(protected billing: BillingService) {}
128
- * async queue() { await this.billing.enqueue(invoice) }
129
- * }
130
- *
131
- * This is the official AdonisJS pattern, and in applications that use it, it is
132
- * frequently the only path from a route down to a write.
133
- *
134
- * **No type checker required.** `@inject()` only works with an explicit type
135
- * annotation — that annotation is how the container knows what to inject — so
136
- * the type is always in the AST as an imported identifier.
137
- */
138
- const propertyServiceResolver = {
139
- name: "property-service",
140
- order: 30,
141
- resolve(call, ctx) {
142
- const expression = call.getExpression();
143
- if (!Node.isPropertyAccessExpression(expression)) return [];
144
- const receiver = expression.getExpression();
145
- if (!Node.isPropertyAccessExpression(receiver)) return [];
146
- if (receiver.getExpression().getKind() !== SyntaxKind.ThisKeyword) return [];
147
- const file = ctx.injected.get(receiver.getName());
148
- if (!file) return [];
149
- return [{
150
- file,
151
- member: expression.getName()
152
- }];
99
+ return refs;
100
+ }
101
+ function listenerFile(entry, from, app) {
102
+ if (Node.isPropertyAccessExpression(entry)) {
103
+ const root = entry.getExpression();
104
+ if (!Node.isIdentifier(root)) return null;
105
+ const registry = importedFrom(root.getText(), from, app);
106
+ return registry ? registryEntry(registry, entry.getName(), from.getProject(), app) : null;
153
107
  }
154
- };
155
- //#endregion
156
- //#region src/inventory/resolvers/same_class_method.ts
157
- /**
158
- * "Same class method" pattern: `this.privateMethod()`.
159
- *
160
- * async expire(uuid: string) {
161
- * const invite = await this.findByUuid(uuid)
162
- * await this.persistExpiration(invite)
163
- * }
164
- *
165
- * A public method delegating to private ones of the same class is where writes
166
- * often live. No other strategy covers it — `property-service` requires
167
- * `this.dependency.method()`, with two levels of access.
168
- *
169
- * Runs before `property-service` because it is more specific: the receiver is
170
- * exactly `this`.
171
- */
172
- const sameClassMethodResolver = {
173
- name: "same-class-method",
174
- order: 5,
175
- resolve(call, ctx) {
176
- const expression = call.getExpression();
177
- if (!Node.isPropertyAccessExpression(expression)) return [];
178
- if (expression.getExpression().getKind() !== SyntaxKind.ThisKeyword) return [];
179
- const member = expression.getName();
180
- const owner = call.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
181
- if (!owner) return [];
182
- /**
183
- * Only claim the call if the method really exists on the class. Otherwise
184
- * `this.someFunctionProperty()` would be claimed, the body lookup would
185
- * fail, and the report would blame inheritance from a package — sending the
186
- * reader to the wrong place.
187
- */
188
- if (!owner.getMethod(member) && !owner.getStaticMethod(member)) return [];
189
- return [{
190
- file: ctx.file.getFilePath(),
191
- member
192
- }];
108
+ if (Node.isIdentifier(entry)) {
109
+ const target = importedFrom(entry.getText(), from, app);
110
+ return target ? toPosix(target) : null;
193
111
  }
194
- };
195
- //#endregion
196
- //#region src/inventory/resolvers/static_service.ts
112
+ return null;
113
+ }
114
+ /** where a local identifier was imported from, resolved through the alias map */
115
+ function importedFrom(local, from, app) {
116
+ for (const declaration of from.getImportDeclarations()) {
117
+ const named = declaration.getNamedImports().some((entry) => (entry.getAliasNode()?.getText() ?? entry.getName()) === local);
118
+ const isDefault = declaration.getDefaultImport()?.getText() === local;
119
+ if (!named && !isDefault) continue;
120
+ return app.resolveSpecifier(declaration.getModuleSpecifierValue());
121
+ }
122
+ return null;
123
+ }
197
124
  /**
198
- * "Static service" pattern: a class method called without instantiating.
125
+ * The file a key of a generated registry points at.
199
126
  *
200
- * await UserService.create(payload)
201
- * await OrderService.finalize(order)
202
- *
203
- * Careful: `Order.findByOrFail(...)` has exactly the same syntactic shape. The
204
- * difference is semantic — a model is a data store, not a body to walk into,
205
- * and the persistence detector handles it. Hence this resolver depends on
206
- * `ctx.dataStoresBySymbol` already being populated.
127
+ * Both shapes the generators emit are handled: a direct reference to an
128
+ * imported class (`events.ts`) and a lazy importer (`listeners.ts`). They differ
129
+ * per artefact and per framework version, and reading only one of them silently
130
+ * lost half the graph.
207
131
  */
208
- const staticServiceResolver = {
209
- name: "static-service",
210
- order: 20,
211
- resolve(call, ctx) {
212
- const expr = call.getExpression();
213
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
214
- const receiver = expr.getExpression();
215
- if (!receiver.isKind(SyntaxKind.Identifier)) return [];
216
- const symbol = receiver.getText();
217
- if (ctx.dataStoresBySymbol.has(symbol)) return [];
218
- const file = ctx.imports.get(symbol);
219
- if (!file) return [];
220
- return [{
221
- file,
222
- member: expr.getName()
223
- }];
132
+ function registryEntry(registryFile, key, project, app) {
133
+ const file = project.getSourceFile(registryFile) ?? project.addSourceFileAtPathIfExists(registryFile);
134
+ if (!file) return null;
135
+ for (const declaration of file.getVariableDeclarations()) {
136
+ const value = ((declaration.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression))?.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment))?.getInitializer();
137
+ if (!value) continue;
138
+ if (Node.isIdentifier(value)) {
139
+ const target = importedFrom(value.getText(), file, app);
140
+ return target ? toPosix(target) : null;
141
+ }
142
+ const specifier = value.getFirstDescendantByKind(SyntaxKind.CallExpression)?.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
143
+ const target = specifier ? app.resolveSpecifier(specifier) : null;
144
+ return target ? toPosix(target) : null;
224
145
  }
225
- };
146
+ return null;
147
+ }
226
148
  //#endregion
227
149
  //#region src/inventory/detectors/lucid.ts
228
150
  const WRITE_METHODS = new Set([
@@ -360,16 +282,56 @@ function detectAccess(call, symbols, relations = /* @__PURE__ */ new Map()) {
360
282
  */
361
283
  const store = symbols.get(pathSymbolOf(receiver) ?? "") ?? symbols.get(rootSymbolOf(receiver) ?? "");
362
284
  if (!store) return null;
285
+ /**
286
+ * `distribution.related('files').create({…})` — the relation is the SUBJECT of
287
+ * the write, not a table read along the way.
288
+ *
289
+ * `relationTargetOf` reads the current method, and here the current method is
290
+ * `create`, whose receiver is the `related(…)` call. Without looking back up the
291
+ * chain the write was attributed to `distributions` alone and `distribution_files`
292
+ * came out as a table this application only reads — an EIF, maintained by
293
+ * somebody else. That is what a production application reported, and it is
294
+ * ordinary Lucid: `related(…)` followed by `create`, `createMany`, `save`,
295
+ * `saveMany`, `attach`, `detach` or `sync` writes the related table.
296
+ */
297
+ const related = relatedCallIn(receiver);
298
+ const viaRelation = relationTargetOf(method, call, store, relations) ?? (related ? relationTargetOf("related", related, store, relations) : void 0);
363
299
  return {
364
300
  mode: isWrite ? "write" : "read",
365
301
  store,
366
302
  method,
367
303
  line: call.getStartLineNumber(),
368
- viaRelation: relationTargetOf(method, call, store, relations),
304
+ viaRelation,
305
+ /** the relation is written when the method acting on it writes */
306
+ relationWritten: isWrite,
369
307
  firesHooks: firesHooks(receiver)
370
308
  };
371
309
  }
372
310
  /**
311
+ * The `related('x')` call inside a receiver chain, if any.
312
+ *
313
+ * Only `related` qualifies: `preload` and `load` hand back the parent, so a write
314
+ * after them acts on the parent. `related` hands back the relation's own query
315
+ * builder, and that is what makes the difference.
316
+ */
317
+ function relatedCallIn(receiver) {
318
+ let current = receiver;
319
+ for (let depth = 0; depth < 20 && current; depth++) {
320
+ if (Node.isCallExpression(current)) {
321
+ const expression = current.getExpression();
322
+ if (Node.isPropertyAccessExpression(expression) && expression.getName() === "related") return current;
323
+ current = expression;
324
+ continue;
325
+ }
326
+ if (Node.isPropertyAccessExpression(current) || Node.isAwaitExpression(current)) {
327
+ current = current.getExpression();
328
+ continue;
329
+ }
330
+ break;
331
+ }
332
+ return null;
333
+ }
334
+ /**
373
335
  * An access fires hooks unless it went through the query builder.
374
336
  *
375
337
  * The signal is a CALL anywhere in the receiver chain: `document.delete()` has
@@ -439,6 +401,301 @@ function rootSymbolOf(node) {
439
401
  return null;
440
402
  }
441
403
  //#endregion
404
+ //#region src/inventory/resolvers/action_object.ts
405
+ /**
406
+ * "Action object" pattern: the transaction delegates to an action instantiated
407
+ * at the call site.
408
+ *
409
+ * await new ExpireInvite().handle({ invite })
410
+ *
411
+ * const mark = new MarkContentChanged()
412
+ * await mark.handle({ documentId })
413
+ *
414
+ * The second form keeps the instance in a local variable, so the declaration
415
+ * has to be followed back to the `new` — that is what `classOfReceiver` does.
416
+ */
417
+ const actionObjectResolver = {
418
+ name: "action-object",
419
+ order: 10,
420
+ resolve(call, ctx) {
421
+ const expr = call.getExpression();
422
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
423
+ const member = expr.getName();
424
+ const className = classOfReceiver(expr.getExpression());
425
+ if (!className) return [];
426
+ const file = ctx.imports.get(className);
427
+ if (!file) return [];
428
+ return [{
429
+ file,
430
+ member
431
+ }];
432
+ }
433
+ };
434
+ /**
435
+ * Finds the class behind a call receiver.
436
+ *
437
+ * new Foo().handle() -> 'Foo'
438
+ * foo.handle() where const foo = new Foo() -> 'Foo'
439
+ */
440
+ function classOfReceiver(receiver) {
441
+ if (receiver.isKind(SyntaxKind.NewExpression)) {
442
+ const target = receiver.getExpression();
443
+ return target.isKind(SyntaxKind.Identifier) ? target.getText() : null;
444
+ }
445
+ if (receiver.isKind(SyntaxKind.Identifier)) {
446
+ const init = (receiver.getSymbol()?.getDeclarations().find((d) => d.isKind(SyntaxKind.VariableDeclaration)))?.asKind(SyntaxKind.VariableDeclaration)?.getInitializer();
447
+ if (init?.isKind(SyntaxKind.NewExpression)) {
448
+ const target = init.getExpression();
449
+ return target.isKind(SyntaxKind.Identifier) ? target.getText() : null;
450
+ }
451
+ }
452
+ return null;
453
+ }
454
+ //#endregion
455
+ //#region src/inventory/resolvers/event_dispatch.ts
456
+ /**
457
+ * "Event" pattern: the handler announces, and listeners act.
458
+ *
459
+ * await events.OrderPlaced.dispatch(order.id)
460
+ *
461
+ * Runs BEFORE `job-dispatch`, which matches `Identifier.dispatch(args)` — the
462
+ * shape the direct form takes. Left to it, the event class was resolved and
463
+ * searched for a `handle` it does not declare (`dispatch` comes from
464
+ * `BaseEvent`), so the call was reported as an unknown and the listeners' reads
465
+ * and writes went uncounted.
466
+ *
467
+ * COUNTING DECISION: the same one taken for a job. The user clicks, the effect
468
+ * happens, and AFP §6.5.3 requires aggregating every path the transaction
469
+ * reaches — the emitter is an implementation detail of how it gets there.
470
+ */
471
+ const eventDispatchResolver = {
472
+ name: "event-dispatch",
473
+ order: 12,
474
+ resolve(call, ctx) {
475
+ if (ctx.eventBindings.size === 0) return [];
476
+ const expression = call.getExpression();
477
+ if (!expression.isKind(SyntaxKind.PropertyAccessExpression)) return [];
478
+ if (expression.getName() !== "dispatch") return [];
479
+ const receiver = expression.getExpression();
480
+ if (!Node.isIdentifier(receiver) && !Node.isPropertyAccessExpression(receiver)) return [];
481
+ const eventFile = resolveEventClass(receiver, ctx.file, { resolveSpecifier: ctx.resolveSpecifier });
482
+ return eventFile ? ctx.eventBindings.get(eventFile) ?? [] : [];
483
+ }
484
+ };
485
+ //#endregion
486
+ //#region src/inventory/resolvers/job_dispatch.ts
487
+ const DISPATCH_METHODS = new Set([
488
+ "dispatch",
489
+ "dispatchMany",
490
+ "dispatchLater",
491
+ "enqueue",
492
+ "later"
493
+ ]);
494
+ /**
495
+ * The method that actually runs the job, by queue package.
496
+ *
497
+ * There is no single name, and this list grew twice by measurement rather than by
498
+ * reasoning. `@rlanz/bull-queue` uses `handle`; `@nemoventures/adonis-jobs` calls
499
+ * it `process`; `@adonisjs/queue` — the official package — generates
500
+ * `async execute()` in its own `make:job` stub. Each omission cost the same: the
501
+ * file resolved, no body was found, the dispatch was reported as an unknown, and
502
+ * every write inside the job went uncounted.
503
+ *
504
+ * `execute` surfaced only once event dispatch started being followed, because the
505
+ * listener was what enqueued the job and that path had never been walked. Which is
506
+ * the argument for adding a name when a real application shows it: a list written
507
+ * from imagination would have missed this one too.
508
+ *
509
+ * Ordered: a class declaring more than one is answering the dispatcher with the
510
+ * first, and `handle` is the most common.
511
+ */
512
+ const EXECUTION_METHODS = [
513
+ "handle",
514
+ "execute",
515
+ "process",
516
+ "run",
517
+ "perform"
518
+ ];
519
+ /**
520
+ * "Job" pattern: the write happens asynchronously.
521
+ *
522
+ * await CreateUserJob.dispatch({ userId })
523
+ *
524
+ * Runs before `static-service` on purpose: the syntactic shape is identical
525
+ * (`Identifier.method(args)`) and the generic strategy would swallow the job.
526
+ * The distinction is semantic, and it matters because the counting decision
527
+ * differs.
528
+ *
529
+ * COUNTING DECISION: a job dispatched by a handler is followed as part of the
530
+ * SAME transactional function, because IFPUG counts what the user recognises —
531
+ * they click and the effect happens, even if execution is asynchronous. A
532
+ * SCHEDULED job, which nobody dispatches, is a different thing: it is an entry
533
+ * point of its own, and out of v1 scope — only HTTP routes are collected.
534
+ */
535
+ const jobDispatchResolver = {
536
+ name: "job-dispatch",
537
+ order: 15,
538
+ resolve(call, ctx) {
539
+ const expr = call.getExpression();
540
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
541
+ if (!DISPATCH_METHODS.has(expr.getName())) return [];
542
+ const receiver = expr.getExpression();
543
+ if (!receiver.isKind(SyntaxKind.Identifier)) return [];
544
+ const symbol = receiver.getText();
545
+ if (ctx.dataStoresBySymbol.has(symbol)) return [];
546
+ const file = ctx.imports.get(symbol);
547
+ if (!file) return [];
548
+ /**
549
+ * `dispatch` enqueues; the execution method is what touches data. When the
550
+ * class declares one, that is the body that matters — and when it declares
551
+ * none, the dispatch name is kept so the gap stays visible instead of being
552
+ * quietly attributed to a body nobody found.
553
+ */
554
+ const classes = ctx.sourceFile(file)?.getClasses() ?? [];
555
+ return [{
556
+ file,
557
+ member: EXECUTION_METHODS.find((name) => classes.some((c) => c.getMethod(name))) ?? expr.getName()
558
+ }];
559
+ }
560
+ };
561
+ //#endregion
562
+ //#region src/inventory/resolvers/module_function.ts
563
+ /**
564
+ * "Module function" pattern: no class at all.
565
+ *
566
+ * await createUser(payload)
567
+ * await syncWithProvider(order)
568
+ *
569
+ * Runs last: it matches any call to an imported identifier and would otherwise
570
+ * swallow the more precise patterns.
571
+ */
572
+ const moduleFunctionResolver = {
573
+ name: "module-function",
574
+ order: 50,
575
+ resolve(call, ctx) {
576
+ const expr = call.getExpression();
577
+ if (!expr.isKind(SyntaxKind.Identifier)) return [];
578
+ const local = expr.getText();
579
+ const file = ctx.imports.get(local);
580
+ if (!file) return [];
581
+ /**
582
+ * The body carries the exported name, not the local one. Following the
583
+ * local name through an alias finds nothing and reports the call as
584
+ * unresolved for a reason that is not true.
585
+ */
586
+ return [{
587
+ file,
588
+ member: ctx.exportedAs.get(local) ?? local
589
+ }];
590
+ }
591
+ };
592
+ //#endregion
593
+ //#region src/inventory/resolvers/property_service.ts
594
+ /**
595
+ * "Injected dependency" pattern: the call leaves through a class property.
596
+ *
597
+ * @inject()
598
+ * class InvoiceController {
599
+ * constructor(protected billing: BillingService) {}
600
+ * async queue() { await this.billing.enqueue(invoice) }
601
+ * }
602
+ *
603
+ * This is the official AdonisJS pattern, and in applications that use it, it is
604
+ * frequently the only path from a route down to a write.
605
+ *
606
+ * **No type checker required.** `@inject()` only works with an explicit type
607
+ * annotation — that annotation is how the container knows what to inject — so
608
+ * the type is always in the AST as an imported identifier.
609
+ */
610
+ const propertyServiceResolver = {
611
+ name: "property-service",
612
+ order: 30,
613
+ resolve(call, ctx) {
614
+ const expression = call.getExpression();
615
+ if (!Node.isPropertyAccessExpression(expression)) return [];
616
+ const receiver = expression.getExpression();
617
+ if (!Node.isPropertyAccessExpression(receiver)) return [];
618
+ if (receiver.getExpression().getKind() !== SyntaxKind.ThisKeyword) return [];
619
+ const file = ctx.injected.get(receiver.getName());
620
+ if (!file) return [];
621
+ return [{
622
+ file,
623
+ member: expression.getName()
624
+ }];
625
+ }
626
+ };
627
+ //#endregion
628
+ //#region src/inventory/resolvers/same_class_method.ts
629
+ /**
630
+ * "Same class method" pattern: `this.privateMethod()`.
631
+ *
632
+ * async expire(uuid: string) {
633
+ * const invite = await this.findByUuid(uuid)
634
+ * await this.persistExpiration(invite)
635
+ * }
636
+ *
637
+ * A public method delegating to private ones of the same class is where writes
638
+ * often live. No other strategy covers it — `property-service` requires
639
+ * `this.dependency.method()`, with two levels of access.
640
+ *
641
+ * Runs before `property-service` because it is more specific: the receiver is
642
+ * exactly `this`.
643
+ */
644
+ const sameClassMethodResolver = {
645
+ name: "same-class-method",
646
+ order: 5,
647
+ resolve(call, ctx) {
648
+ const expression = call.getExpression();
649
+ if (!Node.isPropertyAccessExpression(expression)) return [];
650
+ if (expression.getExpression().getKind() !== SyntaxKind.ThisKeyword) return [];
651
+ const member = expression.getName();
652
+ const owner = call.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
653
+ if (!owner) return [];
654
+ /**
655
+ * Only claim the call if the method really exists on the class. Otherwise
656
+ * `this.someFunctionProperty()` would be claimed, the body lookup would
657
+ * fail, and the report would blame inheritance from a package — sending the
658
+ * reader to the wrong place.
659
+ */
660
+ if (!owner.getMethod(member) && !owner.getStaticMethod(member)) return [];
661
+ return [{
662
+ file: ctx.file.getFilePath(),
663
+ member
664
+ }];
665
+ }
666
+ };
667
+ //#endregion
668
+ //#region src/inventory/resolvers/static_service.ts
669
+ /**
670
+ * "Static service" pattern: a class method called without instantiating.
671
+ *
672
+ * await UserService.create(payload)
673
+ * await OrderService.finalize(order)
674
+ *
675
+ * Careful: `Order.findByOrFail(...)` has exactly the same syntactic shape. The
676
+ * difference is semantic — a model is a data store, not a body to walk into,
677
+ * and the persistence detector handles it. Hence this resolver depends on
678
+ * `ctx.dataStoresBySymbol` already being populated.
679
+ */
680
+ const staticServiceResolver = {
681
+ name: "static-service",
682
+ order: 20,
683
+ resolve(call, ctx) {
684
+ const expr = call.getExpression();
685
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
686
+ const receiver = expr.getExpression();
687
+ if (!receiver.isKind(SyntaxKind.Identifier)) return [];
688
+ const symbol = receiver.getText();
689
+ if (ctx.dataStoresBySymbol.has(symbol)) return [];
690
+ const file = ctx.imports.get(symbol);
691
+ if (!file) return [];
692
+ return [{
693
+ file,
694
+ member: expr.getName()
695
+ }];
696
+ }
697
+ };
698
+ //#endregion
442
699
  //#region src/inventory/resolvers/transformer.ts
443
700
  /** BaseTransformer's public API; all of it funnels through `toObject` */
444
701
  const TRANSFORMER_METHODS = new Set([
@@ -526,6 +783,7 @@ function extendsTransformer(file) {
526
783
  const BUILTIN_CALL_RESOLVERS = [
527
784
  sameClassMethodResolver,
528
785
  actionObjectResolver,
786
+ eventDispatchResolver,
529
787
  jobDispatchResolver,
530
788
  transformerResolver,
531
789
  staticServiceResolver,
@@ -543,6 +801,15 @@ const BUILTIN_CALL_RESOLVERS = [
543
801
  */
544
802
  function resolveCall(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
545
803
  for (const resolver of resolvers) {
804
+ /**
805
+ * Asked before `resolve`, and in the same order: a strategy that claims a
806
+ * call as data-free must not be overtaken by a later, more generic one
807
+ * following it into a body it has no business reading.
808
+ */
809
+ if (resolver.ignores?.(call, ctx)) return {
810
+ by: resolver.name,
811
+ refs: []
812
+ };
546
813
  const refs = resolver.resolve(call, ctx);
547
814
  if (refs.length > 0) return {
548
815
  by: resolver.name,
@@ -552,4 +819,4 @@ function resolveCall(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
552
819
  return null;
553
820
  }
554
821
  //#endregion
555
- export { rootSymbolOf as a, hooksFiredBy as i, resolveCall as n, detectAccess as r, BUILTIN_CALL_RESOLVERS as t };
822
+ export { rootSymbolOf as a, toPosix as c, hooksFiredBy as i, resolveCall as n, collectEventBindings as o, detectAccess as r, samePath as s, BUILTIN_CALL_RESOLVERS as t };