@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.
Files changed (80) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +427 -0
  3. package/bin/cli.js +4 -0
  4. package/build/commands/fp_calibrate.d.ts +16 -0
  5. package/build/commands/fp_count.d.ts +11 -0
  6. package/build/commands/fp_diff.d.ts +16 -0
  7. package/build/commands/fp_explain.d.ts +15 -0
  8. package/build/commands/fp_inventory.d.ts +9 -0
  9. package/build/commands/main.d.ts +5 -0
  10. package/build/commands/main.js +120 -0
  11. package/build/commands/printer.d.ts +9 -0
  12. package/build/configure.d.ts +2 -0
  13. package/build/configure.js +10 -0
  14. package/build/define_config-DOqWyPwV.js +19 -0
  15. package/build/index.d.ts +5 -0
  16. package/build/index.js +4 -0
  17. package/build/pipeline-BzP-ITGN.js +2306 -0
  18. package/build/resolvers-CU9HKYpn.js +555 -0
  19. package/build/runners-Bt8tbISi.js +630 -0
  20. package/build/scripts/smoke_package.d.ts +1 -0
  21. package/build/src/albrecht/calibration.d.ts +62 -0
  22. package/build/src/albrecht/counter.d.ts +48 -0
  23. package/build/src/albrecht/data_functions.d.ts +32 -0
  24. package/build/src/albrecht/diff.d.ts +66 -0
  25. package/build/src/albrecht/index.d.ts +13 -0
  26. package/build/src/albrecht/tables.d.ts +19 -0
  27. package/build/src/albrecht/technical_filter.d.ts +25 -0
  28. package/build/src/albrecht/transactional_functions.d.ts +35 -0
  29. package/build/src/cli/load_config.d.ts +28 -0
  30. package/build/src/cli/print.d.ts +19 -0
  31. package/build/src/cli/runners.d.ts +52 -0
  32. package/build/src/cli.d.ts +19 -0
  33. package/build/src/cli.js +198 -0
  34. package/build/src/define_config.d.ts +137 -0
  35. package/build/src/inventory/app_context.d.ts +73 -0
  36. package/build/src/inventory/detectors/lucid.d.ts +77 -0
  37. package/build/src/inventory/graph/call_graph.d.ts +80 -0
  38. package/build/src/inventory/graph/noise.d.ts +9 -0
  39. package/build/src/inventory/index.d.ts +15 -0
  40. package/build/src/inventory/paths.d.ts +22 -0
  41. package/build/src/inventory/resolvers/action_object.d.ts +14 -0
  42. package/build/src/inventory/resolvers/index.d.ts +23 -0
  43. package/build/src/inventory/resolvers/index.js +2 -0
  44. package/build/src/inventory/resolvers/job_dispatch.d.ts +18 -0
  45. package/build/src/inventory/resolvers/module_function.d.ts +11 -0
  46. package/build/src/inventory/resolvers/property_service.d.ts +18 -0
  47. package/build/src/inventory/resolvers/same_class_method.d.ts +17 -0
  48. package/build/src/inventory/resolvers/static_service.d.ts +13 -0
  49. package/build/src/inventory/resolvers/transformer.d.ts +25 -0
  50. package/build/src/inventory/resolvers/types.d.ts +65 -0
  51. package/build/src/inventory/source.d.ts +39 -0
  52. package/build/src/inventory/sources/data_stores.d.ts +31 -0
  53. package/build/src/inventory/sources/json_schemas.d.ts +32 -0
  54. package/build/src/inventory/sources/routes_ast.d.ts +28 -0
  55. package/build/src/metrics/structure.d.ts +72 -0
  56. package/build/src/pipeline.d.ts +44 -0
  57. package/build/src/pipeline.js +2 -0
  58. package/build/src/reporters/table.d.ts +6 -0
  59. package/build/src/types.d.ts +256 -0
  60. package/build/src/types.js +1 -0
  61. package/build/stubs/config.stub +37 -0
  62. package/build/tmp/probe.d.ts +1 -0
  63. package/build/tmp/probe_cli.d.ts +1 -0
  64. package/build/tmp/probe_cmp.d.ts +1 -0
  65. package/build/tmp/probe_count.d.ts +1 -0
  66. package/build/tmp/probe_data.d.ts +1 -0
  67. package/build/tmp/probe_diff.d.ts +1 -0
  68. package/build/tmp/probe_gap.d.ts +1 -0
  69. package/build/tmp/probe_graph.d.ts +1 -0
  70. package/build/tmp/probe_metrics.d.ts +1 -0
  71. package/build/tmp/probe_miss.d.ts +1 -0
  72. package/build/tmp/probe_names.d.ts +1 -0
  73. package/build/tmp/probe_nodata.d.ts +1 -0
  74. package/build/tmp/probe_one.d.ts +1 -0
  75. package/build/tmp/probe_perf.d.ts +1 -0
  76. package/build/tmp/probe_routes.d.ts +1 -0
  77. package/build/tmp/probe_unres.d.ts +1 -0
  78. package/build/tmp/probe_vazquez.d.ts +1 -0
  79. package/build/tsdown.config.d.ts +2 -0
  80. 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 };