@filipebraida/adonis-function-points 0.4.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,59 @@ release moves the number for unchanged code, the rule set version moves with it
6
6
  otherwise the difference would measure the tool's change rather than the work, and
7
7
  that difference becomes an invoice.
8
8
 
9
+ ## 0.5.0
10
+
11
+ **Rule set `afp@1.4.0`.** Two classification defects are fixed and both move numbers,
12
+ so a 0.4.0 baseline has to be recounted.
13
+
14
+ Found by auditing a production count function by function against the code, rather than
15
+ by reading the report's warnings — which is where the previous rounds had been looking.
16
+
17
+ ### Fixed
18
+
19
+ - **Maintenance was decided per REQUEST instead of per store.** `behavior.writes`
20
+ decides EI against EO and was also read as "this store is maintained", so every store
21
+ a writing transaction touched became an ILF. A reference table merely READ by a route
22
+ that writes something else counted as maintained. On a production application this
23
+ left exactly one EIF in the whole count, which should have been the signal.
24
+ - **Seeders, tests and factories counted as maintenance.** The project-wide pass read a
25
+ seeder's inserts as the application maintaining a table, so reference data only the
26
+ seed populates came out as an ILF — which the CPM does not allow. The filter on scan
27
+ roots drops `tests/` and `database/` only at the ROOT, and a domain-module layout puts
28
+ both inside `app/`. It now applies at any depth.
29
+ - **The override warning counted floors that were already answered**, said "one schema"
30
+ whatever it was given, and therefore fired on a configuration that was complete.
31
+ - **`opaqueReviewed` matching nothing was silent.** `detFromSchema` already warns when
32
+ it names a schema that is not declared; a review naming a field that does not exist
33
+ reviewed nothing while the warning kept firing, which reads as the tool ignoring the
34
+ configuration.
35
+ - **A review was invisible in `fp:explain`.** Its reason appeared nowhere, which defeats
36
+ requiring one. Reviewed floors are now marked `(opaque, reviewed)` and the reason is
37
+ printed — without being counted in the "Declared by override" share, since a review
38
+ declares no number.
39
+
40
+ ### Documented
41
+
42
+ - **In CI, prefer the standalone binary.** `node ace` validates `start/env.ts` before
43
+ running any command, so `node ace fp:count` fails on a missing environment variable
44
+ that has nothing to do with counting — measured on a production application, it
45
+ stopped at `Missing environment variable "AUTHZ_STORE"` and never reached the
46
+ command. The `fp:*` commands declare `startApp: false`, which is not enough. The
47
+ README said the two front-ends were interchangeable; for a pipeline that only checks
48
+ out code, they are not.
49
+
50
+ ### New
51
+
52
+ - **`CallResolver.technicalWrite()`** declares that a write is not what the transaction
53
+ is for. §6.5.3 reads any write as an EI, which misreads a screen that records the
54
+ visit; the CPM asks about primary intent. The fact is declared about the CALL, so a
55
+ bookkeeping helper called from several screens is declared once. It does not hide the
56
+ write: the store stays an ILF and stays an FTR.
57
+ - **`boundary.business` says when it contradicts the code.** It accepted without comment
58
+ a table nothing in the application writes — which is how two read-only lookup tables
59
+ were declared as business data on the belief they had a CRUD, when the routes were
60
+ `.only(['index', 'show'])`. The declaration is still honoured; the fact is reported.
61
+
9
62
  ## 0.4.0
10
63
 
11
64
  **Rule set `afp@1.3.0`.** Conditional validator groups now count, and a nested
package/README.md CHANGED
@@ -13,7 +13,7 @@ node ace fp:count
13
13
 
14
14
  ```
15
15
  Unadjusted count: 46 FP
16
- Ruleset: afp@1.3.0
16
+ Ruleset: afp@1.4.0
17
17
 
18
18
  type n FP
19
19
  ILF 2 14
@@ -63,11 +63,21 @@ For CI, or a one-off count on a project you do not want to touch:
63
63
  npx @filipebraida/adonis-function-points count --root ./my-app
64
64
  ```
65
65
 
66
- Nothing is booted either way — the engine only reads files — so a standalone
67
- run needs no `.env`, no database, and no install inside the analysed project.
68
- The standalone binary **does not replace installing**: a project that installs
69
- the package keeps the `node ace fp:*` commands, and both front-ends call the
70
- same code, so they cannot disagree about a number.
66
+ The engine itself boots nothing — it only reads files — and the standalone binary
67
+ needs no `.env`, no database, and no install inside the analysed project. The
68
+ `fp:*` commands declare `startApp: false` for the same reason.
69
+
70
+ **In CI, prefer the standalone binary.** Not for convenience: `node ace` validates
71
+ `start/env.ts` before it runs any command, so `node ace fp:count` fails on a
72
+ missing environment variable that has nothing to do with counting. Measured on a
73
+ production application, it stopped at `Missing environment variable "AUTHZ_STORE"`
74
+ and never reached the command. A pipeline that only checks out the code has no
75
+ secrets, and does not need them to count.
76
+
77
+ The standalone binary **does not replace installing**: a project that installs the
78
+ package keeps the `node ace fp:*` commands — which is the right front-end at a
79
+ developer's terminal, where the `.env` is already there — and both call the same
80
+ code, so they cannot disagree about a number.
71
81
 
72
82
  ```
73
83
  adonis-function-points <command> [options]
@@ -75,6 +85,7 @@ adonis-function-points <command> [options]
75
85
  count count the unadjusted function points
76
86
  inventory the raw facts: stores, routes, tracing coverage
77
87
  explain <name> why one function was counted that way
88
+ metrics density, coupling and conformance, from the same run
78
89
  diff <previous.json> additions / modifications / deletions, and billable FP
79
90
  calibrate <samples.csv> correction factors against a manual count
80
91
 
@@ -14,27 +14,27 @@
14
14
  const commands = [
15
15
  {
16
16
  commandName: "fp:inventory",
17
- importer: () => import("../fp_inventory-DHwZzEQf.js")
17
+ importer: () => import("../fp_inventory-CPtmuuke.js")
18
18
  },
19
19
  {
20
20
  commandName: "fp:metrics",
21
- importer: () => import("../fp_metrics-M84qLYaE.js")
21
+ importer: () => import("../fp_metrics-et8F1Wvt.js")
22
22
  },
23
23
  {
24
24
  commandName: "fp:count",
25
- importer: () => import("../fp_count-DNSwaLUD.js")
25
+ importer: () => import("../fp_count-ChtblhZV.js")
26
26
  },
27
27
  {
28
28
  commandName: "fp:explain",
29
- importer: () => import("../fp_explain-Dpiby5Qx.js")
29
+ importer: () => import("../fp_explain-DZJ--0-S.js")
30
30
  },
31
31
  {
32
32
  commandName: "fp:diff",
33
- importer: () => import("../fp_diff-CCKxqGKh.js")
33
+ importer: () => import("../fp_diff-Dt7J4IWu.js")
34
34
  },
35
35
  {
36
36
  commandName: "fp:calibrate",
37
- importer: () => import("../fp_calibrate-DLZP5bUp.js")
37
+ importer: () => import("../fp_calibrate-DUbHiifm.js")
38
38
  }
39
39
  ];
40
40
  let cache = null;
@@ -1,4 +1,4 @@
1
- import { s as printResult, t as runCalibrate } from "./runners-DetZGfh5.js";
1
+ import { s as printResult, t as runCalibrate } from "./runners-DIt1G85i.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_calibrate.ts
@@ -1,4 +1,4 @@
1
- import { n as runCount, s as printResult } from "./runners-DetZGfh5.js";
1
+ import { n as runCount, s as printResult } from "./runners-DIt1G85i.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_count.ts
@@ -1,4 +1,4 @@
1
- import { r as runDiff, s as printResult } from "./runners-DetZGfh5.js";
1
+ import { r as runDiff, s as printResult } from "./runners-DIt1G85i.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_diff.ts
@@ -1,4 +1,4 @@
1
- import { i as runExplain, s as printResult } from "./runners-DetZGfh5.js";
1
+ import { i as runExplain, s as printResult } from "./runners-DIt1G85i.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, args } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_explain.ts
@@ -1,4 +1,4 @@
1
- import { a as runInventory, s as printResult } from "./runners-DetZGfh5.js";
1
+ import { a as runInventory, s as printResult } from "./runners-DIt1G85i.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_inventory.ts
@@ -1,4 +1,4 @@
1
- import { o as runMetrics, s as printResult } from "./runners-DetZGfh5.js";
1
+ import { o as runMetrics, s as printResult } from "./runners-DIt1G85i.js";
2
2
  import { n as printerFor, t as __decorate } from "./decorate-D6enDn9D.js";
3
3
  import { BaseCommand, flags } from "@adonisjs/core/ace";
4
4
  //#region commands/fp_metrics.ts
package/build/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { configure } from "./configure.js";
2
2
  import { a as AEP_FACTORS, c as diffCounts, i as measureStructure, n as parseSamples, o as IncomparableRulesetsError, r as measureConformance, s as IncomparableSourcesError, t as calibrate, u as defineConfig } from "./calibration-8eV8CEix.js";
3
3
  import "./src/types.js";
4
- import { t as BUILTIN_CALL_RESOLVERS } from "./resolvers-vMahHkAd.js";
5
- import { i as RULESET_VERSION, n as analyze, r as RULESET, t as CoverageTooLowError } from "./pipeline-Dm9KvUvF.js";
4
+ import { t as BUILTIN_CALL_RESOLVERS } from "./resolvers-PJwo2Z8R.js";
5
+ import { i as RULESET_VERSION, n as analyze, r as RULESET, t as CoverageTooLowError } from "./pipeline-CNTBhs6o.js";
6
6
  export { AEP_FACTORS, BUILTIN_CALL_RESOLVERS, CoverageTooLowError, IncomparableRulesetsError, IncomparableSourcesError, RULESET, RULESET_VERSION, analyze, calibrate, configure, defineConfig, diffCounts, measureConformance, measureStructure, parseSamples };
@@ -1,4 +1,4 @@
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";
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";
@@ -1586,12 +1586,34 @@ function createAnalyzer(app, stores, options = {}) {
1586
1586
  const unresolved = [];
1587
1587
  const validator = validatorFieldsIn(body, file, app);
1588
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
+ };
1589
1600
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1590
1601
  const access = detectAccess(call, symbols, relationsByStore);
1591
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);
1592
1613
  accesses.push({
1593
1614
  store: access.store,
1594
- write: access.mode === "write"
1615
+ write: access.mode === "write",
1616
+ technical
1595
1617
  });
1596
1618
  /**
1597
1619
  * A relation reached by `preload`/`load` is read; one written through
@@ -1619,21 +1641,18 @@ function createAnalyzer(app, stores, options = {}) {
1619
1641
  * shape must not be CLAIMED, not merely not reported.
1620
1642
  */
1621
1643
  if (isIterationCall(call)) continue;
1622
- const resolved = resolveCall(call, {
1623
- file,
1624
- depth: 0,
1625
- imports,
1626
- exportedAs,
1627
- injected,
1628
- eventBindings,
1629
- dataStoresBySymbol: storesByName,
1630
- resolveSpecifier: app.resolveSpecifier,
1631
- sourceFile
1632
- }, resolvers);
1644
+ const resolved = resolveCall(call, context, resolvers);
1633
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);
1634
1652
  for (const next of resolved.refs) followUps.push({
1635
1653
  ref: next,
1636
- by: resolved.by
1654
+ by: resolved.by,
1655
+ technical
1637
1656
  });
1638
1657
  continue;
1639
1658
  }
@@ -1673,6 +1692,17 @@ function createAnalyzer(app, stores, options = {}) {
1673
1692
  const writtenAnywhere = () => {
1674
1693
  const written = /* @__PURE__ */ new Set();
1675
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;
1676
1706
  const symbols = storeSymbolsFor(file, file, app, storesByName);
1677
1707
  if (symbols.size === 0) continue;
1678
1708
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
@@ -1698,6 +1728,7 @@ function createAnalyzer(app, stores, options = {}) {
1698
1728
  };
1699
1729
  function run(handler) {
1700
1730
  const touches = /* @__PURE__ */ new Set();
1731
+ const writtenStores = /* @__PURE__ */ new Set();
1701
1732
  const inputFields = /* @__PURE__ */ new Set();
1702
1733
  const opaqueInputFields = /* @__PURE__ */ new Set();
1703
1734
  const requestFields = /* @__PURE__ */ new Set();
@@ -1707,7 +1738,7 @@ function createAnalyzer(app, stores, options = {}) {
1707
1738
  const unresolved = [];
1708
1739
  const visited = /* @__PURE__ */ new Set();
1709
1740
  let writes = false;
1710
- const visit = (ref, depth) => {
1741
+ const visit = (ref, depth, technical = false) => {
1711
1742
  const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
1712
1743
  if (visited.has(key) || depth > maxDepth) return;
1713
1744
  visited.add(key);
@@ -1732,10 +1763,16 @@ function createAnalyzer(app, stores, options = {}) {
1732
1763
  let bodyWrites = false;
1733
1764
  for (const access of facts.accesses) {
1734
1765
  touches.add(access.store);
1735
- if (access.write) {
1736
- bodyWrites = true;
1737
- writes = true;
1738
- }
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;
1739
1776
  }
1740
1777
  unresolved.push(...facts.unresolved);
1741
1778
  for (const field of facts.validators) inputFields.add(field);
@@ -1757,7 +1794,7 @@ function createAnalyzer(app, stores, options = {}) {
1757
1794
  if (depth >= maxDepth) return;
1758
1795
  for (const followUp of facts.followUps) {
1759
1796
  const before = trace.length;
1760
- visit(followUp.ref, depth + 1);
1797
+ visit(followUp.ref, depth + 1, technical || followUp.technical === true);
1761
1798
  if (trace.length > before) trace[before].by = followUp.by;
1762
1799
  }
1763
1800
  };
@@ -1765,6 +1802,7 @@ function createAnalyzer(app, stores, options = {}) {
1765
1802
  return {
1766
1803
  writes,
1767
1804
  touches: [...touches].sort(),
1805
+ writtenStores: [...writtenStores].sort(),
1768
1806
  inputFields: [...inputFields].sort(),
1769
1807
  opaqueInputFields: [...opaqueInputFields].sort(),
1770
1808
  requestFields: [...requestFields].sort(),
@@ -2348,7 +2386,7 @@ const RULESET = "afp";
2348
2386
  * against this one and bills the tool's own improvement as work done. The guard
2349
2387
  * exists for exactly that, and only this constant arms it.
2350
2388
  */
2351
- const RULESET_VERSION = "1.3.0";
2389
+ const RULESET_VERSION = "1.4.0";
2352
2390
  function count(input, options = {}) {
2353
2391
  const warnings = [];
2354
2392
  const usage = usageOf(input);
@@ -2377,7 +2415,17 @@ function count(input, options = {}) {
2377
2415
  * quietly counting one more store.
2378
2416
  */
2379
2417
  if (business.has(store.name) || business.has(store.table ?? "")) {
2380
- 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"));
2381
2429
  return true;
2382
2430
  }
2383
2431
  warnings.push(`technical, excluded: ${store.name} (${technical})`);
@@ -2410,7 +2458,7 @@ function count(input, options = {}) {
2410
2458
  * reviewed is still printed.
2411
2459
  */
2412
2460
  const reviewed = reviewedOpaque(options.overrides ?? {});
2413
- const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings);
2461
+ const functions = applyOverrides([...dataFunctions, ...transactionalFunctions], options.overrides ?? {}, input.jsonSchemas ?? /* @__PURE__ */ new Map(), tables, weights, warnings, reviewed);
2414
2462
  /**
2415
2463
  * Reported AFTER the overrides are applied, because the overrides are the answer
2416
2464
  * to it.
@@ -2423,7 +2471,8 @@ function count(input, options = {}) {
2423
2471
  const declared = new Set(functions.filter((fn) => fn.rationale.overrides?.some((o) => o.fields.includes("det"))).map((fn) => fn.name));
2424
2472
  warnings.push(...opaqueWarnings(countable, input, {
2425
2473
  reviewed,
2426
- declared
2474
+ declared,
2475
+ overrides: options.overrides ?? {}
2427
2476
  }));
2428
2477
  warnings.push(...unreadableInputWarnings(input));
2429
2478
  return {
@@ -2458,9 +2507,35 @@ function reviewedOpaque(overrides) {
2458
2507
  for (const [name, override] of Object.entries(overrides)) for (const entry of override.opaqueReviewed ?? []) {
2459
2508
  reviewed.add(entry);
2460
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);
2461
2520
  }
2462
2521
  return reviewed;
2463
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
+ }
2464
2539
  /** a column whose shape says nothing about what it holds */
2465
2540
  const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2466
2541
  /**
@@ -2491,6 +2566,7 @@ function opaqueWarnings(stores, input, state) {
2491
2566
  kind,
2492
2567
  floor: [],
2493
2568
  reviewed: 0,
2569
+ reviewedNames: [],
2494
2570
  transactions
2495
2571
  };
2496
2572
  byFunction.set(name, found);
@@ -2501,15 +2577,40 @@ function opaqueWarnings(stores, input, state) {
2501
2577
  for (const attribute of store.attributes) {
2502
2578
  if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
2503
2579
  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})`);
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})`);
2506
2584
  }
2507
2585
  }
2508
2586
  for (const point of input.entryPoints) for (const field of input.behaviors.get(point.id)?.opaqueInputFields ?? []) {
2509
2587
  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);
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);
2512
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);
2607
+ }
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}`);
2513
2614
  const lines = [];
2514
2615
  let answered = 0;
2515
2616
  for (const [name, entry] of byFunction) {
@@ -2529,11 +2630,13 @@ function opaqueWarnings(stores, input, state) {
2529
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(", ")}`);
2530
2631
  }
2531
2632
  const settled = answered === 0 ? [] : [` (${answered} more function(s) whose opaque DETs are all accounted for)`];
2532
- if (lines.length === 0) return settled;
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];
2533
2635
  return [
2534
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:`,
2535
2637
  ...lines,
2536
- ...settled
2638
+ ...settled,
2639
+ ...unmatchedLines
2537
2640
  ];
2538
2641
  }
2539
2642
  /**
@@ -2583,7 +2686,7 @@ function unreadableInputWarnings(input) {
2583
2686
  * An override naming no function is a warning, never silence: a typo in the key
2584
2687
  * would otherwise mean the declaration did nothing and nobody was told.
2585
2688
  */
2586
- function applyOverrides(functions, overrides, schemas, tables, weights, warnings) {
2689
+ function applyOverrides(functions, overrides, schemas, tables, weights, warnings, reviewed) {
2587
2690
  const keys = Object.keys(overrides);
2588
2691
  if (keys.length === 0) return functions;
2589
2692
  const used = /* @__PURE__ */ new Set();
@@ -2639,21 +2742,34 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2639
2742
  det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
2640
2743
  by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
2641
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.`);
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.`);
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
+ }
2643
2758
  }
2644
2759
  }
2645
2760
  const refs = override.refs ?? fn.refs;
2646
2761
  const complexity = complexityOf(fn.type, refs, det, tables);
2647
2762
  /**
2648
- * Only a DECLARED NUMBER is an override in the rationale.
2763
+ * A review is recorded with NO fields, and the reporter's "declared by override"
2764
+ * share counts only entries that declared one.
2649
2765
  *
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.
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.
2655
2771
  */
2656
- if (fields.length === 0) return fn;
2772
+ const marked = markReviewed(fn.rationale.detSources, reviewed);
2657
2773
  return {
2658
2774
  ...fn,
2659
2775
  det,
@@ -2662,6 +2778,7 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2662
2778
  points: pointsOf(fn.type, complexity, weights),
2663
2779
  rationale: {
2664
2780
  ...fn.rationale,
2781
+ detSources: marked,
2665
2782
  overrides: [...fn.rationale.overrides ?? [], {
2666
2783
  by,
2667
2784
  reason: override.reason,
@@ -2690,7 +2807,16 @@ function usageOf(input) {
2690
2807
  };
2691
2808
  usage.set(store, {
2692
2809
  used: true,
2693
- 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)
2694
2820
  });
2695
2821
  }
2696
2822
  }
@@ -2880,6 +3006,7 @@ async function analyze(root, options = {}) {
2880
3006
  entryPointId,
2881
3007
  writes: behavior.writes,
2882
3008
  touches: behavior.touches,
3009
+ writtenStores: behavior.writtenStores,
2883
3010
  inputFields: behavior.inputFields.map((name) => ({
2884
3011
  name,
2885
3012
  provenance: {
@@ -40,6 +40,42 @@ const toPosix = (value) => value.split("\\").join("/");
40
40
  const relativeTo = (root, value) => toPosix(path.relative(toPosix(root), toPosix(value))) || ".";
41
41
  /** Compares two paths that may have come from different sources. */
42
42
  const samePath = (a, b) => a !== void 0 && b !== void 0 && toPosix(a) === toPosix(b);
43
+ /**
44
+ * Is this file the application's own code, as opposed to the scaffolding around it?
45
+ *
46
+ * The top-level filter on `scanRoots` already drops `tests/`, `database/` and the
47
+ * rest — but only at the ROOT. Applications organised by domain module put both
48
+ * inside `app/`:
49
+ *
50
+ * app/billing/tests/functional/invoice.spec.ts
51
+ * app/billing/seeders/plan_seeder.ts
52
+ *
53
+ * so they land in the project, and `writtenAnywhere()` read a seeder's inserts as
54
+ * the application maintaining the table. A reference table only the seed populates
55
+ * came out as an ILF — which the CPM does not allow: data maintained by the
56
+ * development team is at most an EIF, and code data is not counted at all.
57
+ *
58
+ * The segments are AdonisJS's own: `make:test` writes to a suite directory,
59
+ * `make:seeder` to `seeders`, `make:migration` to `migrations`, `make:factory` to
60
+ * `factories`. The `.spec`/`.test` suffixes come from the suite globs in
61
+ * `adonisrc.ts`.
62
+ */
63
+ const SCAFFOLDING = new Set([
64
+ "tests",
65
+ "test",
66
+ "seeders",
67
+ "seeder",
68
+ "migrations",
69
+ "factories"
70
+ ]);
71
+ function isApplicationCode(root, file) {
72
+ const relative = relativeTo(root, file);
73
+ if (relative.startsWith("..")) return false;
74
+ const parts = relative.split("/");
75
+ const name = parts.at(-1) ?? "";
76
+ if (/\.(spec|test)\.[jt]s$/.test(name)) return false;
77
+ return !parts.slice(0, -1).some((segment) => SCAFFOLDING.has(segment));
78
+ }
43
79
  //#endregion
44
80
  //#region src/inventory/sources/event_bindings.ts
45
81
  /** the method a listener declares; AdonisJS calls `handle` unless told otherwise */
@@ -817,6 +853,15 @@ const BUILTIN_CALL_RESOLVERS = [
817
853
  * them apart. Hence specific strategies declare a lower `order` than generic
818
854
  * ones, and `module-function` comes last — it would match almost anything.
819
855
  */
856
+ /**
857
+ * Does any strategy call this a technical write?
858
+ *
859
+ * Asked separately from resolution, because the strategy that recognises the call as
860
+ * incidental is not necessarily the one that knows where it goes.
861
+ */
862
+ function isTechnicalWrite(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
863
+ return resolvers.some((resolver) => resolver.technicalWrite?.(call, ctx) === true);
864
+ }
820
865
  function resolveCall(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
821
866
  for (const resolver of resolvers) {
822
867
  /**
@@ -837,4 +882,4 @@ function resolveCall(call, ctx, resolvers = BUILTIN_CALL_RESOLVERS) {
837
882
  return null;
838
883
  }
839
884
  //#endregion
840
- export { rootSymbolOf as a, samePath as c, hooksFiredBy as i, toPosix as l, resolveCall as n, collectEventBindings as o, detectAccess as r, relativeTo as s, BUILTIN_CALL_RESOLVERS as t };
885
+ export { hooksFiredBy as a, isApplicationCode as c, toPosix as d, detectAccess as i, relativeTo as l, isTechnicalWrite as n, rootSymbolOf as o, resolveCall as r, collectEventBindings as s, BUILTIN_CALL_RESOLVERS as t, samePath as u };
@@ -1,6 +1,6 @@
1
1
  import { c as diffCounts, i as measureStructure, l as DEFAULTS, n as parseSamples, o as IncomparableRulesetsError, r as measureConformance, s as IncomparableSourcesError, t as calibrate, u as defineConfig } from "./calibration-8eV8CEix.js";
2
- import { l as toPosix } from "./resolvers-vMahHkAd.js";
3
- import { n as analyze } from "./pipeline-Dm9KvUvF.js";
2
+ import { d as toPosix } from "./resolvers-PJwo2Z8R.js";
3
+ import { n as analyze } from "./pipeline-CNTBhs6o.js";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { existsSync } from "node:fs";
@@ -91,7 +91,12 @@ function renderCount(result) {
91
91
  * habit: if it grows, the count comes from a spreadsheet and the tool loses
92
92
  * its reason to exist. Printing the share is what keeps that visible.
93
93
  */
94
- const overridden = result.functions.filter((fn) => fn.rationale.overrides?.length);
94
+ /**
95
+ * Only entries that DECLARED a number. A review records a decision and declares
96
+ * nothing, so counting it here would read as "35% of the total declared by
97
+ * override" about a count nobody touched.
98
+ */
99
+ const overridden = result.functions.filter((fn) => fn.rationale.overrides?.some((o) => o.fields.length > 0));
95
100
  if (overridden.length > 0) {
96
101
  const points = overridden.reduce((total, fn) => total + fn.points, 0);
97
102
  const share = (points / (result.totals.unadjusted || 1) * 100).toFixed(1);
@@ -34,7 +34,7 @@ export declare const RULESET = "afp";
34
34
  * against this one and bills the tool's own improvement as work done. The guard
35
35
  * exists for exactly that, and only this constant arms it.
36
36
  */
37
- export declare const RULESET_VERSION = "1.3.0";
37
+ export declare const RULESET_VERSION = "1.4.0";
38
38
  export type CountInput = {
39
39
  app: AppContext;
40
40
  stores: CollectedDataStore[];
package/build/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
- import { t as CoverageTooLowError } from "../pipeline-Dm9KvUvF.js";
2
- import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-DetZGfh5.js";
1
+ import { t as CoverageTooLowError } from "../pipeline-CNTBhs6o.js";
2
+ import { a as runInventory, c as ConfigLoadError, i as runExplain, n as runCount, o as runMetrics, r as runDiff, s as printResult, t as runCalibrate } from "../runners-DIt1G85i.js";
3
3
  import path from "node:path";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -30,6 +30,16 @@ export type Behavior = {
30
30
  writes: boolean;
31
31
  /** data stores reached */
32
32
  touches: string[];
33
+ /**
34
+ * Of those, the ones this transaction WRITES.
35
+ *
36
+ * `writes` is a property of the transaction — it decides EI against EO — and was
37
+ * being read as a property of every store the transaction touched: a table merely
38
+ * read by a route that writes something else counted as maintained, so almost
39
+ * nothing could be an EIF. §6.5.4 asks who maintains THIS store, which is a
40
+ * question about the access, not about the request.
41
+ */
42
+ writtenStores: string[];
33
43
  /**
34
44
  * Declared input fields: `request.validateUsing(x)` resolved down to the
35
45
  * fields of the VineJS schema — counting-decisions §7.
@@ -37,3 +37,4 @@ export declare const toPosix: (value: string) => string;
37
37
  export declare const relativeTo: (root: string, value: string) => string;
38
38
  /** Compares two paths that may have come from different sources. */
39
39
  export declare const samePath: (a: string | undefined, b: string | undefined) => boolean;
40
+ export declare function isApplicationCode(root: string, file: string): boolean;
@@ -17,6 +17,13 @@ export declare const BUILTIN_CALL_RESOLVERS: CallResolver[];
17
17
  * them apart. Hence specific strategies declare a lower `order` than generic
18
18
  * ones, and `module-function` comes last — it would match almost anything.
19
19
  */
20
+ /**
21
+ * Does any strategy call this a technical write?
22
+ *
23
+ * Asked separately from resolution, because the strategy that recognises the call as
24
+ * incidental is not necessarily the one that knows where it goes.
25
+ */
26
+ export declare function isTechnicalWrite(call: import('ts-morph').CallExpression, ctx: import('./types.js').ResolverContext, resolvers?: CallResolver[]): boolean;
20
27
  export declare function resolveCall(call: import('ts-morph').CallExpression, ctx: import('./types.js').ResolverContext, resolvers?: CallResolver[]): {
21
28
  by: string;
22
29
  refs: import('../../types.js').HandlerRef[];
@@ -1,2 +1,2 @@
1
- import { n as resolveCall, t as BUILTIN_CALL_RESOLVERS } from "../../../resolvers-vMahHkAd.js";
2
- export { BUILTIN_CALL_RESOLVERS, resolveCall };
1
+ import { n as isTechnicalWrite, r as resolveCall, t as BUILTIN_CALL_RESOLVERS } from "../../../resolvers-PJwo2Z8R.js";
2
+ export { BUILTIN_CALL_RESOLVERS, isTechnicalWrite, resolveCall };
@@ -97,4 +97,23 @@ export interface CallResolver {
97
97
  * defect this package can have, whoever writes it.
98
98
  */
99
99
  ignores?(call: CallExpression, ctx: ResolverContext): boolean;
100
+ /**
101
+ * "This call writes, and the write is not what the transaction is FOR."
102
+ *
103
+ * AFP §6.5.3 decides EI against EO mechanically: a transaction that modifies a data
104
+ * store is an EI. That is deliberate — repeatability over CPM fidelity — and it
105
+ * misreads one shape: a screen that records a visit, a last-seen organisation, a
106
+ * view counter. The CPM asks what the elementary process is PRIMARILY for, and for a
107
+ * `GET` that shows a record while noting the visit, the answer is presentation.
108
+ *
109
+ * So the fact is declared about the CALL, not about each transaction that reaches it:
110
+ * `persistOrganizationVisit` is called from several screens and saying it once covers
111
+ * all of them.
112
+ *
113
+ * It does NOT hide the write. The store is still maintained by this application —
114
+ * still an ILF, still an FTR of the transaction — and only the transaction's
115
+ * classification changes. A resolver that wanted the write to disappear would use
116
+ * `ignores`, and would be wrong to.
117
+ */
118
+ technicalWrite?(call: CallExpression, ctx: ResolverContext): boolean;
100
119
  }
@@ -1,2 +1,2 @@
1
- import { n as analyze, t as CoverageTooLowError } from "../pipeline-Dm9KvUvF.js";
1
+ import { n as analyze, t as CoverageTooLowError } from "../pipeline-CNTBhs6o.js";
2
2
  export { CoverageTooLowError, analyze };
@@ -84,6 +84,8 @@ export type HandlerBehavior = {
84
84
  writes: boolean;
85
85
  /** DataStores reached (ids) */
86
86
  touches: string[];
87
+ /** of those, the ones this transaction writes — §6.5.4 is per store, not per request */
88
+ writtenStores: string[];
87
89
  /** declared input fields (validators) */
88
90
  inputFields: Field[];
89
91
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@filipebraida/adonis-function-points",
3
3
  "description": "Automated function point counting and code metrics for AdonisJS applications.",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "engines": {
6
6
  "node": ">=24.0.0"
7
7
  },