@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.
- package/CHANGELOG.md +144 -0
- package/README.md +103 -2
- package/build/calibration-8eV8CEix.js +403 -0
- package/build/commands/fp_metrics.d.ts +10 -0
- package/build/commands/main.d.ts +6 -5
- package/build/commands/main.js +48 -114
- package/build/decorate-D6enDn9D.js +24 -0
- package/build/fp_calibrate-iFAec0tA.js +25 -0
- package/build/fp_count-D21tQ_pv.js +22 -0
- package/build/fp_diff-D0pHGMgi.js +25 -0
- package/build/fp_explain-BwFs-LW-.js +24 -0
- package/build/fp_inventory-Bu6O1Nn0.js +18 -0
- package/build/fp_metrics-MGDppfSa.js +20 -0
- package/build/index.d.ts +30 -1
- package/build/index.js +5 -3
- package/build/{pipeline-BzP-ITGN.js → pipeline-CIAydCcT.js} +452 -54
- package/build/{resolvers-CU9HKYpn.js → resolvers-CRB6lXoo.js} +474 -207
- package/build/{runners-Bt8tbISi.js → runners-CmxNHuuq.js} +146 -342
- package/build/src/albrecht/counter.d.ts +21 -1
- package/build/src/albrecht/data_functions.d.ts +6 -0
- package/build/src/albrecht/diff.d.ts +19 -1
- package/build/src/cli/runners.d.ts +12 -0
- package/build/src/cli.js +11 -2
- package/build/src/define_config.d.ts +35 -1
- package/build/src/inventory/detectors/lucid.d.ts +8 -0
- package/build/src/inventory/graph/call_graph.d.ts +29 -0
- package/build/src/inventory/graph/noise.d.ts +13 -0
- package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
- package/build/src/inventory/resolvers/index.js +1 -1
- package/build/src/inventory/resolvers/types.d.ts +35 -0
- package/build/src/inventory/sources/event_bindings.d.ts +44 -0
- package/build/src/metrics/structure.d.ts +16 -2
- package/build/src/pipeline.js +1 -1
- package/build/src/reporters/table.d.ts +11 -1
- package/build/src/types.d.ts +17 -0
- package/build/stubs/config.stub +27 -1
- package/package.json +2 -1
- package/build/define_config-DOqWyPwV.js +0 -19
- package/build/scripts/smoke_package.d.ts +0 -1
- package/build/tmp/probe.d.ts +0 -1
- package/build/tmp/probe_cli.d.ts +0 -1
- package/build/tmp/probe_cmp.d.ts +0 -1
- package/build/tmp/probe_count.d.ts +0 -1
- package/build/tmp/probe_data.d.ts +0 -1
- package/build/tmp/probe_diff.d.ts +0 -1
- package/build/tmp/probe_gap.d.ts +0 -1
- package/build/tmp/probe_graph.d.ts +0 -1
- package/build/tmp/probe_metrics.d.ts +0 -1
- package/build/tmp/probe_miss.d.ts +0 -1
- package/build/tmp/probe_names.d.ts +0 -1
- package/build/tmp/probe_nodata.d.ts +0 -1
- package/build/tmp/probe_one.d.ts +0 -1
- package/build/tmp/probe_perf.d.ts +0 -1
- package/build/tmp/probe_routes.d.ts +0 -1
- package/build/tmp/probe_unres.d.ts +0 -1
- package/build/tmp/probe_vazquez.d.ts +0 -1
- package/build/tsdown.config.d.ts +0 -2
|
@@ -1,228 +1,150 @@
|
|
|
1
|
-
import { Node, SyntaxKind } from "ts-morph";
|
|
2
|
-
//#region src/inventory/
|
|
1
|
+
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
2
|
+
//#region src/inventory/paths.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
* at the call site.
|
|
4
|
+
* One canonical spelling for every path the inventory emits.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
* await mark.handle({ documentId })
|
|
11
|
+
* const key = `${ref.file}#${ref.member ?? ref.line ?? '*'}`
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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:
|
|
117
|
-
}
|
|
96
|
+
member: LISTENER_METHOD
|
|
97
|
+
});
|
|
118
98
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
*
|
|
125
|
+
* The file a key of a generated registry points at.
|
|
199
126
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
if (!
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
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
|
|
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 };
|