@filipebraida/adonis-function-points 0.2.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.
@@ -1,7 +1,7 @@
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
- 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";
@@ -956,7 +956,21 @@ function collectJsonSchemas(app) {
956
956
  skipFileDependencyResolution: true,
957
957
  compilerOptions: { allowJs: false }
958
958
  });
959
- 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`);
960
974
  const found = /* @__PURE__ */ new Map();
961
975
  for (const file of project.getSourceFiles()) for (const declaration of file.getVariableDeclarations()) {
962
976
  const literal = unwrap(declaration.getInitializer())?.asKind(SyntaxKind.ObjectLiteralExpression);
@@ -1224,6 +1238,7 @@ function isNoiseMember(file, member) {
1224
1238
  */
1225
1239
  function validatorFieldsIn(body, file, app) {
1226
1240
  const fields = [];
1241
+ const opaque = [];
1227
1242
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1228
1243
  const expression = call.getExpression();
1229
1244
  if (!Node.isPropertyAccessExpression(expression)) continue;
@@ -1233,9 +1248,18 @@ function validatorFieldsIn(body, file, app) {
1233
1248
  const name = argument.getText();
1234
1249
  const declaration = findValidator(name, file, app);
1235
1250
  if (!declaration) continue;
1236
- 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
+ }
1237
1258
  }
1238
- return fields;
1259
+ return {
1260
+ fields,
1261
+ opaque
1262
+ };
1239
1263
  }
1240
1264
  /**
1241
1265
  * Fields read straight off the request, with no validator in between.
@@ -1320,11 +1344,14 @@ function findValidator(name, file, app) {
1320
1344
  }
1321
1345
  return null;
1322
1346
  }
1323
- /** leaves of a VineJS schema, per the table in §7 */
1324
1347
  function leavesOf(node) {
1325
1348
  const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1326
- if (!object) return [];
1349
+ if (!object) return {
1350
+ leaves: [],
1351
+ opaque: []
1352
+ };
1327
1353
  const leaves = [];
1354
+ const opaque = [];
1328
1355
  const walk = (literal, prefix) => {
1329
1356
  for (const property of literal.getProperties()) {
1330
1357
  if (!Node.isPropertyAssignment(property)) continue;
@@ -1332,14 +1359,36 @@ function leavesOf(node) {
1332
1359
  const text = property.getText();
1333
1360
  const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1334
1361
  if (nested && /vine\.object/.test(text)) {
1335
- 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);
1336
1374
  continue;
1337
1375
  }
1338
1376
  leaves.push(prefix ? `${prefix}.${name}` : name);
1339
1377
  }
1340
1378
  };
1341
1379
  walk(object, "");
1342
- 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
+ };
1343
1392
  }
1344
1393
  /** file name, to identify the unresolved call without dumping the full path */
1345
1394
  const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
@@ -1440,7 +1489,7 @@ function createAnalyzer(app, stores, options = {}) {
1440
1489
  const accesses = [];
1441
1490
  const followUps = [];
1442
1491
  const unresolved = [];
1443
- const validators = validatorFieldsIn(body, file, app);
1492
+ const validator = validatorFieldsIn(body, file, app);
1444
1493
  const request = requestFieldsIn(body);
1445
1494
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1446
1495
  const access = detectAccess(call, symbols, relationsByStore);
@@ -1449,9 +1498,14 @@ function createAnalyzer(app, stores, options = {}) {
1449
1498
  store: access.store,
1450
1499
  write: access.mode === "write"
1451
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
+ */
1452
1506
  if (access.viaRelation) accesses.push({
1453
1507
  store: access.viaRelation,
1454
- write: false
1508
+ write: access.relationWritten === true
1455
1509
  });
1456
1510
  /**
1457
1511
  * counting-decisions §3: a hook belongs to the transaction that fired
@@ -1499,7 +1553,8 @@ function createAnalyzer(app, stores, options = {}) {
1499
1553
  accesses,
1500
1554
  followUps,
1501
1555
  unresolved,
1502
- validators,
1556
+ validators: validator.fields,
1557
+ opaqueValidators: validator.opaque,
1503
1558
  requestFields: request.fields,
1504
1559
  opaqueRequest: request.opaque,
1505
1560
  bodyHash: hashOf(body)
@@ -1527,7 +1582,15 @@ function createAnalyzer(app, stores, options = {}) {
1527
1582
  if (symbols.size === 0) continue;
1528
1583
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1529
1584
  const access = detectAccess(call, symbols, relationsByStore);
1530
- if (access?.mode === "write") written.add(access.store);
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);
1531
1594
  }
1532
1595
  }
1533
1596
  return written;
@@ -1541,6 +1604,7 @@ function createAnalyzer(app, stores, options = {}) {
1541
1604
  function run(handler) {
1542
1605
  const touches = /* @__PURE__ */ new Set();
1543
1606
  const inputFields = /* @__PURE__ */ new Set();
1607
+ const opaqueInputFields = /* @__PURE__ */ new Set();
1544
1608
  const requestFields = /* @__PURE__ */ new Set();
1545
1609
  let opaqueRequest = false;
1546
1610
  const trace = [];
@@ -1580,6 +1644,7 @@ function createAnalyzer(app, stores, options = {}) {
1580
1644
  }
1581
1645
  unresolved.push(...facts.unresolved);
1582
1646
  for (const field of facts.validators) inputFields.add(field);
1647
+ for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
1583
1648
  for (const field of facts.requestFields) requestFields.add(field);
1584
1649
  if (facts.opaqueRequest) opaqueRequest = true;
1585
1650
  trace.push({
@@ -1606,6 +1671,7 @@ function createAnalyzer(app, stores, options = {}) {
1606
1671
  writes,
1607
1672
  touches: [...touches].sort(),
1608
1673
  inputFields: [...inputFields].sort(),
1674
+ opaqueInputFields: [...opaqueInputFields].sort(),
1609
1675
  requestFields: [...requestFields].sort(),
1610
1676
  opaqueRequest,
1611
1677
  trace,
@@ -1957,6 +2023,12 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
1957
2023
  }
1958
2024
  //#endregion
1959
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)/;
1960
2032
  function countDataFunctions(stores, usage, options) {
1961
2033
  const counted = [];
1962
2034
  for (const store of stores) {
@@ -1993,7 +2065,7 @@ function countDataFunctions(stores, usage, options) {
1993
2065
  points: pointsOf(type, complexity, options.weights),
1994
2066
  rationale: {
1995
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",
1996
- detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}`),
2068
+ detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}` + (attribute.type && OPAQUE_TYPE$1.test(attribute.type) ? " (opaque)" : "")),
1997
2069
  refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
1998
2070
  }
1999
2071
  });
@@ -2077,7 +2149,14 @@ function detsFor(entry, behavior, touched, type, options) {
2077
2149
  sources.push(source);
2078
2150
  };
2079
2151
  for (const param of entry.signature.match(/:[A-Za-z_][\w]*/g) ?? []) add(param.slice(1), `param:${param}`);
2080
- 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)" : ""}`);
2081
2160
  /**
2082
2161
  * §7.2 asks whether a user-recognisable field crosses the boundary, not how it
2083
2162
  * was declared. `request.input('title')` does, and counted for nothing while
@@ -2141,6 +2220,17 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
2141
2220
  for (const { label, pattern } of patterns) if (pattern.test(table)) return `${label} (AFP §6.5.2.1.3: ${pattern.source})`;
2142
2221
  return null;
2143
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";
2144
2234
  /**
2145
2235
  * Version of the rule set.
2146
2236
  *
@@ -2149,14 +2239,18 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
2149
2239
  * change rather than the work.
2150
2240
  *
2151
2241
  * 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
2242
+ * that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
2153
2243
  * across the whole project rather than from routes alone, a job followed into
2154
2244
  * `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.
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.
2158
2252
  */
2159
- const RULESET_VERSION = "1.1.0";
2253
+ const RULESET_VERSION = "1.2.0";
2160
2254
  function count(input, options = {}) {
2161
2255
  const warnings = [];
2162
2256
  const usage = usageOf(input);
@@ -2208,6 +2302,7 @@ function count(input, options = {}) {
2208
2302
  });
2209
2303
  warnings.push(...opaqueColumnWarnings(countable, input));
2210
2304
  warnings.push(...unreadableInputWarnings(input));
2305
+ warnings.push(...openValidatorWarnings(input));
2211
2306
  const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2212
2307
  return {
2213
2308
  ruleset: "afp",
@@ -2246,6 +2341,32 @@ function opaqueColumnWarnings(stores, input) {
2246
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];
2247
2342
  }
2248
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
+ /**
2249
2370
  * Transactions that read the request in a way that enumerates nothing.
2250
2371
  *
2251
2372
  * `request.all()`, `request.body()`, `request.except([…])` — whatever arrives is
@@ -2319,8 +2440,20 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2319
2440
  if (override.detFromSchema) {
2320
2441
  const schema = schemas.get(override.detFromSchema);
2321
2442
  if (schema) {
2322
- 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;
2323
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.`);
2324
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.`);
2325
2458
  }
2326
2459
  const refs = override.refs ?? fn.refs;
@@ -2524,6 +2657,13 @@ async function analyze(root, options = {}) {
2524
2657
  by: "validator"
2525
2658
  }
2526
2659
  })),
2660
+ opaqueInputFields: behavior.opaqueInputFields.map((name) => ({
2661
+ name,
2662
+ provenance: {
2663
+ file: app.root,
2664
+ by: "validator"
2665
+ }
2666
+ })),
2527
2667
  requestFields: behavior.requestFields.map((name) => ({
2528
2668
  name,
2529
2669
  provenance: {
@@ -2561,4 +2701,4 @@ async function analyze(root, options = {}) {
2561
2701
  };
2562
2702
  }
2563
2703
  //#endregion
2564
- export { analyze as n, CoverageTooLowError as t };
2704
+ export { RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };