@filipebraida/adonis-function-points 0.2.0 → 0.4.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.
@@ -1,55 +1,5 @@
1
+ import path from "node:path";
1
2
  import { Node, Project, SyntaxKind } from "ts-morph";
2
- //#region src/inventory/resolvers/action_object.ts
3
- /**
4
- * "Action object" pattern: the transaction delegates to an action instantiated
5
- * at the call site.
6
- *
7
- * await new ExpireInvite().handle({ invite })
8
- *
9
- * const mark = new MarkContentChanged()
10
- * await mark.handle({ documentId })
11
- *
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.
34
- *
35
- * new Foo().handle() -> 'Foo'
36
- * foo.handle() where const foo = new Foo() -> 'Foo'
37
- */
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
- }
49
- }
50
- return null;
51
- }
52
- //#endregion
53
3
  //#region src/inventory/paths.ts
54
4
  /**
55
5
  * One canonical spelling for every path the inventory emits.
@@ -71,6 +21,23 @@ function classOfReceiver(receiver) {
71
21
  * it to each comparison costs vigilance forever.
72
22
  */
73
23
  const toPosix = (value) => value.split("\\").join("/");
24
+ /**
25
+ * A path as it should appear in an EMITTED artefact: relative to the application.
26
+ *
27
+ * `CountSource.app` is documented as never being the absolute path, because that
28
+ * says where the machine keeps its files and travels with every count sent
29
+ * anywhere. One field below it, `config` shipped the absolute path — and so did
30
+ * every `trace[].file`, 858 times in a single production count. The rule was
31
+ * stated and then applied to one field.
32
+ *
33
+ * Internally the absolute path is the right thing: it is what ts-morph resolves
34
+ * and what the call graph keys its caches on. So this converts at the boundary
35
+ * where a path LEAVES, and nowhere else.
36
+ *
37
+ * A path outside the root keeps its `../` prefix, which describes where it is
38
+ * without naming the home directory.
39
+ */
40
+ const relativeTo = (root, value) => toPosix(path.relative(toPosix(root), toPosix(value))) || ".";
74
41
  /** Compares two paths that may have come from different sources. */
75
42
  const samePath = (a, b) => a !== void 0 && b !== void 0 && toPosix(a) === toPosix(b);
76
43
  //#endregion
@@ -170,29 +137,335 @@ function importedFrom(local, from, app) {
170
137
  if (!named && !isDefault) continue;
171
138
  return app.resolveSpecifier(declaration.getModuleSpecifierValue());
172
139
  }
