@filipebraida/adonis-function-points 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +427 -0
- package/bin/cli.js +4 -0
- package/build/commands/fp_calibrate.d.ts +16 -0
- package/build/commands/fp_count.d.ts +11 -0
- package/build/commands/fp_diff.d.ts +16 -0
- package/build/commands/fp_explain.d.ts +15 -0
- package/build/commands/fp_inventory.d.ts +9 -0
- package/build/commands/main.d.ts +5 -0
- package/build/commands/main.js +120 -0
- package/build/commands/printer.d.ts +9 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +10 -0
- package/build/define_config-DOqWyPwV.js +19 -0
- package/build/index.d.ts +5 -0
- package/build/index.js +4 -0
- package/build/pipeline-BzP-ITGN.js +2306 -0
- package/build/resolvers-CU9HKYpn.js +555 -0
- package/build/runners-Bt8tbISi.js +630 -0
- package/build/scripts/smoke_package.d.ts +1 -0
- package/build/src/albrecht/calibration.d.ts +62 -0
- package/build/src/albrecht/counter.d.ts +48 -0
- package/build/src/albrecht/data_functions.d.ts +32 -0
- package/build/src/albrecht/diff.d.ts +66 -0
- package/build/src/albrecht/index.d.ts +13 -0
- package/build/src/albrecht/tables.d.ts +19 -0
- package/build/src/albrecht/technical_filter.d.ts +25 -0
- package/build/src/albrecht/transactional_functions.d.ts +35 -0
- package/build/src/cli/load_config.d.ts +28 -0
- package/build/src/cli/print.d.ts +19 -0
- package/build/src/cli/runners.d.ts +52 -0
- package/build/src/cli.d.ts +19 -0
- package/build/src/cli.js +198 -0
- package/build/src/define_config.d.ts +137 -0
- package/build/src/inventory/app_context.d.ts +73 -0
- package/build/src/inventory/detectors/lucid.d.ts +77 -0
- package/build/src/inventory/graph/call_graph.d.ts +80 -0
- package/build/src/inventory/graph/noise.d.ts +9 -0
- package/build/src/inventory/index.d.ts +15 -0
- package/build/src/inventory/paths.d.ts +22 -0
- package/build/src/inventory/resolvers/action_object.d.ts +14 -0
- package/build/src/inventory/resolvers/index.d.ts +23 -0
- package/build/src/inventory/resolvers/index.js +2 -0
- package/build/src/inventory/resolvers/job_dispatch.d.ts +18 -0
- package/build/src/inventory/resolvers/module_function.d.ts +11 -0
- package/build/src/inventory/resolvers/property_service.d.ts +18 -0
- package/build/src/inventory/resolvers/same_class_method.d.ts +17 -0
- package/build/src/inventory/resolvers/static_service.d.ts +13 -0
- package/build/src/inventory/resolvers/transformer.d.ts +25 -0
- package/build/src/inventory/resolvers/types.d.ts +65 -0
- package/build/src/inventory/source.d.ts +39 -0
- package/build/src/inventory/sources/data_stores.d.ts +31 -0
- package/build/src/inventory/sources/json_schemas.d.ts +32 -0
- package/build/src/inventory/sources/routes_ast.d.ts +28 -0
- package/build/src/metrics/structure.d.ts +72 -0
- package/build/src/pipeline.d.ts +44 -0
- package/build/src/pipeline.js +2 -0
- package/build/src/reporters/table.d.ts +6 -0
- package/build/src/types.d.ts +256 -0
- package/build/src/types.js +1 -0
- package/build/stubs/config.stub +37 -0
- package/build/tmp/probe.d.ts +1 -0
- package/build/tmp/probe_cli.d.ts +1 -0
- package/build/tmp/probe_cmp.d.ts +1 -0
- package/build/tmp/probe_count.d.ts +1 -0
- package/build/tmp/probe_data.d.ts +1 -0
- package/build/tmp/probe_diff.d.ts +1 -0
- package/build/tmp/probe_gap.d.ts +1 -0
- package/build/tmp/probe_graph.d.ts +1 -0
- package/build/tmp/probe_metrics.d.ts +1 -0
- package/build/tmp/probe_miss.d.ts +1 -0
- package/build/tmp/probe_names.d.ts +1 -0
- package/build/tmp/probe_nodata.d.ts +1 -0
- package/build/tmp/probe_one.d.ts +1 -0
- package/build/tmp/probe_perf.d.ts +1 -0
- package/build/tmp/probe_routes.d.ts +1 -0
- package/build/tmp/probe_unres.d.ts +1 -0
- package/build/tmp/probe_vazquez.d.ts +1 -0
- package/build/tsdown.config.d.ts +2 -0
- package/package.json +133 -0
|
@@ -0,0 +1,2306 @@
|
|
|
1
|
+
import { a as rootSymbolOf, i as hooksFiredBy, n as resolveCall, r as detectAccess, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-CU9HKYpn.js";
|
|
2
|
+
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
//#region src/inventory/paths.ts
|
|
9
|
+
/**
|
|
10
|
+
* One canonical spelling for every path the inventory emits.
|
|
11
|
+
*
|
|
12
|
+
* Two path styles meet in this package. ts-morph always returns forward
|
|
13
|
+
* slashes, including on Windows; node's `path.join` returns backslashes there.
|
|
14
|
+
* Both end up in `HandlerRef.file`, and the call graph uses that string as a
|
|
15
|
+
* cache key:
|
|
16
|
+
*
|
|
17
|
+
* const key = `${ref.file}#${ref.member ?? ref.line ?? '*'}`
|
|
18
|
+
*
|
|
19
|
+
* Two spellings of the same file are two keys, so the same body would be
|
|
20
|
+
* analysed twice and pushed twice into the trace and the implementation scope
|
|
21
|
+
* — and a repeated scope entry changes the hash `fp:diff` compares.
|
|
22
|
+
*
|
|
23
|
+
* Forward slashes win because ts-morph cannot be told otherwise, node's `fs`
|
|
24
|
+
* accepts them on Windows, and `path.relative` normalises mixed input anyway.
|
|
25
|
+
* Normalising at the boundary where a path is created costs one call; leaving
|
|
26
|
+
* it to each comparison costs vigilance forever.
|
|
27
|
+
*/
|
|
28
|
+
const toPosix = (value) => value.split("\\").join("/");
|
|
29
|
+
/** Compares two paths that may have come from different sources. */
|
|
30
|
+
const samePath = (a, b) => a !== void 0 && b !== void 0 && toPosix(a) === toPosix(b);
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/inventory/app_context.ts
|
|
33
|
+
/** folders naming an artefact TYPE, in either layout */
|
|
34
|
+
const ARTIFACT_KINDS = new Set([
|
|
35
|
+
"models",
|
|
36
|
+
"controllers",
|
|
37
|
+
"services",
|
|
38
|
+
"actions",
|
|
39
|
+
"queries",
|
|
40
|
+
"validators",
|
|
41
|
+
"transformers",
|
|
42
|
+
"jobs",
|
|
43
|
+
"policies",
|
|
44
|
+
"middleware",
|
|
45
|
+
"middlewares",
|
|
46
|
+
"listeners",
|
|
47
|
+
"events",
|
|
48
|
+
"exceptions",
|
|
49
|
+
"mails",
|
|
50
|
+
"enums",
|
|
51
|
+
"abilities",
|
|
52
|
+
"notifications",
|
|
53
|
+
"dtos",
|
|
54
|
+
"resources",
|
|
55
|
+
"mixins",
|
|
56
|
+
"hooks",
|
|
57
|
+
"providers",
|
|
58
|
+
"commands"
|
|
59
|
+
]);
|
|
60
|
+
const GENERATED_MARKER = "automatically generated";
|
|
61
|
+
async function discoverApp(root) {
|
|
62
|
+
const abs = toPosix(path.resolve(root));
|
|
63
|
+
const pkg = await readJson(path.join(abs, "package.json"));
|
|
64
|
+
const subpathImports = readSubpathImports(pkg);
|
|
65
|
+
const layout = await detectLayout(abs);
|
|
66
|
+
const resolveSpecifier = (specifier) => resolveWithImports(abs, subpathImports, specifier);
|
|
67
|
+
const generated = {
|
|
68
|
+
routeRegistry: await firstExisting(abs, [".adonisjs/client/registry/schema.d.ts"]),
|
|
69
|
+
controllersMap: await firstExisting(abs, [".adonisjs/server/controllers.ts"]),
|
|
70
|
+
dataSchema: await findDataSchema(abs, resolveSpecifier)
|
|
71
|
+
};
|
|
72
|
+
const scanRoots = await collectScanRoots(abs, subpathImports);
|
|
73
|
+
const routeFiles = await collectRouteFiles(abs, resolveSpecifier);
|
|
74
|
+
/**
|
|
75
|
+
* Normalised here, at the one place every discovered path leaves: `abs` and
|
|
76
|
+
* `toSource` were normalised at creation, and the roots and generated
|
|
77
|
+
* artefacts were not — which Windows CI caught and Linux never could.
|
|
78
|
+
* Covering the whole return is what makes the omission impossible to repeat.
|
|
79
|
+
*/
|
|
80
|
+
return {
|
|
81
|
+
root: abs,
|
|
82
|
+
subpathImports,
|
|
83
|
+
generated: {
|
|
84
|
+
routeRegistry: generated.routeRegistry && toPosix(generated.routeRegistry),
|
|
85
|
+
controllersMap: generated.controllersMap && toPosix(generated.controllersMap),
|
|
86
|
+
dataSchema: generated.dataSchema && toPosix(generated.dataSchema)
|
|
87
|
+
},
|
|
88
|
+
layout,
|
|
89
|
+
routeFiles: routeFiles.map(toPosix),
|
|
90
|
+
scanRoots: scanRoots.map(toPosix),
|
|
91
|
+
framework: readFramework(pkg),
|
|
92
|
+
resolveSpecifier,
|
|
93
|
+
moduleOf: (absPath) => moduleOf(abs, absPath)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const majorOf = (range) => {
|
|
97
|
+
const m = range?.match(/(\d+)\./);
|
|
98
|
+
return m ? Number(m[1]) : void 0;
|
|
99
|
+
};
|
|
100
|
+
function readFramework(pkg) {
|
|
101
|
+
const deps = {
|
|
102
|
+
...pkg?.dependencies ?? {},
|
|
103
|
+
...pkg?.devDependencies ?? {}
|
|
104
|
+
};
|
|
105
|
+
const core = majorOf(deps["@adonisjs/core"]);
|
|
106
|
+
const lucid = majorOf(deps["@adonisjs/lucid"]);
|
|
107
|
+
return {
|
|
108
|
+
core,
|
|
109
|
+
lucid,
|
|
110
|
+
orm: lucid ? "lucid" : deps["kysely"] ? "kysely" : "unknown",
|
|
111
|
+
tuyau: Boolean(deps["@tuyau/core"]),
|
|
112
|
+
supported: core === 7 && lucid !== void 0 && lucid >= 22
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Derives the directories to scan from the alias TARGETS, collapsing nested
|
|
117
|
+
* ones.
|
|
118
|
+
*
|
|
119
|
+
* Keeping `app/admin` alongside `app` would scan each file twice and, worse,
|
|
120
|
+
* change the computed module: `app/admin/catalog/...` would become "catalog"
|
|
121
|
+
* instead of "admin/catalog".
|
|
122
|
+
*/
|
|
123
|
+
async function collectScanRoots(root, imports) {
|
|
124
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
125
|
+
for (const target of imports.values()) {
|
|
126
|
+
const dir = target.replace(/^\.\//, "").split("*")[0].replace(/\/$/, "");
|
|
127
|
+
if (!dir || dir.startsWith(".")) continue;
|
|
128
|
+
if (NON_APPLICATION_ROOTS.some((pattern) => pattern.test(dir))) continue;
|
|
129
|
+
candidates.add(dir);
|
|
130
|
+
}
|
|
131
|
+
const existing = [];
|
|
132
|
+
for (const dir of candidates) if (await isDirectory(path.join(root, dir))) existing.push(dir);
|
|
133
|
+
const collapsed = existing.filter((dir) => !existing.some((other) => other !== dir && isInside(dir, other)));
|
|
134
|
+
return [...new Set(collapsed)].sort().map((dir) => path.join(root, dir));
|
|
135
|
+
}
|
|
136
|
+
const isInside = (child, parent) => child.startsWith(parent + "/");
|
|
137
|
+
/**
|
|
138
|
+
* Roots an alias reaches that are NOT application code.
|
|
139
|
+
*
|
|
140
|
+
* Not pedantry: test factories routinely contain real persistence calls.
|
|
141
|
+
* Scanning them would count test writes as application functions — and the
|
|
142
|
+
* number goes into an invoice.
|
|
143
|
+
*/
|
|
144
|
+
const NON_APPLICATION_ROOTS = [
|
|
145
|
+
/^tests?(\/|$)/,
|
|
146
|
+
/^config(\/|$)/,
|
|
147
|
+
/^database(\/|$)/,
|
|
148
|
+
/^public(\/|$)/,
|
|
149
|
+
/^resources(\/|$)/,
|
|
150
|
+
/^inertia(\/|$)/,
|
|
151
|
+
/^bin(\/|$)/,
|
|
152
|
+
/^build(\/|$)/,
|
|
153
|
+
/^node_modules(\/|$)/
|
|
154
|
+
];
|
|
155
|
+
const ROUTE_CALL = /\brouter\s*\.\s*(get|post|put|patch|delete|any|resource|on|group)\s*\(/;
|
|
156
|
+
/**
|
|
157
|
+
* Starts from the adonisrc `preloads` and follows static imports.
|
|
158
|
+
*
|
|
159
|
+
* A preload may be a hub that defines no route at all and only re-exports.
|
|
160
|
+
* Stopping at the preload would return an empty file.
|
|
161
|
+
*/
|
|
162
|
+
async function collectRouteFiles(root, resolveSpecifier) {
|
|
163
|
+
const adonisrc = await readFileOrNull(path.join(root, "adonisrc.ts"));
|
|
164
|
+
if (!adonisrc) return [];
|
|
165
|
+
const found = [];
|
|
166
|
+
const seen = /* @__PURE__ */ new Set();
|
|
167
|
+
const visit = async (file, depth) => {
|
|
168
|
+
if (seen.has(file) || depth < 0) return;
|
|
169
|
+
seen.add(file);
|
|
170
|
+
const source = await readFileOrNull(file);
|
|
171
|
+
if (source === null) return;
|
|
172
|
+
if (ROUTE_CALL.test(source)) found.push(file);
|
|
173
|
+
for (const spec of staticImportsOf(source)) {
|
|
174
|
+
const target = resolveSpecifier(spec);
|
|
175
|
+
if (target) await visit(target, depth - 1);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
for (const spec of preloadSpecifiersOf(adonisrc)) {
|
|
179
|
+
const target = resolveSpecifier(spec);
|
|
180
|
+
if (target) await visit(target, 3);
|
|
181
|
+
}
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
/** `preloads: [() => import('#start/routes'), ...]` */
|
|
185
|
+
function preloadSpecifiersOf(adonisrc) {
|
|
186
|
+
const block = adonisrc.match(/preloads\s*:\s*\[([\s\S]*?)\]/);
|
|
187
|
+
if (!block) return [];
|
|
188
|
+
return [...block[1].matchAll(/import\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]);
|
|
189
|
+
}
|
|
190
|
+
/** static imports, including `import '#x'` with no binding */
|
|
191
|
+
function staticImportsOf(source) {
|
|
192
|
+
return [...source.matchAll(/\bimport\s+(?:[^'"]*?\bfrom\s*)?['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
193
|
+
}
|
|
194
|
+
function readSubpathImports(pkg) {
|
|
195
|
+
const map = /* @__PURE__ */ new Map();
|
|
196
|
+
const imports = pkg?.imports ?? {};
|
|
197
|
+
for (const [key, value] of Object.entries(imports)) {
|
|
198
|
+
const target = typeof value === "string" ? value : pickDefault(value);
|
|
199
|
+
if (target) map.set(key, target);
|
|
200
|
+
}
|
|
201
|
+
return map;
|
|
202
|
+
}
|
|
203
|
+
/** conditional entries: { "import": "./x.js", "default": "./x.js" } */
|
|
204
|
+
function pickDefault(value) {
|
|
205
|
+
if (!value || typeof value !== "object") return null;
|
|
206
|
+
const obj = value;
|
|
207
|
+
for (const key of [
|
|
208
|
+
"import",
|
|
209
|
+
"default",
|
|
210
|
+
"node",
|
|
211
|
+
"require"
|
|
212
|
+
]) if (typeof obj[key] === "string") return obj[key];
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Resolves a specifier against the `imports` map.
|
|
217
|
+
*
|
|
218
|
+
* The map points at `.js` (what Node executes) while we analyse source, hence
|
|
219
|
+
* the translation to `.ts`. More specific entries win over generic ones:
|
|
220
|
+
* `#app/legacy/*` must beat `#app/*` when both match.
|
|
221
|
+
*/
|
|
222
|
+
function resolveWithImports(root, imports, specifier) {
|
|
223
|
+
if (!specifier.startsWith("#")) return null;
|
|
224
|
+
let best = null;
|
|
225
|
+
for (const [pattern, target] of imports) {
|
|
226
|
+
const star = pattern.indexOf("*");
|
|
227
|
+
if (star === -1) {
|
|
228
|
+
if (pattern === specifier) return toSource(root, target);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const prefix = pattern.slice(0, star);
|
|
232
|
+
const suffix = pattern.slice(star + 1);
|
|
233
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
|
|
234
|
+
const middle = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
235
|
+
const resolved = target.replace("*", middle);
|
|
236
|
+
if (!best || prefix.length > best.specificity) best = {
|
|
237
|
+
target: resolved,
|
|
238
|
+
specificity: prefix.length
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return best ? toSource(root, best.target) : null;
|
|
242
|
+
}
|
|
243
|
+
function toSource(root, target) {
|
|
244
|
+
const rel = target.replace(/^\.\//, "").replace(/\.js$/, ".ts");
|
|
245
|
+
return toPosix(path.join(root, rel));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Decides by weight of evidence, not by the first folder encountered.
|
|
249
|
+
*
|
|
250
|
+
* `app/models` directly is evidence of a flat layout; `app/catalog/models` is
|
|
251
|
+
* evidence of module-per-domain. An application can show both (a loose
|
|
252
|
+
* `app/middleware` in a modular project), so both sides are counted.
|
|
253
|
+
*/
|
|
254
|
+
async function detectLayout(root) {
|
|
255
|
+
const appDir = path.join(root, "app");
|
|
256
|
+
const top = await listDirs(appDir);
|
|
257
|
+
if (top.length === 0) return "unknown";
|
|
258
|
+
let flat = 0;
|
|
259
|
+
let modular = 0;
|
|
260
|
+
for (const entry of top) {
|
|
261
|
+
if (ARTIFACT_KINDS.has(entry)) {
|
|
262
|
+
flat++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if ((await listDirs(path.join(appDir, entry))).some((child) => ARTIFACT_KINDS.has(child))) modular++;
|
|
266
|
+
}
|
|
267
|
+
if (modular > flat) return "module-per-domain";
|
|
268
|
+
if (flat > 0) return "flat";
|
|
269
|
+
return "unknown";
|
|
270
|
+
}
|
|
271
|
+
/** top-level containers that name no domain — they only hold code */
|
|
272
|
+
const CODE_CONTAINERS = new Set(["app", "src"]);
|
|
273
|
+
/**
|
|
274
|
+
* Module = the segments between the top-level container and the first segment
|
|
275
|
+
* naming an artefact TYPE.
|
|
276
|
+
*
|
|
277
|
+
* app/models/book.ts -> 'app' (nothing before the type)
|
|
278
|
+
* app/catalog/models/book.ts -> 'catalog'
|
|
279
|
+
* app/admin/catalog/models/book.ts -> 'admin/catalog' (nested)
|
|
280
|
+
* src/catalog/actions/create_book.ts -> 'catalog' (outside app/)
|
|
281
|
+
*
|
|
282
|
+
* Deliberately does NOT use `scanRoots`: in a flat layout there is no `#app/*`
|
|
283
|
+
* alias, so the roots end up being the artefact-type folders themselves
|
|
284
|
+
* (`app/models`), which would make the module "models".
|
|
285
|
+
*
|
|
286
|
+
* Used only to group the report. Never to find a file.
|
|
287
|
+
*/
|
|
288
|
+
function moduleOf(root, absPath) {
|
|
289
|
+
const segments = path.relative(root, absPath).split(path.sep).slice(0, -1);
|
|
290
|
+
const body = CODE_CONTAINERS.has(segments[0]) ? segments.slice(1) : segments;
|
|
291
|
+
const kindAt = body.findIndex((segment) => ARTIFACT_KINDS.has(segment));
|
|
292
|
+
const moduleSegments = kindAt === -1 ? body : body.slice(0, kindAt);
|
|
293
|
+
return moduleSegments.length > 0 ? moduleSegments.join("/") : "app";
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Finds `database/schema.ts` by what it IS, not by where it sits.
|
|
297
|
+
*
|
|
298
|
+
* It appears as `database/schema.ts` and as `app/core/database/schema.ts`
|
|
299
|
+
* depending on the project. The `#database/schema` alias is tried first, then
|
|
300
|
+
* the known paths, then a shallow scan for the generation header — because the
|
|
301
|
+
* path is the least reliable signal.
|
|
302
|
+
*/
|
|
303
|
+
async function findDataSchema(root, resolveSpecifier) {
|
|
304
|
+
const viaAlias = resolveSpecifier("#database/schema");
|
|
305
|
+
if (viaAlias && await isGeneratedSchema(viaAlias)) return viaAlias;
|
|
306
|
+
for (const candidate of [
|
|
307
|
+
"database/schema.ts",
|
|
308
|
+
"app/core/database/schema.ts",
|
|
309
|
+
"app/database/schema.ts"
|
|
310
|
+
]) {
|
|
311
|
+
const full = path.join(root, candidate);
|
|
312
|
+
if (await isGeneratedSchema(full)) return full;
|
|
313
|
+
}
|
|
314
|
+
return scanForSchema(root, 4);
|
|
315
|
+
}
|
|
316
|
+
async function isGeneratedSchema(file) {
|
|
317
|
+
try {
|
|
318
|
+
const head = (await fs.readFile(file, "utf8")).slice(0, 2e3);
|
|
319
|
+
return head.includes(GENERATED_MARKER) && /class\s+\w+Schema\b/.test(head);
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const SKIP_DIRS = new Set([
|
|
325
|
+
"node_modules",
|
|
326
|
+
".git",
|
|
327
|
+
"build",
|
|
328
|
+
"dist",
|
|
329
|
+
"coverage",
|
|
330
|
+
"tmp"
|
|
331
|
+
]);
|
|
332
|
+
async function scanForSchema(dir, depth) {
|
|
333
|
+
if (depth < 0) return void 0;
|
|
334
|
+
const entries = await readEntries(dir);
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
const full = path.join(dir, entry.name);
|
|
337
|
+
if (entry.isFile() && entry.name === "schema.ts") {
|
|
338
|
+
if (await isGeneratedSchema(full)) return full;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
|
|
343
|
+
const found = await scanForSchema(path.join(dir, entry.name), depth - 1);
|
|
344
|
+
if (found) return found;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function readJson(file) {
|
|
348
|
+
try {
|
|
349
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function readEntries(dir) {
|
|
355
|
+
try {
|
|
356
|
+
return await fs.readdir(dir, { withFileTypes: true });
|
|
357
|
+
} catch {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function listDirs(dir) {
|
|
362
|
+
return (await readEntries(dir)).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
363
|
+
}
|
|
364
|
+
async function isDirectory(dir) {
|
|
365
|
+
try {
|
|
366
|
+
return (await fs.stat(dir)).isDirectory();
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function readFileOrNull(file) {
|
|
372
|
+
try {
|
|
373
|
+
return await fs.readFile(file, "utf8");
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function firstExisting(root, candidates) {
|
|
379
|
+
for (const candidate of candidates) {
|
|
380
|
+
const full = path.join(root, candidate);
|
|
381
|
+
try {
|
|
382
|
+
await fs.access(full);
|
|
383
|
+
return full;
|
|
384
|
+
} catch {}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/inventory/sources/data_stores.ts
|
|
389
|
+
const LUCID_ORM = "@adonisjs/lucid/orm";
|
|
390
|
+
const BASE_MODEL = "BaseModel";
|
|
391
|
+
/** decorators marking a composition relation — RET subgroup candidates */
|
|
392
|
+
const COMPOSITION_RELATIONS = new Set(["hasMany", "hasOne"]);
|
|
393
|
+
/** every Lucid relation decorator */
|
|
394
|
+
const ALL_RELATIONS = new Set([
|
|
395
|
+
"belongsTo",
|
|
396
|
+
"hasMany",
|
|
397
|
+
"hasOne",
|
|
398
|
+
"manyToMany",
|
|
399
|
+
"hasManyThrough"
|
|
400
|
+
]);
|
|
401
|
+
async function collectDataStores(app) {
|
|
402
|
+
const project = new Project({
|
|
403
|
+
skipAddingFilesFromTsConfig: true,
|
|
404
|
+
skipFileDependencyResolution: true,
|
|
405
|
+
compilerOptions: { allowJs: false }
|
|
406
|
+
});
|
|
407
|
+
for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
|
|
408
|
+
if (app.generated.dataSchema) project.addSourceFileAtPathIfExists(app.generated.dataSchema);
|
|
409
|
+
const candidates = [];
|
|
410
|
+
const unresolved = [];
|
|
411
|
+
/**
|
|
412
|
+
* Classes appearing as an ANCESTOR of some model.
|
|
413
|
+
*
|
|
414
|
+
* A base class has no table; counting it invents a data store that does not
|
|
415
|
+
* exist. Applications commonly define their own `BaseModel`, so this is the
|
|
416
|
+
* normal case rather than an edge case.
|
|
417
|
+
*/
|
|
418
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
419
|
+
for (const file of project.getSourceFiles()) {
|
|
420
|
+
if (samePath(file.getFilePath(), app.generated.dataSchema)) continue;
|
|
421
|
+
for (const cls of file.getClasses()) {
|
|
422
|
+
const described = describeStore(cls, app, project, unresolved);
|
|
423
|
+
if (!described) continue;
|
|
424
|
+
candidates.push(described.store);
|
|
425
|
+
for (const ancestor of described.ancestors) ancestors.add(ancestor);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
stores: candidates.filter((store) => !ancestors.has(store.id)).sort(byName),
|
|
430
|
+
unresolved
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/** stable identity of a class, to separate a base from a store */
|
|
434
|
+
const classKey = (cls) => `${cls.getSourceFile().getFilePath()}#${cls.getName()}`;
|
|
435
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
436
|
+
function describeStore(cls, app, project, unresolved) {
|
|
437
|
+
const name = cls.getName();
|
|
438
|
+
if (!name) return null;
|
|
439
|
+
const chain = walkChain(cls, app, project);
|
|
440
|
+
if (!chain.reachesLucid) return null;
|
|
441
|
+
unresolved.push(...chain.unresolved);
|
|
442
|
+
const file = cls.getSourceFile().getFilePath();
|
|
443
|
+
return {
|
|
444
|
+
store: {
|
|
445
|
+
id: classKey(cls),
|
|
446
|
+
name,
|
|
447
|
+
module: app.moduleOf(file),
|
|
448
|
+
table: tableOf(cls) ?? tableFromName(name),
|
|
449
|
+
attributes: chain.attributes,
|
|
450
|
+
subgroups: subgroupsOf(chain.classes),
|
|
451
|
+
relations: relationsOf(chain.classes),
|
|
452
|
+
maintainedExternally: false,
|
|
453
|
+
columnSource: chain.columnSource,
|
|
454
|
+
provenance: {
|
|
455
|
+
file,
|
|
456
|
+
line: cls.getStartLineNumber(),
|
|
457
|
+
by: "data-stores"
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
ancestors: chain.classes.slice(1).map(classKey)
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Walks up the inheritance chain accumulating columns.
|
|
465
|
+
*
|
|
466
|
+
* `extends compose(A, B)` has more than one parent: both are followed. Anything
|
|
467
|
+
* not resolvable inside the application becomes an unresolved entry — that is
|
|
468
|
+
* not a detail, it is the difference between "has no column" and "I don't know
|
|
469
|
+
* whether it has one".
|
|
470
|
+
*/
|
|
471
|
+
function walkChain(start, app, project) {
|
|
472
|
+
const classes = [];
|
|
473
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
474
|
+
const seen = /* @__PURE__ */ new Set();
|
|
475
|
+
/**
|
|
476
|
+
* Unresolved entries stay local to the chain and only bubble up if the chain
|
|
477
|
+
* really is a model's. Otherwise every application class extending something
|
|
478
|
+
* from a package — controller, exception, middleware — would become noise in
|
|
479
|
+
* the coverage report.
|
|
480
|
+
*/
|
|
481
|
+
const unresolved = [];
|
|
482
|
+
let reachesLucid = false;
|
|
483
|
+
let columnSource = "ast";
|
|
484
|
+
const visit = (cls) => {
|
|
485
|
+
const key = `${cls.getSourceFile().getFilePath()}#${cls.getName()}`;
|
|
486
|
+
if (seen.has(key)) return;
|
|
487
|
+
seen.add(key);
|
|
488
|
+
classes.push(cls);
|
|
489
|
+
if (samePath(cls.getSourceFile().getFilePath(), app.generated.dataSchema)) columnSource = "generated-schema";
|
|
490
|
+
for (const attribute of columnsOf(cls)) if (!attributes.has(attribute.name)) attributes.set(attribute.name, attribute);
|
|
491
|
+
for (const parent of parentsOf(cls)) {
|
|
492
|
+
const origin = originOf(parent.getText(), cls.getSourceFile());
|
|
493
|
+
if (origin?.specifier === LUCID_ORM && origin.exportedName === BASE_MODEL) {
|
|
494
|
+
reachesLucid = true;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const resolved = resolveClass(parent.getText(), cls.getSourceFile(), app, project);
|
|
498
|
+
if (resolved) {
|
|
499
|
+
visit(resolved);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
unresolved.push({
|
|
503
|
+
file: cls.getSourceFile().getFilePath(),
|
|
504
|
+
line: parent.getStartLineNumber(),
|
|
505
|
+
expression: parent.getText(),
|
|
506
|
+
reason: reasonFor(parent, cls.getSourceFile(), app)
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
visit(start);
|
|
511
|
+
return {
|
|
512
|
+
reachesLucid,
|
|
513
|
+
classes,
|
|
514
|
+
attributes: [...attributes.values()],
|
|
515
|
+
columnSource,
|
|
516
|
+
unresolved
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Why the base class could not be resolved.
|
|
521
|
+
*
|
|
522
|
+
* The right reason matters as much as the fact: saying "outside the
|
|
523
|
+
* application" about code that is inside it sends the reader to the wrong
|
|
524
|
+
* place, and the coverage report exists precisely to be actionable.
|
|
525
|
+
*/
|
|
526
|
+
function reasonFor(parent, file, app) {
|
|
527
|
+
if (Node.isCallExpression(parent)) return "mixin factory: the column only exists on the class the function returns, and evaluating that return is beyond the current static analysis";
|
|
528
|
+
const origin = originOf(parent.getText(), file);
|
|
529
|
+
if (origin && !app.resolveSpecifier(origin.specifier)) return `base class outside the application (${origin.specifier}): the package cannot know which columns it adds`;
|
|
530
|
+
return "base class not found in the application";
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Parents of a class. `extends compose(A, B)` yields A and B; `extends X`
|
|
534
|
+
* yields X.
|
|
535
|
+
*/
|
|
536
|
+
function parentsOf(cls) {
|
|
537
|
+
const extended = cls.getExtends();
|
|
538
|
+
if (!extended) return [];
|
|
539
|
+
const expression = extended.getExpression();
|
|
540
|
+
if (Node.isCallExpression(expression) && expression.getExpression().getText() === "compose") return expression.getArguments();
|
|
541
|
+
return [expression];
|
|
542
|
+
}
|
|
543
|
+
function resolveClass(name, from, app, project) {
|
|
544
|
+
const local = from.getClass(name);
|
|
545
|
+
if (local) return local;
|
|
546
|
+
const origin = originOf(name, from);
|
|
547
|
+
if (!origin) return null;
|
|
548
|
+
const target = app.resolveSpecifier(origin.specifier);
|
|
549
|
+
if (!target) return null;
|
|
550
|
+
const file = project.getSourceFile(target) ?? project.addSourceFileAtPathIfExists(target);
|
|
551
|
+
if (!file) return null;
|
|
552
|
+
if (origin.exportedName === "default") return file.getClasses().find((candidate) => candidate.isDefaultExport()) ?? null;
|
|
553
|
+
return file.getClass(origin.exportedName) ?? null;
|
|
554
|
+
}
|
|
555
|
+
function originOf(local, file) {
|
|
556
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
557
|
+
const specifier = declaration.getModuleSpecifierValue();
|
|
558
|
+
if (declaration.getDefaultImport()?.getText() === local) return {
|
|
559
|
+
specifier,
|
|
560
|
+
exportedName: "default"
|
|
561
|
+
};
|
|
562
|
+
for (const named of declaration.getNamedImports()) if ((named.getAliasNode()?.getText() ?? named.getName()) === local) return {
|
|
563
|
+
specifier,
|
|
564
|
+
exportedName: named.getName()
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* `@column()`, `@column({ isPrimary: true })` and `@column.dateTime(...)`.
|
|
571
|
+
*
|
|
572
|
+
* `static $columns` is the canonical list generated from migrations, but it
|
|
573
|
+
* says nothing about which field is the primary key nor where each one is
|
|
574
|
+
* declared — so it serves to CROSS-CHECK, while the decorators remain the
|
|
575
|
+
* primary reading.
|
|
576
|
+
*/
|
|
577
|
+
function columnsOf(cls) {
|
|
578
|
+
const file = cls.getSourceFile().getFilePath();
|
|
579
|
+
const attributes = [];
|
|
580
|
+
for (const property of cls.getProperties()) for (const decorator of property.getDecorators()) {
|
|
581
|
+
const full = decorator.getFullName();
|
|
582
|
+
if (full !== "column" && !full.startsWith("column.")) continue;
|
|
583
|
+
const isIdentifier = /isPrimary\s*:\s*true/.test(decorator.getExpression().getText());
|
|
584
|
+
attributes.push({
|
|
585
|
+
name: property.getName(),
|
|
586
|
+
type: property.getTypeNode()?.getText(),
|
|
587
|
+
isIdentifier,
|
|
588
|
+
provenance: {
|
|
589
|
+
file,
|
|
590
|
+
line: property.getStartLineNumber(),
|
|
591
|
+
by: "column-decorator"
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return attributes;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Declared relations: property -> target store.
|
|
599
|
+
*
|
|
600
|
+
* `@belongsTo(() => Author) declare author` yields `{ author: 'Author' }`,
|
|
601
|
+
* which is what lets `.preload('author')` resolve later.
|
|
602
|
+
*/
|
|
603
|
+
function relationsOf(classes) {
|
|
604
|
+
const relations = {};
|
|
605
|
+
for (const cls of classes) for (const property of cls.getProperties()) for (const decorator of property.getDecorators()) {
|
|
606
|
+
if (!ALL_RELATIONS.has(decorator.getName())) continue;
|
|
607
|
+
const target = decorator.getExpression().getText().match(/=>\s*([A-Za-z_$][\w$]*)/);
|
|
608
|
+
if (target) relations[property.getName()] = target[1];
|
|
609
|
+
}
|
|
610
|
+
return relations;
|
|
611
|
+
}
|
|
612
|
+
/** composition relations, candidates for a logical subgroup (RET) */
|
|
613
|
+
function subgroupsOf(classes) {
|
|
614
|
+
const subgroups = /* @__PURE__ */ new Set();
|
|
615
|
+
for (const cls of classes) for (const property of cls.getProperties()) for (const decorator of property.getDecorators()) {
|
|
616
|
+
if (!COMPOSITION_RELATIONS.has(decorator.getName())) continue;
|
|
617
|
+
const target = decorator.getExpression().getText().match(/=>\s*([A-Za-z_$][\w$]*)/);
|
|
618
|
+
if (target) subgroups.add(target[1]);
|
|
619
|
+
}
|
|
620
|
+
return [...subgroups].sort();
|
|
621
|
+
}
|
|
622
|
+
function tableOf(cls) {
|
|
623
|
+
const declared = cls.getStaticProperty("table");
|
|
624
|
+
if (!declared || !Node.isPropertyDeclaration(declared)) return void 0;
|
|
625
|
+
return declared.getInitializer()?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
|
|
626
|
+
}
|
|
627
|
+
/** Lucid convention when `static table` is not declared */
|
|
628
|
+
function tableFromName(name) {
|
|
629
|
+
const snake = name.replace(/([a-z\d])([A-Z])/g, "$1_$2").toLowerCase();
|
|
630
|
+
return snake.endsWith("s") ? snake : `${snake}s`;
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/inventory/sources/routes_ast.ts
|
|
634
|
+
const VERBS = new Set([
|
|
635
|
+
"get",
|
|
636
|
+
"post",
|
|
637
|
+
"put",
|
|
638
|
+
"patch",
|
|
639
|
+
"delete",
|
|
640
|
+
"any"
|
|
641
|
+
]);
|
|
642
|
+
/** resource action -> verb and path suffix */
|
|
643
|
+
const RESOURCE_ACTIONS = {
|
|
644
|
+
index: {
|
|
645
|
+
verb: "GET",
|
|
646
|
+
suffix: ""
|
|
647
|
+
},
|
|
648
|
+
create: {
|
|
649
|
+
verb: "GET",
|
|
650
|
+
suffix: "/create"
|
|
651
|
+
},
|
|
652
|
+
store: {
|
|
653
|
+
verb: "POST",
|
|
654
|
+
suffix: ""
|
|
655
|
+
},
|
|
656
|
+
show: {
|
|
657
|
+
verb: "GET",
|
|
658
|
+
suffix: "/:id"
|
|
659
|
+
},
|
|
660
|
+
edit: {
|
|
661
|
+
verb: "GET",
|
|
662
|
+
suffix: "/:id/edit"
|
|
663
|
+
},
|
|
664
|
+
update: {
|
|
665
|
+
verb: "PUT",
|
|
666
|
+
suffix: "/:id"
|
|
667
|
+
},
|
|
668
|
+
destroy: {
|
|
669
|
+
verb: "DELETE",
|
|
670
|
+
suffix: "/:id"
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
const API_ACTIONS = [
|
|
674
|
+
"index",
|
|
675
|
+
"store",
|
|
676
|
+
"show",
|
|
677
|
+
"update",
|
|
678
|
+
"destroy"
|
|
679
|
+
];
|
|
680
|
+
async function collectEntryPoints(app) {
|
|
681
|
+
const project = new Project({
|
|
682
|
+
skipAddingFilesFromTsConfig: true,
|
|
683
|
+
skipFileDependencyResolution: true,
|
|
684
|
+
compilerOptions: { allowJs: false }
|
|
685
|
+
});
|
|
686
|
+
const entryPoints = [];
|
|
687
|
+
const unresolved = [];
|
|
688
|
+
const controllers = app.generated.controllersMap ? readControllersMap(project, app.generated.controllersMap) : /* @__PURE__ */ new Map();
|
|
689
|
+
for (const routeFile of app.routeFiles) {
|
|
690
|
+
const file = project.addSourceFileAtPathIfExists(routeFile);
|
|
691
|
+
if (!file) continue;
|
|
692
|
+
const resolver = handlerResolver(file, app, controllers);
|
|
693
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
694
|
+
const verb = verbOf(call);
|
|
695
|
+
if (!verb) continue;
|
|
696
|
+
const prefix = prefixFor(call);
|
|
697
|
+
const args = call.getArguments();
|
|
698
|
+
const pattern = literalOf(args[0]);
|
|
699
|
+
if (pattern === null) continue;
|
|
700
|
+
if (verb === "resource") {
|
|
701
|
+
entryPoints.push(...expandResource(call, prefix + pattern, args[1], resolver, app, unresolved));
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (verb === "on") {
|
|
705
|
+
entryPoints.push(describe("GET", prefix + pattern, null, call, app, nameOf(call)));
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const { handler, problem } = resolver(args[1], call);
|
|
709
|
+
if (problem) unresolved.push(problem);
|
|
710
|
+
entryPoints.push(describe(verb.toUpperCase(), prefix + pattern, handler, call, app, nameOf(call)));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
entryPoints,
|
|
715
|
+
unresolved
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* `router.get`, even when broken across several lines.
|
|
720
|
+
*
|
|
721
|
+
* Normalising the whitespace is what separates seeing a fraction of the routes
|
|
722
|
+
* from seeing all of them.
|
|
723
|
+
*/
|
|
724
|
+
function verbOf(call) {
|
|
725
|
+
const match = call.getExpression().getText().replace(/\s+/g, "").match(/^router\.(\w+)$/);
|
|
726
|
+
if (!match) return null;
|
|
727
|
+
const verb = match[1];
|
|
728
|
+
if (VERBS.has(verb) || verb === "resource" || verb === "on") return verb;
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Methods chained AFTER a call: `.prefix('/x')`, `.as('y')`, `.only([...])`.
|
|
733
|
+
*
|
|
734
|
+
* This has to be structural, never a regex over the statement text. The text of
|
|
735
|
+
* an outer group contains the groups nested inside it, and the inner
|
|
736
|
+
* `.prefix()` appears BEFORE the outer one — so the first occurrence is the
|
|
737
|
+
* wrong one, and `/admin/trash/books` comes out as `/trash/trash/books`.
|
|
738
|
+
*/
|
|
739
|
+
function chainOf(call) {
|
|
740
|
+
const applied = /* @__PURE__ */ new Map();
|
|
741
|
+
let current = call;
|
|
742
|
+
for (let depth = 0; depth < 40; depth++) {
|
|
743
|
+
const access = current.getParent();
|
|
744
|
+
if (!access || !Node.isPropertyAccessExpression(access)) break;
|
|
745
|
+
if (access.getExpression() !== current) break;
|
|
746
|
+
const invocation = access.getParent();
|
|
747
|
+
if (!invocation || !Node.isCallExpression(invocation)) break;
|
|
748
|
+
if (!applied.has(access.getName())) applied.set(access.getName(), invocation.getArguments());
|
|
749
|
+
current = invocation;
|
|
750
|
+
}
|
|
751
|
+
return applied;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Prefix accumulated from the groups enclosing this call.
|
|
755
|
+
*
|
|
756
|
+
* `.prefix()` is applied to the group, after the callback — the information
|
|
757
|
+
* sits ABOVE in the AST, not beside it. Nested groups accumulate outside in.
|
|
758
|
+
*/
|
|
759
|
+
function prefixFor(call) {
|
|
760
|
+
const prefixes = [];
|
|
761
|
+
let current = call;
|
|
762
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
763
|
+
const group = current.getAncestors().find((ancestor) => Node.isCallExpression(ancestor) && ancestor.getExpression().getText().replace(/\s+/g, "") === "router.group");
|
|
764
|
+
if (!group) break;
|
|
765
|
+
const declared = literalOf(chainOf(group).get("prefix")?.[0]);
|
|
766
|
+
if (declared) prefixes.unshift(normalizePrefix(declared));
|
|
767
|
+
current = group;
|
|
768
|
+
}
|
|
769
|
+
return prefixes.join("");
|
|
770
|
+
}
|
|
771
|
+
const normalizePrefix = (value) => {
|
|
772
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
773
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
774
|
+
};
|
|
775
|
+
/**
|
|
776
|
+
* Route name: the call's own `.as()`, prefixed by the groups' `.as()`.
|
|
777
|
+
*
|
|
778
|
+
* AdonisJS composes `group.route`, so an `.as('admin')` on the group turns
|
|
779
|
+
* `books.index` into `admin.books.index`.
|
|
780
|
+
*/
|
|
781
|
+
function nameOf(call) {
|
|
782
|
+
const own = literalOf(chainOf(call).get("as")?.[0]);
|
|
783
|
+
if (!own) return void 0;
|
|
784
|
+
const groups = [];
|
|
785
|
+
let current = call;
|
|
786
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
787
|
+
const group = current.getAncestors().find((ancestor) => Node.isCallExpression(ancestor) && ancestor.getExpression().getText().replace(/\s+/g, "") === "router.group");
|
|
788
|
+
if (!group) break;
|
|
789
|
+
const declared = literalOf(chainOf(group).get("as")?.[0]);
|
|
790
|
+
if (declared) groups.unshift(declared);
|
|
791
|
+
current = group;
|
|
792
|
+
}
|
|
793
|
+
return [...groups, own].join(".");
|
|
794
|
+
}
|
|
795
|
+
function literalOf(node) {
|
|
796
|
+
return node?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue() ?? null;
|
|
797
|
+
}
|
|
798
|
+
function expandResource(call, base, handlerArg, resolve, app, unresolved) {
|
|
799
|
+
const chain = chainOf(call);
|
|
800
|
+
let actions = Object.keys(RESOURCE_ACTIONS);
|
|
801
|
+
const only = arrayOf(chain.get("only")?.[0]);
|
|
802
|
+
const except = arrayOf(chain.get("except")?.[0]);
|
|
803
|
+
if (only) actions = only;
|
|
804
|
+
else if (chain.has("apiOnly")) actions = API_ACTIONS;
|
|
805
|
+
if (except) actions = actions.filter((action) => !except.includes(action));
|
|
806
|
+
const { handler, problem } = resolve(handlerArg, call);
|
|
807
|
+
if (problem) unresolved.push(problem);
|
|
808
|
+
const name = nameOf(call);
|
|
809
|
+
return actions.filter((action) => RESOURCE_ACTIONS[action]).map((action) => {
|
|
810
|
+
const { verb, suffix } = RESOURCE_ACTIONS[action];
|
|
811
|
+
return describe(verb, base + suffix, handler ? {
|
|
812
|
+
...handler,
|
|
813
|
+
member: action
|
|
814
|
+
} : null, call, app, name ? `${name}.${action}` : void 0);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/** `['index', 'show']` as a list of strings, straight from the AST */
|
|
818
|
+
function arrayOf(node) {
|
|
819
|
+
const array = node?.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
820
|
+
if (!array) return null;
|
|
821
|
+
return array.getElements().map((element) => literalOf(element)).filter((value) => value !== null);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Resolves `[Controller, 'method']` to a file and a method.
|
|
825
|
+
*
|
|
826
|
+
* Two forms coexist in the same application: a local alias via lazy import
|
|
827
|
+
* (`const X = () => import('...')`) and the generated map (`controllers.mod.Name`,
|
|
828
|
+
* possibly destructured). The map is indexed by the DOTTED PATH, not by the
|
|
829
|
+
* bare name — modular applications routinely have several controllers sharing a
|
|
830
|
+
* name across modules.
|
|
831
|
+
*/
|
|
832
|
+
function handlerResolver(file, app, controllers) {
|
|
833
|
+
const lazyImports = /* @__PURE__ */ new Map();
|
|
834
|
+
const destructured = /* @__PURE__ */ new Map();
|
|
835
|
+
for (const declaration of file.getVariableDeclarations()) {
|
|
836
|
+
const text = declaration.getInitializer()?.getText().replace(/\s+/g, "") ?? "";
|
|
837
|
+
const lazy = text.match(/import\(['"]([^'"]+)['"]\)/);
|
|
838
|
+
if (lazy && Node.isIdentifier(declaration.getNameNode())) {
|
|
839
|
+
lazyImports.set(declaration.getName(), lazy[1]);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const binding = declaration.getNameNode().asKind(SyntaxKind.ObjectBindingPattern);
|
|
843
|
+
if (binding && text.startsWith("controllers")) {
|
|
844
|
+
const base = text === "controllers" ? "" : text.replace(/^controllers\.?/, "");
|
|
845
|
+
for (const element of binding.getElements()) destructured.set(element.getName(), base ? `${base}.${element.getName()}` : element.getName());
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return (handlerArg, call) => {
|
|
849
|
+
if (handlerArg && isInlineHandler(handlerArg)) return {
|
|
850
|
+
handler: {
|
|
851
|
+
file: call.getSourceFile().getFilePath(),
|
|
852
|
+
line: handlerArg.getStartLineNumber()
|
|
853
|
+
},
|
|
854
|
+
problem: null
|
|
855
|
+
};
|
|
856
|
+
const { reference, member } = referenceOf(handlerArg);
|
|
857
|
+
if (!reference) return {
|
|
858
|
+
handler: null,
|
|
859
|
+
problem: problemAt(call, handlerArg?.getText() ?? "?", "unrecognised handler")
|
|
860
|
+
};
|
|
861
|
+
const specifier = specifierFor(reference, lazyImports, destructured, controllers);
|
|
862
|
+
if (!specifier) return {
|
|
863
|
+
handler: null,
|
|
864
|
+
problem: problemAt(call, reference, "unresolved controller")
|
|
865
|
+
};
|
|
866
|
+
const target = app.resolveSpecifier(specifier);
|
|
867
|
+
if (!target) return {
|
|
868
|
+
handler: null,
|
|
869
|
+
problem: problemAt(call, reference, `unresolved path: ${specifier}`)
|
|
870
|
+
};
|
|
871
|
+
return {
|
|
872
|
+
handler: {
|
|
873
|
+
file: target,
|
|
874
|
+
member
|
|
875
|
+
},
|
|
876
|
+
problem: null
|
|
877
|
+
};
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* `({ response }) => …` or `function (ctx) { … }` passed straight to the route.
|
|
882
|
+
*
|
|
883
|
+
* This is not "unresolved controller": it is a handler with a body, and filing
|
|
884
|
+
* it as unresolved would lose the whole transaction and point at the wrong
|
|
885
|
+
* reason as well.
|
|
886
|
+
*/
|
|
887
|
+
function isInlineHandler(node) {
|
|
888
|
+
return Node.isArrowFunction(node) || Node.isFunctionExpression(node);
|
|
889
|
+
}
|
|
890
|
+
/** `[X, 'method']`, `[X]` or `X` */
|
|
891
|
+
function referenceOf(node) {
|
|
892
|
+
if (!node) return { reference: null };
|
|
893
|
+
const array = node.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
894
|
+
if (array) {
|
|
895
|
+
const elements = array.getElements();
|
|
896
|
+
return {
|
|
897
|
+
reference: elements[0]?.getText().replace(/\s+/g, "") ?? null,
|
|
898
|
+
member: literalOf(elements[1]) ?? void 0
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return { reference: node.getText().replace(/\s+/g, "") };
|
|
902
|
+
}
|
|
903
|
+
function specifierFor(reference, lazyImports, destructured, controllers) {
|
|
904
|
+
if (lazyImports.has(reference)) return lazyImports.get(reference);
|
|
905
|
+
if (reference.startsWith("controllers.")) return controllers.get(reference.slice(12)) ?? null;
|
|
906
|
+
const [head, ...rest] = reference.split(".");
|
|
907
|
+
const base = destructured.get(head);
|
|
908
|
+
if (base) {
|
|
909
|
+
const key = rest.length > 0 ? `${base}.${rest.join(".")}` : base;
|
|
910
|
+
return controllers.get(key) ?? null;
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
/** generated map, indexed by the full dotted path */
|
|
915
|
+
function readControllersMap(project, file) {
|
|
916
|
+
const map = /* @__PURE__ */ new Map();
|
|
917
|
+
const source = project.addSourceFileAtPathIfExists(file);
|
|
918
|
+
if (!source) return map;
|
|
919
|
+
const root = source.getVariableDeclaration("controllers")?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
920
|
+
if (!root) return map;
|
|
921
|
+
const walk = (object, prefix) => {
|
|
922
|
+
for (const property of object.getProperties()) {
|
|
923
|
+
if (!Node.isPropertyAssignment(property)) continue;
|
|
924
|
+
const key = property.getName().replace(/['"]/g, "");
|
|
925
|
+
const dotted = prefix ? `${prefix}.${key}` : key;
|
|
926
|
+
const initializer = property.getInitializer();
|
|
927
|
+
const nested = initializer?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
928
|
+
if (nested) {
|
|
929
|
+
walk(nested, dotted);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
const specifier = initializer?.getText().match(/import\(['"]([^'"]+)['"]\)/);
|
|
933
|
+
if (specifier) map.set(dotted, specifier[1]);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
walk(root, "");
|
|
937
|
+
return map;
|
|
938
|
+
}
|
|
939
|
+
function describe(verb, pattern, handler, call, app, name) {
|
|
940
|
+
const signature = normalizePattern(pattern);
|
|
941
|
+
const file = call.getSourceFile().getFilePath();
|
|
942
|
+
return {
|
|
943
|
+
id: `${verb} ${signature}`,
|
|
944
|
+
kind: "http",
|
|
945
|
+
module: handler ? app.moduleOf(handler.file) : app.moduleOf(file),
|
|
946
|
+
trigger: verb,
|
|
947
|
+
signature,
|
|
948
|
+
name,
|
|
949
|
+
handler,
|
|
950
|
+
identity: `${verb} ${anonymizeParams(signature)}`,
|
|
951
|
+
provenance: {
|
|
952
|
+
file,
|
|
953
|
+
line: call.getStartLineNumber(),
|
|
954
|
+
by: "routes-ast"
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const normalizePattern = (pattern) => {
|
|
959
|
+
const withSlash = pattern.startsWith("/") ? pattern : `/${pattern}`;
|
|
960
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
|
961
|
+
};
|
|
962
|
+
/**
|
|
963
|
+
* `/books/:id` and `/books/:uuid` are the same function to the user.
|
|
964
|
+
*
|
|
965
|
+
* Without this, renaming a parameter would read as a deletion plus an addition
|
|
966
|
+
* in `fp:diff` and bill twice — counting-decisions §5.
|
|
967
|
+
*/
|
|
968
|
+
const anonymizeParams = (pattern) => pattern.replace(/:[A-Za-z_][\w]*/g, ":param");
|
|
969
|
+
const problemAt = (call, expression, reason) => ({
|
|
970
|
+
file: call.getSourceFile().getFilePath(),
|
|
971
|
+
line: call.getStartLineNumber(),
|
|
972
|
+
expression,
|
|
973
|
+
reason
|
|
974
|
+
});
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/inventory/sources/json_schemas.ts
|
|
977
|
+
function collectJsonSchemas(app) {
|
|
978
|
+
const project = new Project({
|
|
979
|
+
skipAddingFilesFromTsConfig: true,
|
|
980
|
+
skipFileDependencyResolution: true,
|
|
981
|
+
compilerOptions: { allowJs: false }
|
|
982
|
+
});
|
|
983
|
+
for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
|
|
984
|
+
const found = /* @__PURE__ */ new Map();
|
|
985
|
+
for (const file of project.getSourceFiles()) for (const declaration of file.getVariableDeclarations()) {
|
|
986
|
+
const literal = unwrap(declaration.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
987
|
+
if (!literal || !isJsonSchema(literal)) continue;
|
|
988
|
+
const leaves = leavesOf$1(literal);
|
|
989
|
+
if (leaves.length === 0) continue;
|
|
990
|
+
found.set(declaration.getName(), {
|
|
991
|
+
name: declaration.getName(),
|
|
992
|
+
fields: leaves.length,
|
|
993
|
+
leaves,
|
|
994
|
+
provenance: {
|
|
995
|
+
file: toPosix(file.getFilePath()),
|
|
996
|
+
line: declaration.getStartLineNumber(),
|
|
997
|
+
by: "json-schema"
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
return found;
|
|
1002
|
+
}
|
|
1003
|
+
/** `{ … } as const` and `{ … } satisfies X` still hold the literal */
|
|
1004
|
+
function unwrap(node) {
|
|
1005
|
+
if (!node) return void 0;
|
|
1006
|
+
if (Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) return unwrap(node.getExpression());
|
|
1007
|
+
return node;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Recognised by shape, never by name.
|
|
1011
|
+
*
|
|
1012
|
+
* A const called `schema` may be anything; an object declaring `type: 'object'`
|
|
1013
|
+
* with a `properties` map is a JSON Schema whatever it is called.
|
|
1014
|
+
*/
|
|
1015
|
+
function isJsonSchema(literal) {
|
|
1016
|
+
return (literal.getProperty("type")?.asKind(SyntaxKind.PropertyAssignment))?.getInitializer()?.asKind(SyntaxKind.StringLiteral)?.getLiteralValue() === "object" && literal.getProperty("properties") !== void 0;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Leaves of a JSON Schema, by the table in counting-decisions §7.
|
|
1020
|
+
*
|
|
1021
|
+
* scalar 1
|
|
1022
|
+
* nested object leaves counted individually
|
|
1023
|
+
* array of scalar 1 (repeating group)
|
|
1024
|
+
* array of object the object's leaves, once
|
|
1025
|
+
* enum / const 1
|
|
1026
|
+
*/
|
|
1027
|
+
function leavesOf$1(literal, prefix = "") {
|
|
1028
|
+
const properties = literal.getProperty("properties")?.asKind(SyntaxKind.PropertyAssignment);
|
|
1029
|
+
const items = literal.getProperty("items")?.asKind(SyntaxKind.PropertyAssignment);
|
|
1030
|
+
if (properties) {
|
|
1031
|
+
const map = properties.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
1032
|
+
if (!map) return [];
|
|
1033
|
+
return map.getProperties().flatMap((property) => {
|
|
1034
|
+
const assignment = property.asKind(SyntaxKind.PropertyAssignment);
|
|
1035
|
+
if (!assignment) return [];
|
|
1036
|
+
const name = assignment.getName().replace(/['"]/g, "");
|
|
1037
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
1038
|
+
const nested = unwrap(assignment.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
1039
|
+
const inner = nested ? leavesOf$1(nested, path) : [];
|
|
1040
|
+
return inner.length > 0 ? inner : [path];
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
if (items) {
|
|
1044
|
+
const element = unwrap(items.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
1045
|
+
const inner = element ? leavesOf$1(element, prefix) : [];
|
|
1046
|
+
return inner.length > 0 ? inner : [prefix];
|
|
1047
|
+
}
|
|
1048
|
+
return [];
|
|
1049
|
+
}
|
|
1050
|
+
//#endregion
|
|
1051
|
+
//#region src/inventory/graph/noise.ts
|
|
1052
|
+
/**
|
|
1053
|
+
* Calls that cannot be a data access, and so must not count against coverage.
|
|
1054
|
+
*
|
|
1055
|
+
* Coverage is all-or-nothing per transaction — one unresolved call sinks the
|
|
1056
|
+
* whole thing — so reporting a `Map.get()` costs a transaction, not a line. On
|
|
1057
|
+
* a production application that dragged the metric from what the tracing
|
|
1058
|
+
* actually achieves down to 53%, and a gate that fails spuriously does not
|
|
1059
|
+
* protect: it teaches people to switch the gate off.
|
|
1060
|
+
*
|
|
1061
|
+
* The bar for silencing anything here is high, because a silent drop is the
|
|
1062
|
+
* worst defect this package can have. So the rule is to decide by what the
|
|
1063
|
+
* RECEIVER IS, resolved from the code, rather than by what the method is
|
|
1064
|
+
* called: `get`, `find` and `has` are Map methods and repository methods alike,
|
|
1065
|
+
* and a list of method names would hide real gaps to buy coverage.
|
|
1066
|
+
*/
|
|
1067
|
+
/** built-ins whose methods are never an application data access */
|
|
1068
|
+
const NATIVE_TYPES = new Set([
|
|
1069
|
+
"Map",
|
|
1070
|
+
"Set",
|
|
1071
|
+
"WeakMap",
|
|
1072
|
+
"WeakSet",
|
|
1073
|
+
"Array",
|
|
1074
|
+
"Date",
|
|
1075
|
+
"RegExp",
|
|
1076
|
+
"Promise",
|
|
1077
|
+
"Intl",
|
|
1078
|
+
"URL",
|
|
1079
|
+
"URLSearchParams"
|
|
1080
|
+
]);
|
|
1081
|
+
/**
|
|
1082
|
+
* Methods that cannot be a data access whatever the receiver.
|
|
1083
|
+
*
|
|
1084
|
+
* Deliberately short, and deliberately excludes `get`, `set`, `has`, `find`,
|
|
1085
|
+
* `first`, `all`, `create`, `update`, `delete`, `save` and `count` — every one
|
|
1086
|
+
* of those is as plausible on a repository as on a collection.
|
|
1087
|
+
*/
|
|
1088
|
+
const NEVER_DATA_METHODS = new Set([
|
|
1089
|
+
"includes",
|
|
1090
|
+
"indexOf",
|
|
1091
|
+
"join",
|
|
1092
|
+
"split",
|
|
1093
|
+
"trim",
|
|
1094
|
+
"toLowerCase",
|
|
1095
|
+
"toUpperCase",
|
|
1096
|
+
"padStart",
|
|
1097
|
+
"padEnd",
|
|
1098
|
+
"toISO",
|
|
1099
|
+
"toISODate",
|
|
1100
|
+
"toFormat",
|
|
1101
|
+
"toUTC",
|
|
1102
|
+
"toSQL",
|
|
1103
|
+
"toJSDate",
|
|
1104
|
+
"toRelative",
|
|
1105
|
+
"useTransaction",
|
|
1106
|
+
"serialize",
|
|
1107
|
+
"serializeAttributes",
|
|
1108
|
+
"toJSON",
|
|
1109
|
+
"validate",
|
|
1110
|
+
"catch",
|
|
1111
|
+
"then",
|
|
1112
|
+
"finally",
|
|
1113
|
+
"pick",
|
|
1114
|
+
"whenLoaded",
|
|
1115
|
+
"whenNotNull",
|
|
1116
|
+
"primitive"
|
|
1117
|
+
]);
|
|
1118
|
+
/**
|
|
1119
|
+
* AdonisJS services, which reach the tracer through an application alias.
|
|
1120
|
+
*
|
|
1121
|
+
* `env` is imported from `#start/env`, an application module, so the symbol
|
|
1122
|
+
* resolves and then no body is found — the implementation lives in the
|
|
1123
|
+
* package. They are named by convention and unambiguous in an AdonisJS app.
|
|
1124
|
+
*/
|
|
1125
|
+
const FRAMEWORK_SERVICES = new Set([
|
|
1126
|
+
"env",
|
|
1127
|
+
"logger",
|
|
1128
|
+
"health",
|
|
1129
|
+
"hash",
|
|
1130
|
+
"mail",
|
|
1131
|
+
"drive",
|
|
1132
|
+
"emitter",
|
|
1133
|
+
"router",
|
|
1134
|
+
"encryption",
|
|
1135
|
+
"i18n",
|
|
1136
|
+
"ally",
|
|
1137
|
+
"bouncer"
|
|
1138
|
+
]);
|
|
1139
|
+
/** Is this call one that cannot reach a data store? */
|
|
1140
|
+
function isNoise(call, owner) {
|
|
1141
|
+
const expression = call.getExpression();
|
|
1142
|
+
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1143
|
+
if (NEVER_DATA_METHODS.has(expression.getName())) return true;
|
|
1144
|
+
const receiver = expression.getExpression();
|
|
1145
|
+
if (Node.isIdentifier(receiver) && FRAMEWORK_SERVICES.has(receiver.getText())) return true;
|
|
1146
|
+
/**
|
|
1147
|
+
* `this.logger?.error(…)`: the same services also arrive as class properties,
|
|
1148
|
+
* injected or assigned, and then the receiver is a property access rather
|
|
1149
|
+
* than a bare identifier.
|
|
1150
|
+
*/
|
|
1151
|
+
if (Node.isPropertyAccessExpression(receiver) && Node.isThisExpression(receiver.getExpression()) && FRAMEWORK_SERVICES.has(receiver.getName())) return true;
|
|
1152
|
+
return isNativeReceiver(receiver, owner);
|
|
1153
|
+
}
|
|
1154
|
+
/** Does the receiver resolve to a built-in, by its declaration? */
|
|
1155
|
+
function isNativeReceiver(receiver, owner) {
|
|
1156
|
+
if (Node.isArrayLiteralExpression(receiver) || Node.isStringLiteral(receiver)) return true;
|
|
1157
|
+
if (Node.isPropertyAccessExpression(receiver) && receiver.getExpression().getKind()) {
|
|
1158
|
+
const inner = receiver.getExpression();
|
|
1159
|
+
if (Node.isThisExpression(inner) && owner) return isNativeProperty(owner, receiver.getName());
|
|
1160
|
+
}
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
function isNativeProperty(owner, name) {
|
|
1164
|
+
/**
|
|
1165
|
+
* Both shapes declare a field: a class property, and a constructor parameter
|
|
1166
|
+
* property. Reading only the first missed every `Map` handed in through the
|
|
1167
|
+
* constructor, which is how a transformer usually receives its lookups.
|
|
1168
|
+
*/
|
|
1169
|
+
const property = owner.getProperty(name) ?? owner.getConstructors()[0]?.getParameters().find((parameter) => parameter.getName() === name);
|
|
1170
|
+
if (!property) return false;
|
|
1171
|
+
const typeNode = property.getTypeNode()?.getText();
|
|
1172
|
+
if (typeNode && NATIVE_TYPES.has(typeNode.replace(/<.*/, "").trim())) return true;
|
|
1173
|
+
const initializer = property.getInitializer();
|
|
1174
|
+
if (initializer && Node.isNewExpression(initializer)) return NATIVE_TYPES.has(initializer.getExpression().getText());
|
|
1175
|
+
return initializer !== void 0 && (Node.isArrayLiteralExpression(initializer) || Node.isStringLiteral(initializer));
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The body-not-found path: a symbol resolved to an application file whose
|
|
1179
|
+
* member is not there. For a framework service that is expected — the
|
|
1180
|
+
* implementation is in the package — and says nothing about tracing quality.
|
|
1181
|
+
*/
|
|
1182
|
+
function isNoiseMember(file, member) {
|
|
1183
|
+
if (member && NEVER_DATA_METHODS.has(member)) return true;
|
|
1184
|
+
const base = file.split("/").pop()?.replace(/\.[jt]s$/, "");
|
|
1185
|
+
return base !== void 0 && FRAMEWORK_SERVICES.has(base);
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
1188
|
+
//#region src/inventory/graph/call_graph.ts
|
|
1189
|
+
/**
|
|
1190
|
+
* Fields declared by the validators used in this body.
|
|
1191
|
+
*
|
|
1192
|
+
* `request.validateUsing(createBookValidator)` -> resolve the validator ->
|
|
1193
|
+
* count the leaves of the `vine.object`, per the table in counting-decisions §7:
|
|
1194
|
+
*
|
|
1195
|
+
* scalar 1
|
|
1196
|
+
* nested object leaves counted individually
|
|
1197
|
+
* array of scalar 1 (repeating group)
|
|
1198
|
+
* array of object leaves, counted once
|
|
1199
|
+
* unresolved spread 0, and reported — never guessed
|
|
1200
|
+
*/
|
|
1201
|
+
function validatorFieldsIn(body, file, app) {
|
|
1202
|
+
const fields = [];
|
|
1203
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1204
|
+
const expression = call.getExpression();
|
|
1205
|
+
if (!Node.isPropertyAccessExpression(expression)) continue;
|
|
1206
|
+
if (expression.getName() !== "validateUsing") continue;
|
|
1207
|
+
const argument = call.getArguments()[0];
|
|
1208
|
+
if (!argument || !Node.isIdentifier(argument)) continue;
|
|
1209
|
+
const name = argument.getText();
|
|
1210
|
+
const declaration = findValidator(name, file, app);
|
|
1211
|
+
if (!declaration) continue;
|
|
1212
|
+
for (const leaf of leavesOf(declaration)) fields.push(`${name}.${leaf}`);
|
|
1213
|
+
}
|
|
1214
|
+
return fields;
|
|
1215
|
+
}
|
|
1216
|
+
/** validator declaration: in this file, or imported from the application */
|
|
1217
|
+
function findValidator(name, file, app) {
|
|
1218
|
+
const local = file.getVariableDeclaration(name)?.getInitializer();
|
|
1219
|
+
if (local) return local;
|
|
1220
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
1221
|
+
if (!declaration.getNamedImports().map((named) => named.getName()).includes(name)) continue;
|
|
1222
|
+
const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
|
|
1223
|
+
if (!target) continue;
|
|
1224
|
+
const initializer = file.getProject().getSourceFile(target)?.getVariableDeclaration(name)?.getInitializer();
|
|
1225
|
+
if (initializer) return initializer;
|
|
1226
|
+
}
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
/** leaves of a VineJS schema, per the table in §7 */
|
|
1230
|
+
function leavesOf(node) {
|
|
1231
|
+
const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
|
|
1232
|
+
if (!object) return [];
|
|
1233
|
+
const leaves = [];
|
|
1234
|
+
const walk = (literal, prefix) => {
|
|
1235
|
+
for (const property of literal.getProperties()) {
|
|
1236
|
+
if (!Node.isPropertyAssignment(property)) continue;
|
|
1237
|
+
const name = property.getName().replace(/['"]/g, "");
|
|
1238
|
+
const text = property.getText();
|
|
1239
|
+
const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
|
|
1240
|
+
if (nested && /vine\.object/.test(text)) {
|
|
1241
|
+
walk(nested, prefix ? `${prefix}.${name}` : name);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
leaves.push(prefix ? `${prefix}.${name}` : name);
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
walk(object, "");
|
|
1248
|
+
return leaves;
|
|
1249
|
+
}
|
|
1250
|
+
/** file name, to identify the unresolved call without dumping the full path */
|
|
1251
|
+
const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
|
|
1252
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
1253
|
+
/**
|
|
1254
|
+
* Analyzer with state shared across handlers.
|
|
1255
|
+
*
|
|
1256
|
+
* The ts-morph `Project` and the symbol cache are expensive to build and
|
|
1257
|
+
* identical for every handler of the same application. Creating one per handler
|
|
1258
|
+
* multiplies the cost by the number of routes, which is the difference between
|
|
1259
|
+
* minutes and seconds on an application of a few hundred routes.
|
|
1260
|
+
*/
|
|
1261
|
+
function createAnalyzer(app, stores, options = {}) {
|
|
1262
|
+
const project = new Project({
|
|
1263
|
+
skipAddingFilesFromTsConfig: true,
|
|
1264
|
+
skipFileDependencyResolution: true,
|
|
1265
|
+
compilerOptions: { allowJs: false }
|
|
1266
|
+
});
|
|
1267
|
+
/**
|
|
1268
|
+
* Every file is loaded up front.
|
|
1269
|
+
*
|
|
1270
|
+
* Adding a file part-way through the analysis invalidates the TypeScript
|
|
1271
|
+
* program, and the next query to the checker rebuilds it — a cost paid once
|
|
1272
|
+
* per route, uniformly. Loading everything first trades N rebuilds for one.
|
|
1273
|
+
*/
|
|
1274
|
+
for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
|
|
1275
|
+
const storesByName = new Map(stores.map((store) => [store.name, store]));
|
|
1276
|
+
const relationsByStore = new Map(stores.map((store) => [store.name, store.relations]));
|
|
1277
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1278
|
+
const resolvers = [...options.callResolvers ?? [], ...BUILTIN_CALL_RESOLVERS].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
|
1279
|
+
const files = /* @__PURE__ */ new Map();
|
|
1280
|
+
const sourceFile = (absPath) => {
|
|
1281
|
+
if (!files.has(absPath)) files.set(absPath, project.getSourceFile(absPath) ?? project.addSourceFileAtPathIfExists(absPath) ?? null);
|
|
1282
|
+
return files.get(absPath) ?? null;
|
|
1283
|
+
};
|
|
1284
|
+
/** imports per file, computed once */
|
|
1285
|
+
const importCache = /* @__PURE__ */ new Map();
|
|
1286
|
+
const importsFor = (file) => {
|
|
1287
|
+
const key = file.getFilePath();
|
|
1288
|
+
let cached = importCache.get(key);
|
|
1289
|
+
if (!cached) {
|
|
1290
|
+
cached = importsOf(file, app);
|
|
1291
|
+
importCache.set(key, cached);
|
|
1292
|
+
}
|
|
1293
|
+
return cached;
|
|
1294
|
+
};
|
|
1295
|
+
/**
|
|
1296
|
+
* Facts about a body: what it accesses and where it calls into.
|
|
1297
|
+
*
|
|
1298
|
+
* They are INDEPENDENT of the caller — only the decision to follow depends on
|
|
1299
|
+
* depth. Without this cache a shared service is re-analysed once per route
|
|
1300
|
+
* that reaches it, and the cost grows with routes × depth.
|
|
1301
|
+
*/
|
|
1302
|
+
/**
|
|
1303
|
+
* Hook methods of a model, by the decorators an access fires.
|
|
1304
|
+
*
|
|
1305
|
+
* The model file comes from the store's own provenance, so this asks the
|
|
1306
|
+
* inventory rather than guessing a path. Cached per store and method because
|
|
1307
|
+
* a transaction can touch the same model many times.
|
|
1308
|
+
*/
|
|
1309
|
+
const hookCache = /* @__PURE__ */ new Map();
|
|
1310
|
+
const hookRefsFor = (access) => {
|
|
1311
|
+
const decorators = hooksFiredBy(access);
|
|
1312
|
+
if (decorators.length === 0) return [];
|
|
1313
|
+
const key = `${access.store}#${access.method}`;
|
|
1314
|
+
const cached = hookCache.get(key);
|
|
1315
|
+
if (cached) return cached;
|
|
1316
|
+
const store = storesByName.get(access.store);
|
|
1317
|
+
const modelFile = store ? sourceFile(store.provenance.file) : null;
|
|
1318
|
+
const wanted = new Set(decorators);
|
|
1319
|
+
const refs = [];
|
|
1320
|
+
for (const cls of modelFile?.getClasses() ?? []) for (const method of cls.getMethods()) if (method.getDecorators().some((decorator) => wanted.has(decorator.getName()))) refs.push({
|
|
1321
|
+
file: store.provenance.file,
|
|
1322
|
+
member: method.getName()
|
|
1323
|
+
});
|
|
1324
|
+
hookCache.set(key, refs);
|
|
1325
|
+
return refs;
|
|
1326
|
+
};
|
|
1327
|
+
const factsCache = /* @__PURE__ */ new Map();
|
|
1328
|
+
const factsFor = (ref) => {
|
|
1329
|
+
const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
|
|
1330
|
+
if (factsCache.has(key)) return factsCache.get(key) ?? null;
|
|
1331
|
+
const facts = computeFacts(ref);
|
|
1332
|
+
factsCache.set(key, facts);
|
|
1333
|
+
return facts;
|
|
1334
|
+
};
|
|
1335
|
+
function computeFacts(ref) {
|
|
1336
|
+
const file = sourceFile(ref.file);
|
|
1337
|
+
if (!file) return null;
|
|
1338
|
+
const body = findBody(file, ref);
|
|
1339
|
+
if (!body) return null;
|
|
1340
|
+
const imports = importsFor(file);
|
|
1341
|
+
/** the class this body belongs to: how `this.something` resolves */
|
|
1342
|
+
const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
|
1343
|
+
const injected = injectedFor(owner, file, app);
|
|
1344
|
+
const symbols = storeSymbolsFor(body, file, app, storesByName);
|
|
1345
|
+
const accesses = [];
|
|
1346
|
+
const followUps = [];
|
|
1347
|
+
const unresolved = [];
|
|
1348
|
+
const validators = validatorFieldsIn(body, file, app);
|
|
1349
|
+
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1350
|
+
const access = detectAccess(call, symbols, relationsByStore);
|
|
1351
|
+
if (access) {
|
|
1352
|
+
accesses.push({
|
|
1353
|
+
store: access.store,
|
|
1354
|
+
write: access.mode === "write"
|
|
1355
|
+
});
|
|
1356
|
+
if (access.viaRelation) accesses.push({
|
|
1357
|
+
store: access.viaRelation,
|
|
1358
|
+
write: false
|
|
1359
|
+
});
|
|
1360
|
+
/**
|
|
1361
|
+
* counting-decisions §3: a hook belongs to the transaction that fired
|
|
1362
|
+
* it. It crosses no boundary — it fires inside one that already did —
|
|
1363
|
+
* so its accesses are this transaction's, and AFP §6.5.3 requires
|
|
1364
|
+
* aggregating every path reached.
|
|
1365
|
+
*/
|
|
1366
|
+
for (const hook of hookRefsFor(access)) followUps.push({
|
|
1367
|
+
ref: hook,
|
|
1368
|
+
by: "model-hook"
|
|
1369
|
+
});
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const resolved = resolveCall(call, {
|
|
1373
|
+
file,
|
|
1374
|
+
depth: 0,
|
|
1375
|
+
imports,
|
|
1376
|
+
injected,
|
|
1377
|
+
dataStoresBySymbol: storesByName,
|
|
1378
|
+
resolveSpecifier: app.resolveSpecifier,
|
|
1379
|
+
sourceFile
|
|
1380
|
+
}, resolvers);
|
|
1381
|
+
if (resolved) {
|
|
1382
|
+
for (const next of resolved.refs) followUps.push({
|
|
1383
|
+
ref: next,
|
|
1384
|
+
by: resolved.by
|
|
1385
|
+
});
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
if (isWorthReporting(call, symbols, imports) && !isNoise(call, owner)) unresolved.push({
|
|
1389
|
+
file: ref.file,
|
|
1390
|
+
line: call.getStartLineNumber(),
|
|
1391
|
+
expression: call.getExpression().getText().replace(/\s+/g, ""),
|
|
1392
|
+
reason: "call that no strategy knew how to follow"
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
return {
|
|
1396
|
+
accesses,
|
|
1397
|
+
followUps,
|
|
1398
|
+
unresolved,
|
|
1399
|
+
validators,
|
|
1400
|
+
bodyHash: hashOf(body)
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
analyze: (handler) => run(handler),
|
|
1405
|
+
/** how many files the project loaded — used to prove it does not grow */
|
|
1406
|
+
fileCount: () => project.getSourceFiles().length
|
|
1407
|
+
};
|
|
1408
|
+
function run(handler) {
|
|
1409
|
+
const touches = /* @__PURE__ */ new Set();
|
|
1410
|
+
const inputFields = /* @__PURE__ */ new Set();
|
|
1411
|
+
const trace = [];
|
|
1412
|
+
const scope = [];
|
|
1413
|
+
const unresolved = [];
|
|
1414
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1415
|
+
let writes = false;
|
|
1416
|
+
const visit = (ref, depth) => {
|
|
1417
|
+
const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
|
|
1418
|
+
if (visited.has(key) || depth > maxDepth) return;
|
|
1419
|
+
visited.add(key);
|
|
1420
|
+
const facts = factsFor(ref);
|
|
1421
|
+
if (!facts) {
|
|
1422
|
+
/**
|
|
1423
|
+
* The resolver got the file right, but the body is not there — an
|
|
1424
|
+
* inherited method from a package class, for example
|
|
1425
|
+
* (`Transformer.transform()` coming from `BaseTransformer`).
|
|
1426
|
+
*
|
|
1427
|
+
* Dropping it silently is the worst possible defect: the transaction
|
|
1428
|
+
* loses a path and nobody knows.
|
|
1429
|
+
*/
|
|
1430
|
+
if (!isNoiseMember(ref.file, ref.member)) unresolved.push({
|
|
1431
|
+
file: ref.file,
|
|
1432
|
+
line: ref.line ?? 0,
|
|
1433
|
+
expression: `${pathOf(ref.file)}.${ref.member ?? "handle"}`,
|
|
1434
|
+
reason: "body not found in the resolved file: probably inherited from a package class"
|
|
1435
|
+
});
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
let bodyWrites = false;
|
|
1439
|
+
for (const access of facts.accesses) {
|
|
1440
|
+
touches.add(access.store);
|
|
1441
|
+
if (access.write) {
|
|
1442
|
+
bodyWrites = true;
|
|
1443
|
+
writes = true;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
unresolved.push(...facts.unresolved);
|
|
1447
|
+
for (const field of facts.validators) inputFields.add(field);
|
|
1448
|
+
trace.push({
|
|
1449
|
+
file: ref.file,
|
|
1450
|
+
member: ref.member,
|
|
1451
|
+
depth,
|
|
1452
|
+
by: ref.member ?? "entry",
|
|
1453
|
+
writes: bodyWrites
|
|
1454
|
+
});
|
|
1455
|
+
scope.push({
|
|
1456
|
+
file: ref.file,
|
|
1457
|
+
member: ref.member,
|
|
1458
|
+
bodyHash: facts.bodyHash
|
|
1459
|
+
});
|
|
1460
|
+
if (depth >= maxDepth) return;
|
|
1461
|
+
for (const followUp of facts.followUps) {
|
|
1462
|
+
const before = trace.length;
|
|
1463
|
+
visit(followUp.ref, depth + 1);
|
|
1464
|
+
if (trace.length > before) trace[before].by = followUp.by;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
visit(handler, 0);
|
|
1468
|
+
return {
|
|
1469
|
+
writes,
|
|
1470
|
+
touches: [...touches].sort(),
|
|
1471
|
+
inputFields: [...inputFields].sort(),
|
|
1472
|
+
trace,
|
|
1473
|
+
scope,
|
|
1474
|
+
unresolved
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Resolves a `HandlerRef` to the corresponding body.
|
|
1480
|
+
*
|
|
1481
|
+
* Three forms coexist: a named method, a single-action handler (`handle`), and
|
|
1482
|
+
* an inline closure declared on the route itself — the last one located by
|
|
1483
|
+
* line, because it has no name.
|
|
1484
|
+
*/
|
|
1485
|
+
function findBody(file, ref) {
|
|
1486
|
+
if (ref.line !== void 0) {
|
|
1487
|
+
const inline = file.getDescendants().find((node) => (Node.isArrowFunction(node) || Node.isFunctionExpression(node)) && node.getStartLineNumber() === ref.line);
|
|
1488
|
+
if (inline) return inline;
|
|
1489
|
+
}
|
|
1490
|
+
if (ref.member) {
|
|
1491
|
+
for (const cls of file.getClasses()) {
|
|
1492
|
+
const method = cls.getMethod(ref.member);
|
|
1493
|
+
if (method) return method;
|
|
1494
|
+
}
|
|
1495
|
+
const fn = file.getFunction(ref.member);
|
|
1496
|
+
if (fn) return fn;
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
for (const cls of file.getClasses()) {
|
|
1500
|
+
const handle = cls.getMethod("handle");
|
|
1501
|
+
if (handle) return handle;
|
|
1502
|
+
const publicMethods = cls.getMethods().filter((method) => !method.hasModifier("private"));
|
|
1503
|
+
if (publicMethods.length === 1) return publicMethods[0];
|
|
1504
|
+
}
|
|
1505
|
+
return null;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Builds the symbol map valid INSIDE this body.
|
|
1509
|
+
*
|
|
1510
|
+
* It includes the models imported in the file and the local variables derived
|
|
1511
|
+
* from them: `const invite = await Invite.findOrFail(...)` makes `invite.save()`
|
|
1512
|
+
* count as a write to `Invite`.
|
|
1513
|
+
*/
|
|
1514
|
+
function storeSymbolsFor(body, file, app, stores) {
|
|
1515
|
+
const symbols = /* @__PURE__ */ new Map();
|
|
1516
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
1517
|
+
if (!app.resolveSpecifier(declaration.getModuleSpecifierValue())) continue;
|
|
1518
|
+
const local = declaration.getDefaultImport()?.getText();
|
|
1519
|
+
if (local && stores.has(local)) symbols.set(local, local);
|
|
1520
|
+
for (const named of declaration.getNamedImports()) {
|
|
1521
|
+
const binding = named.getAliasNode()?.getText() ?? named.getName();
|
|
1522
|
+
if (stores.has(named.getName())) symbols.set(binding, named.getName());
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
1526
|
+
const initializer = declaration.getInitializer();
|
|
1527
|
+
const name = declaration.getNameNode();
|
|
1528
|
+
if (!initializer || !Node.isIdentifier(name)) continue;
|
|
1529
|
+
const root = rootSymbolOf(initializer);
|
|
1530
|
+
const store = root ? symbols.get(root) : void 0;
|
|
1531
|
+
if (store) symbols.set(name.getText(), store);
|
|
1532
|
+
}
|
|
1533
|
+
if (Node.isMethodDeclaration(body) || Node.isFunctionDeclaration(body)) for (const parameter of body.getParameters()) {
|
|
1534
|
+
const typeNode = parameter.getTypeNode();
|
|
1535
|
+
const nameNode = parameter.getNameNode();
|
|
1536
|
+
const typeName = typeNode?.getText();
|
|
1537
|
+
if (typeName && stores.has(typeName) && Node.isIdentifier(nameNode)) {
|
|
1538
|
+
symbols.set(nameNode.getText(), typeName);
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Named type: `handle(input: ExpireInviteInput)` with
|
|
1543
|
+
* `interface ExpireInviteInput { invite: Invite }`.
|
|
1544
|
+
*
|
|
1545
|
+
* Registers the PATH `input.invite`, because that is how the write
|
|
1546
|
+
* appears: `input.invite.save()`.
|
|
1547
|
+
*/
|
|
1548
|
+
if (typeNode && Node.isIdentifier(nameNode)) {
|
|
1549
|
+
for (const [property, propertyType] of membersOfType(typeNode, file, app)) if (stores.has(propertyType)) symbols.set(`${nameNode.getText()}.${property}`, propertyType);
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Destructured form: `handle({ invite }: { invite: Invite })`.
|
|
1553
|
+
*
|
|
1554
|
+
* This is the dominant shape in the action-object pattern — the action
|
|
1555
|
+
* receives a named payload. Without it, `invite.save()` inside the action
|
|
1556
|
+
* does not count as a write, and the whole transaction becomes an EO
|
|
1557
|
+
* instead of an EI.
|
|
1558
|
+
*/
|
|
1559
|
+
const binding = nameNode.asKind(SyntaxKind.ObjectBindingPattern);
|
|
1560
|
+
const literal = typeNode?.asKind(SyntaxKind.TypeLiteral);
|
|
1561
|
+
if (!binding || !literal) continue;
|
|
1562
|
+
const propertyTypes = /* @__PURE__ */ new Map();
|
|
1563
|
+
for (const member of literal.getMembers()) {
|
|
1564
|
+
if (!Node.isPropertySignature(member)) continue;
|
|
1565
|
+
const memberType = member.getTypeNode()?.getText();
|
|
1566
|
+
if (memberType) propertyTypes.set(member.getName(), memberType);
|
|
1567
|
+
}
|
|
1568
|
+
for (const element of binding.getElements()) {
|
|
1569
|
+
const property = element.getPropertyNameNode()?.getText() ?? element.getName();
|
|
1570
|
+
const resolved = propertyTypes.get(property);
|
|
1571
|
+
if (resolved && stores.has(resolved)) symbols.set(element.getName(), resolved);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return symbols;
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Injected dependencies visible in the body: property name -> file.
|
|
1578
|
+
*
|
|
1579
|
+
* Two forms, both with the type annotated explicitly — `@inject()` does not
|
|
1580
|
+
* work without it:
|
|
1581
|
+
*
|
|
1582
|
+
* constructor(protected billing: BillingService) {}
|
|
1583
|
+
* private declare billing: BillingService
|
|
1584
|
+
*
|
|
1585
|
+
* Since the type is an imported identifier, it resolves through the same path
|
|
1586
|
+
* as any import. No type checker is needed.
|
|
1587
|
+
*/
|
|
1588
|
+
function injectedFor(owner, file, app) {
|
|
1589
|
+
const injected = /* @__PURE__ */ new Map();
|
|
1590
|
+
if (!owner) return injected;
|
|
1591
|
+
const register = (property, typeName) => {
|
|
1592
|
+
if (!typeName) return;
|
|
1593
|
+
const target = resolveTypeToFile(typeName, file, app);
|
|
1594
|
+
if (target) injected.set(property, target);
|
|
1595
|
+
};
|
|
1596
|
+
for (const parameter of owner.getConstructors()[0]?.getParameters() ?? []) register(parameter.getName(), dependencyTypeOf(parameter));
|
|
1597
|
+
for (const property of owner.getProperties()) register(property.getName(), dependencyTypeOf(property));
|
|
1598
|
+
return injected;
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* The declared type of a dependency, or the class its default value builds.
|
|
1602
|
+
*
|
|
1603
|
+
* constructor(private invites: InviteService) {} annotation
|
|
1604
|
+
* constructor(private invites = new InviteService()) {} default value
|
|
1605
|
+
*
|
|
1606
|
+
* The second is injection without the container, and it carries no type
|
|
1607
|
+
* annotation at all — the type is inferred from the initialiser. Reading only
|
|
1608
|
+
* `getTypeNode()` saw nothing there, and the consequence was not a gap in
|
|
1609
|
+
* coverage but a wrong classification: the write inside the service stayed
|
|
1610
|
+
* invisible, so the transaction counted as an EO instead of an EI.
|
|
1611
|
+
*/
|
|
1612
|
+
function dependencyTypeOf(node) {
|
|
1613
|
+
const declared = node.getTypeNode()?.getText();
|
|
1614
|
+
if (declared) return declared;
|
|
1615
|
+
const initializer = node.getInitializer();
|
|
1616
|
+
if (initializer && Node.isNewExpression(initializer)) {
|
|
1617
|
+
const target = initializer.getExpression();
|
|
1618
|
+
if (Node.isIdentifier(target)) return target.getText();
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
/** type identifier -> application file where it is declared */
|
|
1622
|
+
function resolveTypeToFile(typeName, file, app) {
|
|
1623
|
+
const bare = typeName.replace(/<.*/, "").trim();
|
|
1624
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
1625
|
+
const specifier = declaration.getModuleSpecifierValue();
|
|
1626
|
+
if (declaration.getDefaultImport()?.getText() === bare) return app.resolveSpecifier(specifier);
|
|
1627
|
+
for (const named of declaration.getNamedImports()) if ((named.getAliasNode()?.getText() ?? named.getName()) === bare) return app.resolveSpecifier(specifier);
|
|
1628
|
+
}
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Members of a declared type: `interface X { a: A }` -> { a: 'A' }.
|
|
1633
|
+
*
|
|
1634
|
+
* Accepts an inline type literal and a named type declared in this file or
|
|
1635
|
+
* imported from the application. Anything else yields empty — no guessing.
|
|
1636
|
+
*/
|
|
1637
|
+
function membersOfType(typeNode, file, app) {
|
|
1638
|
+
const members = /* @__PURE__ */ new Map();
|
|
1639
|
+
const collect = (node) => {
|
|
1640
|
+
const holders = Node.isTypeLiteral(node) ? node.getMembers() : Node.isInterfaceDeclaration(node) ? node.getMembers() : [];
|
|
1641
|
+
for (const member of holders) {
|
|
1642
|
+
if (!Node.isPropertySignature(member)) continue;
|
|
1643
|
+
const memberType = member.getTypeNode()?.getText();
|
|
1644
|
+
if (memberType) members.set(member.getName(), memberType);
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
if (Node.isTypeLiteral(typeNode)) {
|
|
1648
|
+
collect(typeNode);
|
|
1649
|
+
return members;
|
|
1650
|
+
}
|
|
1651
|
+
if (!Node.isTypeReference(typeNode)) return members;
|
|
1652
|
+
const name = typeNode.getTypeName().getText();
|
|
1653
|
+
const local = file.getInterface(name) ?? file.getTypeAlias(name);
|
|
1654
|
+
if (local) {
|
|
1655
|
+
collect(Node.isTypeAliasDeclaration(local) ? local.getTypeNode() ?? local : local);
|
|
1656
|
+
return members;
|
|
1657
|
+
}
|
|
1658
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
1659
|
+
const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
|
|
1660
|
+
if (!target) continue;
|
|
1661
|
+
if (!declaration.getNamedImports().map((named) => named.getName()).includes(name)) continue;
|
|
1662
|
+
const source = file.getProject().addSourceFileAtPathIfExists(target);
|
|
1663
|
+
const declared = source?.getInterface(name) ?? source?.getTypeAlias(name);
|
|
1664
|
+
if (declared) collect(Node.isTypeAliasDeclaration(declared) ? declared.getTypeNode() ?? declared : declared);
|
|
1665
|
+
}
|
|
1666
|
+
return members;
|
|
1667
|
+
}
|
|
1668
|
+
function importsOf(file, app) {
|
|
1669
|
+
const map = /* @__PURE__ */ new Map();
|
|
1670
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
1671
|
+
const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
|
|
1672
|
+
if (!target) continue;
|
|
1673
|
+
const defaultImport = declaration.getDefaultImport()?.getText();
|
|
1674
|
+
if (defaultImport) map.set(defaultImport, target);
|
|
1675
|
+
for (const named of declaration.getNamedImports()) map.set(named.getAliasNode()?.getText() ?? named.getName(), target);
|
|
1676
|
+
}
|
|
1677
|
+
return map;
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Not every unfollowed call is an unresolved call — but the filter must err on
|
|
1681
|
+
* the side of reporting.
|
|
1682
|
+
*
|
|
1683
|
+
* `response.redirect()` and `inertia.render()` lead to no data at all and would
|
|
1684
|
+
* only drown the report. But a call on a symbol imported from the APPLICATION
|
|
1685
|
+
* itself may hide a data access, and silencing it is the worst possible defect
|
|
1686
|
+
* here: the transaction becomes an EO and nobody knows.
|
|
1687
|
+
*
|
|
1688
|
+
* A filter that only reported `this.` would hide most of the real gap.
|
|
1689
|
+
*/
|
|
1690
|
+
function isWorthReporting(call, symbols, imports) {
|
|
1691
|
+
const expression = call.getExpression();
|
|
1692
|
+
if (Node.isIdentifier(expression)) return imports.has(expression.getText());
|
|
1693
|
+
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
1694
|
+
const root = rootSymbolOf(expression.getExpression());
|
|
1695
|
+
if (!root) return false;
|
|
1696
|
+
if (symbols.has(root)) return false;
|
|
1697
|
+
if (root === "this") return true;
|
|
1698
|
+
return imports.has(root);
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Hash of the NORMALISED body: comments and whitespace removed.
|
|
1702
|
+
*
|
|
1703
|
+
* counting-decisions §5 measures modification by a checksum of the
|
|
1704
|
+
* implementation scope. If the hash were over the raw bytes, running Prettier
|
|
1705
|
+
* would turn into an invoice.
|
|
1706
|
+
*/
|
|
1707
|
+
function hashOf(body) {
|
|
1708
|
+
const normalized = body.getText().replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").replace(/\s+/g, "");
|
|
1709
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
1710
|
+
}
|
|
1711
|
+
//#endregion
|
|
1712
|
+
//#region src/albrecht/tables.ts
|
|
1713
|
+
/** shared grid: [ref band][DET band] -> complexity */
|
|
1714
|
+
const GRID = [
|
|
1715
|
+
[
|
|
1716
|
+
"low",
|
|
1717
|
+
"low",
|
|
1718
|
+
"average"
|
|
1719
|
+
],
|
|
1720
|
+
[
|
|
1721
|
+
"low",
|
|
1722
|
+
"average",
|
|
1723
|
+
"high"
|
|
1724
|
+
],
|
|
1725
|
+
[
|
|
1726
|
+
"average",
|
|
1727
|
+
"high",
|
|
1728
|
+
"high"
|
|
1729
|
+
]
|
|
1730
|
+
];
|
|
1731
|
+
const DEFAULT_TABLES = {
|
|
1732
|
+
ILF: {
|
|
1733
|
+
refBands: [1, 5],
|
|
1734
|
+
detBands: [19, 50]
|
|
1735
|
+
},
|
|
1736
|
+
EIF: {
|
|
1737
|
+
refBands: [1, 5],
|
|
1738
|
+
detBands: [19, 50]
|
|
1739
|
+
},
|
|
1740
|
+
EI: {
|
|
1741
|
+
refBands: [1, 2],
|
|
1742
|
+
detBands: [4, 15]
|
|
1743
|
+
},
|
|
1744
|
+
EO: {
|
|
1745
|
+
refBands: [1, 3],
|
|
1746
|
+
detBands: [5, 19]
|
|
1747
|
+
},
|
|
1748
|
+
EQ: {
|
|
1749
|
+
refBands: [1, 3],
|
|
1750
|
+
detBands: [5, 19]
|
|
1751
|
+
}
|
|
1752
|
+
};
|
|
1753
|
+
const DEFAULT_WEIGHTS = {
|
|
1754
|
+
ILF: {
|
|
1755
|
+
low: 7,
|
|
1756
|
+
average: 10,
|
|
1757
|
+
high: 15
|
|
1758
|
+
},
|
|
1759
|
+
EIF: {
|
|
1760
|
+
low: 5,
|
|
1761
|
+
average: 7,
|
|
1762
|
+
high: 10
|
|
1763
|
+
},
|
|
1764
|
+
EI: {
|
|
1765
|
+
low: 3,
|
|
1766
|
+
average: 4,
|
|
1767
|
+
high: 6
|
|
1768
|
+
},
|
|
1769
|
+
EO: {
|
|
1770
|
+
low: 4,
|
|
1771
|
+
average: 5,
|
|
1772
|
+
high: 7
|
|
1773
|
+
},
|
|
1774
|
+
EQ: {
|
|
1775
|
+
low: 3,
|
|
1776
|
+
average: 4,
|
|
1777
|
+
high: 6
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
const bandOf = (value, [a, b]) => value <= a ? 0 : value <= b ? 1 : 2;
|
|
1781
|
+
function complexityOf(type, refs, det, tables = DEFAULT_TABLES) {
|
|
1782
|
+
const table = tables[type];
|
|
1783
|
+
return GRID[bandOf(refs, table.refBands)][bandOf(det, table.detBands)];
|
|
1784
|
+
}
|
|
1785
|
+
function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
|
|
1786
|
+
return weights[type][complexity];
|
|
1787
|
+
}
|
|
1788
|
+
//#endregion
|
|
1789
|
+
//#region src/albrecht/data_functions.ts
|
|
1790
|
+
function countDataFunctions(stores, usage, options) {
|
|
1791
|
+
const counted = [];
|
|
1792
|
+
for (const store of stores) {
|
|
1793
|
+
const use = usage.get(store.name);
|
|
1794
|
+
if (!use?.used) continue;
|
|
1795
|
+
/**
|
|
1796
|
+
* DETs exclude the technical identifier.
|
|
1797
|
+
*
|
|
1798
|
+
* IFPUG defines a DET as a "user recognizable" attribute, and an
|
|
1799
|
+
* auto-increment surrogate key is not something the user recognises.
|
|
1800
|
+
* Counting it would inflate every data function by one.
|
|
1801
|
+
*/
|
|
1802
|
+
const detAttributes = store.attributes.filter((attribute) => !attribute.isIdentifier);
|
|
1803
|
+
const det = detAttributes.length;
|
|
1804
|
+
const refs = options.retStrategy === "composition" ? 1 + store.subgroups.length : 1;
|
|
1805
|
+
const type = options.externallyMaintained.has(store.name) || !use.written ? "EIF" : "ILF";
|
|
1806
|
+
const complexity = complexityOf(type, refs, det, options.tables);
|
|
1807
|
+
counted.push({
|
|
1808
|
+
id: `data:${store.name}`,
|
|
1809
|
+
name: store.name,
|
|
1810
|
+
module: store.module,
|
|
1811
|
+
type,
|
|
1812
|
+
det,
|
|
1813
|
+
refs,
|
|
1814
|
+
complexity,
|
|
1815
|
+
points: pointsOf(type, complexity, options.weights),
|
|
1816
|
+
rationale: {
|
|
1817
|
+
rule: options.externallyMaintained.has(store.name) ? "afp:6.5.4 externally maintained by boundary configuration -> EIF" : use.written ? "afp:6.5.4 maintained by an application transaction -> ILF" : "afp:6.5.4 used but not maintained -> EIF",
|
|
1818
|
+
detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}`),
|
|
1819
|
+
refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
return counted;
|
|
1824
|
+
}
|
|
1825
|
+
//#endregion
|
|
1826
|
+
//#region src/albrecht/transactional_functions.ts
|
|
1827
|
+
function countTransactionalFunctions(entryPoints, behaviors, options) {
|
|
1828
|
+
const counted = [];
|
|
1829
|
+
for (const entry of entryPoints) {
|
|
1830
|
+
const behavior = behaviors.get(entry.id);
|
|
1831
|
+
if (!behavior) continue;
|
|
1832
|
+
const touched = behavior.touches.filter((store) => options.countedStores.has(store));
|
|
1833
|
+
/**
|
|
1834
|
+
* No path down to any data function means there is no transaction to
|
|
1835
|
+
* identify (AFP §6.5.3). This falls out of the general rule — no special
|
|
1836
|
+
* case is needed for static routes.
|
|
1837
|
+
*/
|
|
1838
|
+
if (touched.length === 0) continue;
|
|
1839
|
+
const type = behavior.writes ? "EI" : "EO";
|
|
1840
|
+
const refs = touched.length;
|
|
1841
|
+
const { det, sources } = detsFor(entry, behavior, touched, type, options);
|
|
1842
|
+
const complexity = complexityOf(type, refs, det, options.tables);
|
|
1843
|
+
counted.push({
|
|
1844
|
+
id: `tx:${entry.identity}`,
|
|
1845
|
+
name: entry.identity,
|
|
1846
|
+
module: entry.module,
|
|
1847
|
+
type,
|
|
1848
|
+
det,
|
|
1849
|
+
refs,
|
|
1850
|
+
complexity,
|
|
1851
|
+
points: pointsOf(type, complexity, options.weights),
|
|
1852
|
+
scopeHash: scopeHashOf(behavior),
|
|
1853
|
+
rationale: {
|
|
1854
|
+
rule: behavior.writes ? "afp:6.5.3 modifies a data store -> EI" : "afp:6.5.3 uses without modifying -> EO (EQ collapsed per 6.5.3)",
|
|
1855
|
+
detSources: sources,
|
|
1856
|
+
refSources: touched.map((store) => `reaches:${store}`),
|
|
1857
|
+
trace: behavior.trace
|
|
1858
|
+
}
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
return counted;
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Combined hash of the implementation scope, consumed by `fp:diff`.
|
|
1865
|
+
*
|
|
1866
|
+
* Sorted before combining: traversal order can vary without the code having
|
|
1867
|
+
* changed, and an unstable hash would turn every release into a "change".
|
|
1868
|
+
*/
|
|
1869
|
+
function scopeHashOf(behavior) {
|
|
1870
|
+
return behavior.scope.map((entry) => `${entry.member ?? "*"}:${entry.bodyHash}`).sort().join("|");
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* DETs of a transaction — AFP §7.3.
|
|
1874
|
+
*
|
|
1875
|
+
* "Count only one DET for each unique field that is required to complete the
|
|
1876
|
+
* External Input. […] Count only one DET for each unique field that is
|
|
1877
|
+
* required to complete the Output Transaction. If a DET both enters and exits
|
|
1878
|
+
* the boundary, count that DET only once."
|
|
1879
|
+
*
|
|
1880
|
+
* The distinction that matters is the transaction TYPE, not whether input
|
|
1881
|
+
* exists:
|
|
1882
|
+
*
|
|
1883
|
+
* EI fields the user supplies — route parameters and validator fields.
|
|
1884
|
+
* What the transaction reads in order to write is not an input DET.
|
|
1885
|
+
* EO what the user supplies PLUS what the transaction presents. A report has
|
|
1886
|
+
* both: the period queried and the fields displayed.
|
|
1887
|
+
*
|
|
1888
|
+
* With no `.select()` and no visible transformer, the output fields are the
|
|
1889
|
+
* whole table, which **overestimates**. That is the trade AFP makes on purpose,
|
|
1890
|
+
* favouring repeatability over fidelity; the origin is recorded in `Rationale`
|
|
1891
|
+
* so `fp:calibrate` can measure the bias.
|
|
1892
|
+
*/
|
|
1893
|
+
function detsFor(entry, behavior, touched, type, options) {
|
|
1894
|
+
const sources = [];
|
|
1895
|
+
const counted = /* @__PURE__ */ new Set();
|
|
1896
|
+
const add = (field, source) => {
|
|
1897
|
+
if (counted.has(field)) return;
|
|
1898
|
+
counted.add(field);
|
|
1899
|
+
sources.push(source);
|
|
1900
|
+
};
|
|
1901
|
+
for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
|
|
1902
|
+
for (const field of behavior.inputFields) add(field.split(".").pop(), `validator:${field}`);
|
|
1903
|
+
if (type === "EO" || type === "EQ") for (const store of touched) {
|
|
1904
|
+
const columns = options.countedStores.get(store).attributes.filter((attribute) => !attribute.isIdentifier);
|
|
1905
|
+
for (const column of columns) add(`${store}.${column.name}`, `output:${store}.${column.name}`);
|
|
1906
|
+
}
|
|
1907
|
+
let det = counted.size + options.messageDet;
|
|
1908
|
+
if (options.messageDet > 0) sources.push("message:1");
|
|
1909
|
+
return {
|
|
1910
|
+
det: Math.max(det, 1),
|
|
1911
|
+
sources
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/albrecht/technical_filter.ts
|
|
1916
|
+
/**
|
|
1917
|
+
* Temporary and technical data filter — AFP §6.5.2.1.1.
|
|
1918
|
+
*
|
|
1919
|
+
* "Database tables identified as temporary or technical shall be marked as
|
|
1920
|
+
* such to be presented in the final report, and shall be ignored in the rest
|
|
1921
|
+
* of this process."
|
|
1922
|
+
*
|
|
1923
|
+
* Returns the reason when a table is technical, `null` otherwise: the report
|
|
1924
|
+
* must say WHY something was excluded, not merely that it was.
|
|
1925
|
+
*/
|
|
1926
|
+
/**
|
|
1927
|
+
* Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3).
|
|
1928
|
+
*
|
|
1929
|
+
* The standard treats these as user-provided inputs, so they stay overridable
|
|
1930
|
+
* through the boundary configuration.
|
|
1931
|
+
*/
|
|
1932
|
+
const DEFAULT_TECHNICAL_PATTERNS = [
|
|
1933
|
+
{
|
|
1934
|
+
label: "temporary entity",
|
|
1935
|
+
pattern: /^(.+temp|.*session.*|.*error.*|.*search.*|.*login.*|.*logon.*|.*filter.*)$/i
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
label: "status entity",
|
|
1939
|
+
pattern: /^(.+status)$/i
|
|
1940
|
+
},
|
|
1941
|
+
{
|
|
1942
|
+
label: "lookup entity",
|
|
1943
|
+
pattern: /^(lkp_.+|.+types?|.+_t)$/i
|
|
1944
|
+
},
|
|
1945
|
+
{
|
|
1946
|
+
label: "template entity",
|
|
1947
|
+
pattern: /^(.*template.*)$/i
|
|
1948
|
+
}
|
|
1949
|
+
];
|
|
1950
|
+
function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
|
|
1951
|
+
const table = store.table ?? store.name;
|
|
1952
|
+
for (const { label, pattern } of patterns) if (pattern.test(table)) return `${label} (AFP §6.5.2.1.3: ${pattern.source})`;
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Version of the rule set.
|
|
1957
|
+
*
|
|
1958
|
+
* It appears in every report, and `fp:diff` refuses to compare counts produced
|
|
1959
|
+
* by different versions — otherwise the difference would measure the rule
|
|
1960
|
+
* change rather than the work.
|
|
1961
|
+
*/
|
|
1962
|
+
const RULESET_VERSION = "1.0.0";
|
|
1963
|
+
function count(input, options = {}) {
|
|
1964
|
+
const warnings = [];
|
|
1965
|
+
const usage = usageOf(input);
|
|
1966
|
+
const tables = {
|
|
1967
|
+
...DEFAULT_TABLES,
|
|
1968
|
+
...options.complexityTables
|
|
1969
|
+
};
|
|
1970
|
+
const weights = {
|
|
1971
|
+
...DEFAULT_WEIGHTS,
|
|
1972
|
+
...options.weights
|
|
1973
|
+
};
|
|
1974
|
+
const infrastructure = new Set(options.boundary?.infrastructure ?? []);
|
|
1975
|
+
const countable = input.stores.filter((store) => {
|
|
1976
|
+
if (infrastructure.has(store.name) || infrastructure.has(store.table ?? "")) {
|
|
1977
|
+
warnings.push(`excluded by boundary configuration: ${store.name}`);
|
|
1978
|
+
return false;
|
|
1979
|
+
}
|
|
1980
|
+
const technical = isTechnical(store);
|
|
1981
|
+
if (technical) warnings.push(`technical, excluded: ${store.name} (${technical})`);
|
|
1982
|
+
return !technical;
|
|
1983
|
+
});
|
|
1984
|
+
const dataFunctions = countDataFunctions(countable, usage, {
|
|
1985
|
+
retStrategy: options.retStrategy ?? "constant",
|
|
1986
|
+
externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
|
|
1987
|
+
tables,
|
|
1988
|
+
weights
|
|
1989
|
+
});
|
|
1990
|
+
const countedStores = new Map(dataFunctions.map((fn) => [fn.name, countable.find((store) => store.name === fn.name)]));
|
|
1991
|
+
const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
|
|
1992
|
+
const transactionalFunctions = countTransactionalFunctions(input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? "")), input.behaviors, {
|
|
1993
|
+
countedStores,
|
|
1994
|
+
messageDet: options.messageDet ?? 0,
|
|
1995
|
+
tables,
|
|
1996
|
+
weights
|
|
1997
|
+
});
|
|
1998
|
+
warnings.push(...opaqueColumnWarnings(countable, input));
|
|
1999
|
+
const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
|
|
2000
|
+
return {
|
|
2001
|
+
ruleset: "afp",
|
|
2002
|
+
rulesetVersion: RULESET_VERSION,
|
|
2003
|
+
functions,
|
|
2004
|
+
totals: totalsOf(functions),
|
|
2005
|
+
confidence: confidenceOf(input, warnings)
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Columns whose content static analysis cannot read — counting-decisions §8.
|
|
2010
|
+
*
|
|
2011
|
+
* A JSON column holding a form the user fills counts as 1 DET, because the
|
|
2012
|
+
* schema is runtime data. That is the documented trade, and until now it was
|
|
2013
|
+
* documented ONLY: the count said nothing, which is the one known blind spot
|
|
2014
|
+
* this package reported nowhere. It reports an unresolved call, a technical
|
|
2015
|
+
* table, an unresolved mixin and a handler-less route — and stayed silent here.
|
|
2016
|
+
*
|
|
2017
|
+
* Only columns on a store some transaction reaches are named. An untouched
|
|
2018
|
+
* `metadata` column changes no number, and warning about it would be the noise
|
|
2019
|
+
* that teaches people to stop reading the confidence block.
|
|
2020
|
+
*/
|
|
2021
|
+
function opaqueColumnWarnings(stores, input) {
|
|
2022
|
+
const reached = /* @__PURE__ */ new Map();
|
|
2023
|
+
for (const entry of input.entryPoints) for (const store of input.behaviors.get(entry.id)?.touches ?? []) reached.set(store, (reached.get(store) ?? 0) + 1);
|
|
2024
|
+
const found = [];
|
|
2025
|
+
for (const store of stores) {
|
|
2026
|
+
const transactions = reached.get(store.name);
|
|
2027
|
+
if (!transactions) continue;
|
|
2028
|
+
for (const attribute of store.attributes) {
|
|
2029
|
+
if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
|
|
2030
|
+
found.push(` ${store.name}.${attribute.name} (${attribute.type}) — ${transactions} transaction(s)`);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (found.length === 0) return [];
|
|
2034
|
+
return [`${found.length} opaque column(s), each counted as 1 DET. If the user recognises fields inside one, declare the count with \`overrides\` — counting-decisions §8:`, ...found];
|
|
2035
|
+
}
|
|
2036
|
+
/** a column whose shape says nothing about what it holds */
|
|
2037
|
+
const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
2038
|
+
/**
|
|
2039
|
+
* Replaces what the analysis found with what a person declared.
|
|
2040
|
+
*
|
|
2041
|
+
* Only for facts static analysis cannot reach — a JSON column whose schema
|
|
2042
|
+
* lives in the database, per counting-decisions §8. The declared number is
|
|
2043
|
+
* reproducible because it comes from a versioned file, and auditable because it
|
|
2044
|
+
* travels with its justification into the rationale, which `fp:explain` prints.
|
|
2045
|
+
*
|
|
2046
|
+
* An override naming no function is a warning, never silence: a typo in the key
|
|
2047
|
+
* would otherwise mean the declaration did nothing and nobody was told.
|
|
2048
|
+
*/
|
|
2049
|
+
function applyOverrides(functions, overrides, schemas, tables, weights, warnings) {
|
|
2050
|
+
const keys = Object.keys(overrides);
|
|
2051
|
+
if (keys.length === 0) return functions;
|
|
2052
|
+
const used = /* @__PURE__ */ new Set();
|
|
2053
|
+
const applied = functions.map((fn) => {
|
|
2054
|
+
const override = overrides[fn.name];
|
|
2055
|
+
if (!override) return fn;
|
|
2056
|
+
used.add(fn.name);
|
|
2057
|
+
const fields = [];
|
|
2058
|
+
if (override.det !== void 0 || override.detFromSchema) fields.push("det");
|
|
2059
|
+
if (override.refs !== void 0) fields.push("refs");
|
|
2060
|
+
let det = override.det ?? fn.det;
|
|
2061
|
+
let by = `config:overrides.${fn.name}`;
|
|
2062
|
+
/**
|
|
2063
|
+
* Read from the schema rather than declared as a number.
|
|
2064
|
+
*
|
|
2065
|
+
* A frozen number goes stale the moment someone adds a field: the count
|
|
2066
|
+
* would not move and `fp:diff` would report no change for real functional
|
|
2067
|
+
* growth. Naming the schema keeps the number coming from the code, and the
|
|
2068
|
+
* only thing maintained by hand is the mapping — which changes when a form
|
|
2069
|
+
* is born, not when a field is.
|
|
2070
|
+
*/
|
|
2071
|
+
if (override.detFromSchema) {
|
|
2072
|
+
const schema = schemas.get(override.detFromSchema);
|
|
2073
|
+
if (schema) {
|
|
2074
|
+
det = Math.max(fn.det - 1, 0) + schema.fields;
|
|
2075
|
+
by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
|
|
2076
|
+
} else warnings.push(`override for "${fn.name}" names schema "${override.detFromSchema}", which is not declared anywhere in the code: the DET count was left as found. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
|
|
2077
|
+
}
|
|
2078
|
+
const refs = override.refs ?? fn.refs;
|
|
2079
|
+
const complexity = complexityOf(fn.type, refs, det, tables);
|
|
2080
|
+
return {
|
|
2081
|
+
...fn,
|
|
2082
|
+
det,
|
|
2083
|
+
refs,
|
|
2084
|
+
complexity,
|
|
2085
|
+
points: pointsOf(fn.type, complexity, weights),
|
|
2086
|
+
rationale: {
|
|
2087
|
+
...fn.rationale,
|
|
2088
|
+
overrides: [...fn.rationale.overrides ?? [], {
|
|
2089
|
+
by,
|
|
2090
|
+
reason: override.reason,
|
|
2091
|
+
fields
|
|
2092
|
+
}]
|
|
2093
|
+
}
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
2096
|
+
for (const key of keys) if (!used.has(key)) warnings.push(`override "${key}" matched no counted function: the declaration had no effect`);
|
|
2097
|
+
return applied;
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* How each store is used by the transactions.
|
|
2101
|
+
*
|
|
2102
|
+
* `written` decides ILF vs EIF; `used` decides whether it is counted at all.
|
|
2103
|
+
*/
|
|
2104
|
+
function usageOf(input) {
|
|
2105
|
+
const usage = /* @__PURE__ */ new Map();
|
|
2106
|
+
for (const entry of input.entryPoints) {
|
|
2107
|
+
const behavior = input.behaviors.get(entry.id);
|
|
2108
|
+
if (!behavior) continue;
|
|
2109
|
+
for (const store of behavior.touches) {
|
|
2110
|
+
const current = usage.get(store) ?? {
|
|
2111
|
+
written: false,
|
|
2112
|
+
used: false
|
|
2113
|
+
};
|
|
2114
|
+
usage.set(store, {
|
|
2115
|
+
used: true,
|
|
2116
|
+
written: current.written || behavior.writes
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return usage;
|
|
2121
|
+
}
|
|
2122
|
+
const EMPTY_BY_TYPE = {
|
|
2123
|
+
ILF: {
|
|
2124
|
+
count: 0,
|
|
2125
|
+
points: 0
|
|
2126
|
+
},
|
|
2127
|
+
EIF: {
|
|
2128
|
+
count: 0,
|
|
2129
|
+
points: 0
|
|
2130
|
+
},
|
|
2131
|
+
EI: {
|
|
2132
|
+
count: 0,
|
|
2133
|
+
points: 0
|
|
2134
|
+
},
|
|
2135
|
+
EO: {
|
|
2136
|
+
count: 0,
|
|
2137
|
+
points: 0
|
|
2138
|
+
},
|
|
2139
|
+
EQ: {
|
|
2140
|
+
count: 0,
|
|
2141
|
+
points: 0
|
|
2142
|
+
}
|
|
2143
|
+
};
|
|
2144
|
+
function totalsOf(functions) {
|
|
2145
|
+
const byType = structuredClone(EMPTY_BY_TYPE);
|
|
2146
|
+
const byModule = {};
|
|
2147
|
+
let unadjusted = 0;
|
|
2148
|
+
for (const fn of functions) {
|
|
2149
|
+
byType[fn.type].count++;
|
|
2150
|
+
byType[fn.type].points += fn.points;
|
|
2151
|
+
byModule[fn.module] = (byModule[fn.module] ?? 0) + fn.points;
|
|
2152
|
+
unadjusted += fn.points;
|
|
2153
|
+
}
|
|
2154
|
+
return {
|
|
2155
|
+
unadjusted,
|
|
2156
|
+
byType,
|
|
2157
|
+
byModule
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Confidence of the count.
|
|
2162
|
+
*
|
|
2163
|
+
* AFP §6.5.3 requires whatever could not be traced to appear in the report. A
|
|
2164
|
+
* total resting on many unresolved calls should not become an invoice, and the
|
|
2165
|
+
* reader must see that without having to go looking.
|
|
2166
|
+
*/
|
|
2167
|
+
function confidenceOf(input, warnings) {
|
|
2168
|
+
let unresolvedCalls = 0;
|
|
2169
|
+
let withoutHandler = 0;
|
|
2170
|
+
for (const entry of input.entryPoints) {
|
|
2171
|
+
const behavior = input.behaviors.get(entry.id);
|
|
2172
|
+
if (!behavior) {
|
|
2173
|
+
withoutHandler++;
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
unresolvedCalls += behavior.unresolved.length;
|
|
2177
|
+
}
|
|
2178
|
+
return {
|
|
2179
|
+
unresolvedCalls,
|
|
2180
|
+
entryPointsWithoutHandler: withoutHandler,
|
|
2181
|
+
warnings
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
//#endregion
|
|
2185
|
+
//#region src/inventory/source.ts
|
|
2186
|
+
const git = (root, args) => {
|
|
2187
|
+
try {
|
|
2188
|
+
return execFileSync("git", [
|
|
2189
|
+
"-C",
|
|
2190
|
+
root,
|
|
2191
|
+
...args
|
|
2192
|
+
], {
|
|
2193
|
+
encoding: "utf8",
|
|
2194
|
+
stdio: [
|
|
2195
|
+
"ignore",
|
|
2196
|
+
"pipe",
|
|
2197
|
+
"ignore"
|
|
2198
|
+
]
|
|
2199
|
+
}).trim();
|
|
2200
|
+
} catch {
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
function describeSource(root, config) {
|
|
2205
|
+
/**
|
|
2206
|
+
* Normalised here rather than trusted from the caller. `loadConfig` already
|
|
2207
|
+
* returns a canonical path, but `analyze()` takes `configFile` from anyone
|
|
2208
|
+
* using the package as a library, and whatever arrives is what ships inside
|
|
2209
|
+
* every saved count.
|
|
2210
|
+
*/
|
|
2211
|
+
const revision = git(root, ["rev-parse", "HEAD"]);
|
|
2212
|
+
const status = revision === void 0 ? void 0 : git(root, ["status", "--porcelain"]);
|
|
2213
|
+
return {
|
|
2214
|
+
app: appName(root),
|
|
2215
|
+
revision,
|
|
2216
|
+
branch: revision === void 0 ? void 0 : git(root, [
|
|
2217
|
+
"rev-parse",
|
|
2218
|
+
"--abbrev-ref",
|
|
2219
|
+
"HEAD"
|
|
2220
|
+
]),
|
|
2221
|
+
dirty: status === void 0 ? void 0 : status.length > 0,
|
|
2222
|
+
countedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2223
|
+
config: config === null ? null : toPosix(config)
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
function appName(root) {
|
|
2227
|
+
try {
|
|
2228
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
2229
|
+
if (pkg.name) return pkg.name;
|
|
2230
|
+
} catch {}
|
|
2231
|
+
return path.basename(root);
|
|
2232
|
+
}
|
|
2233
|
+
//#endregion
|
|
2234
|
+
//#region src/pipeline.ts
|
|
2235
|
+
var CoverageTooLowError = class extends Error {
|
|
2236
|
+
constructor(ratio, minimum) {
|
|
2237
|
+
super(`tracing coverage ${(ratio * 100).toFixed(1)}% is below the minimum of ${(minimum * 100).toFixed(0)}%: the count is not reliable enough to become a number. Run \`fp:inventory\` to see what is unresolved.`);
|
|
2238
|
+
this.ratio = ratio;
|
|
2239
|
+
this.minimum = minimum;
|
|
2240
|
+
this.name = "CoverageTooLowError";
|
|
2241
|
+
}
|
|
2242
|
+
};
|
|
2243
|
+
async function analyze(root, options = {}) {
|
|
2244
|
+
const app = await discoverApp(root);
|
|
2245
|
+
const { stores, unresolved: storeProblems } = await collectDataStores(app);
|
|
2246
|
+
const { entryPoints, unresolved: routeProblems } = await collectEntryPoints(app);
|
|
2247
|
+
/** only read when an override names one — but collected once, like everything else */
|
|
2248
|
+
const jsonSchemas = collectJsonSchemas(app);
|
|
2249
|
+
const analyzer = createAnalyzer(app, stores, {
|
|
2250
|
+
maxDepth: options.maxDepth,
|
|
2251
|
+
callResolvers: options.resolvers?.call
|
|
2252
|
+
});
|
|
2253
|
+
const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
|
|
2254
|
+
const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
|
|
2255
|
+
const unresolvedCalls = storeProblems.length + routeProblems.length + [...behaviors.values()].reduce((total, behavior) => total + behavior.unresolved.length, 0);
|
|
2256
|
+
const inventory = {
|
|
2257
|
+
version: 1,
|
|
2258
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2259
|
+
app: app.root,
|
|
2260
|
+
framework: {
|
|
2261
|
+
core: app.framework.core,
|
|
2262
|
+
lucid: app.framework.lucid,
|
|
2263
|
+
orm: app.framework.orm
|
|
2264
|
+
},
|
|
2265
|
+
dataStores: stores,
|
|
2266
|
+
entryPoints,
|
|
2267
|
+
behaviors: [...behaviors.entries()].map(([entryPointId, behavior]) => ({
|
|
2268
|
+
entryPointId,
|
|
2269
|
+
writes: behavior.writes,
|
|
2270
|
+
touches: behavior.touches,
|
|
2271
|
+
inputFields: behavior.inputFields.map((name) => ({
|
|
2272
|
+
name,
|
|
2273
|
+
provenance: {
|
|
2274
|
+
file: app.root,
|
|
2275
|
+
by: "validator"
|
|
2276
|
+
}
|
|
2277
|
+
})),
|
|
2278
|
+
outputFields: [],
|
|
2279
|
+
trace: behavior.trace,
|
|
2280
|
+
unresolved: behavior.unresolved
|
|
2281
|
+
})),
|
|
2282
|
+
coverage: {
|
|
2283
|
+
entryPointsTotal: entryPoints.length,
|
|
2284
|
+
entryPointsResolved: resolved,
|
|
2285
|
+
unresolvedCalls,
|
|
2286
|
+
ratio: entryPoints.length === 0 ? 1 : resolved / entryPoints.length
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
const minimum = options.minCoverage ?? 0;
|
|
2290
|
+
if (inventory.coverage.ratio < minimum) throw new CoverageTooLowError(inventory.coverage.ratio, minimum);
|
|
2291
|
+
return {
|
|
2292
|
+
inventory,
|
|
2293
|
+
count: {
|
|
2294
|
+
...count({
|
|
2295
|
+
app,
|
|
2296
|
+
stores,
|
|
2297
|
+
entryPoints,
|
|
2298
|
+
behaviors,
|
|
2299
|
+
jsonSchemas
|
|
2300
|
+
}, options),
|
|
2301
|
+
source: describeSource(root, options.configFile ?? null)
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
//#endregion
|
|
2306
|
+
export { analyze as n, toPosix as r, CoverageTooLowError as t };
|