@filipebraida/adonis-function-points 0.3.0 → 0.5.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,4 +1,4 @@
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";
1
+ import { a as hooksFiredBy, c as isApplicationCode, d as toPosix, i as detectAccess, l as relativeTo, n as isTechnicalWrite, o as rootSymbolOf, r as resolveCall, s as collectEventBindings, t as BUILTIN_CALL_RESOLVERS, u as samePath } from "./resolvers-PJwo2Z8R.js";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Node, Project, SyntaxKind } from "ts-morph";
@@ -1248,7 +1248,14 @@ function validatorFieldsIn(body, file, app) {
1248
1248
  const name = argument.getText();
1249
1249
  const declaration = findValidator(name, file, app);
1250
1250
  if (!declaration) continue;
1251
- const { leaves, opaque: unreadable } = leavesOf(declaration);
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));
1252
1259
  const isOpaque = new Set(unreadable);
1253
1260
  for (const leaf of leaves) {
1254
1261
  const field = `${name}.${leaf}`;
@@ -1344,7 +1351,54 @@ function findValidator(name, file, app) {
1344
1351
  }
1345
1352
  return null;
1346
1353
  }
1347
- 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) {
1348
1402
  const object = node.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1349
1403
  if (!object) return {
1350
1404
  leaves: [],
@@ -1356,9 +1410,8 @@ function leavesOf(node) {
1356
1410
  for (const property of literal.getProperties()) {
1357
1411
  if (!Node.isPropertyAssignment(property)) continue;
1358
1412
  const name = property.getName().replace(/['"]/g, "");
1359
- const text = property.getText();
1360
1413
  const nested = property.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
1361
- if (nested && /vine\.object/.test(text)) {
1414
+ if (nested && isArgumentOfCallNamed(nested, "object")) {
1362
1415
  const path = prefix ? `${prefix}.${name}` : name;
1363
1416
  /**
1364
1417
  * An object declaring no properties enumerates nothing. It is the field
@@ -1378,6 +1431,20 @@ function leavesOf(node) {
1378
1431
  };
1379
1432
  walk(object, "");
1380
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
+ /**
1381
1448
  * The whole validator is an open object: nothing is enumerable, and the body
1382
1449
  * that carries it is measured at the floor. Counted as one, reported as such.
1383
1450
  */
@@ -1386,10 +1453,38 @@ function leavesOf(node) {
1386
1453
  opaque: ["*"]
1387
1454
  };
1388
1455
  return {
1389
- leaves,
1390
- opaque
1456
+ leaves: [...new Set(leaves)],
1457
+ opaque: [...new Set(opaque)]
1391
1458
  };
1392
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;
1487
+ }
1393
1488
  /** file name, to identify the unresolved call without dumping the full path */
1394
1489
  const pathOf = (file) => file.split("/").pop()?.replace(/\.ts$/, "") ?? file;
1395
1490
  const DEFAULT_MAX_DEPTH = 3;
@@ -1491,12 +1586,34 @@ function createAnalyzer(app, stores, options = {}) {
1491
1586
  const unresolved = [];
1492
1587
  const validator = validatorFieldsIn(body, file, app);
1493
1588
  const request = requestFieldsIn(body);
1589
+ const context = {
1590
+ file,
1591
+ depth: 0,
1592
+ imports,
1593
+ exportedAs,
1594
+ injected,
1595
+ eventBindings,
1596
+ dataStoresBySymbol: storesByName,
1597
+ resolveSpecifier: app.resolveSpecifier,
1598
+ sourceFile
1599
+ };
1494
1600
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1495
1601
  const access = detectAccess(call, symbols, relationsByStore);
1496
1602
  if (access) {
1603
+ /**
1604
+ * Asked about a DIRECT write too, not only about a call a resolver follows.
1605
+ *
1606
+ * `Notification.query().…update({ status: 'read' })` in a `show` handler is
1607
+ * exactly the shape this exists for, and the write IS the call — there is no
1608
+ * method to declare. Asking in one place only would have covered the service
1609
+ * call and missed the query builder beside it, which is the same fact written
1610
+ * differently.
1611
+ */
1612
+ const technical = access.mode === "write" && isTechnicalWrite(call, context, resolvers);
1497
1613
  accesses.push({
1498
1614
  store: access.store,
1499
- write: access.mode === "write"
1615
+ write: access.mode === "write",
1616
+ technical
1500
1617
  });
1501
1618
  /**
1502
1619
  * A relation reached by `preload`/`load` is read; one written through
@@ -1524,21 +1641,18 @@ function createAnalyzer(app, stores, options = {}) {
1524
1641
  * shape must not be CLAIMED, not merely not reported.
1525
1642
  */
1526
1643
  if (isIterationCall(call)) continue;
1527
- const resolved = resolveCall(call, {
1528
- file,
1529
- depth: 0,
1530
- imports,
1531
- exportedAs,
1532
- injected,
1533
- eventBindings,
1534
- dataStoresBySymbol: storesByName,
1535
- resolveSpecifier: app.resolveSpecifier,
1536
- sourceFile
1537
- }, resolvers);
1644
+ const resolved = resolveCall(call, context, resolvers);
1538
1645
  if (resolved) {
1646
+ /**
1647
+ * Declared about the CALL, so everything reached through it is incidental too:
1648
+ * `persistOrganizationVisit` is called from several screens and saying it once
1649
+ * covers all of them.
1650
+ */
1651
+ const technical = isTechnicalWrite(call, context, resolvers);
1539
1652
  for (const next of resolved.refs) followUps.push({
1540
1653
  ref: next,
1541
- by: resolved.by
1654
+ by: resolved.by,
1655
+ technical
1542
1656
  });
1543
1657
  continue;
1544
1658
  }
@@ -1578,6 +1692,17 @@ function createAnalyzer(app, stores, options = {}) {
1578
1692
  const writtenAnywhere = () => {
1579
1693
  const written = /* @__PURE__ */ new Set();
1580
1694
  for (const file of project.getSourceFiles()) {
1695
+ /**
1696
+ * A seeder's inserts are not the application maintaining a table, and a test
1697
+ * factory's are not either. Counting them made every reference table an ILF:
1698
+ * the CPM puts data maintained by the development team at an EIF at most, and
1699
+ * code data outside the count entirely.
1700
+ *
1701
+ * This is the same notion `scanRoots` applies at the root, applied at any
1702
+ * depth — because a domain-module layout puts `tests/` and `seeders/` inside
1703
+ * `app/`, where the root filter never looks.
1704
+ */
1705
+ if (!isApplicationCode(app.root, file.getFilePath())) continue;
1581
1706
  const symbols = storeSymbolsFor(file, file, app, storesByName);
1582
1707
  if (symbols.size === 0) continue;
1583
1708
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
@@ -1603,6 +1728,7 @@ function createAnalyzer(app, stores, options = {}) {
1603
1728
  };
1604
1729
  function run(handler) {
1605
1730
  const touches = /* @__PURE__ */ new Set();
1731
+ const writtenStores = /* @__PURE__ */ new Set();
1606
1732
  const inputFields = /* @__PURE__ */ new Set();
1607
1733
  const opaqueInputFields = /* @__PURE__ */ new Set();
1608
1734
  const requestFields = /* @__PURE__ */ new Set();
@@ -1612,7 +1738,7 @@ function createAnalyzer(app, stores, options = {}) {
1612
1738
  const unresolved = [];
1613
1739
  const visited = /* @__PURE__ */ new Set();
1614
1740
  let writes = false;
1615
- const visit = (ref, depth) => {
1741
+ const visit = (ref, depth, technical = false) => {
1616
1742
  const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
1617
1743
  if (visited.has(key) || depth > maxDepth) return;
1618
1744
  visited.add(key);
@@ -1637,10 +1763,16 @@ function createAnalyzer(app, stores, options = {}) {
1637
1763
  let bodyWrites = false;
1638
1764
  for (const access of facts.accesses) {
1639
1765
  touches.add(access.store);
1640
- if (access.write) {
1641
- bodyWrites = true;
1642
- writes = true;
1643
- }
1766
+ if (!access.write) continue;
1767
+ /**
1768
+ * The store is maintained either way — a visit table really is written by this
1769
+ * application, so it stays an ILF and stays an FTR. What a technical write does
1770
+ * not do is decide what the transaction is FOR: §6.5.3 would read a `GET` that
1771
+ * notes the visit as an EI, and the CPM asks about primary intent.
1772
+ */
1773
+ writtenStores.add(access.store);
1774
+ bodyWrites = true;
1775
+ if (!technical && !access.technical) writes = true;
1644
1776
  }
1645
1777
  unresolved.push(...facts.unresolved);
1646
1778
  for (const field of facts.validators) inputFields.add(field);
@@ -1662,7 +1794,7 @@ function createAnalyzer(app, stores, options = {}) {
1662
1794
  if (depth >= maxDepth) return;
1663
1795
  for (const followUp of facts.followUps) {
1664
1796
  const before = trace.length;
1665
- visit(followUp.ref, depth + 1);
1797
+ visit(followUp.ref, depth + 1, technical || followUp.technical === true);
1666
1798
  if (trace.length > before) trace[before].by = followUp.by;
1667
1799
  }
1668
1800
  };
@@ -1670,6 +1802,7 @@ function createAnalyzer(app, stores, options = {}) {
1670
1802
  return {
1671
1803
  writes,
1672
1804
  touches: [...touches].sort(),
1805
+ writtenStores: [...writtenStores].sort(),
1673
1806
  inputFields: [...inputFields].sort(),
1674
1807
  opaqueInputFields: [...opaqueInputFields].sort(),
1675
1808
  requestFields: [...requestFields].sort(),
@@ -2104,7 +2237,10 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
2104
2237
  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)",
2105
2238
  detSources: sources,
2106
2239
  refSources: touched.map((store) => `reaches:${store}`),
2107
- trace: behavior.trace
2240
+ trace: behavior.trace.map((step) => ({
2241
+ ...step,
2242
+ file: relativeTo(options.root, step.file)
2243
+ }))
2108
2244
  }
2109
2245
  });
2110
2246
  }
@@ -2250,7 +2386,7 @@ const RULESET = "afp";
2250
2386
  * against this one and bills the tool's own improvement as work done. The guard
2251
2387
  * exists for exactly that, and only this constant arms it.
2252
2388
  */
2253
- const RULESET_VERSION = "1.2.0";
2389
+ const RULESET_VERSION = "1.4.0";
2254
2390
  function count(input, options = {}) {
2255
2391
  const warnings = [];
2256
2392
  const usage = usageOf(input);
@@ -2279,7 +2415,17 @@ function count(input, options = {}) {
2279
2415
  * quietly counting one more store.
2280
2416
  */
2281
2417
  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})`);
2418
+ /**
2419
+ * Whether any transaction WRITES it, said out loud.
2420
+ *
2421
+ * `business` is meant for data the user maintains, and it accepted without
2422
+ * comment a table nothing in the application writes. That is how a team put two
2423
+ * read-only lookup tables in it believing they had a CRUD — the routes were
2424
+ * `.only(['index', 'show'])`. The declaration is still honoured, because only a
2425
+ * person knows, but the fact that contradicts it is now in the report.
2426
+ */
2427
+ const maintained = usage.get(store.name)?.written === true || (input.writtenAnywhere?.has(store.name) ?? false);
2428
+ warnings.push(`kept by boundary configuration: ${store.name} — the AFP naming filter had excluded it (${technical})` + (maintained ? "" : ". NOTE: no transaction of this application writes it, so it counts as an EIF — check that the screens for it are more than index and show"));
2283
2429
  return true;
2284
2430
  }
2285
2431
  warnings.push(`technical, excluded: ${store.name} (${technical})`);
@@ -2296,14 +2442,39 @@ function count(input, options = {}) {
2296
2442
  const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
2297
2443
  const transactionalFunctions = countTransactionalFunctions(input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? "")), input.behaviors, {
2298
2444
  countedStores,
2445
+ root: input.app.root,
2299
2446
  messageDet: options.messageDet ?? 0,
2300
2447
  tables,
2301
2448
  weights
2302
2449
  });
2303
- warnings.push(...opaqueColumnWarnings(countable, input));
2450
+ /**
2451
+ * `opaqueReviewed` answers a warning that is CORRECT and therefore permanent.
2452
+ *
2453
+ * 1 DET for an opaque column is a floor, and `fp:count` says so on every run. But
2454
+ * some of those columns are one field — a copy, a checksum, a bag of metadata —
2455
+ * and there was no way to record that someone had looked. A warning that cannot be
2456
+ * answered is one the team learns to scroll past, which costs more than the warning
2457
+ * reports. It silences nothing else: the count does not move, and how many were
2458
+ * reviewed is still printed.
2459
+ */
2460
+ const reviewed = reviewedOpaque(options.overrides ?? {});
2461
+ const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings, reviewed);
2462
+ /**
2463
+ * Reported AFTER the overrides are applied, because the overrides are the answer
2464
+ * to it.
2465
+ *
2466
+ * Computed first, the list kept naming functions whose floor had already been
2467
+ * replaced by `detFromSchema` — telling the reader to go and map something that
2468
+ * was mapped. It cost a real misreading: a report was taken as "two forms still
2469
+ * unmapped" when both were declared, by whoever wrote this code.
2470
+ */
2471
+ const declared = new Set(functions.filter((fn) => fn.rationale.overrides?.some((o) => o.fields.includes("det"))).map((fn) => fn.name));
2472
+ warnings.push(...opaqueWarnings(countable, input, {
2473
+ reviewed,
2474
+ declared,
2475
+ overrides: options.overrides ?? {}
2476
+ }));
2304
2477
  warnings.push(...unreadableInputWarnings(input));
2305
- warnings.push(...openValidatorWarnings(input));
2306
- const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2307
2478
  return {
2308
2479
  ruleset: "afp",
2309
2480
  rulesetVersion: RULESET_VERSION,
@@ -2325,45 +2496,147 @@ function count(input, options = {}) {
2325
2496
  * `metadata` column changes no number, and warning about it would be the noise
2326
2497
  * that teaches people to stop reading the confidence block.
2327
2498
  */
2328
- function opaqueColumnWarnings(stores, input) {
2499
+ /**
2500
+ * Opaque DETs someone has declared reviewed, in both spellings a person might use.
2501
+ *
2502
+ * Qualified (`Petition.schema`) is unambiguous; bare (`schema`) is what someone reads
2503
+ * off the warning line.
2504
+ */
2505
+ function reviewedOpaque(overrides) {
2506
+ const reviewed = /* @__PURE__ */ new Set();
2507
+ for (const [name, override] of Object.entries(overrides)) for (const entry of override.opaqueReviewed ?? []) {
2508
+ reviewed.add(entry);
2509
+ reviewed.add(`${name}.${entry}`);
2510
+ /**
2511
+ * The bare field too, because the two sides of this comparison spell things
2512
+ * differently. `opaqueReviewed` is written against the FUNCTION (`Petition.schema`)
2513
+ * while a rationale source carries the TABLE (`ast:petitions.schema`), and the
2514
+ * qualified form cannot be recovered from either. The last segment is what they
2515
+ * share, and without it a correctly written review matched the count and not the
2516
+ * rationale — so `fp:explain` showed no review and the override warning still
2517
+ * claimed five unanswered floors.
2518
+ */
2519
+ reviewed.add(entry.split(".").pop() ?? entry);
2520
+ }
2521
+ return reviewed;
2522
+ }
2523
+ /**
2524
+ * The identity of an opaque DET inside a rationale source.
2525
+ *
2526
+ * `ast:petitions.schema (opaque)` is the store's TABLE name, and `opaqueReviewed` is
2527
+ * written against the FUNCTION name (`Petition.schema`), so the qualified form cannot
2528
+ * be recovered from the source alone — the bare field is what both sides share.
2529
+ */
2530
+ const opaqueNameOf = (source) => source.replace(/^[a-z-]+:/, "").replace(/ \(opaque.*\)$/, "");
2531
+ const shortOpaqueNameOf = (source) => opaqueNameOf(source).split(".").pop() ?? "";
2532
+ /** `fp:explain` should say which floors someone has already looked at */
2533
+ function markReviewed(sources, reviewed) {
2534
+ return sources.map((source) => {
2535
+ if (!source.endsWith("(opaque)")) return source;
2536
+ return reviewed.has(opaqueNameOf(source)) || reviewed.has(shortOpaqueNameOf(source)) ? source.replace("(opaque)", "(opaque, reviewed)") : source;
2537
+ });
2538
+ }
2539
+ /** a column whose shape says nothing about what it holds */
2540
+ const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2541
+ /**
2542
+ * DETs the analysis cannot read: an opaque column, or an open input object.
2543
+ *
2544
+ * Both count 1, which is a FLOOR rather than a measurement, and counting-decisions §8
2545
+ * is the trade. Reporting it is the point — this was the one known blind spot the
2546
+ * package reported nowhere.
2547
+ *
2548
+ * Grouped by FUNCTION and stating what has already been answered, because a flat list
2549
+ * of columns could not say that. Computed before the overrides ran, it named functions
2550
+ * whose floor `detFromSchema` had already replaced, which reads as "go and map this"
2551
+ * about something already mapped. That misreading actually happened, to the author of
2552
+ * this code, reading someone else's report.
2553
+ *
2554
+ * Three states per function, and only the third is a request to do something:
2555
+ *
2556
+ * replaced a `detFromSchema` override stands in for one of them
2557
+ * reviewed someone looked and 1 is the right answer
2558
+ * floor still unanswered
2559
+ */
2560
+ function opaqueWarnings(stores, input, state) {
2329
2561
  const reached = /* @__PURE__ */ new Map();
2330
2562
  for (const entry of input.entryPoints) for (const store of input.behaviors.get(entry.id)?.touches ?? []) reached.set(store, (reached.get(store) ?? 0) + 1);
2331
- const found = [];
2563
+ const byFunction = /* @__PURE__ */ new Map();
2564
+ const tally = (name, kind, transactions) => {
2565
+ const found = byFunction.get(name) ?? {
2566
+ kind,
2567
+ floor: [],
2568
+ reviewed: 0,
2569
+ reviewedNames: [],
2570
+ transactions
2571
+ };
2572
+ byFunction.set(name, found);
2573
+ return found;
2574
+ };
2332
2575
  for (const store of stores) {
2333
- const transactions = reached.get(store.name);
2334
- if (!transactions) continue;
2576
+ if (!reached.get(store.name)) continue;
2335
2577
  for (const attribute of store.attributes) {
2336
2578
  if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
2337
- found.push(` ${store.name}.${attribute.name} (${attribute.type}) — ${transactions} transaction(s)`);
2579
+ const entry = tally(store.name, "column", reached.get(store.name) ?? 0);
2580
+ if (state.reviewed.has(`${store.name}.${attribute.name}`) || state.reviewed.has(attribute.name)) {
2581
+ entry.reviewed += 1;
2582
+ entry.reviewedNames.push(attribute.name);
2583
+ } else entry.floor.push(`${attribute.name} (${attribute.type})`);
2338
2584
  }
2339
2585
  }
2340
- if (found.length === 0) return [];
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];
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}`);
2586
+ for (const point of input.entryPoints) for (const field of input.behaviors.get(point.id)?.opaqueInputFields ?? []) {
2587
+ const entry = tally(point.identity, "input object", 1);
2588
+ if (state.reviewed.has(field) || state.reviewed.has(`${point.identity}.${field}`)) {
2589
+ entry.reviewed += 1;
2590
+ entry.reviewedNames.push(field);
2591
+ } else entry.floor.push(field);
2592
+ }
2593
+ /**
2594
+ * A review that matches nothing is a review that does nothing.
2595
+ *
2596
+ * `detFromSchema` already warns when it names a schema that is not declared, and
2597
+ * `opaqueReviewed` did not — so `['messages.schema']` against a field actually named
2598
+ * `createMessageValidator.messages.schema` reviewed nothing in silence while the
2599
+ * warning kept firing, which reads as the tool ignoring the configuration.
2600
+ */
2601
+ const seen = /* @__PURE__ */ new Set();
2602
+ for (const [name, entry] of byFunction) for (const field of [...entry.floor, ...entry.reviewedNames]) {
2603
+ const bare = field.replace(/ \(.*\)$/, "");
2604
+ seen.add(bare);
2605
+ seen.add(`${name}.${bare}`);
2606
+ seen.add(bare.split(".").pop() ?? bare);
2361
2607
  }
2362
- if (found.length === 0) return [];
2608
+ const unmatched = [];
2609
+ for (const [name, override] of Object.entries(state.overrides)) for (const declaredName of override.opaqueReviewed ?? []) if (![
2610
+ declaredName,
2611
+ `${name}.${declaredName}`,
2612
+ declaredName.split(".").pop() ?? ""
2613
+ ].some((spelling) => seen.has(spelling))) unmatched.push(`${name}.opaqueReviewed: ${declaredName}`);
2614
+ const lines = [];
2615
+ let answered = 0;
2616
+ for (const [name, entry] of byFunction) {
2617
+ /** a declared schema stands in for exactly one placeholder — §8, and the override warns when there are more */
2618
+ const replaced = state.declared.has(name) && entry.floor.length > 0 ? 1 : 0;
2619
+ const remaining = entry.floor.slice(replaced);
2620
+ if (remaining.length === 0) {
2621
+ answered += 1;
2622
+ continue;
2623
+ }
2624
+ const answeredHere = [...replaced > 0 ? [`${replaced} replaced by override`] : [], ...entry.reviewed > 0 ? [`${entry.reviewed} reviewed`] : []];
2625
+ /**
2626
+ * How many transactions reach the store, so the reader can judge whether the
2627
+ * floor is worth answering. A blob nothing touches changes no number.
2628
+ */
2629
+ const reach = entry.kind === "column" ? `, reached by ${entry.transactions} transaction(s)` : "";
2630
+ 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(", ")}`);
2631
+ }
2632
+ const settled = answered === 0 ? [] : [` (${answered} more function(s) whose opaque DETs are all accounted for)`];
2633
+ const unmatchedLines = unmatched.length === 0 ? [] : [`${unmatched.length} \`opaqueReviewed\` entr(ies) match no opaque DET, so they review nothing. The name is the one the count prints:`, ...unmatched.map((u) => ` ${u}`)];
2634
+ if (lines.length === 0) return [...unmatchedLines, ...settled];
2363
2635
  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`] : []
2636
+ `${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:`,
2637
+ ...lines,
2638
+ ...settled,
2639
+ ...unmatchedLines
2367
2640
  ];
2368
2641
  }
2369
2642
  /**
@@ -2402,8 +2675,6 @@ function unreadableInputWarnings(input) {
2402
2675
  ...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
2403
2676
  ];
2404
2677
  }
2405
- /** a column whose shape says nothing about what it holds */
2406
- const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2407
2678
  /**
2408
2679
  * Replaces what the analysis found with what a person declared.
2409
2680
  *
@@ -2415,7 +2686,7 @@ const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2415
2686
  * An override naming no function is a warning, never silence: a typo in the key
2416
2687
  * would otherwise mean the declaration did nothing and nobody was told.
2417
2688
  */
2418
- function applyOverrides(functions, overrides, schemas, tables, weights, warnings) {
2689
+ function applyOverrides(functions, overrides, schemas, tables, weights, warnings, reviewed) {
2419
2690
  const keys = Object.keys(overrides);
2420
2691
  if (keys.length === 0) return functions;
2421
2692
  const used = /* @__PURE__ */ new Set();
@@ -2438,7 +2709,25 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2438
2709
  * is born, not when a field is.
2439
2710
  */
2440
2711
  if (override.detFromSchema) {
2441
- const schema = schemas.get(override.detFromSchema);
2712
+ /**
2713
+ * One name or several, unioned by leaf path.
2714
+ *
2715
+ * An ILF's DETs are the fields the user recognises in the file, and an
2716
+ * application with one schema per template recognises all of them. A field
2717
+ * two templates share is one DET, so the union is over paths rather than a
2718
+ * sum of counts.
2719
+ */
2720
+ const named = [override.detFromSchema].flat();
2721
+ const resolved = named.map((name) => schemas.get(name)).filter((s) => s !== void 0);
2722
+ const missing = named.filter((name) => !schemas.has(name));
2723
+ const union = new Set(resolved.flatMap((s) => s.leaves));
2724
+ const schema = resolved.length === 0 ? void 0 : {
2725
+ /** only what resolved: naming a schema that contributed nothing would mislead */
2726
+ name: resolved.map((s) => s.name).join(" + "),
2727
+ fields: union.size,
2728
+ leaves: [...union]
2729
+ };
2730
+ 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.`);
2442
2731
  if (schema) {
2443
2732
  /**
2444
2733
  * Replaces the opaque placeholder — the one the rationale marks — rather
@@ -2453,11 +2742,34 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2453
2742
  det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
2454
2743
  by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
2455
2744
  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.`);
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.`);
2745
+ else {
2746
+ /**
2747
+ * Only the placeholders nobody has answered are worth reporting.
2748
+ *
2749
+ * The message used to count every opaque DET of the function and say "names
2750
+ * one schema" whatever it was given. With four of five columns in
2751
+ * `opaqueReviewed` and a LIST of two schemas, it still fired, still said
2752
+ * "one schema", and still counted the four already answered — a warning
2753
+ * wrong on all three counts, about a configuration that was complete.
2754
+ */
2755
+ const unanswered = placeholders.filter((source) => !reviewed.has(opaqueNameOf(source)) && !reviewed.has(shortOpaqueNameOf(source)));
2756
+ if (unanswered.length > 1) warnings.push(`override for "${fn.name}" names ${named.length === 1 ? "one schema" : `${named.length} schemas`} and the function has ${unanswered.length} unanswered opaque DETs (${unanswered.join(", ")}). One was replaced; the others still count 1 each — declare them or record them with \`opaqueReviewed\`.`);
2757
+ }
2758
+ }
2458
2759
  }
2459
2760
  const refs = override.refs ?? fn.refs;
2460
2761
  const complexity = complexityOf(fn.type, refs, det, tables);
2762
+ /**
2763
+ * A review is recorded with NO fields, and the reporter's "declared by override"
2764
+ * share counts only entries that declared one.
2765
+ *
2766
+ * Dropping it entirely lost the `reason`, so an `opaqueReviewed`-only decision
2767
+ * appeared nowhere — not in `fp:explain`, not anywhere — which defeats the point
2768
+ * of requiring a reason. Counting it in the share was the opposite error: it read
2769
+ * as "1 function, 7 FP, 35% of the total declared by override" when no number had
2770
+ * been declared at all.
2771
+ */
2772
+ const marked = markReviewed(fn.rationale.detSources, reviewed);
2461
2773
  return {
2462
2774
  ...fn,
2463
2775
  det,
@@ -2466,6 +2778,7 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2466
2778
  points: pointsOf(fn.type, complexity, weights),
2467
2779
  rationale: {
2468
2780
  ...fn.rationale,
2781
+ detSources: marked,
2469
2782
  overrides: [...fn.rationale.overrides ?? [], {
2470
2783
  by,
2471
2784
  reason: override.reason,
@@ -2494,7 +2807,16 @@ function usageOf(input) {
2494
2807
  };
2495
2808
  usage.set(store, {
2496
2809
  used: true,
2497
- written: current.written || behavior.writes
2810
+ /**
2811
+ * Per STORE, not per transaction.
2812
+ *
2813
+ * `behavior.writes` decides EI against EO and says nothing about which of
2814
+ * the tables was written. Read as "this store is maintained", a reference
2815
+ * table merely READ by a route that writes something else became an ILF —
2816
+ * and on a production application that left exactly one EIF in the whole
2817
+ * count, which should have been the signal.
2818
+ */
2819
+ written: current.written || behavior.writtenStores.includes(store)
2498
2820
  });
2499
2821
  }
2500
2822
  }
@@ -2601,7 +2923,7 @@ function describeSource(root, config) {
2601
2923
  ]),
2602
2924
  dirty: status === void 0 ? void 0 : status.length > 0,
2603
2925
  countedAt: (/* @__PURE__ */ new Date()).toISOString(),
2604
- config: config === null ? null : toPosix(config)
2926
+ config: config === null ? null : relativeTo(root, config)
2605
2927
  };
2606
2928
  }
2607
2929
  function appName(root) {
@@ -2635,46 +2957,87 @@ async function analyze(root, options = {}) {
2635
2957
  const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
2636
2958
  const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
2637
2959
  const unresolvedCalls = storeProblems.length + routeProblems.length + [...behaviors.values()].reduce((total, behavior) => total + behavior.unresolved.length, 0);
2960
+ /**
2961
+ * Every path that LEAVES is relative to the application root.
2962
+ *
2963
+ * `CountSource.app` is documented as never being the absolute path, because that
2964
+ * says where the machine keeps its files and travels with every artefact sent
2965
+ * anywhere. The rule was stated on one field and applied to one field: the
2966
+ * inventory carried 2036 absolute paths across ten of them, and a count carried
2967
+ * 858 in its traces alone.
2968
+ *
2969
+ * Absolute is right INTERNALLY — it is what ts-morph resolves and what the call
2970
+ * graph keys its caches on — so the conversion happens here, at the boundary, and
2971
+ * the data-store `id` is relativised only after the ancestor filter has used it.
2972
+ */
2973
+ const source = describeSource(root, options.configFile ?? null);
2974
+ const emit = (value) => relativeTo(app.root, value);
2975
+ const emitProvenance = (p) => ({
2976
+ ...p,
2977
+ file: emit(p.file)
2978
+ });
2638
2979
  const inventory = {
2639
2980
  version: 1,
2640
2981
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2641
- app: app.root,
2982
+ app: source.app,
2642
2983
  framework: {
2643
2984
  core: app.framework.core,
2644
2985
  lucid: app.framework.lucid,
2645
2986
  orm: app.framework.orm
2646
2987
  },
2647
- dataStores: stores,
2648
- entryPoints,
2988
+ dataStores: stores.map((store) => ({
2989
+ ...store,
2990
+ id: emit(store.id),
2991
+ provenance: emitProvenance(store.provenance),
2992
+ attributes: store.attributes.map((a) => ({
2993
+ ...a,
2994
+ provenance: emitProvenance(a.provenance)
2995
+ }))
2996
+ })),
2997
+ entryPoints: entryPoints.map((entry) => ({
2998
+ ...entry,
2999
+ provenance: emitProvenance(entry.provenance),
3000
+ handler: entry.handler ? {
3001
+ ...entry.handler,
3002
+ file: emit(entry.handler.file)
3003
+ } : null
3004
+ })),
2649
3005
  behaviors: [...behaviors.entries()].map(([entryPointId, behavior]) => ({
2650
3006
  entryPointId,
2651
3007
  writes: behavior.writes,
2652
3008
  touches: behavior.touches,
3009
+ writtenStores: behavior.writtenStores,
2653
3010
  inputFields: behavior.inputFields.map((name) => ({
2654
3011
  name,
2655
3012
  provenance: {
2656
- file: app.root,
3013
+ file: emit(app.root),
2657
3014
  by: "validator"
2658
3015
  }
2659
3016
  })),
2660
3017
  opaqueInputFields: behavior.opaqueInputFields.map((name) => ({
2661
3018
  name,
2662
3019
  provenance: {
2663
- file: app.root,
3020
+ file: emit(app.root),
2664
3021
  by: "validator"
2665
3022
  }
2666
3023
  })),
2667
3024
  requestFields: behavior.requestFields.map((name) => ({
2668
3025
  name,
2669
3026
  provenance: {
2670
- file: app.root,
3027
+ file: emit(app.root),
2671
3028
  by: "request"
2672
3029
  }
2673
3030
  })),
2674
3031
  opaqueRequest: behavior.opaqueRequest,
2675
3032
  outputFields: [],
2676
- trace: behavior.trace,
2677
- unresolved: behavior.unresolved
3033
+ trace: behavior.trace.map((step) => ({
3034
+ ...step,
3035
+ file: emit(step.file)
3036
+ })),
3037
+ unresolved: behavior.unresolved.map((call) => ({
3038
+ ...call,
3039
+ file: emit(call.file)
3040
+ }))
2678
3041
  })),
2679
3042
  coverage: {
2680
3043
  entryPointsTotal: entryPoints.length,
@@ -2696,7 +3059,7 @@ async function analyze(root, options = {}) {
2696
3059
  jsonSchemas,
2697
3060
  writtenAnywhere: analyzer.writtenAnywhere()
2698
3061
  }, options),
2699
- source: describeSource(root, options.configFile ?? null)
3062
+ source
2700
3063
  }
2701
3064
  };
2702
3065
  }