173
- return null;
174
- }
140
+ return null;
141
+ }
142
+ /**
143
+ * The file a key of a generated registry points at.
144
+ *
145
+ * Both shapes the generators emit are handled: a direct reference to an
146
+ * imported class (`events.ts`) and a lazy importer (`listeners.ts`). They differ
147
+ * per artefact and per framework version, and reading only one of them silently
148
+ * lost half the graph.
149
+ */
150
+ function registryEntry(registryFile, key, project, app) {
151
+ const file = project.getSourceFile(registryFile) ?? project.addSourceFileAtPathIfExists(registryFile);
152
+ if (!file) return null;
153
+ for (const declaration of file.getVariableDeclarations()) {
154
+ const value = ((declaration.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression))?.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment))?.getInitializer();
155
+ if (!value) continue;
156
+ if (Node.isIdentifier(value)) {
157
+ const target = importedFrom(value.getText(), file, app);
158
+ return target ? toPosix(target) : null;
159
+ }
160
+ const specifier = value.getFirstDescendantByKind(SyntaxKind.CallExpression)?.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
161
+ const target = specifier ? app.resolveSpecifier(specifier) : null;
162
+ return target ? toPosix(target) : null;
163
+ }
164
+ return null;
165
+ }
166
+ //#endregion
167
+ //#region src/inventory/detectors/lucid.ts
168
+ const WRITE_METHODS = new Set([
169
+ "save",
170
+ "delete",
171
+ "create",
172
+ "createMany",
173
+ "merge",
174
+ "fill",
175
+ "updateOrCreate",
176
+ "fetchOrCreateMany",
177
+ "firstOrCreate",
178
+ "updateOrCreateMany",
179
+ "attach",
180
+ "detach",
181
+ "sync",
182
+ "increment",
183
+ "decrement",
184
+ "update",
185
+ "truncate",
186
+ "restore",
187
+ "forceDelete"
188
+ ]);
189
+ /**
190
+ * Which hooks each access fires, by decorator name — counting-decisions §3.
191
+ *
192
+ * `save()` fires the save pair AND the create-or-update pair, and which of the
193
+ * two runs is not knowable statically. That is not a compromise here: AFP
194
+ * §6.5.3 requires treating multiple optional paths as part of the same
195
+ * transaction, so following both is the specified behaviour.
196
+ */
197
+ const HOOKS_BY_METHOD = {
198
+ save: [
199
+ "beforeSave",
200
+ "afterSave",
201
+ "beforeCreate",
202
+ "afterCreate",
203
+ "beforeUpdate",
204
+ "afterUpdate"
205
+ ],
206
+ create: [
207
+ "beforeCreate",
208
+ "afterCreate",
209
+ "beforeSave",
210
+ "afterSave"
211
+ ],
212
+ createMany: [
213
+ "beforeCreate",
214
+ "afterCreate",
215
+ "beforeSave",
216
+ "afterSave"
217
+ ],
218
+ firstOrCreate: [
219
+ "beforeCreate",
220
+ "afterCreate",
221
+ "beforeSave",
222
+ "afterSave"
223
+ ],
224
+ fetchOrCreateMany: [
225
+ "beforeCreate",
226
+ "afterCreate",
227
+ "beforeSave",
228
+ "afterSave"
229
+ ],
230
+ updateOrCreate: [
231
+ "beforeCreate",
232
+ "afterCreate",
233
+ "beforeUpdate",
234
+ "afterUpdate",
235
+ "beforeSave",
236
+ "afterSave"
237
+ ],
238
+ updateOrCreateMany: [
239
+ "beforeCreate",
240
+ "afterCreate",
241
+ "beforeUpdate",
242
+ "afterUpdate",
243
+ "beforeSave",
244
+ "afterSave"
245
+ ],
246
+ delete: ["beforeDelete", "afterDelete"],
247
+ forceDelete: ["beforeDelete", "afterDelete"],
248
+ find: ["beforeFind", "afterFind"],
249
+ findOrFail: ["beforeFind", "afterFind"],
250
+ findBy: ["beforeFind", "afterFind"],
251
+ findByOrFail: ["beforeFind", "afterFind"],
252
+ first: ["beforeFind", "afterFind"],
253
+ firstOrFail: ["beforeFind", "afterFind"],
254
+ all: ["beforeFetch", "afterFetch"],
255
+ findMany: ["beforeFetch", "afterFetch"]
256
+ };
257
+ new Set(Object.values(HOOKS_BY_METHOD).flat());
258
+ /**
259
+ * Hook decorators fired by an access, or `[]` when it fires none.
260
+ *
261
+ * `truncate`, `increment`, `decrement` and the pivot operations change rows
262
+ * without instantiating a model, so no hook runs.
263
+ */
264
+ function hooksFiredBy(access) {
265
+ if (!access.firesHooks) return [];
266
+ return HOOKS_BY_METHOD[access.method] ?? [];
267
+ }
268
+ const READ_METHODS = new Set([
269
+ "find",
270
+ "findOrFail",
271
+ "findBy",
272
+ "findByOrFail",
273
+ "findMany",
274
+ "first",
275
+ "firstOrFail",
276
+ "all",
277
+ "query",
278
+ "preload",
279
+ "load",
280
+ "paginate",
281
+ "count",
282
+ "exists",
283
+ "related",
284
+ "where",
285
+ "orderBy"
286
+ ]);
287
+ function detectAccess(call, symbols, relations = /* @__PURE__ */ new Map()) {
288
+ const expression = call.getExpression();
289
+ if (!Node.isPropertyAccessExpression(expression)) return null;
290
+ const method = expression.getName();
291
+ const isWrite = WRITE_METHODS.has(method);
292
+ if (!isWrite && !READ_METHODS.has(method)) return null;
293
+ const receiver = expression.getExpression();
294
+ /**
295
+ * Looks up the PATH before the root: `input.invite.save()` has root `input`,
296
+ * which is no store at all — `input.invite` is.
297
+ *
298
+ * This is the dominant shape in action objects with a typed input, and
299
+ * without it the graph reaches the action and sees no write.
300
+ */
301
+ const store = symbols.get(pathSymbolOf(receiver) ?? "") ?? symbols.get(rootSymbolOf(receiver) ?? "");
302
+ if (!store) return null;
303
+ /**
304
+ * `distribution.related('files').create({…})` — the relation is the SUBJECT of
305
+ * the write, not a table read along the way.
306
+ *
307
+ * `relationTargetOf` reads the current method, and here the current method is
308
+ * `create`, whose receiver is the `related(…)` call. Without looking back up the
309
+ * chain the write was attributed to `distributions` alone and `distribution_files`
310
+ * came out as a table this application only reads — an EIF, maintained by
311
+ * somebody else. That is what a production application reported, and it is
312
+ * ordinary Lucid: `related(…)` followed by `create`, `createMany`, `save`,
313
+ * `saveMany`, `attach`, `detach` or `sync` writes the related table.
314
+ */
315
+ const related = relatedCallIn(receiver);
316
+ const viaRelation = relationTargetOf(method, call, store, relations) ?? (related ? relationTargetOf("related", related, store, relations) : void 0);
317
+ return {
318
+ mode: isWrite ? "write" : "read",
319
+ store,
320
+ method,
321
+ line: call.getStartLineNumber(),
322
+ viaRelation,
323
+ /** the relation is written when the method acting on it writes */
324
+ relationWritten: isWrite,
325
+ firesHooks: firesHooks(receiver)
326
+ };
327
+ }
328
+ /**
329
+ * The `related('x')` call inside a receiver chain, if any.
330
+ *
331
+ * Only `related` qualifies: `preload` and `load` hand back the parent, so a write
332
+ * after them acts on the parent. `related` hands back the relation's own query
333
+ * builder, and that is what makes the difference.
334
+ */
335
+ function relatedCallIn(receiver) {
336
+ let current = receiver;
337
+ for (let depth = 0; depth < 20 && current; depth++) {
338
+ if (Node.isCallExpression(current)) {
339
+ const expression = current.getExpression();
340
+ if (Node.isPropertyAccessExpression(expression) && expression.getName() === "related") return current;
341
+ current = expression;
342
+ continue;
343
+ }
344
+ if (Node.isPropertyAccessExpression(current) || Node.isAwaitExpression(current)) {
345
+ current = current.getExpression();
346
+ continue;
347
+ }
348
+ break;
349
+ }
350
+ return null;
351
+ }
352
+ /**
353
+ * An access fires hooks unless it went through the query builder.
354
+ *
355
+ * The signal is a CALL anywhere in the receiver chain: `document.delete()` has
356
+ * none, `Document.query().where(…).delete()` has two. It errs towards NOT
357
+ * following — `(await Document.find(id))!.delete()` is read as bulk — because
358
+ * an FTR that is missing understates, and one that is invented overstates.
359
+ */
360
+ function firesHooks(receiver) {
361
+ let current = receiver;
362
+ for (let depth = 0; depth < 20; depth++) {
363
+ if (Node.isCallExpression(current)) return false;
364
+ if (!Node.isPropertyAccessExpression(current)) return Node.isIdentifier(current);
365
+ current = current.getExpression();
366
+ }
367
+ return false;
368
+ }
369
+ const RELATION_ACCESSORS = new Set([
370
+ "preload",
371
+ "load",
372
+ "related",
373
+ "withCount"
374
+ ]);
375
+ /**
376
+ * `.preload('author')` on a store declaring `{ author: 'Author' }` reaches
377
+ * `Author`.
378
+ */
379
+ function relationTargetOf(method, call, store, relations) {
380
+ if (!RELATION_ACCESSORS.has(method)) return void 0;
381
+ const name = call.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
382
+ if (!name) return void 0;
383
+ return relations.get(store)?.[name];
384
+ }
385
+ /**
386
+ * Dotted path of a receiver made only of property accesses: `input.invite`
387
+ * yields "input.invite". Any call in between invalidates the path, because the
388
+ * value stops being statically traceable.
389
+ */
390
+ function pathSymbolOf(node) {
391
+ const parts = [];
392
+ let current = node;
393
+ for (let depth = 0; depth < 20; depth++) {
394
+ if (Node.isIdentifier(current)) return [current.getText(), ...parts].join(".");
395
+ if (!Node.isPropertyAccessExpression(current)) return null;
396
+ parts.unshift(current.getName());
397
+ current = current.getExpression();
398
+ }
399
+ return null;
400
+ }
401
+ /**
402
+ * Root of an `a.b().c()` chain — the left-most identifier.
403
+ *
404
+ * It must traverse `await`, calls, property access and `new`, otherwise
405
+ * `await new Action().handle()` and `Invite.query().where().update()` stop at
406
+ * the first node and the write disappears.
407
+ */
408
+ function rootSymbolOf(node) {
409
+ let current = node;
410
+ for (let depth = 0; depth < 60 && current; depth++) {
411
+ if (Node.isIdentifier(current)) return current.getText();
412
+ if (current.getKind() === SyntaxKind.ThisKeyword) return "this";
413
+ if (Node.isPropertyAccessExpression(current) || Node.isElementAccessExpression(current) || Node.isCallExpression(current) || Node.isNewExpression(current) || Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) {
414
+ current = current.getExpression();
415
+ continue;
416
+ }
417
+ return null;
418
+ }
419
+ return null;
420
+ }
421
+ //#endregion
422
+ //#region src/inventory/resolvers/action_object.ts
423
+ /**
424
+ * "Action object" pattern: the transaction delegates to an action instantiated
425
+ * at the call site.
426
+ *
427
+ * await new ExpireInvite().handle({ invite })
428
+ *
429
+ * const mark = new MarkContentChanged()
430
+ * await mark.handle({ documentId })
431
+ *
432
+ * The second form keeps the instance in a local variable, so the declaration
433
+ * has to be followed back to the `new` — that is what `classOfReceiver` does.
434
+ */
435
+ const actionObjectResolver = {
436
+ name: "action-object",
437
+ order: 10,
438
+ resolve(call, ctx) {
439
+ const expr = call.getExpression();
440
+ if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) return [];
441
+ const member = expr.getName();
442
+ const className = classOfReceiver(expr.getExpression());
443
+ if (!className) return [];
444
+ const file = ctx.imports.get(className);
445
+ if (!file) return [];
446
+ return [{
447
+ file,
448
+ member
449
+ }];
450
+ }
451
+ };
175
452
  /**
176
- * The file a key of a generated registry points at.
453
+ * Finds the class behind a call receiver.
177
454
  *
178
- * Both shapes the generators emit are handled: a direct reference to an
179
- * imported class (`events.ts`) and a lazy importer (`listeners.ts`). They differ
180
- * per artefact and per framework version, and reading only one of them silently
181
- * lost half the graph.
455
+ * new Foo().handle() -> 'Foo'
456
+ * foo.handle() where const foo = new Foo() -> 'Foo'
182
457
  */
183
- function registryEntry(registryFile, key, project, app) {
184
- const file = project.getSourceFile(registryFile) ?? project.addSourceFileAtPathIfExists(registryFile);
185
- if (!file) return null;
186
- for (const declaration of file.getVariableDeclarations()) {
187
- const value = ((declaration.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression))?.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment))?.getInitializer();
188
- if (!value) continue;
189
- if (Node.isIdentifier(value)) {
190
- const target = importedFrom(value.getText(), file, app);
191
- return target ? toPosix(target) : null;
458
+ function classOfReceiver(receiver) {
459
+ if (receiver.isKind(SyntaxKind.NewExpression)) {
460
+ const target = receiver.getExpression();
461
+ return target.isKind(SyntaxKind.Identifier) ? target.getText() : null;
462
+ }
463
+ if (receiver.isKind(SyntaxKind.Identifier)) {
464
+ const init = (receiver.getSymbol()?.getDeclarations().find((d) => d.isKind(SyntaxKind.VariableDeclaration)))?.asKind(SyntaxKind.VariableDeclaration)?.getInitializer();
465
+ if (init?.isKind(SyntaxKind.NewExpression)) {
466
+ const target = init.getExpression();
467
+ return target.isKind(SyntaxKind.Identifier) ? target.getText() : null;
192
468
  }
193
- const specifier = value.getFirstDescendantByKind(SyntaxKind.CallExpression)?.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
194
- const target = specifier ? app.resolveSpecifier(specifier) : null;
195
- return target ? toPosix(target) : null;
196
469
  }
197
470
  return null;
198
471
  }
@@ -239,17 +512,24 @@ const DISPATCH_METHODS = new Set([
239
512
  /**
240
513
  * The method that actually runs the job, by queue package.
241
514
  *
242
- * There is no single name: `@adonisjs/queue` and `@rlanz/bull-queue` call it
243
- * `handle`, `@nemoventures/adonis-jobs` calls it `process`. Looking only for
244
- * `handle` meant every job in an application using the second one resolved to
245
- * a file and then to no body, so the dispatch was reported as an unknown while
246
- * the writes inside it went uncounted — the worst of both outcomes.
515
+ * There is no single name, and this list grew twice by measurement rather than by
516
+ * reasoning. `@rlanz/bull-queue` uses `handle`; `@nemoventures/adonis-jobs` calls
517
+ * it `process`; `@adonisjs/queue` — the official package — generates
518
+ * `async execute()` in its own `make:job` stub. Each omission cost the same: the
519
+ * file resolved, no body was found, the dispatch was reported as an unknown, and
520
+ * every write inside the job went uncounted.
521
+ *
522
+ * `execute` surfaced only once event dispatch started being followed, because the
523
+ * listener was what enqueued the job and that path had never been walked. Which is
524
+ * the argument for adding a name when a real application shows it: a list written
525
+ * from imagination would have missed this one too.
247
526
  *
248
527
  * Ordered: a class declaring more than one is answering the dispatcher with the
249
528
  * first, and `handle` is the most common.
250
529
  */
251
530
  const EXECUTION_METHODS = [
252
531
  "handle",
532
+ "execute",
253
533
  "process",
254
534
  "run",
255
535
  "perform"
@@ -434,221 +714,6 @@ const staticServiceResolver = {
434
714
  }
435
715
  };
436
716
  //#endregion
437
- //#region src/inventory/detectors/lucid.ts
438
- const WRITE_METHODS = new Set([
439
- "save",
440
- "delete",
441
- "create",
442
- "createMany",
443
- "merge",
444
- "fill",
445
- "updateOrCreate",
446
- "fetchOrCreateMany",
447
- "firstOrCreate",
448
- "updateOrCreateMany",
449
- "attach",
450
- "detach",
451
- "sync",
452
- "increment",
453
- "decrement",
454
- "update",
455
- "truncate",
456
- "restore",
457
- "forceDelete"
458
- ]);
459
- /**
460
- * Which hooks each access fires, by decorator name — counting-decisions §3.
461
- *
462
- * `save()` fires the save pair AND the create-or-update pair, and which of the
463
- * two runs is not knowable statically. That is not a compromise here: AFP
464
- * §6.5.3 requires treating multiple optional paths as part of the same
465
- * transaction, so following both is the specified behaviour.
466
- */
467
- const HOOKS_BY_METHOD = {
468
- save: [
469
- "beforeSave",
470
- "afterSave",
471
- "beforeCreate",
472
- "afterCreate",
473
- "beforeUpdate",
474
- "afterUpdate"
475
- ],
476
- create: [
477
- "beforeCreate",
478
- "afterCreate",
479
- "beforeSave",
480
- "afterSave"
481
- ],
482
- createMany: [
483
- "beforeCreate",
484
- "afterCreate",
485
- "beforeSave",
486
- "afterSave"
487
- ],
488
- firstOrCreate: [
489
- "beforeCreate",
490
- "afterCreate",
491
- "beforeSave",
492
- "afterSave"
493
- ],
494
- fetchOrCreateMany: [
495
- "beforeCreate",
496
- "afterCreate",
497
- "beforeSave",
498
- "afterSave"
499
- ],
500
- updateOrCreate: [
501
- "beforeCreate",
502
- "afterCreate",
503
- "beforeUpdate",
504
- "afterUpdate",
505
- "beforeSave",
506
- "afterSave"
507
- ],
508
- updateOrCreateMany: [
509
- "beforeCreate",
510
- "afterCreate",
511
- "beforeUpdate",
512
- "afterUpdate",
513
- "beforeSave",
514
- "afterSave"
515
- ],
516
- delete: ["beforeDelete", "afterDelete"],
517
- forceDelete: ["beforeDelete", "afterDelete"],
518
- find: ["beforeFind", "afterFind"],
519
- findOrFail: ["beforeFind", "afterFind"],
520
- findBy: ["beforeFind", "afterFind"],
521
- findByOrFail: ["beforeFind", "afterFind"],
522
- first: ["beforeFind", "afterFind"],
523
- firstOrFail: ["beforeFind", "afterFind"],
524
- all: ["beforeFetch", "afterFetch"],
525
- findMany: ["beforeFetch", "afterFetch"]
526
- };
527
- new Set(Object.values(HOOKS_BY_METHOD).flat());
528
- /**
529
- * Hook decorators fired by an access, or `[]` when it fires none.
530
- *
531
- * `truncate`, `increment`, `decrement` and the pivot operations change rows
532
- * without instantiating a model, so no hook runs.
533
- */
534
- function hooksFiredBy(access) {
535
- if (!access.firesHooks) return [];
536
- return HOOKS_BY_METHOD[access.method] ?? [];
537
- }
538
- const READ_METHODS = new Set([
539
- "find",
540
- "findOrFail",
541
- "findBy",
542
- "findByOrFail",
543
- "findMany",
544
- "first",
545
- "firstOrFail",
546
- "all",
547
- "query",
548
- "preload",
549
- "load",
550
- "paginate",
551
- "count",
552
- "exists",
553
- "related",
554
- "where",
555
- "orderBy"
556
- ]);
557
- function detectAccess(call, symbols, relations = /* @__PURE__ */ new Map()) {
558
- const expression = call.getExpression();
559
- if (!Node.isPropertyAccessExpression(expression)) return null;
560
- const method = expression.getName();
561
- const isWrite = WRITE_METHODS.has(method);
562
- if (!isWrite && !READ_METHODS.has(method)) return null;
563
- const receiver = expression.getExpression();
564
- /**
565
- * Looks up the PATH before the root: `input.invite.save()` has root `input`,
566
- * which is no store at all — `input.invite` is.
567
- *
568
- * This is the dominant shape in action objects with a typed input, and
569
- * without it the graph reaches the action and sees no write.
570
- */
571
- const store = symbols.get(pathSymbolOf(receiver) ?? "") ?? symbols.get(rootSymbolOf(receiver) ?? "");
572
- if (!store) return null;
573
- return {
574
- mode: isWrite ? "write" : "read",
575
- store,
576
- method,
577
- line: call.getStartLineNumber(),
578
- viaRelation: relationTargetOf(method, call, store, relations),
579
- firesHooks: firesHooks(receiver)
580
- };
581
- }
582
- /**
583
- * An access fires hooks unless it went through the query builder.
584
- *
585
- * The signal is a CALL anywhere in the receiver chain: `document.delete()` has
586
- * none, `Document.query().where(…).delete()` has two. It errs towards NOT
587
- * following — `(await Document.find(id))!.delete()` is read as bulk — because
588
- * an FTR that is missing understates, and one that is invented overstates.
589
- */
590
- function firesHooks(receiver) {
591
- let current = receiver;
592
- for (let depth = 0; depth < 20; depth++) {
593
- if (Node.isCallExpression(current)) return false;
594
- if (!Node.isPropertyAccessExpression(current)) return Node.isIdentifier(current);
595
- current = current.getExpression();
596
- }
597
- return false;
598
- }
599
- const RELATION_ACCESSORS = new Set([
600
- "preload",
601
- "load",
602
- "related",
603
- "withCount"
604
- ]);
605
- /**
606
- * `.preload('author')` on a store declaring `{ author: 'Author' }` reaches
607
- * `Author`.
608
- */
609
- function relationTargetOf(method, call, store, relations) {
610
- if (!RELATION_ACCESSORS.has(method)) return void 0;
611
- const name = call.getArguments()[0]?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
612
- if (!name) return void 0;
613
- return relations.get(store)?.[name];
614
- }
615
- /**
616
- * Dotted path of a receiver made only of property accesses: `input.invite`
617
- * yields "input.invite". Any call in between invalidates the path, because the
618
- * value stops being statically traceable.
619
- */
620
- function pathSymbolOf(node) {
621
- const parts = [];
622
- let current = node;
623
- for (let depth = 0; depth < 20; depth++) {
624
- if (Node.isIdentifier(current)) return [current.getText(), ...parts].join(".");
625
- if (!Node.isPropertyAccessExpression(current)) return null;
626
- parts.unshift(current.getName());
627
- current = current.getExpression();
628
- }
629
- return null;
630
- }
631
- /**
632
- * Root of an `a.b().c()` chain — the left-most identifier.
633
- *
634
- * It must traverse `await`, calls, property access and `new`, otherwise
635
- * `await new Action().handle()` and `Invite.query().where().update()` stop at
636
- * the first node and the write disappears.
637
- */
638
- function rootSymbolOf(node) {
639
- let current = node;
640
- for (let depth = 0; depth < 60 && current; depth++) {
641
- if (Node.isIdentifier(current)) return current.getText();
642
- if (current.getKind() === SyntaxKind.ThisKeyword) return "this";
643
- if (Node.isPropertyAccessExpression(current) || Node.isElementAccessExpression(current) || Node.isCallExpression(current) || Node.isNewExpression(current) || Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) {
644
- current = current.getExpression();
645
- continue;
646
- }
647
- return null;
648
- }
649
- return null;
650
- }
651
- //#endregion
652
717
  //#region src/inventory/resolvers/transformer.ts
653
718
  /** BaseTransformer's public API; all of it funnels through `toObject` */
654
719
  const TRANSFORMER_METHODS = new Set([
@@ -772,4 +837,4 @@ function resolveCall(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
772
837
  return null;
773
838
  }
774
839
  //#endregion
775
- 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 };
840
+ export { rootSymbolOf as a, samePath as c, hooksFiredBy as i, toPosix as l, resolveCall as n, collectEventBindings as o, detectAccess as r, relativeTo as s, BUILTIN_CALL_RESOLVERS as t };