@filipebraida/adonis-function-points 0.1.0 → 0.2.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 (54) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +103 -2
  3. package/build/commands/fp_metrics.d.ts +10 -0
  4. package/build/commands/main.d.ts +6 -5
  5. package/build/commands/main.js +48 -114
  6. package/build/decorate-D6enDn9D.js +24 -0
  7. package/build/fp_calibrate-Cm079xWL.js +25 -0
  8. package/build/fp_count-CfcXPuj5.js +22 -0
  9. package/build/fp_diff-DE_t3twv.js +25 -0
  10. package/build/fp_explain-MKyEoi0h.js +24 -0
  11. package/build/fp_inventory-DSrCVhEy.js +18 -0
  12. package/build/fp_metrics-BUWj9dLw.js +20 -0
  13. package/build/index.d.ts +2 -1
  14. package/build/index.js +3 -2
  15. package/build/{pipeline-BzP-ITGN.js → pipeline-DySlMWcN.js} +298 -40
  16. package/build/{resolvers-CU9HKYpn.js → resolvers-MFjRl2ef.js} +225 -5
  17. package/build/{runners-Bt8tbISi.js → runners-DpMd-yZM.js} +252 -64
  18. package/build/src/albrecht/counter.d.ts +17 -1
  19. package/build/src/albrecht/data_functions.d.ts +6 -0
  20. package/build/src/albrecht/diff.d.ts +19 -1
  21. package/build/src/cli/runners.d.ts +12 -0
  22. package/build/src/cli.js +11 -2
  23. package/build/src/define_config.d.ts +35 -1
  24. package/build/src/inventory/graph/call_graph.d.ts +27 -0
  25. package/build/src/inventory/graph/noise.d.ts +13 -0
  26. package/build/src/inventory/resolvers/event_dispatch.d.ts +17 -0
  27. package/build/src/inventory/resolvers/index.js +1 -1
  28. package/build/src/inventory/resolvers/types.d.ts +35 -0
  29. package/build/src/inventory/sources/event_bindings.d.ts +44 -0
  30. package/build/src/metrics/structure.d.ts +16 -2
  31. package/build/src/pipeline.js +1 -1
  32. package/build/src/reporters/table.d.ts +11 -1
  33. package/build/src/types.d.ts +10 -0
  34. package/build/stubs/config.stub +27 -1
  35. package/package.json +2 -1
  36. package/build/scripts/smoke_package.d.ts +0 -1
  37. package/build/tmp/probe.d.ts +0 -1
  38. package/build/tmp/probe_cli.d.ts +0 -1
  39. package/build/tmp/probe_cmp.d.ts +0 -1
  40. package/build/tmp/probe_count.d.ts +0 -1
  41. package/build/tmp/probe_data.d.ts +0 -1
  42. package/build/tmp/probe_diff.d.ts +0 -1
  43. package/build/tmp/probe_gap.d.ts +0 -1
  44. package/build/tmp/probe_graph.d.ts +0 -1
  45. package/build/tmp/probe_metrics.d.ts +0 -1
  46. package/build/tmp/probe_miss.d.ts +0 -1
  47. package/build/tmp/probe_names.d.ts +0 -1
  48. package/build/tmp/probe_nodata.d.ts +0 -1
  49. package/build/tmp/probe_one.d.ts +0 -1
  50. package/build/tmp/probe_perf.d.ts +0 -1
  51. package/build/tmp/probe_routes.d.ts +0 -1
  52. package/build/tmp/probe_unres.d.ts +0 -1
  53. package/build/tmp/probe_vazquez.d.ts +0 -1
  54. 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";
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-MFjRl2ef.js";
2
2
  import { Node, Project, SyntaxKind } from "ts-morph";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
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([
@@ -1097,6 +1073,7 @@ const NEVER_DATA_METHODS = new Set([
1097
1073
  "padEnd",
1098
1074
  "toISO",
1099
1075
  "toISODate",
1076
+ "toISOString",
1100
1077
  "toFormat",
1101
1078
  "toUTC",
1102
1079
  "toSQL",
@@ -1116,6 +1093,27 @@ const NEVER_DATA_METHODS = new Set([
1116
1093
  "primitive"
1117
1094
  ]);
1118
1095
  /**
1096
+ * Array iteration, which is noise ONLY when a callback is passed.
1097
+ *
1098
+ * This is the one place a method name is allowed to matter, and it is guarded:
1099
+ * `repo.find(id)` is a data access while `rows.find((r) => r.id === id)` is a
1100
+ * predicate over a list already in memory. The callback is what separates them,
1101
+ * so the list alone decides nothing — `some`, `every` and `find` stay safe.
1102
+ */
1103
+ const ITERATION_METHODS = new Set([
1104
+ "map",
1105
+ "filter",
1106
+ "find",
1107
+ "findIndex",
1108
+ "findLast",
1109
+ "some",
1110
+ "every",
1111
+ "forEach",
1112
+ "flatMap",
1113
+ "reduce",
1114
+ "sort"
1115
+ ]);
1116
+ /**
1119
1117
  * AdonisJS services, which reach the tracer through an application alias.
1120
1118
  *
1121
1119
  * `env` is imported from `#start/env`, an application module, so the symbol
@@ -1124,6 +1122,8 @@ const NEVER_DATA_METHODS = new Set([
1124
1122
  */
1125
1123
  const FRAMEWORK_SERVICES = new Set([
1126
1124
  "env",
1125
+ "redis",
1126
+ "limiter",
1127
1127
  "logger",
1128
1128
  "health",
1129
1129
  "hash",
@@ -1141,6 +1141,7 @@ function isNoise(call, owner) {
1141
1141
  const expression = call.getExpression();
1142
1142
  if (!Node.isPropertyAccessExpression(expression)) return false;
1143
1143
  if (NEVER_DATA_METHODS.has(expression.getName())) return true;
1144
+ if (isIteration(expression.getName(), call)) return true;
1144
1145
  const receiver = expression.getExpression();
1145
1146
  if (Node.isIdentifier(receiver) && FRAMEWORK_SERVICES.has(receiver.getText())) return true;
1146
1147
  /**
@@ -1151,6 +1152,29 @@ function isNoise(call, owner) {
1151
1152
  if (Node.isPropertyAccessExpression(receiver) && Node.isThisExpression(receiver.getExpression()) && FRAMEWORK_SERVICES.has(receiver.getName())) return true;
1152
1153
  return isNativeReceiver(receiver, owner);
1153
1154
  }
1155
+ /**
1156
+ * Iteration over a list, checked BEFORE the resolvers run.
1157
+ *
1158
+ * `PAPEIS_CONCEDIVEIS.map((name) => …)` is `Identifier.method(args)`, the shape
1159
+ * `static-service` exists for, so the resolver claimed it, resolved the enum
1160
+ * module, found no `map` in it and reported a gap — noise never got asked,
1161
+ * because it is only consulted once every resolver has declined.
1162
+ *
1163
+ * No resolver's pattern is `X.map(callback)`, so refusing this shape up front
1164
+ * costs nothing and is not the same as silencing an unresolved call: nothing
1165
+ * was ever there to resolve.
1166
+ */
1167
+ function isIterationCall(call) {
1168
+ const expression = call.getExpression();
1169
+ if (!Node.isPropertyAccessExpression(expression)) return false;
1170
+ return isIteration(expression.getName(), call);
1171
+ }
1172
+ /** An iteration method whose first argument is an inline callback. */
1173
+ function isIteration(method, call) {
1174
+ if (!ITERATION_METHODS.has(method)) return false;
1175
+ const first = call.getArguments()[0];
1176
+ return first !== void 0 && (Node.isArrowFunction(first) || Node.isFunctionExpression(first));
1177
+ }
1154
1178
  /** Does the receiver resolve to a built-in, by its declaration? */
1155
1179
  function isNativeReceiver(receiver, owner) {
1156
1180
  if (Node.isArrayLiteralExpression(receiver) || Node.isStringLiteral(receiver)) return true;
@@ -1213,6 +1237,76 @@ function validatorFieldsIn(body, file, app) {
1213
1237
  }
1214
1238
  return fields;
1215
1239
  }
1240
+ /**
1241
+ * Fields read straight off the request, with no validator in between.
1242
+ *
1243
+ * `request.input('title')` is a user-recognisable field crossing the boundary —
1244
+ * §7.2's definition of a DET — and it was worth nothing, because input DETs came
1245
+ * only from VineJS. A transaction that reads six fields this way landed at 1 DET
1246
+ * and therefore at the floor of its complexity band.
1247
+ *
1248
+ * On four production applications about half the submitting transactions have no
1249
+ * validator, so this was not an edge case: it was a systematic undercount, and a
1250
+ * silent one.
1251
+ *
1252
+ * `all()`, `body()`, `except()` and `qs()` enumerate nothing — they read whatever
1253
+ * arrives. Those are the honest blind spot, reported rather than guessed, which
1254
+ * is why they come back as a flag and not as a field.
1255
+ */
1256
+ const ENUMERATES_FIELDS = new Set(["input", "only"]);
1257
+ const READS_OPAQUELY = new Set([
1258
+ "all",
1259
+ "body",
1260
+ "except",
1261
+ "qs"
1262
+ ]);
1263
+ function requestFieldsIn(body) {
1264
+ const fields = /* @__PURE__ */ new Set();
1265
+ let opaque = false;
1266
+ for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1267
+ const expression = call.getExpression();
1268
+ if (!Node.isPropertyAccessExpression(expression)) continue;
1269
+ if (!isRequest(expression.getExpression())) continue;
1270
+ const method = expression.getName();
1271
+ if (READS_OPAQUELY.has(method)) {
1272
+ opaque = true;
1273
+ continue;
1274
+ }
1275
+ if (!ENUMERATES_FIELDS.has(method)) continue;
1276
+ const argument = call.getArguments()[0];
1277
+ if (!argument) continue;
1278
+ /** `request.input('title')` */
1279
+ const single = argument.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
1280
+ if (single) {
1281
+ fields.add(single);
1282
+ continue;
1283
+ }
1284
+ /** `request.only(['title', 'isbn'])` */
1285
+ const list = argument.asKind(SyntaxKind.ArrayLiteralExpression);
1286
+ if (!list) {
1287
+ opaque = true;
1288
+ continue;
1289
+ }
1290
+ for (const element of list.getElements()) {
1291
+ const name = element.asKind(SyntaxKind.StringLiteral)?.getLiteralValue();
1292
+ if (name) fields.add(name);
1293
+ else opaque = true;
1294
+ }
1295
+ }
1296
+ return {
1297
+ fields: [...fields],
1298
+ opaque
1299
+ };
1300
+ }
1301
+ /**
1302
+ * `request` as an AdonisJS handler receives it: destructured from the context,
1303
+ * or reached through it. Resolved by shape, not by a name list — `ctx.request`
1304
+ * and `{ request }` are the same object.
1305
+ */
1306
+ function isRequest(receiver) {
1307
+ if (Node.isIdentifier(receiver)) return receiver.getText() === "request";
1308
+ return Node.isPropertyAccessExpression(receiver) && receiver.getName() === "request";
1309
+ }
1216
1310
  /** validator declaration: in this file, or imported from the application */
1217
1311
  function findValidator(name, file, app) {
1218
1312
  const local = file.getVariableDeclaration(name)?.getInitializer();
@@ -1259,6 +1353,7 @@ const DEFAULT_MAX_DEPTH = 3;
1259
1353
  * minutes and seconds on an application of a few hundred routes.
1260
1354
  */
1261
1355
  function createAnalyzer(app, stores, options = {}) {
1356
+ const eventBindings = options.eventBindings ?? /* @__PURE__ */ new Map();
1262
1357
  const project = new Project({
1263
1358
  skipAddingFilesFromTsConfig: true,
1264
1359
  skipFileDependencyResolution: true,
@@ -1287,7 +1382,7 @@ function createAnalyzer(app, stores, options = {}) {
1287
1382
  const key = file.getFilePath();
1288
1383
  let cached = importCache.get(key);
1289
1384
  if (!cached) {
1290
- cached = importsOf(file, app);
1385
+ cached = importMapsOf(file, app);
1291
1386
  importCache.set(key, cached);
1292
1387
  }
1293
1388
  return cached;
@@ -1337,7 +1432,7 @@ function createAnalyzer(app, stores, options = {}) {
1337
1432
  if (!file) return null;
1338
1433
  const body = findBody(file, ref);
1339
1434
  if (!body) return null;
1340
- const imports = importsFor(file);
1435
+ const { imports, exportedAs } = importsFor(file);
1341
1436
  /** the class this body belongs to: how `this.something` resolves */
1342
1437
  const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
1343
1438
  const injected = injectedFor(owner, file, app);
@@ -1346,6 +1441,7 @@ function createAnalyzer(app, stores, options = {}) {
1346
1441
  const followUps = [];
1347
1442
  const unresolved = [];
1348
1443
  const validators = validatorFieldsIn(body, file, app);
1444
+ const request = requestFieldsIn(body);
1349
1445
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1350
1446
  const access = detectAccess(call, symbols, relationsByStore);
1351
1447
  if (access) {
@@ -1369,11 +1465,18 @@ function createAnalyzer(app, stores, options = {}) {
1369
1465
  });
1370
1466
  continue;
1371
1467
  }
1468
+ /**
1469
+ * Asked before the resolvers, unlike the rest of the noise filter: this
1470
+ * shape must not be CLAIMED, not merely not reported.
1471
+ */
1472
+ if (isIterationCall(call)) continue;
1372
1473
  const resolved = resolveCall(call, {
1373
1474
  file,
1374
1475
  depth: 0,
1375
1476
  imports,
1477
+ exportedAs,
1376
1478
  injected,
1479
+ eventBindings,
1377
1480
  dataStoresBySymbol: storesByName,
1378
1481
  resolveSpecifier: app.resolveSpecifier,
1379
1482
  sourceFile
@@ -1397,17 +1500,49 @@ function createAnalyzer(app, stores, options = {}) {
1397
1500
  followUps,
1398
1501
  unresolved,
1399
1502
  validators,
1503
+ requestFields: request.fields,
1504
+ opaqueRequest: request.opaque,
1400
1505
  bodyHash: hashOf(body)
1401
1506
  };
1402
1507
  }
1508
+ /**
1509
+ * Stores written anywhere in the application's own code, reachable from an
1510
+ * entry point or not.
1511
+ *
1512
+ * AFP §6.5.4 decides ILF vs EIF by whether the APPLICATION maintains the
1513
+ * store. The graph only walks from HTTP routes, so a table written solely by a
1514
+ * job or a seeder looked unmaintained and came out as an EIF — data held by
1515
+ * another system. It is not: a job is this application. The misclassification
1516
+ * costs 2 points per store and, worse, says the wrong thing about who owns the
1517
+ * data.
1518
+ *
1519
+ * This is a separate pass because reachability is not the question. Whether a
1520
+ * transaction reaches the store still decides if it is counted at all; this
1521
+ * only decides who maintains it.
1522
+ */
1523
+ const writtenAnywhere = () => {
1524
+ const written = /* @__PURE__ */ new Set();
1525
+ for (const file of project.getSourceFiles()) {
1526
+ const symbols = storeSymbolsFor(file, file, app, storesByName);
1527
+ if (symbols.size === 0) continue;
1528
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1529
+ const access = detectAccess(call, symbols, relationsByStore);
1530
+ if (access?.mode === "write") written.add(access.store);
1531
+ }
1532
+ }
1533
+ return written;
1534
+ };
1403
1535
  return {
1404
1536
  analyze: (handler) => run(handler),
1537
+ writtenAnywhere,
1405
1538
  /** how many files the project loaded — used to prove it does not grow */
1406
1539
  fileCount: () => project.getSourceFiles().length
1407
1540
  };
1408
1541
  function run(handler) {
1409
1542
  const touches = /* @__PURE__ */ new Set();
1410
1543
  const inputFields = /* @__PURE__ */ new Set();
1544
+ const requestFields = /* @__PURE__ */ new Set();
1545
+ let opaqueRequest = false;
1411
1546
  const trace = [];
1412
1547
  const scope = [];
1413
1548
  const unresolved = [];
@@ -1445,6 +1580,8 @@ function createAnalyzer(app, stores, options = {}) {
1445
1580
  }
1446
1581
  unresolved.push(...facts.unresolved);
1447
1582
  for (const field of facts.validators) inputFields.add(field);
1583
+ for (const field of facts.requestFields) requestFields.add(field);
1584
+ if (facts.opaqueRequest) opaqueRequest = true;
1448
1585
  trace.push({
1449
1586
  file: ref.file,
1450
1587
  member: ref.member,
@@ -1469,6 +1606,8 @@ function createAnalyzer(app, stores, options = {}) {
1469
1606
  writes,
1470
1607
  touches: [...touches].sort(),
1471
1608
  inputFields: [...inputFields].sort(),
1609
+ requestFields: [...requestFields].sort(),
1610
+ opaqueRequest,
1472
1611
  trace,
1473
1612
  scope,
1474
1613
  unresolved
@@ -1665,16 +1804,32 @@ function membersOfType(typeNode, file, app) {
1665
1804
  }
1666
1805
  return members;
1667
1806
  }
1668
- function importsOf(file, app) {
1669
- const map = /* @__PURE__ */ new Map();
1807
+ /**
1808
+ * Both maps a file's imports produce: where a local name resolves, and what it
1809
+ * was called where it was exported.
1810
+ *
1811
+ * Exported because the tests need the same answer the pipeline gets: a second
1812
+ * implementation in the helpers drifted from this one and missed aliases.
1813
+ */
1814
+ function importMapsOf(file, app) {
1815
+ const imports = /* @__PURE__ */ new Map();
1816
+ const exportedAs = /* @__PURE__ */ new Map();
1670
1817
  for (const declaration of file.getImportDeclarations()) {
1671
1818
  const target = app.resolveSpecifier(declaration.getModuleSpecifierValue());
1672
1819
  if (!target) continue;
1673
1820
  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);
1821
+ if (defaultImport) imports.set(defaultImport, target);
1822
+ for (const named of declaration.getNamedImports()) {
1823
+ const alias = named.getAliasNode()?.getText();
1824
+ const local = alias ?? named.getName();
1825
+ imports.set(local, target);
1826
+ if (alias) exportedAs.set(alias, named.getName());
1827
+ }
1676
1828
  }
1677
- return map;
1829
+ return {
1830
+ imports,
1831
+ exportedAs
1832
+ };
1678
1833
  }
1679
1834
  /**
1680
1835
  * Not every unfollowed call is an unresolved call — but the filter must err on
@@ -1691,12 +1846,27 @@ function isWorthReporting(call, symbols, imports) {
1691
1846
  const expression = call.getExpression();
1692
1847
  if (Node.isIdentifier(expression)) return imports.has(expression.getText());
1693
1848
  if (!Node.isPropertyAccessExpression(expression)) return false;
1849
+ /**
1850
+ * A call ON THE RESULT of another call — `dispatch(job).waitResult()`,
1851
+ * `load(id).unwrap()`. The receiver is a value this body already holds, and
1852
+ * the call that produced it is a call site of this same body: it is visited
1853
+ * too, and reports the gap if there is one. Reporting here as well charges
1854
+ * the same unknown twice, and the second charge reads as a distinct defect.
1855
+ */
1856
+ const receiver = unwrapAwait(expression.getExpression());
1857
+ if (Node.isCallExpression(receiver)) return false;
1694
1858
  const root = rootSymbolOf(expression.getExpression());
1695
1859
  if (!root) return false;
1696
1860
  if (symbols.has(root)) return false;
1697
1861
  if (root === "this") return true;
1698
1862
  return imports.has(root);
1699
1863
  }
1864
+ /** `(await x())` and `x()` are the same receiver for this purpose. */
1865
+ function unwrapAwait(node) {
1866
+ let current = node;
1867
+ while (Node.isAwaitExpression(current) || Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) current = current.getExpression();
1868
+ return current;
1869
+ }
1700
1870
  /**
1701
1871
  * Hash of the NORMALISED body: comments and whitespace removed.
1702
1872
  *
@@ -1802,7 +1972,15 @@ function countDataFunctions(stores, usage, options) {
1802
1972
  const detAttributes = store.attributes.filter((attribute) => !attribute.isIdentifier);
1803
1973
  const det = detAttributes.length;
1804
1974
  const refs = options.retStrategy === "composition" ? 1 + store.subgroups.length : 1;
1805
- const type = options.externallyMaintained.has(store.name) || !use.written ? "EIF" : "ILF";
1975
+ /**
1976
+ * Maintained by the application, or by another system?
1977
+ *
1978
+ * A write reachable from an entry point is the common case. A write from a
1979
+ * job or a seeder maintains the store just as much — AFP §6.5.4 asks who
1980
+ * maintains it, not which route does.
1981
+ */
1982
+ const maintained = use.written || options.writtenAnywhere.has(store.name);
1983
+ const type = options.externallyMaintained.has(store.name) || !maintained ? "EIF" : "ILF";
1806
1984
  const complexity = complexityOf(type, refs, det, options.tables);
1807
1985
  counted.push({
1808
1986
  id: `data:${store.name}`,
@@ -1814,7 +1992,7 @@ function countDataFunctions(stores, usage, options) {
1814
1992
  complexity,
1815
1993
  points: pointsOf(type, complexity, options.weights),
1816
1994
  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",
1995
+ 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",
1818
1996
  detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}`),
1819
1997
  refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
1820
1998
  }
@@ -1900,6 +2078,17 @@ function detsFor(entry, behavior, touched, type, options) {
1900
2078
  };
1901
2079
  for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
1902
2080
  for (const field of behavior.inputFields) add(field.split(".").pop(), `validator:${field}`);
2081
+ /**
2082
+ * §7.2 asks whether a user-recognisable field crosses the boundary, not how it
2083
+ * was declared. `request.input('title')` does, and counted for nothing while
2084
+ * input DETs came only from VineJS — so a transaction reading six fields this
2085
+ * way sat at 1 DET, the floor of its band.
2086
+ *
2087
+ * After the validator, and deduplicated by field name: where both exist the
2088
+ * validator is the better provenance to print, and the same field must not be
2089
+ * paid for twice.
2090
+ */
2091
+ for (const field of behavior.requestFields) add(field, `request:${field}`);
1903
2092
  if (type === "EO" || type === "EQ") for (const store of touched) {
1904
2093
  const columns = options.countedStores.get(store).attributes.filter((attribute) => !attribute.isIdentifier);
1905
2094
  for (const column of columns) add(`${store}.${column.name}`, `output:${store}.${column.name}`);
@@ -1958,8 +2147,16 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
1958
2147
  * It appears in every report, and `fp:diff` refuses to compare counts produced
1959
2148
  * by different versions — otherwise the difference would measure the rule
1960
2149
  * change rather than the work.
2150
+ *
2151
+ * It must be bumped by ANY change that moves the number for unchanged code, and
2152
+ * that is easy to forget: four such changes landed in 1.1.0 — maintenance read
2153
+ * across the whole project rather than from routes alone, a job followed into
2154
+ * `process`, an event followed into its listeners, and `request.input(…)` counted
2155
+ * as a DET. Without the bump, a baseline saved by the previous version would have
2156
+ * compared cleanly against this one and billed the tool's own improvement as work
2157
+ * done. The guard exists for exactly that, and only this constant arms it.
1961
2158
  */
1962
- const RULESET_VERSION = "1.0.0";
2159
+ const RULESET_VERSION = "1.1.0";
1963
2160
  function count(input, options = {}) {
1964
2161
  const warnings = [];
1965
2162
  const usage = usageOf(input);
@@ -1972,16 +2169,30 @@ function count(input, options = {}) {
1972
2169
  ...options.weights
1973
2170
  };
1974
2171
  const infrastructure = new Set(options.boundary?.infrastructure ?? []);
2172
+ const business = new Set(options.boundary?.business ?? []);
1975
2173
  const countable = input.stores.filter((store) => {
1976
2174
  if (infrastructure.has(store.name) || infrastructure.has(store.table ?? "")) {
1977
2175
  warnings.push(`excluded by boundary configuration: ${store.name}`);
1978
2176
  return false;
1979
2177
  }
1980
2178
  const technical = isTechnical(store);
1981
- if (technical) warnings.push(`technical, excluded: ${store.name} (${technical})`);
1982
- return !technical;
2179
+ if (!technical) return true;
2180
+ /**
2181
+ * The naming filter is a heuristic over names, so it catches business data
2182
+ * whose name happens to match — a chat session the user manages, a document
2183
+ * template they maintain. Only a person knows which, so a declaration wins
2184
+ * over the pattern, and the report says it was overruled rather than
2185
+ * quietly counting one more store.
2186
+ */
2187
+ if (business.has(store.name) || business.has(store.table ?? "")) {
2188
+ warnings.push(`kept by boundary configuration: ${store.name} — the AFP naming filter had excluded it (${technical})`);
2189
+ return true;
2190
+ }
2191
+ warnings.push(`technical, excluded: ${store.name} (${technical})`);
2192
+ return false;
1983
2193
  });
1984
2194
  const dataFunctions = countDataFunctions(countable, usage, {
2195
+ writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
1985
2196
  retStrategy: options.retStrategy ?? "constant",
1986
2197
  externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
1987
2198
  tables,
@@ -1996,6 +2207,7 @@ function count(input, options = {}) {
1996
2207
  weights
1997
2208
  });
1998
2209
  warnings.push(...opaqueColumnWarnings(countable, input));
2210
+ warnings.push(...unreadableInputWarnings(input));
1999
2211
  const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2000
2212
  return {
2001
2213
  ruleset: "afp",
@@ -2033,6 +2245,42 @@ function opaqueColumnWarnings(stores, input) {
2033
2245
  if (found.length === 0) return [];
2034
2246
  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
2247
  }
2248
+ /**
2249
+ * Transactions that read the request in a way that enumerates nothing.
2250
+ *
2251
+ * `request.all()`, `request.body()`, `request.except([…])` — whatever arrives is
2252
+ * read, and no analysis can say how many fields that is. The transaction is
2253
+ * counted from its route parameters alone, which puts it at the floor of its
2254
+ * complexity band: an undercount, and a silent one until now.
2255
+ *
2256
+ * Two earlier versions of this warning were wrong and are worth recording,
2257
+ * because both looked like rigour. The first flagged every write with no
2258
+ * validator and named `users.destroy`, `DELETE /questions/:id` and
2259
+ * `notifications.markRead` — transactions that legitimately carry nothing beyond
2260
+ * the route parameter, exactly as counting-decisions §7 describes. The second
2261
+ * narrowed to POST, PUT and PATCH, and still named `POST /orders/:id/submit` and
2262
+ * `POST /orders/:id/clear`: in an AdonisJS application POST is how a state
2263
+ * transition is expressed, so the verb does not separate a submission from a
2264
+ * trigger.
2265
+ *
2266
+ * What separates them is whether the handler reads the request at all. A trigger
2267
+ * does not. So the enumerable reads are now COUNTED — `request.input('title')` is
2268
+ * a DET — and only what cannot be enumerated is reported. A warning that names
2269
+ * routes with nothing wrong with them is the noise that teaches people to stop
2270
+ * reading the confidence block.
2271
+ */
2272
+ function unreadableInputWarnings(input) {
2273
+ const blind = input.entryPoints.map((entry) => ({
2274
+ entry,
2275
+ behavior: input.behaviors.get(entry.id)
2276
+ })).filter(({ behavior }) => behavior?.opaqueRequest && behavior.inputFields.length === 0);
2277
+ if (blind.length === 0) return [];
2278
+ return [
2279
+ `${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:`,
2280
+ ...blind.slice(0, 10).map(({ entry }) => ` ${entry.trigger} ${entry.signature}`),
2281
+ ...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
2282
+ ];
2283
+ }
2036
2284
  /** a column whose shape says nothing about what it holds */
2037
2285
  const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2038
2286
  /**
@@ -2248,7 +2496,8 @@ async function analyze(root, options = {}) {
2248
2496
  const jsonSchemas = collectJsonSchemas(app);
2249
2497
  const analyzer = createAnalyzer(app, stores, {
2250
2498
  maxDepth: options.maxDepth,
2251
- callResolvers: options.resolvers?.call
2499
+ callResolvers: options.resolvers?.call,
2500
+ eventBindings: collectEventBindings(app)
2252
2501
  });
2253
2502
  const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
2254
2503
  const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
@@ -2275,6 +2524,14 @@ async function analyze(root, options = {}) {
2275
2524
  by: "validator"
2276
2525
  }
2277
2526
  })),
2527
+ requestFields: behavior.requestFields.map((name) => ({
2528
+ name,
2529
+ provenance: {
2530
+ file: app.root,
2531
+ by: "request"
2532
+ }
2533
+ })),
2534
+ opaqueRequest: behavior.opaqueRequest,
2278
2535
  outputFields: [],
2279
2536
  trace: behavior.trace,
2280
2537
  unresolved: behavior.unresolved
@@ -2296,11 +2553,12 @@ async function analyze(root, options = {}) {
2296
2553
  stores,
2297
2554
  entryPoints,
2298
2555
  behaviors,
2299
- jsonSchemas
2556
+ jsonSchemas,
2557
+ writtenAnywhere: analyzer.writtenAnywhere()
2300
2558
  }, options),
2301
2559
  source: describeSource(root, options.configFile ?? null)
2302
2560
  }
2303
2561
  };
2304
2562
  }
2305
2563
  //#endregion
2306
- export { analyze as n, toPosix as r, CoverageTooLowError as t };
2564
+ export { analyze as n, CoverageTooLowError as t };