@filipebraida/adonis-function-points 0.2.0 → 0.4.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 samePath, i as hooksFiredBy, l as toPosix, n as resolveCall, o as collectEventBindings, r as detectAccess, s as relativeTo, t as BUILTIN_CALL_RESOLVERS } from "./resolvers-vMahHkAd.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,25 @@ 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
+ /**
1252
+ * The reference is resolved in the VALIDATOR's file, not the caller's.
1253
+ *
1254
+ * `vine.object({}).merge(openaiOrAws)` names a constant that lives beside the
1255
+ * validator and is usually not exported, so looking for it where the call site
1256
+ * is finds nothing — which is how five fields stayed invisible.
1257
+ */
1258
+ const { leaves, opaque: unreadable } = leavesOf(declaration, (ref) => findValidator(ref, declaration.getSourceFile(), app));
1259
+ const isOpaque = new Set(unreadable);
1260
+ for (const leaf of leaves) {
1261
+ const field = `${name}.${leaf}`;
1262
+ fields.push(field);
1263
+ if (isOpaque.has(leaf)) opaque.push(field);
1264
+ }
1237
1265
  }
1238
- return fields;
1266
+ return {
1267
+ fields,
1268
+ opaque
1269
+ };
1239
1270
  }
1240
1271
  /**
1241
1272
  * Fields read straight off the request, with no validator in between.
@@ -1320,26 +1351,139 @@ function findValidator(name, file, app) {
1320
1351
  }
1321
1352
  return null;
1322
1353
  }
1323
- /** leaves of a VineJS schema, per the table in §7 */
1324
- function leavesOf(node) {
1354
+ /**
1355
+ * Leaves of a VineJS schema, per the table in §7, and which of them are opaque.
1356
+ *
1357
+ * `answers: vine.object({}).allowUnknownProperties()` declares a field whose own
1358
+ * fields live in data, not in code. Walking into the empty literal found nothing
1359
+ * and then never pushed `answers` either, so the field counted ZERO — while an
1360
+ * opaque JSON column in the same position counts 1. The two are the same
1361
+ * situation and now get the same answer: one DET, and a warning that says the
1362
+ * number is a floor.
1363
+ *
1364
+ * That zero is also why `detFromSchema` was off by one. Its formula replaces the
1365
+ * opaque placeholder with the schema's fields, and there was no placeholder to
1366
+ * replace, so the subtraction ate a real field instead.
1367
+ */
1368
+ /**
1369
+ * Is this literal the argument of a call named `name`?
1370
+ *
1371
+ * Structural, because the test used to be a regex over the property's source text
1372
+ * (`/vine\.object/`) and Prettier breaks a long chain across lines:
1373
+ *
1374
+ * data: vine
1375
+ * .object({})
1376
+ * .allowUnknownProperties()
1377
+ *
1378
+ * `vine` and `.object` then sit on different lines, the regex misses, and the field
1379
+ * silently stops being recognised as an open object. A count that depends on where
1380
+ * the formatter put a newline is not a measurement — the same reason the
1381
+ * implementation-scope hash strips whitespace before hashing.
1382
+ */
1383
+ function isArgumentOfCallNamed(literal, name) {
1384
+ const call = literal.getParent();
1385
+ if (!call || !Node.isCallExpression(call)) return false;
1386
+ const callee = call.getExpression();
1387
+ return Node.isPropertyAccessExpression(callee) && callee.getName() === name;
1388
+ }
1389
+ /** The last member of a call's callee: `vine.group.if(…)` is `if`, however it is wrapped. */
1390
+ function calleeName(call) {
1391
+ const callee = call.getExpression();
1392
+ return Node.isPropertyAccessExpression(callee) ? callee.getName() : void 0;
1393
+ }
1394
+ /** …and the member before it, so `group.if` can be told from any other `if`. */
1395
+ function calleeOwner(call) {
1396
+ const callee = call.getExpression();
1397
+ if (!Node.isPropertyAccessExpression(callee)) return void 0;
1398
+ const owner = callee.getExpression();
1399
+ return Node.isPropertyAccessExpression(owner) ? owner.getName() : void 0;
1400
+ }
1401
+ function leavesOf(node, resolveRef) {
1325
1402
  const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1326
- if (!object) return [];
1403
+ if (!object) return {
1404
+ leaves: [],
1405
+ opaque: []
1406
+ };
1327
1407
  const leaves = [];
1408
+ const opaque = [];
1328
1409
  const walk = (literal, prefix) => {
1329
1410
  for (const property of literal.getProperties()) {
1330
1411
  if (!Node.isPropertyAssignment(property)) continue;
1331
1412
  const name = property.getName().replace(/['"]/g, "");
1332
- const text = property.getText();
1333
1413
  const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1334
- if (nested && /vine\.object/.test(text)) {
1335
- walk(nested, prefix ? `${prefix}.${name}` : name);
1414
+ if (nested && isArgumentOfCallNamed(nested, "object")) {
1415
+ const path = prefix ? `${prefix}.${name}` : name;
1416
+ /**
1417
+ * An object declaring no properties enumerates nothing. It is the field
1418
+ * itself that crosses the boundary, so it counts once — never zero,
1419
+ * which would make it cheaper than a plain string.
1420
+ */
1421
+ if (nested.getProperties().length === 0) {
1422
+ leaves.push(path);
1423
+ opaque.push(path);
1424
+ continue;
1425
+ }
1426
+ walk(nested, path);
1336
1427
  continue;
1337
1428
  }
1338
1429
  leaves.push(prefix ? `${prefix}.${name}` : name);
1339
1430
  }
1340
1431
  };
1341
1432
  walk(object, "");
1342
- return leaves;
1433
+ /**
1434
+ * Conditional groups: `vine.object({}).merge(vine.group([vine.group.if(p, {…})]))`.
1435
+ *
1436
+ * The branches are mutually exclusive at runtime and the transaction can carry
1437
+ * any of them, so §7.2 counts the UNION — the fields the elementary process
1438
+ * handles. Read from the first object literal alone, the whole validator looked
1439
+ * like an open object and the count said the fields were data when they are
1440
+ * plainly in the code: five fields reported as one, and `detFromSchema` could not
1441
+ * fix it, because a group is not a JSON Schema.
1442
+ *
1443
+ * A `group.if` literal inside the outer object would be a nested schema `walk`
1444
+ * already handled, so only the ones outside it are roots here.
1445
+ */
1446
+ for (const branch of groupBranchesIn(node, object, resolveRef)) walk(branch, "");
1447
+ /**
1448
+ * The whole validator is an open object: nothing is enumerable, and the body
1449
+ * that carries it is measured at the floor. Counted as one, reported as such.
1450
+ */
1451
+ if (leaves.length === 0 && object.getProperties().length === 0) return {
1452
+ leaves: ["*"],
1453
+ opaque: ["*"]
1454
+ };
1455
+ return {
1456
+ leaves: [...new Set(leaves)],
1457
+ opaque: [...new Set(opaque)]
1458
+ };
1459
+ }
1460
+ /**
1461
+ * Object literals passed to `vine.group.if(…)`, following `.merge(x)` when `x` is
1462
+ * a name this file can resolve.
1463
+ *
1464
+ * The groups usually live in their own constant — which is why following the
1465
+ * reference matters more than recognising the inline form.
1466
+ */
1467
+ function groupBranchesIn(node, outer, resolveRef, depth = 0) {
1468
+ if (depth > 3) return [];
1469
+ const found = [];
1470
+ for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1471
+ const method = calleeName(call);
1472
+ if ((method === "if" || method === "else") && calleeOwner(call) === "group") {
1473
+ for (const argument of call.getArguments()) {
1474
+ const literal = argument.asKind(SyntaxKind.ObjectLiteralExpression);
1475
+ if (literal && !outer.getDescendants().includes(literal)) found.push(literal);
1476
+ }
1477
+ continue;
1478
+ }
1479
+ /** `.merge(openaiOrAws)`: the group is declared elsewhere */
1480
+ if (method !== "merge" || !resolveRef) continue;
1481
+ const reference = call.getArguments()[0];
1482
+ if (!reference || !Node.isIdentifier(reference)) continue;
1483
+ const declaration = resolveRef(reference.getText());
1484
+ if (declaration) found.push(...groupBranchesIn(declaration, outer, resolveRef, depth + 1));
1485
+ }
1486
+ return found;
1343
1487
  }
1344
1488
  /** file name, to identify the unresolved call without dumping the full path */
1345
1489
  const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
@@ -1440,7 +1584,7 @@ function createAnalyzer(app, stores, options = {}) {
1440
1584
  const accesses = [];
1441
1585
  const followUps = [];
1442
1586
  const unresolved = [];
1443
- const validators = validatorFieldsIn(body, file, app);
1587
+ const validator = validatorFieldsIn(body, file, app);
1444
1588
  const request = requestFieldsIn(body);
1445
1589
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1446
1590
  const access = detectAccess(call, symbols, relationsByStore);
@@ -1449,9 +1593,14 @@ function createAnalyzer(app, stores, options = {}) {
1449
1593
  store: access.store,
1450
1594
  write: access.mode === "write"
1451
1595
  });
1596
+ /**
1597
+ * A relation reached by `preload`/`load` is read; one written through
1598
+ * `related('files').create(…)` is written. Assuming read either way made
1599
+ * a table maintained only through a relation come out as an EIF.
1600
+ */
1452
1601
  if (access.viaRelation) accesses.push({
1453
1602
  store: access.viaRelation,
1454
- write: false
1603
+ write: access.relationWritten === true
1455
1604
  });
1456
1605
  /**
1457
1606
  * counting-decisions §3: a hook belongs to the transaction that fired
@@ -1499,7 +1648,8 @@ function createAnalyzer(app, stores, options = {}) {
1499
1648
  accesses,
1500
1649
  followUps,
1501
1650
  unresolved,
1502
- validators,
1651
+ validators: validator.fields,
1652
+ opaqueValidators: validator.opaque,
1503
1653
  requestFields: request.fields,
1504
1654
  opaqueRequest: request.opaque,
1505
1655
  bodyHash: hashOf(body)
@@ -1527,7 +1677,15 @@ function createAnalyzer(app, stores, options = {}) {
1527
1677
  if (symbols.size === 0) continue;
1528
1678
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1529
1679
  const access = detectAccess(call, symbols, relationsByStore);
1530
- if (access?.mode === "write") written.add(access.store);
1680
+ if (access?.mode !== "write") continue;
1681
+ written.add(access.store);
1682
+ /**
1683
+ * `distribution.related('files').create(…)` maintains the related table.
1684
+ * Recording only the parent left a table written exclusively that way
1685
+ * looking like somebody else's — reported by a production application as
1686
+ * an EIF it was sure it maintained.
1687
+ */
1688
+ if (access.viaRelation && access.relationWritten) written.add(access.viaRelation);
1531
1689
  }
1532
1690
  }
1533
1691
  return written;
@@ -1541,6 +1699,7 @@ function createAnalyzer(app, stores, options = {}) {
1541
1699
  function run(handler) {
1542
1700
  const touches = /* @__PURE__ */ new Set();
1543
1701
  const inputFields = /* @__PURE__ */ new Set();
1702
+ const opaqueInputFields = /* @__PURE__ */ new Set();
1544
1703
  const requestFields = /* @__PURE__ */ new Set();
1545
1704
  let opaqueRequest = false;
1546
1705
  const trace = [];
@@ -1580,6 +1739,7 @@ function createAnalyzer(app, stores, options = {}) {
1580
1739
  }
1581
1740
  unresolved.push(...facts.unresolved);
1582
1741
  for (const field of facts.validators) inputFields.add(field);
1742
+ for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
1583
1743
  for (const field of facts.requestFields) requestFields.add(field);
1584
1744
  if (facts.opaqueRequest) opaqueRequest = true;
1585
1745
  trace.push({
@@ -1606,6 +1766,7 @@ function createAnalyzer(app, stores, options = {}) {
1606
1766
  writes,
1607
1767
  touches: [...touches].sort(),
1608
1768
  inputFields: [...inputFields].sort(),
1769
+ opaqueInputFields: [...opaqueInputFields].sort(),
1609
1770
  requestFields: [...requestFields].sort(),
1610
1771
  opaqueRequest,
1611
1772
  trace,
@@ -1957,6 +2118,12 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
1957
2118
  }
1958
2119
  //#endregion
1959
2120
  //#region src/albrecht/data_functions.ts
2121
+ /**
2122
+ * A column whose shape says nothing about what it holds. Marked in the rationale
2123
+ * because `detFromSchema` replaces exactly this placeholder, and because a reader
2124
+ * deserves to know which of the DETs is a floor rather than a count.
2125
+ */
2126
+ const OPAQUE_TYPE$1 = /^(object|any|unknown|Record<|Json|JSON)/;
1960
2127
  function countDataFunctions(stores, usage, options) {
1961
2128
  const counted = [];
1962
2129
  for (const store of stores) {
@@ -1993,7 +2160,7 @@ function countDataFunctions(stores, usage, options) {
1993
2160
  points: pointsOf(type, complexity, options.weights),
1994
2161
  rationale: {
1995
2162
  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}`),
2163
+ detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}` + (attribute.type && OPAQUE_TYPE$1.test(attribute.type) ? " (opaque)" : "")),
1997
2164
  refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
1998
2165
  }
1999
2166
  });
@@ -2032,7 +2199,10 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
2032
2199
  rule: behavior.writes ? "afp:6.5.3 modifies a data store -> EI" : "afp:6.5.3 uses without modifying -> EO (EQ collapsed per 6.5.3)",
2033
2200
  detSources: sources,
2034
2201
  refSources: touched.map((store) => `reaches:${store}`),
2035
- trace: behavior.trace
2202
+ trace: behavior.trace.map((step) => ({
2203
+ ...step,
2204
+ file: relativeTo(options.root, step.file)
2205
+ }))
2036
2206
  }
2037
2207
  });
2038
2208
  }
@@ -2077,7 +2247,14 @@ function detsFor(entry, behavior, touched, type, options) {
2077
2247
  sources.push(source);
2078
2248
  };
2079
2249
  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}`);
2250
+ /**
2251
+ * `(opaque)` is not decoration: `detFromSchema` replaces the opaque placeholder
2252
+ * with a schema's fields, and it used to assume there was exactly one and that
2253
+ * it was worth 1. An open `vine.object` counted zero, so the subtraction ate a
2254
+ * real field — off by one, in the direction of undercounting.
2255
+ */
2256
+ const opaqueInputs = new Set(behavior.opaqueInputFields);
2257
+ for (const field of behavior.inputFields) add(field.split(".").pop(), `validator:${field}${opaqueInputs.has(field) ? " (opaque)" : ""}`);
2081
2258
  /**
2082
2259
  * §7.2 asks whether a user-recognisable field crosses the boundary, not how it
2083
2260
  * was declared. `request.input('title')` does, and counted for nothing while
@@ -2141,6 +2318,17 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
2141
2318
  for (const { label, pattern } of patterns) if (pattern.test(table)) return `${label} (AFP §6.5.2.1.3: ${pattern.source})`;
2142
2319
  return null;
2143
2320
  }
2321
+ //#endregion
2322
+ //#region src/albrecht/counter.ts
2323
+ /**
2324
+ * Assembles the count from the inventory.
2325
+ *
2326
+ * The order is not arbitrary: data functions depend on HOW transactions use
2327
+ * each store (AFP §6.5.4), and transactional functions depend on which stores
2328
+ * ended up counted. Hence: usage first, then the technical filter, then data,
2329
+ * then transactions.
2330
+ */
2331
+ const RULESET = "afp";
2144
2332
  /**
2145
2333
  * Version of the rule set.
2146
2334
  *
@@ -2149,14 +2337,18 @@ function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
2149
2337
  * change rather than the work.
2150
2338
  *
2151
2339
  * 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
2340
+ * that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
2153
2341
  * across the whole project rather than from routes alone, a job followed into
2154
2342
  * `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.
2343
+ * as a DET — and three more in 1.2.0: an open input object counting 1 instead of 0,
2344
+ * `detFromSchema` no longer subtracting a placeholder that was not there, and a
2345
+ * write through `related(…)` maintaining the related table.
2346
+ *
2347
+ * Without the bump, a baseline saved by the previous version compares cleanly
2348
+ * against this one and bills the tool's own improvement as work done. The guard
2349
+ * exists for exactly that, and only this constant arms it.
2158
2350
  */
2159
- const RULESET_VERSION = "1.1.0";
2351
+ const RULESET_VERSION = "1.3.0";
2160
2352
  function count(input, options = {}) {
2161
2353
  const warnings = [];
2162
2354
  const usage = usageOf(input);
@@ -2202,13 +2394,38 @@ function count(input, options = {}) {
2202
2394
  const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
2203
2395
  const transactionalFunctions = countTransactionalFunctions(input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? "")), input.behaviors, {
2204
2396
  countedStores,
2397
+ root: input.app.root,
2205
2398
  messageDet: options.messageDet ?? 0,
2206
2399
  tables,
2207
2400
  weights
2208
2401
  });
2209
- warnings.push(...opaqueColumnWarnings(countable, input));
2210
- warnings.push(...unreadableInputWarnings(input));
2402
+ /**
2403
+ * `opaqueReviewed` answers a warning that is CORRECT and therefore permanent.
2404
+ *
2405
+ * 1 DET for an opaque column is a floor, and `fp:count` says so on every run. But
2406
+ * some of those columns are one field — a copy, a checksum, a bag of metadata —
2407
+ * and there was no way to record that someone had looked. A warning that cannot be
2408
+ * answered is one the team learns to scroll past, which costs more than the warning
2409
+ * reports. It silences nothing else: the count does not move, and how many were
2410
+ * reviewed is still printed.
2411
+ */
2412
+ const reviewed = reviewedOpaque(options.overrides ?? {});
2211
2413
  const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2414
+ /**
2415
+ * Reported AFTER the overrides are applied, because the overrides are the answer
2416
+ * to it.
2417
+ *
2418
+ * Computed first, the list kept naming functions whose floor had already been
2419
+ * replaced by `detFromSchema` — telling the reader to go and map something that
2420
+ * was mapped. It cost a real misreading: a report was taken as "two forms still
2421
+ * unmapped" when both were declared, by whoever wrote this code.
2422
+ */
2423
+ const declared = new Set(functions.filter((fn) => fn.rationale.overrides?.some((o) => o.fields.includes("det"))).map((fn) => fn.name));
2424
+ warnings.push(...opaqueWarnings(countable, input, {
2425
+ reviewed,
2426
+ declared
2427
+ }));
2428
+ warnings.push(...unreadableInputWarnings(input));
2212
2429
  return {
2213
2430
  ruleset: "afp",
2214
2431
  rulesetVersion: RULESET_VERSION,
@@ -2230,20 +2447,94 @@ function count(input, options = {}) {
2230
2447
  * `metadata` column changes no number, and warning about it would be the noise
2231
2448
  * that teaches people to stop reading the confidence block.
2232
2449
  */
2233
- function opaqueColumnWarnings(stores, input) {
2450
+ /**
2451
+ * Opaque DETs someone has declared reviewed, in both spellings a person might use.
2452
+ *
2453
+ * Qualified (`Petition.schema`) is unambiguous; bare (`schema`) is what someone reads
2454
+ * off the warning line.
2455
+ */
2456
+ function reviewedOpaque(overrides) {
2457
+ const reviewed = /* @__PURE__ */ new Set();
2458
+ for (const [name, override] of Object.entries(overrides)) for (const entry of override.opaqueReviewed ?? []) {
2459
+ reviewed.add(entry);
2460
+ reviewed.add(`${name}.${entry}`);
2461
+ }
2462
+ return reviewed;
2463
+ }
2464
+ /** a column whose shape says nothing about what it holds */
2465
+ const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2466
+ /**
2467
+ * DETs the analysis cannot read: an opaque column, or an open input object.
2468
+ *
2469
+ * Both count 1, which is a FLOOR rather than a measurement, and counting-decisions §8
2470
+ * is the trade. Reporting it is the point — this was the one known blind spot the
2471
+ * package reported nowhere.
2472
+ *
2473
+ * Grouped by FUNCTION and stating what has already been answered, because a flat list
2474
+ * of columns could not say that. Computed before the overrides ran, it named functions
2475
+ * whose floor `detFromSchema` had already replaced, which reads as "go and map this"
2476
+ * about something already mapped. That misreading actually happened, to the author of
2477
+ * this code, reading someone else's report.
2478
+ *
2479
+ * Three states per function, and only the third is a request to do something:
2480
+ *
2481
+ * replaced a `detFromSchema` override stands in for one of them
2482
+ * reviewed someone looked and 1 is the right answer
2483
+ * floor still unanswered
2484
+ */
2485
+ function opaqueWarnings(stores, input, state) {
2234
2486
  const reached = /* @__PURE__ */ new Map();
2235
2487
  for (const entry of input.entryPoints) for (const store of input.behaviors.get(entry.id)?.touches ?? []) reached.set(store, (reached.get(store) ?? 0) + 1);
2236
- const found = [];
2488
+ const byFunction = /* @__PURE__ */ new Map();
2489
+ const tally = (name, kind, transactions) => {
2490
+ const found = byFunction.get(name) ?? {
2491
+ kind,
2492
+ floor: [],
2493
+ reviewed: 0,
2494
+ transactions
2495
+ };
2496
+ byFunction.set(name, found);
2497
+ return found;
2498
+ };
2237
2499
  for (const store of stores) {
2238
- const transactions = reached.get(store.name);
2239
- if (!transactions) continue;
2500
+ if (!reached.get(store.name)) continue;
2240
2501
  for (const attribute of store.attributes) {
2241
2502
  if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
2242
- found.push(` ${store.name}.${attribute.name} (${attribute.type}) — ${transactions} transaction(s)`);
2503
+ const entry = tally(store.name, "column", reached.get(store.name) ?? 0);
2504
+ if (state.reviewed.has(`${store.name}.${attribute.name}`) || state.reviewed.has(attribute.name)) entry.reviewed += 1;
2505
+ else entry.floor.push(`${attribute.name} (${attribute.type})`);
2243
2506
  }
2244
2507
  }
2245
- if (found.length === 0) return [];
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];
2508
+ for (const point of input.entryPoints) for (const field of input.behaviors.get(point.id)?.opaqueInputFields ?? []) {
2509
+ const entry = tally(point.identity, "input object", 1);
2510
+ if (state.reviewed.has(field) || state.reviewed.has(`${point.identity}.${field}`)) entry.reviewed += 1;
2511
+ else entry.floor.push(field);
2512
+ }
2513
+ const lines = [];
2514
+ let answered = 0;
2515
+ for (const [name, entry] of byFunction) {
2516
+ /** a declared schema stands in for exactly one placeholder — §8, and the override warns when there are more */
2517
+ const replaced = state.declared.has(name) && entry.floor.length > 0 ? 1 : 0;
2518
+ const remaining = entry.floor.slice(replaced);
2519
+ if (remaining.length === 0) {
2520
+ answered += 1;
2521
+ continue;
2522
+ }
2523
+ const answeredHere = [...replaced > 0 ? [`${replaced} replaced by override`] : [], ...entry.reviewed > 0 ? [`${entry.reviewed} reviewed`] : []];
2524
+ /**
2525
+ * How many transactions reach the store, so the reader can judge whether the
2526
+ * floor is worth answering. A blob nothing touches changes no number.
2527
+ */
2528
+ const reach = entry.kind === "column" ? `, reached by ${entry.transactions} transaction(s)` : "";
2529
+ lines.push(` ${name} — ${remaining.length} ${entry.kind}(s) at 1 DET${reach}` + (answeredHere.length > 0 ? ` (${answeredHere.join(", ")} already)` : "") + `: ${remaining.map((f) => entry.kind === "column" ? `${name}.${f}` : f).join(", ")}`);
2530
+ }
2531
+ const settled = answered === 0 ? [] : [` (${answered} more function(s) whose opaque DETs are all accounted for)`];
2532
+ if (lines.length === 0) return settled;
2533
+ return [
2534
+ `${lines.length} function(s) with a DET the analysis cannot read, counted as 1 each — a FLOOR, not a measurement. Where the fields are declared in the source, name that schema with \`overrides.detFromSchema\`; where 1 is the right answer, record it with \`overrides.<fn>.opaqueReviewed\` — counting-decisions §8:`,
2535
+ ...lines,
2536
+ ...settled
2537
+ ];
2247
2538
  }
2248
2539
  /**
2249
2540
  * Transactions that read the request in a way that enumerates nothing.
@@ -2281,8 +2572,6 @@ function unreadableInputWarnings(input) {
2281
2572
  ...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
2282
2573
  ];
2283
2574
  }
2284
- /** a column whose shape says nothing about what it holds */
2285
- const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2286
2575
  /**
2287
2576
  * Replaces what the analysis found with what a person declared.
2288
2577
  *
@@ -2317,14 +2606,54 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2317
2606
  * is born, not when a field is.
2318
2607
  */
2319
2608
  if (override.detFromSchema) {
2320
- const schema = schemas.get(override.detFromSchema);
2609
+ /**
2610
+ * One name or several, unioned by leaf path.
2611
+ *
2612
+ * An ILF's DETs are the fields the user recognises in the file, and an
2613
+ * application with one schema per template recognises all of them. A field
2614
+ * two templates share is one DET, so the union is over paths rather than a
2615
+ * sum of counts.
2616
+ */
2617
+ const named = [override.detFromSchema].flat();
2618
+ const resolved = named.map((name) => schemas.get(name)).filter((s) => s !== void 0);
2619
+ const missing = named.filter((name) => !schemas.has(name));
2620
+ const union = new Set(resolved.flatMap((s) => s.leaves));
2621
+ const schema = resolved.length === 0 ? void 0 : {
2622
+ /** only what resolved: naming a schema that contributed nothing would mislead */
2623
+ name: resolved.map((s) => s.name).join(" + "),
2624
+ fields: union.size,
2625
+ leaves: [...union]
2626
+ };
2627
+ for (const name of missing) warnings.push(`override for "${fn.name}" names schema "${name}", which is not declared anywhere in the code: it contributed nothing. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
2321
2628
  if (schema) {
2322
- det = Math.max(fn.det - 1, 0) + schema.fields;
2629
+ /**
2630
+ * Replaces the opaque placeholder — the one the rationale marks — rather
2631
+ * than assuming there is one and that it is worth 1.
2632
+ *
2633
+ * That assumption was wrong twice over. An open `vine.object` counted
2634
+ * ZERO, not 1, so subtracting 1 removed a field the analysis had read
2635
+ * correctly: 86 DETs where 87 was right. And a function with no opaque
2636
+ * DET at all was silently charged the subtraction too.
2637
+ */
2638
+ const placeholders = fn.rationale.detSources.filter((source) => source.endsWith("(opaque)"));
2639
+ det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
2323
2640
  by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
2324
- } 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.`);
2641
+ 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.`);
2642
+ 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.`);
2643
+ }
2325
2644
  }
2326
2645
  const refs = override.refs ?? fn.refs;
2327
2646
  const complexity = complexityOf(fn.type, refs, det, tables);
2647
+ /**
2648
+ * Only a DECLARED NUMBER is an override in the rationale.
2649
+ *
2650
+ * `fp:count` prints what share of the total came from a person, and that line is
2651
+ * the reason the mechanism is acceptable at all. An `opaqueReviewed`-only entry
2652
+ * declares no number, and recording it here read as "1 function, 7 FP, 35% of the
2653
+ * total declared by override" — misrepresenting the one number that exists to keep
2654
+ * this honest. The review is recorded in the warning, which is where it belongs.
2655
+ */
2656
+ if (fields.length === 0) return fn;
2328
2657
  return {
2329
2658
  ...fn,
2330
2659
  det,
@@ -2468,7 +2797,7 @@ function describeSource(root, config) {
2468
2797
  ]),
2469
2798
  dirty: status === void 0 ? void 0 : status.length > 0,
2470
2799
  countedAt: (/* @__PURE__ */ new Date()).toISOString(),
2471
- config: config === null ? null : toPosix(config)
2800
+ config: config === null ? null : relativeTo(root, config)
2472
2801
  };
2473
2802
  }
2474
2803
  function appName(root) {
@@ -2502,17 +2831,51 @@ async function analyze(root, options = {}) {
2502
2831
  const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
2503
2832
  const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
2504
2833
  const unresolvedCalls = storeProblems.length + routeProblems.length + [...behaviors.values()].reduce((total, behavior) => total + behavior.unresolved.length, 0);
2834
+ /**
2835
+ * Every path that LEAVES is relative to the application root.
2836
+ *
2837
+ * `CountSource.app` is documented as never being the absolute path, because that
2838
+ * says where the machine keeps its files and travels with every artefact sent
2839
+ * anywhere. The rule was stated on one field and applied to one field: the
2840
+ * inventory carried 2036 absolute paths across ten of them, and a count carried
2841
+ * 858 in its traces alone.
2842
+ *
2843
+ * Absolute is right INTERNALLY — it is what ts-morph resolves and what the call
2844
+ * graph keys its caches on — so the conversion happens here, at the boundary, and
2845
+ * the data-store `id` is relativised only after the ancestor filter has used it.
2846
+ */
2847
+ const source = describeSource(root, options.configFile ?? null);
2848
+ const emit = (value) => relativeTo(app.root, value);
2849
+ const emitProvenance = (p) => ({
2850
+ ...p,
2851
+ file: emit(p.file)
2852
+ });
2505
2853
  const inventory = {
2506
2854
  version: 1,
2507
2855
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2508
- app: app.root,
2856
+ app: source.app,
2509
2857
  framework: {
2510
2858
  core: app.framework.core,
2511
2859
  lucid: app.framework.lucid,
2512
2860
  orm: app.framework.orm
2513
2861
  },
2514
- dataStores: stores,
2515
- entryPoints,
2862
+ dataStores: stores.map((store) => ({
2863
+ ...store,
2864
+ id: emit(store.id),
2865
+ provenance: emitProvenance(store.provenance),
2866
+ attributes: store.attributes.map((a) => ({
2867
+ ...a,
2868
+ provenance: emitProvenance(a.provenance)
2869
+ }))
2870
+ })),
2871
+ entryPoints: entryPoints.map((entry) => ({
2872
+ ...entry,
2873
+ provenance: emitProvenance(entry.provenance),
2874
+ handler: entry.handler ? {
2875
+ ...entry.handler,
2876
+ file: emit(entry.handler.file)
2877
+ } : null
2878
+ })),
2516
2879
  behaviors: [...behaviors.entries()].map(([entryPointId, behavior]) => ({
2517
2880
  entryPointId,
2518
2881
  writes: behavior.writes,
@@ -2520,21 +2883,34 @@ async function analyze(root, options = {}) {
2520
2883
  inputFields: behavior.inputFields.map((name) => ({
2521
2884
  name,
2522
2885
  provenance: {
2523
- file: app.root,
2886
+ file: emit(app.root),
2887
+ by: "validator"
2888
+ }
2889
+ })),
2890
+ opaqueInputFields: behavior.opaqueInputFields.map((name) => ({
2891
+ name,
2892
+ provenance: {
2893
+ file: emit(app.root),
2524
2894
  by: "validator"
2525
2895
  }
2526
2896
  })),
2527
2897
  requestFields: behavior.requestFields.map((name) => ({
2528
2898
  name,
2529
2899
  provenance: {
2530
- file: app.root,
2900
+ file: emit(app.root),
2531
2901
  by: "request"
2532
2902
  }
2533
2903
  })),
2534
2904
  opaqueRequest: behavior.opaqueRequest,
2535
2905
  outputFields: [],
2536
- trace: behavior.trace,
2537
- unresolved: behavior.unresolved
2906
+ trace: behavior.trace.map((step) => ({
2907
+ ...step,
2908
+ file: emit(step.file)
2909
+ })),
2910
+ unresolved: behavior.unresolved.map((call) => ({
2911
+ ...call,
2912
+ file: emit(call.file)
2913
+ }))
2538
2914
  })),
2539
2915
  coverage: {
2540
2916
  entryPointsTotal: entryPoints.length,
@@ -2556,9 +2932,9 @@ async function analyze(root, options = {}) {
2556
2932
  jsonSchemas,
2557
2933
  writtenAnywhere: analyzer.writtenAnywhere()
2558
2934
  }, options),
2559
- source: describeSource(root, options.configFile ?? null)
2935
+ source
2560
2936
  }
2561
2937
  };
2562
2938
  }
2563
2939
  //#endregion
2564
- export { analyze as n, CoverageTooLowError as t };
2940
+ export { RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };