@filipebraida/adonis-function-points 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/README.md +103 -2
  3. package/build/calibration-8eV8CEix.js +403 -0
  4. package/build/commands/fp_metrics.d.ts +10 -0
  5. package/build/commands/main.d.ts +6 -5
  6. package/build/commands/main.js +48 -114
  7. package/build/decorate-D6enDn9D.js +24 -0
  8. package/build/fp_calibrate-iFAec0tA.js +25 -0
  9. package/build/fp_count-D21tQ_pv.js +22 -0
  10. package/build/fp_diff-D0pHGMgi.js +25 -0
  11. package/build/fp_explain-BwFs-LW-.js +24 -0
  12. package/build/fp_inventory-Bu6O1Nn0.js +18 -0
  13. package/build/fp_metrics-MGDppfSa.js +20 -0
  14. package/build/index.d.ts +30 -1
  15. package/build/index.js +5 -3
  16. package/build/{pipeline-BzP-ITGN.js → pipeline-CIAydCcT.js} +452 -54
  17. package/build/{resolvers-CU9HKYpn.js → resolvers-CRB6lXoo.js} +474 -207
  18. package/build/{runners-Bt8tbISi.js → runners-CmxNHuuq.js} +146 -342
  19. package/build/src/albrecht/counter.d.ts +21 -1
  20. package/build/src/albrecht/data_functions.d.ts +6 -0
  21. package/build/src/albrecht/diff.d.ts +19 -1
  22. package/build/src/cli/runners.d.ts +12 -0
  23. package/build/src/cli.js +11 -2
  24. package/build/src/define_config.d.ts +35 -1
  25. package/build/src/inventory/detectors/lucid.d.ts +8 -0
  26. package/build/src/inventory/graph/call_graph.d.ts +29 -0
  27. package/build/src/inventory/graph/noise.d.ts +13 -0
  28. package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
  29. package/build/src/inventory/resolvers/index.js +1 -1
  30. package/build/src/inventory/resolvers/types.d.ts +35 -0
  31. package/build/src/inventory/sources/event_bindings.d.ts +44 -0
  32. package/build/src/metrics/structure.d.ts +16 -2
  33. package/build/src/pipeline.js +1 -1
  34. package/build/src/reporters/table.d.ts +11 -1
  35. package/build/src/types.d.ts +17 -0
  36. package/build/stubs/config.stub +27 -1
  37. package/package.json +2 -1
  38. package/build/define_config-DOqWyPwV.js +0 -19
  39. package/build/scripts/smoke_package.d.ts +0 -1
  40. package/build/tmp/probe.d.ts +0 -1
  41. package/build/tmp/probe_cli.d.ts +0 -1
  42. package/build/tmp/probe_cmp.d.ts +0 -1
  43. package/build/tmp/probe_count.d.ts +0 -1
  44. package/build/tmp/probe_data.d.ts +0 -1
  45. package/build/tmp/probe_diff.d.ts +0 -1
  46. package/build/tmp/probe_gap.d.ts +0 -1
  47. package/build/tmp/probe_graph.d.ts +0 -1
  48. package/build/tmp/probe_metrics.d.ts +0 -1
  49. package/build/tmp/probe_miss.d.ts +0 -1
  50. package/build/tmp/probe_names.d.ts +0 -1
  51. package/build/tmp/probe_nodata.d.ts +0 -1
  52. package/build/tmp/probe_one.d.ts +0 -1
  53. package/build/tmp/probe_perf.d.ts +0 -1
  54. package/build/tmp/probe_routes.d.ts +0 -1
  55. package/build/tmp/probe_unres.d.ts +0 -1
  56. package/build/tmp/probe_vazquez.d.ts +0 -1
  57. package/build/tsdown.config.d.ts +0 -2
@@ -1,34 +1,10 @@
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";
1
+ import { a as rootSymbolOf, c as toPosix, i as hooksFiredBy, n as resolveCall, o as collectEventBindings, r as detectAccess, s as samePath, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-CRB6lXoo.js";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
4
+ import { Node, Project, SyntaxKind } from "ts-morph";
5
5
  import { createHash } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
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
8
  //#region src/inventory/app_context.ts
33
9
  /** folders naming an artefact TYPE, in either layout */
34
10
  const ARTIFACT_KINDS = new Set([
@@ -980,7 +956,21 @@ function collectJsonSchemas(app) {
980
956
  skipFileDependencyResolution: true,
981
957
  compilerOptions: { allowJs: false }
982
958
  });
983
- for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
959
+ /**
960
+ * Wider than `scanRoots`, and only here.
961
+ *
962
+ * `database/` is excluded from the application roots on purpose: test factories
963
+ * and migrations contain real persistence calls, and scanning them would turn a
964
+ * test write into a counted function. But `node ace make:seeder` puts seeders in
965
+ * `database/seeders`, which is where seed data — and therefore a form's schema —
966
+ * normally lives. Reading a literal counts nothing, so there is no conflict: the
967
+ * exclusion protects the call graph, not the schema catalogue.
968
+ *
969
+ * Without this, `overrides.detFromSchema` naming a schema declared in a seeder
970
+ * reported "not declared anywhere in the code" and left the DET count at the
971
+ * floor — the exact case the override exists for.
972
+ */
973
+ for (const root of [...app.scanRoots.map(toPosix), `${toPosix(app.root)}/database`]) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
984
974
  const found = /* @__PURE__ */ new Map();
985
975
  for (const file of project.getSourceFiles()) for (const declaration of file.getVariableDeclarations()) {
986
976
  const literal = unwrap(declaration.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
@@ -1097,6 +1087,7 @@ const NEVER_DATA_METHODS = new Set([
1097
1087
  "padEnd",
1098
1088
  "toISO",
1099
1089
  "toISODate",
1090
+ "toISOString",
1100
1091
  "toFormat",
1101
1092
  "toUTC",
1102
1093
  "toSQL",
@@ -1116,6 +1107,27 @@ const NEVER_DATA_METHODS = new Set([
1116
1107
  "primitive"
1117
1108
  ]);
1118
1109
  /**
1110
+ * Array iteration, which is noise ONLY when a callback is passed.
1111
+ *
1112
+ * This is the one place a method name is allowed to matter, and it is guarded:
1113
+ * `repo.find(id)` is a data access while `rows.find((r) => r.id === id)` is a
1114
+ * predicate over a list already in memory. The callback is what separates them,
1115
+ * so the list alone decides nothing — `some`, `every` and `find` stay safe.
1116
+ */
1117
+ const ITERATION_METHODS = new Set([
1118
+ "map",
1119
+ "filter",
1120
+ "find",
1121
+ "findIndex",
1122
+ "findLast",
1123
+ "some",
1124
+ "every",
1125
+ "forEach",
1126
+ "flatMap",
1127
+ "reduce",
1128
+ "sort"
1129
+ ]);
1130
+ /**
1119
1131
  * AdonisJS services, which reach the tracer through an application alias.
1120
1132
  *
1121
1133
  * `env` is imported from `#start/env`, an application module, so the symbol
@@ -1124,6 +1136,8 @@ const NEVER_DATA_METHODS = new Set([
1124
1136
  */
1125
1137
  const FRAMEWORK_SERVICES = new Set([
1126
1138
  "env",
1139
+ "redis",
1140
+ "limiter",
1127
1141
  "logger",
1128
1142
  "health",
1129
1143
  "hash",
@@ -1141,6 +1155,7 @@ function isNoise(call, owner) {
1141
1155
  const expression = call.getExpression();
1142
1156
  if (!Node.isPropertyAccessExpression(expression)) return false;
1143
1157
  if (NEVER_DATA_METHODS.has(expression.getName())) return true;
1158
+ if (isIteration(expression.getName(), call)) return true;
1144
1159
  const receiver = expression.getExpression();
1145
1160
  if (Node.isIdentifier(receiver) && FRAMEWORK_SERVICES.has(receiver.getText())) return true;
1146
1161
  /**
@@ -1151,6 +1166,29 @@ function isNoise(call, owner) {
1151
1166
  if (Node.isPropertyAccessExpression(receiver) && Node.isThisExpression(receiver.getExpression()) && FRAMEWORK_SERVICES.has(receiver.getName())) return true;
1152
1167
  return isNativeReceiver(receiver, owner);
1153
1168
  }
1169
+ /**
1170
+ * Iteration over a list, checked BEFORE the resolvers run.
1171
+ *
1172
+ * `PAPEIS_CONCEDIVEIS.map((name) => …)` is `Identifier.method(args)`, the shape
1173
+ * `static-service` exists for, so the resolver claimed it, resolved the enum
1174
+ * module, found no `map` in it and reported a gap — noise never got asked,
1175
+ * because it is only consulted once every resolver has declined.
1176
+ *
1177
+ * No resolver's pattern is `X.map(callback)`, so refusing this shape up front
1178
+ * costs nothing and is not the same as silencing an unresolved call: nothing
1179
+ * was ever there to resolve.
1180
+ */
1181
+ function isIterationCall(call) {
1182
+ const expression = call.getExpression();
1183
+ if (!Node.isPropertyAccessExpression(expression)) return false;
1184
+ return isIteration(expression.getName(), call);
1185
+ }
1186
+ /** An iteration method whose first argument is an inline callback. */
1187
+ function isIteration(method, call) {
1188
+ if (!ITERATION_METHODS.has(method)) return false;
1189
+ const first = call.getArguments()[0];
1190
+ return first !== void 0 && (Node.isArrowFunction(first) || Node.isFunctionExpression(first));
1191
+ }
1154
1192
  /** Does the receiver resolve to a built-in, by its declaration? */
1155
1193
  function isNativeReceiver(receiver, owner) {
1156
1194
  if (Node.isArrayLiteralExpression(receiver) || Node.isStringLiteral(receiver)) return true;
@@ -1200,6 +1238,7 @@ function isNoiseMember(file, member) {
1200
1238
  */
1201
1239
  function validatorFieldsIn(body, file, app) {
1202
1240
  const fields = [];
1241
+ const opaque = [];
1203
1242
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1204
1243
  const expression = call.getExpression();
1205
1244
  if (!Node.isPropertyAccessExpression(expression)) continue;
@@ -1209,9 +1248,88 @@ function validatorFieldsIn(body, file, app) {
1209
1248
  const name = argument.getText();
1210
1249
  const declaration = findValidator(name, file, app);
1211
1250
  if (!declaration) continue;
1212
- for (const leaf of leavesOf(declaration)) fields.push(`${name}.${leaf}`);
1251
+ const { leaves, opaque: unreadable } = leavesOf(declaration);
1252
+ const isOpaque = new Set(unreadable);
1253
+ for (const leaf of leaves) {
1254
+ const field = `${name}.${leaf}`;
1255
+ fields.push(field);
1256
+ if (isOpaque.has(leaf)) opaque.push(field);
1257
+ }
1213
1258
  }
1214
- return fields;
1259
+ return {
1260
+ fields,
1261
+ opaque
1262
+ };
1263
+ }
1264
+ /**
1265
+ * Fields read straight off the request, with no validator in between.
1266
+ *
1267
+ * `request.input('title')` is a user-recognisable field crossing the boundary —
1268
+ * §7.2's definition of a DET — and it was worth nothing, because input DETs came
1269
+ * only from VineJS. A transaction that reads six fields this way landed at 1 DET
1270
+ * and therefore at the floor of its complexity band.
1271
+ *
1272
+ * On four production applications about half the submitting transactions have no
1273
+ * validator, so this was not an edge case: it was a systematic undercount, and a
1274
+ * silent one.
1275
+ *
1276
+ * `all()`, `body()`, `except()` and `qs()` enumerate nothing — they read whatever
1277
+ * arrives. Those are the honest blind spot, reported rather than guessed, which
1278
+ * is why they come back as a flag and not as a field.
1279
+ */
1280
+ const ENUMERATES_FIELDS = new Set(["input", "only"]);
1281
+ const READS_OPAQUELY = new Set([
1282
+ "all",
1283
+ "body",
1284
+ "except",
1285
+ "qs"
1286
+ ]);
1287
+ function requestFieldsIn(body) {
1288
+ const fields = /* @__PURE__ */ new Set();
1289
+ let opaque = false;
1290
+ for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1291
+ const expression = call.getExpression();
1292
+ if (!Node.isPropertyAccessExpression(expression)) continue;
1293
+ if (!isRequest(expression.getExpression())) continue;
1294
+ const method = expression.getName();
1295
+ if (READS_OPAQUELY.has(method)) {
1296
+ opaque = true;
1297
+ continue;
1298
+ }
1299
+ if (!ENUMERATES_FIELDS.has(method)) continue;
1300
+ const argument = call.getArguments()[0];
1301
+ if (!argument) continue;
1302
+ /** `request.input('title')` */
1303
+ const single = argument.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
1304
+ if (single) {
1305
+ fields.add(single);
1306
+ continue;
1307
+ }
1308
+ /** `request.only(['title', 'isbn'])` */
1309
+ const list = argument.asKind(SyntaxKind.ArrayLiteralExpression);
1310
+ if (!list) {
1311
+ opaque = true;
1312
+ continue;
1313
+ }
1314
+ for (const element of list.getElements()) {
1315
+ const name = element.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
1316
+ if (name) fields.add(name);
1317
+ else opaque = true;
1318
+ }
1319
+ }
1320
+ return {
1321
+ fields: [...fields],
1322
+ opaque
1323
+ };
1324
+ }
1325
+ /**
1326
+ * `request` as an AdonisJS handler receives it: destructured from the context,
1327
+ * or reached through it. Resolved by shape, not by a name list — `ctx.request`
1328
+ * and `{ request }` are the same object.
1329
+ */
1330
+ function isRequest(receiver) {
1331
+ if (Node.isIdentifier(receiver)) return receiver.getText() === "request";
1332
+ return Node.isPropertyAccessExpression(receiver) && receiver.getName() === "request";
1215
1333
  }
1216
1334
  /** validator declaration: in this file, or imported from the application */
1217
1335
  function findValidator(name, file, app) {
@@ -1226,11 +1344,14 @@ function findValidator(name, file, app) {
1226
1344
  }
1227
1345
  return null;
1228
1346
  }
1229
- /** leaves of a VineJS schema, per the table in §7 */
1230
1347
  function leavesOf(node) {
1231
1348
  const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1232
- if (!object) return [];
1349
+ if (!object) return {
1350
+ leaves: [],
1351
+ opaque: []
1352
+ };
1233
1353
  const leaves = [];
1354
+ const opaque = [];
1234
1355
  const walk = (literal, prefix) => {
1235
1356
  for (const property of literal.getProperties()) {
1236
1357
  if (!Node.isPropertyAssignment(property)) continue;
@@ -1238,14 +1359,36 @@ function leavesOf(node) {
1238
1359
  const text = property.getText();
1239
1360
  const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1240
1361
  if (nested && /vine\.object/.test(text)) {
1241
- walk(nested, prefix ? `${prefix}.${name}` : name);
1362
+ const path = prefix ? `${prefix}.${name}` : name;
1363
+ /**
1364
+ * An object declaring no properties enumerates nothing. It is the field
1365
+ * itself that crosses the boundary, so it counts once — never zero,
1366
+ * which would make it cheaper than a plain string.
1367
+ */
1368
+ if (nested.getProperties().length === 0) {
1369
+ leaves.push(path);
1370
+ opaque.push(path);
1371
+ continue;
1372
+ }
1373
+ walk(nested, path);
1242
1374
  continue;
1243
1375
  }
1244
1376
  leaves.push(prefix ? `${prefix}.${name}` : name);
1245
1377
  }
1246
1378
  };
1247
1379
  walk(object, "");
1248
- return leaves;
1380
+ /**
1381
+ * The whole validator is an open object: nothing is enumerable, and the body
1382
+ * that carries it is measured at the floor. Counted as one, reported as such.
1383
+ */
1384
+ if (leaves.length === 0 && object.getProperties().length === 0) return {
1385
+ leaves: ["*"],
1386
+ opaque: ["*"]
1387
+ };
1388
+ return {
1389
+ leaves,
1390
+ opaque
1391
+ };
1249
1392
  }
1250
1393
  /** file name, to identify the unresolved call without dumping the full path */
1251
1394
  const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
@@ -1259,6 +1402,7 @@ const DEFAULT_MAX_DEPTH = 3;
1259
1402
  * minutes and seconds on an application of a few hundred routes.
1260
1403
  */
1261
1404
  function createAnalyzer(app, stores, options = {}) {
1405
+ const eventBindings = options.eventBindings ?? /* @__PURE__ */ new Map();
1262
1406
  const project = new Project({
1263
1407
  skipAddingFilesFromTsConfig: true,
1264
1408
  skipFileDependencyResolution: true,
@@ -1287,7 +1431,7 @@ function createAnalyzer(app, stores, options = {}) {
1287
1431
  const key = file.getFilePath();
1288
1432
  let cached = importCache.get(key);
1289
1433
  if (!cached) {
1290
- cached = importsOf(file, app);
1434
+ cached = importMapsOf(file, app);
1291
1435
  importCache.set(key, cached);
1292
1436
  }
1293
1437
  return cached;
@@ -1337,7 +1481,7 @@ function createAnalyzer(app, stores, options = {}) {
1337
1481
  if (!file) return null;
1338
1482
  const body = findBody(file, ref);
1339
1483
  if (!body) return null;
1340
- const imports = importsFor(file);
1484
+ const { imports, exportedAs } = importsFor(file);
1341
1485
  /** the class this body belongs to: how `this.something` resolves */
1342
1486
  const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
1343
1487
  const injected = injectedFor(owner, file, app);
@@ -1345,7 +1489,8 @@ function createAnalyzer(app, stores, options = {}) {
1345
1489
  const accesses = [];
1346
1490
  const followUps = [];
1347
1491
  const unresolved = [];
1348
- const validators = validatorFieldsIn(body, file, app);
1492
+ const validator = validatorFieldsIn(body, file, app);
1493
+ const request = requestFieldsIn(body);
1349
1494
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1350
1495
  const access = detectAccess(call, symbols, relationsByStore);
1351
1496
  if (access) {
@@ -1353,9 +1498,14 @@ function createAnalyzer(app, stores, options = {}) {
1353
1498
  store: access.store,
1354
1499
  write: access.mode === "write"
1355
1500
  });
1501
+ /**
1502
+ * A relation reached by `preload`/`load` is read; one written through
1503
+ * `related('files').create(…)` is written. Assuming read either way made
1504
+ * a table maintained only through a relation come out as an EIF.
1505
+ */
1356
1506
  if (access.viaRelation) accesses.push({
1357
1507
  store: access.viaRelation,
1358
- write: false
1508
+ write: access.relationWritten === true
1359
1509
  });
1360
1510
  /**
1361
1511
  * counting-decisions §3: a hook belongs to the transaction that fired
@@ -1369,11 +1519,18 @@ function createAnalyzer(app, stores, options = {}) {
1369
1519
  });
1370
1520
  continue;
1371
1521
  }
1522
+ /**
1523
+ * Asked before the resolvers, unlike the rest of the noise filter: this
1524
+ * shape must not be CLAIMED, not merely not reported.
1525
+ */
1526
+ if (isIterationCall(call)) continue;
1372
1527
  const resolved = resolveCall(call, {
1373
1528
  file,
1374
1529
  depth: 0,
1375
1530
  imports,
1531
+ exportedAs,
1376
1532
  injected,
1533
+ eventBindings,
1377
1534
  dataStoresBySymbol: storesByName,
1378
1535
  resolveSpecifier: app.resolveSpecifier,
1379
1536
  sourceFile
@@ -1396,18 +1553,60 @@ function createAnalyzer(app, stores, options = {}) {
1396
1553
  accesses,
1397
1554
  followUps,
1398
1555
  unresolved,
1399
- validators,
1556
+ validators: validator.fields,
1557
+ opaqueValidators: validator.opaque,
1558
+ requestFields: request.fields,
1559
+ opaqueRequest: request.opaque,
1400
1560
  bodyHash: hashOf(body)
1401
1561
  };
1402
1562
  }
1563
+ /**
1564
+ * Stores written anywhere in the application's own code, reachable from an
1565
+ * entry point or not.
1566
+ *
1567
+ * AFP §6.5.4 decides ILF vs EIF by whether the APPLICATION maintains the
1568
+ * store. The graph only walks from HTTP routes, so a table written solely by a
1569
+ * job or a seeder looked unmaintained and came out as an EIF — data held by
1570
+ * another system. It is not: a job is this application. The misclassification
1571
+ * costs 2 points per store and, worse, says the wrong thing about who owns the
1572
+ * data.
1573
+ *
1574
+ * This is a separate pass because reachability is not the question. Whether a
1575
+ * transaction reaches the store still decides if it is counted at all; this
1576
+ * only decides who maintains it.
1577
+ */
1578
+ const writtenAnywhere = () => {
1579
+ const written = /* @__PURE__ */ new Set();
1580
+ for (const file of project.getSourceFiles()) {
1581
+ const symbols = storeSymbolsFor(file, file, app, storesByName);
1582
+ if (symbols.size === 0) continue;
1583
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1584
+ const access = detectAccess(call, symbols, relationsByStore);
1585
+ if (access?.mode !== "write") continue;
1586
+ written.add(access.store);
1587
+ /**
1588
+ * `distribution.related('files').create(…)` maintains the related table.
1589
+ * Recording only the parent left a table written exclusively that way
1590
+ * looking like somebody else's — reported by a production application as
1591
+ * an EIF it was sure it maintained.
1592
+ */
1593
+ if (access.viaRelation && access.relationWritten) written.add(access.viaRelation);
1594
+ }
1595
+ }
1596
+ return written;
1597
+ };
1403
1598
  return {
1404
1599
  analyze: (handler) => run(handler),
1600
+ writtenAnywhere,
1405
1601
  /** how many files the project loaded — used to prove it does not grow */
1406
1602
  fileCount: () => project.getSourceFiles().length
1407
1603
  };
1408
1604
  function run(handler) {
1409
1605
  const touches = /* @__PURE__ */ new Set();
1410
1606
  const inputFields = /* @__PURE__ */ new Set();
1607
+ const opaqueInputFields = /* @__PURE__ */ new Set();
1608
+ const requestFields = /* @__PURE__ */ new Set();
1609
+ let opaqueRequest = false;
1411
1610
  const trace = [];
1412
1611
  const scope = [];
1413
1612
  const unresolved = [];
@@ -1445,6 +1644,9 @@ function createAnalyzer(app, stores, options = {}) {
1445
1644
  }
1446
1645
  unresolved.push(...facts.unresolved);
1447
1646
  for (const field of facts.validators) inputFields.add(field);
1647
+ for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
1648
+ for (const field of facts.requestFields) requestFields.add(field);
1649
+ if (facts.opaqueRequest) opaqueRequest = true;
1448
1650
  trace.push({
1449
1651
  file: ref.file,
1450
1652
  member: ref.member,
@@ -1469,6 +1671,9 @@ function createAnalyzer(app, stores, options = {}) {
1469
1671
  writes,
1470
1672
  touches: [...touches].sort(),
1471
1673
  inputFields: [...inputFields].sort(),
1674
+ opaqueInputFields: [...opaqueInputFields].sort(),
1675
+ requestFields: [...requestFields].sort(),
1676
+ opaqueRequest,
1472
1677
  trace,
1473
1678
  scope,
1474
1679
  unresolved
@@ -1665,16 +1870,32 @@ function membersOfType(typeNode, file, app) {
1665
1870
  }
1666
1871
  return members;
1667
1872
  }
1668
- function importsOf(file, app) {
1669
- const map = /* @__PURE__ */ new Map();
1873
+ /**
1874
+ * Both maps a file's imports produce: where a local name resolves, and what it
1875
+ * was called where it was exported.
1876
+ *
1877
+ * Exported because the tests need the same answer the pipeline gets: a second
1878
+ * implementation in the helpers drifted from this one and missed aliases.
1879
+ */
1880
+ function importMapsOf(file, app) {
1881
+ const imports = /* @__PURE__ */ new Map();
1882
+ const exportedAs = /* @__PURE__ */ new Map();
1670
1883
  for (const declaration of file.getImportDeclarations()) {
1671
1884
  const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
1672
1885
  if (!target) continue;
1673
1886
  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);
1887
+ if (defaultImport) imports.set(defaultImport, target);
1888
+ for (const named of declaration.getNamedImports()) {
1889
+ const alias = named.getAliasNode()?.getText();
1890
+ const local = alias ?? named.getName();
1891
+ imports.set(local, target);
1892
+ if (alias) exportedAs.set(alias, named.getName());
1893
+ }
1676
1894
  }
1677
- return map;
1895
+ return {
1896
+ imports,
1897
+ exportedAs
1898
+ };
1678
1899
  }
1679
1900
  /**
1680
1901
  * Not every unfollowed call is an unresolved call — but the filter must err on
@@ -1691,12 +1912,27 @@ function isWorthReporting(call, symbols, imports) {
1691
1912
  const expression = call.getExpression();
1692
1913
  if (Node.isIdentifier(expression)) return imports.has(expression.getText());
1693
1914
  if (!Node.isPropertyAccessExpression(expression)) return false;
1915
+ /**
1916
+ * A call ON THE RESULT of another call — `dispatch(job).waitResult()`,
1917
+ * `load(id).unwrap()`. The receiver is a value this body already holds, and
1918
+ * the call that produced it is a call site of this same body: it is visited
1919
+ * too, and reports the gap if there is one. Reporting here as well charges
1920
+ * the same unknown twice, and the second charge reads as a distinct defect.
1921
+ */
1922
+ const receiver = unwrapAwait(expression.getExpression());
1923
+ if (Node.isCallExpression(receiver)) return false;
1694
1924
  const root = rootSymbolOf(expression.getExpression());
1695
1925
  if (!root) return false;
1696
1926
  if (symbols.has(root)) return false;
1697
1927
  if (root === "this") return true;
1698
1928
  return imports.has(root);
1699
1929
  }
1930
+ /** `(await x())` and `x()` are the same receiver for this purpose. */
1931
+ function unwrapAwait(node) {
1932
+ let current = node;
1933
+ while (Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) current = current.getExpression();
1934
+ return current;
1935
+ }
1700
1936
  /**
1701
1937
  * Hash of the NORMALISED body: comments and whitespace removed.
1702
1938
  *
@@ -1787,6 +2023,12 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
1787
2023
  }
1788
2024
  //#endregion
1789
2025
  //#region src/albrecht/data_functions.ts
2026
+ /**
2027
+ * A column whose shape says nothing about what it holds. Marked in the rationale
2028
+ * because `detFromSchema` replaces exactly this placeholder, and because a reader
2029
+ * deserves to know which of the DETs is a floor rather than a count.
2030
+ */
2031
+ const OPAQUE_TYPE$1 = /^(object|any|unknown|Record<|Json|JSON)/;
1790
2032
  function countDataFunctions(stores, usage, options) {
1791
2033
  const counted = [];
1792
2034
  for (const store of stores) {
@@ -1802,7 +2044,15 @@ function countDataFunctions(stores, usage, options) {
1802
2044
  const detAttributes = store.attributes.filter((attribute) => !attribute.isIdentifier);
1803
2045
  const det = detAttributes.length;
1804
2046
  const refs = options.retStrategy === "composition" ? 1 + store.subgroups.length : 1;
1805
- const type = options.externallyMaintained.has(store.name) || !use.written ? "EIF" : "ILF";
2047
+ /**
2048
+ * Maintained by the application, or by another system?
2049
+ *
2050
+ * A write reachable from an entry point is the common case. A write from a
2051
+ * job or a seeder maintains the store just as much — AFP §6.5.4 asks who
2052
+ * maintains it, not which route does.
2053
+ */
2054
+ const maintained = use.written || options.writtenAnywhere.has(store.name);
2055
+ const type = options.externallyMaintained.has(store.name) || !maintained ? "EIF" : "ILF";
1806
2056
  const complexity = complexityOf(type, refs, det, options.tables);
1807
2057
  counted.push({
1808
2058
  id: `data:${store.name}`,
@@ -1814,8 +2064,8 @@ function countDataFunctions(stores, usage, options) {
1814
2064
  complexity,
1815
2065
  points: pointsOf(type, complexity, options.weights),
1816
2066
  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}`),
2067
+ rule: options.externallyMaintained.has(store.name) ? "afp:6.5.4 externally maintained by boundary configuration -> EIF" : maintained ? "afp:6.5.4 maintained by an application transaction -> ILF" : "afp:6.5.4 used but not maintained -> EIF",
2068
+ detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}` + (attribute.type && OPAQUE_TYPE$1.test(attribute.type) ? " (opaque)" : "")),
1819
2069
  refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
1820
2070
  }
1821
2071
  });
@@ -1899,7 +2149,25 @@ function detsFor(entry, behavior, touched, type, options) {
1899
2149
  sources.push(source);
1900
2150
  };
1901
2151
  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}`);
2152
+ /**
2153
+ * `(opaque)` is not decoration: `detFromSchema` replaces the opaque placeholder
2154
+ * with a schema's fields, and it used to assume there was exactly one and that
2155
+ * it was worth 1. An open `vine.object` counted zero, so the subtraction ate a
2156
+ * real field — off by one, in the direction of undercounting.
2157
+ */
2158
+ const opaqueInputs = new Set(behavior.opaqueInputFields);
2159
+ for (const field of behavior.inputFields) add(field.split(".").pop(), `validator:${field}${opaqueInputs.has(field) ? " (opaque)" : ""}`);
2160
+ /**
2161
+ * §7.2 asks whether a user-recognisable field crosses the boundary, not how it
2162
+ * was declared. `request.input('title')` does, and counted for nothing while
2163
+ * input DETs came only from VineJS — so a transaction reading six fields this
2164
+ * way sat at 1 DET, the floor of its band.
2165
+ *
2166
+ * After the validator, and deduplicated by field name: where both exist the
2167
+ * validator is the better provenance to print, and the same field must not be
2168
+ * paid for twice.
2169
+ */
2170
+ for (const field of behavior.requestFields) add(field, `request:${field}`);
1903
2171
  if (type === "EO" || type === "EQ") for (const store of touched) {
1904
2172
  const columns = options.countedStores.get(store).attributes.filter((attribute) => !attribute.isIdentifier);
1905
2173
  for (const column of columns) add(`${store}.${column.name}`, `output:${store}.${column.name}`);
@@ -1952,14 +2220,37 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
1952
2220
  for (const { label, pattern } of patterns) if (pattern.test(table)) return `${label} (AFP §6.5.2.1.3: ${pattern.source})`;
1953
2221
  return null;
1954
2222
  }
2223
+ //#endregion
2224
+ //#region src/albrecht/counter.ts
2225
+ /**
2226
+ * Assembles the count from the inventory.
2227
+ *
2228
+ * The order is not arbitrary: data functions depend on HOW transactions use
2229
+ * each store (AFP §6.5.4), and transactional functions depend on which stores
2230
+ * ended up counted. Hence: usage first, then the technical filter, then data,
2231
+ * then transactions.
2232
+ */
2233
+ const RULESET = "afp";
1955
2234
  /**
1956
2235
  * Version of the rule set.
1957
2236
  *
1958
2237
  * It appears in every report, and `fp:diff` refuses to compare counts produced
1959
2238
  * by different versions — otherwise the difference would measure the rule
1960
2239
  * change rather than the work.
2240
+ *
2241
+ * It must be bumped by ANY change that moves the number for unchanged code, and
2242
+ * that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
2243
+ * across the whole project rather than from routes alone, a job followed into
2244
+ * `process`, an event followed into its listeners, and `request.input(…)` counted
2245
+ * as a DET — and three more in 1.2.0: an open input object counting 1 instead of 0,
2246
+ * `detFromSchema` no longer subtracting a placeholder that was not there, and a
2247
+ * write through `related(…)` maintaining the related table.
2248
+ *
2249
+ * Without the bump, a baseline saved by the previous version compares cleanly
2250
+ * against this one and bills the tool's own improvement as work done. The guard
2251
+ * exists for exactly that, and only this constant arms it.
1961
2252
  */
1962
- const RULESET_VERSION = "1.0.0";
2253
+ const RULESET_VERSION = "1.2.0";
1963
2254
  function count(input, options = {}) {
1964
2255
  const warnings = [];
1965
2256
  const usage = usageOf(input);
@@ -1972,16 +2263,30 @@ function count(input, options = {}) {
1972
2263
  ...options.weights
1973
2264
  };
1974
2265
  const infrastructure = new Set(options.boundary?.infrastructure ?? []);
2266
+ const business = new Set(options.boundary?.business ?? []);
1975
2267
  const countable = input.stores.filter((store) => {
1976
2268
  if (infrastructure.has(store.name) || infrastructure.has(store.table ?? "")) {
1977
2269
  warnings.push(`excluded by boundary configuration: ${store.name}`);
1978
2270
  return false;
1979
2271
  }
1980
2272
  const technical = isTechnical(store);
1981
- if (technical) warnings.push(`technical, excluded: ${store.name} (${technical})`);
1982
- return !technical;
2273
+ if (!technical) return true;
2274
+ /**
2275
+ * The naming filter is a heuristic over names, so it catches business data
2276
+ * whose name happens to match — a chat session the user manages, a document
2277
+ * template they maintain. Only a person knows which, so a declaration wins
2278
+ * over the pattern, and the report says it was overruled rather than
2279
+ * quietly counting one more store.
2280
+ */
2281
+ if (business.has(store.name) || business.has(store.table ?? "")) {
2282
+ warnings.push(`kept by boundary configuration: ${store.name} — the AFP naming filter had excluded it (${technical})`);
2283
+ return true;
2284
+ }
2285
+ warnings.push(`technical, excluded: ${store.name} (${technical})`);
2286
+ return false;
1983
2287
  });
1984
2288
  const dataFunctions = countDataFunctions(countable, usage, {
2289
+ writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
1985
2290
  retStrategy: options.retStrategy ?? "constant",
1986
2291
  externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
1987
2292
  tables,
@@ -1996,6 +2301,8 @@ function count(input, options = {}) {
1996
2301
  weights
1997
2302
  });
1998
2303
  warnings.push(...opaqueColumnWarnings(countable, input));
2304
+ warnings.push(...unreadableInputWarnings(input));
2305
+ warnings.push(...openValidatorWarnings(input));
1999
2306
  const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2000
2307
  return {
2001
2308
  ruleset: "afp",
@@ -2033,6 +2340,68 @@ function opaqueColumnWarnings(stores, input) {
2033
2340
  if (found.length === 0) return [];
2034
2341
  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
2342
  }
2343
+ /**
2344
+ * Input fields declared as an open object: `vine.object({}).allowUnknownProperties()`.
2345
+ *
2346
+ * The same blind spot as an opaque column and, until now, reported nowhere — the
2347
+ * opaque-column warning names stores, and this one is on the transaction side, so
2348
+ * a route whose whole form arrives through one of these was invisible in the
2349
+ * confidence block. On a production application that was the route that saves the
2350
+ * main document, and it was the reason its EI never looked wrong.
2351
+ *
2352
+ * It counts 1 DET, which is a floor. When the fields are declared in the code
2353
+ * somewhere — a seeder, a schema module — `detFromSchema` replaces the floor with
2354
+ * the real count, and §8 of counting-decisions says how.
2355
+ */
2356
+ function openValidatorWarnings(input) {
2357
+ const found = [];
2358
+ for (const entry of input.entryPoints) {
2359
+ const fields = input.behaviors.get(entry.id)?.opaqueInputFields ?? [];
2360
+ for (const field of fields) found.push(` ${entry.trigger} ${entry.signature} — ${field}`);
2361
+ }
2362
+ if (found.length === 0) return [];
2363
+ return [
2364
+ `${found.length} open input object(s), each counted as 1 DET. The fields the user fills are data, not code, so this is a FLOOR: if they are declared anywhere in the source, name that schema with \`overrides.detFromSchema\` — counting-decisions §8:`,
2365
+ ...found.slice(0, 10),
2366
+ ...found.length > 10 ? [` … and ${found.length - 10} more`] : []
2367
+ ];
2368
+ }
2369
+ /**
2370
+ * Transactions that read the request in a way that enumerates nothing.
2371
+ *
2372
+ * `request.all()`, `request.body()`, `request.except([…])` — whatever arrives is
2373
+ * read, and no analysis can say how many fields that is. The transaction is
2374
+ * counted from its route parameters alone, which puts it at the floor of its
2375
+ * complexity band: an undercount, and a silent one until now.
2376
+ *
2377
+ * Two earlier versions of this warning were wrong and are worth recording,
2378
+ * because both looked like rigour. The first flagged every write with no
2379
+ * validator and named `users.destroy`, `DELETE /questions/:id` and
2380
+ * `notifications.markRead` — transactions that legitimately carry nothing beyond
2381
+ * the route parameter, exactly as counting-decisions §7 describes. The second
2382
+ * narrowed to POST, PUT and PATCH, and still named `POST /orders/:id/submit` and
2383
+ * `POST /orders/:id/clear`: in an AdonisJS application POST is how a state
2384
+ * transition is expressed, so the verb does not separate a submission from a
2385
+ * trigger.
2386
+ *
2387
+ * What separates them is whether the handler reads the request at all. A trigger
2388
+ * does not. So the enumerable reads are now COUNTED — `request.input('title')` is
2389
+ * a DET — and only what cannot be enumerated is reported. A warning that names
2390
+ * routes with nothing wrong with them is the noise that teaches people to stop
2391
+ * reading the confidence block.
2392
+ */
2393
+ function unreadableInputWarnings(input) {
2394
+ const blind = input.entryPoints.map((entry) => ({
2395
+ entry,
2396
+ behavior: input.behaviors.get(entry.id)
2397
+ })).filter(({ behavior }) => behavior?.opaqueRequest && behavior.inputFields.length === 0);
2398
+ if (blind.length === 0) return [];
2399
+ return [
2400
+ `${blind.length} transaction(s) read the request without enumerating fields (\`all()\`, \`body()\`, \`except()\`), so their input DETs could not be counted and each sits at the floor of its band. This UNDERSTATES the total — the fix is a validator, not a configuration:`,
2401
+ ...blind.slice(0, 10).map(({ entry }) => ` ${entry.trigger} ${entry.signature}`),
2402
+ ...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
2403
+ ];
2404
+ }
2036
2405
  /** a column whose shape says nothing about what it holds */
2037
2406
  const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2038
2407
  /**
@@ -2071,8 +2440,20 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2071
2440
  if (override.detFromSchema) {
2072
2441
  const schema = schemas.get(override.detFromSchema);
2073
2442
  if (schema) {
2074
- det = Math.max(fn.det - 1, 0) + schema.fields;
2443
+ /**
2444
+ * Replaces the opaque placeholder — the one the rationale marks — rather
2445
+ * than assuming there is one and that it is worth 1.
2446
+ *
2447
+ * That assumption was wrong twice over. An open `vine.object` counted
2448
+ * ZERO, not 1, so subtracting 1 removed a field the analysis had read
2449
+ * correctly: 86 DETs where 87 was right. And a function with no opaque
2450
+ * DET at all was silently charged the subtraction too.
2451
+ */
2452
+ const placeholders = fn.rationale.detSources.filter((source) => source.endsWith("(opaque)"));
2453
+ det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
2075
2454
  by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
2455
+ if (placeholders.length === 0) warnings.push(`override for "${fn.name}" names schema "${schema.name}", but this function has no opaque DET for it to stand in for: the ${schema.fields} fields were ADDED to the ${fn.det} already counted. Check the override is on the right function.`);
2456
+ else if (placeholders.length > 1) warnings.push(`override for "${fn.name}" names one schema and the function has ${placeholders.length} opaque DETs (${placeholders.join(", ")}). Only one was replaced; the others still count 1 each.`);
2076
2457
  } 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
2458
  }
2078
2459
  const refs = override.refs ?? fn.refs;
@@ -2248,7 +2629,8 @@ async function analyze(root, options = {}) {
2248
2629
  const jsonSchemas = collectJsonSchemas(app);
2249
2630
  const analyzer = createAnalyzer(app, stores, {
2250
2631
  maxDepth: options.maxDepth,
2251
- callResolvers: options.resolvers?.call
2632
+ callResolvers: options.resolvers?.call,
2633
+ eventBindings: collectEventBindings(app)
2252
2634
  });
2253
2635
  const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
2254
2636
  const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
@@ -2275,6 +2657,21 @@ async function analyze(root, options = {}) {
2275
2657
  by: "validator"
2276
2658
  }
2277
2659
  })),
2660
+ opaqueInputFields: behavior.opaqueInputFields.map((name) => ({
2661
+ name,
2662
+ provenance: {
2663
+ file: app.root,
2664
+ by: "validator"
2665
+ }
2666
+ })),
2667
+ requestFields: behavior.requestFields.map((name) => ({
2668
+ name,
2669
+ provenance: {
2670
+ file: app.root,
2671
+ by: "request"
2672
+ }
2673
+ })),
2674
+ opaqueRequest: behavior.opaqueRequest,
2278
2675
  outputFields: [],
2279
2676
  trace: behavior.trace,
2280
2677
  unresolved: behavior.unresolved
@@ -2296,11 +2693,12 @@ async function analyze(root, options = {}) {
2296
2693
  stores,
2297
2694
  entryPoints,
2298
2695
  behaviors,
2299
- jsonSchemas
2696
+ jsonSchemas,
2697
+ writtenAnywhere: analyzer.writtenAnywhere()
2300
2698
  }, options),
2301
2699
  source: describeSource(root, options.configFile ?? null)
2302
2700
  }
2303
2701
  };
2304
2702
  }
2305
2703
  //#endregion
2306
- export { analyze as n, toPosix as r, CoverageTooLowError as t };
2704
+ export { RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };