@filipebraida/adonis-function-points 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +101 -292
  3. package/build/{calibration-8eV8CEix.js → calibration-DVIf8hcE.js} +42 -3
  4. package/build/commands/main.js +6 -6
  5. package/build/{fp_calibrate-DUbHiifm.js → fp_calibrate-EAuAtdbq.js} +1 -1
  6. package/build/{fp_count-ChtblhZV.js → fp_count-CZ0cUUBQ.js} +1 -1
  7. package/build/{fp_diff-Dt7J4IWu.js → fp_diff-BTg_LX0r.js} +1 -1
  8. package/build/{fp_explain-DZJ--0-S.js → fp_explain-D6QvDLKQ.js} +1 -1
  9. package/build/{fp_inventory-CPtmuuke.js → fp_inventory-C43fU39x.js} +1 -1
  10. package/build/{fp_metrics-et8F1Wvt.js → fp_metrics-DEMPk4xC.js} +1 -1
  11. package/build/index.d.ts +8 -4
  12. package/build/index.js +4 -4
  13. package/build/{pipeline-CNTBhs6o.js → pipeline-Cq4dNTNE.js} +763 -313
  14. package/build/{resolvers-PJwo2Z8R.js → resolvers-DlKJOZnk.js} +328 -63
  15. package/build/{runners-DIt1G85i.js → runners-FYmPIPub.js} +6 -3
  16. package/build/src/albrecht/counter.d.ts +31 -5
  17. package/build/src/albrecht/data_functions.d.ts +49 -3
  18. package/build/src/albrecht/diff.d.ts +27 -0
  19. package/build/src/albrecht/index.d.ts +1 -0
  20. package/build/src/albrecht/opaque.d.ts +90 -0
  21. package/build/src/albrecht/technical_filter.d.ts +18 -11
  22. package/build/src/albrecht/transactional_functions.d.ts +7 -0
  23. package/build/src/cli.js +2 -2
  24. package/build/src/define_config.d.ts +55 -57
  25. package/build/src/inventory/graph/call_graph.d.ts +29 -0
  26. package/build/src/inventory/graph/output_fields.d.ts +99 -0
  27. package/build/src/inventory/paths.d.ts +2 -0
  28. package/build/src/inventory/resolvers/index.d.ts +21 -0
  29. package/build/src/inventory/resolvers/index.js +2 -2
  30. package/build/src/pipeline.js +1 -1
  31. package/build/src/types.d.ts +30 -1
  32. package/build/stubs/config.stub +29 -16
  33. package/package.json +1 -1
@@ -1,4 +1,4 @@
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";
1
+ import { a as chainShapeOf, c as hooksFiredBy, d as isApplicationCode, f as isSeeder, h as toPosix, i as resolveCall, l as rootSymbolOf, m as samePath, o as outputFieldsIn, p as relativeTo, r as isTechnicalWrite, s as detectAccess, t as BUILTIN_CALL_RESOLVERS, u as collectEventBindings } from "./resolvers-DlKJOZnk.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";
@@ -465,7 +465,7 @@ function walkChain(start, app, project) {
465
465
  if (samePath(cls.getSourceFile().getFilePath(), app.generated.dataSchema)) columnSource = "generated-schema";
466
466
  for (const attribute of columnsOf(cls)) if (!attributes.has(attribute.name)) attributes.set(attribute.name, attribute);
467
467
  for (const parent of parentsOf(cls)) {
468
- const origin = originOf(parent.getText(), cls.getSourceFile());
468
+ const origin = originOf$1(parent.getText(), cls.getSourceFile());
469
469
  if (origin?.specifier === LUCID_ORM && origin.exportedName === BASE_MODEL) {
470
470
  reachesLucid = true;
471
471
  continue;
@@ -501,7 +501,7 @@ function walkChain(start, app, project) {
501
501
  */
502
502
  function reasonFor(parent, file, app) {
503
503
  if (Node.isCallExpression(parent)) return "mixin factory: the column only exists on the class the function returns, and evaluating that return is beyond the current static analysis";
504
- const origin = originOf(parent.getText(), file);
504
+ const origin = originOf$1(parent.getText(), file);
505
505
  if (origin && !app.resolveSpecifier(origin.specifier)) return `base class outside the application (${origin.specifier}): the package cannot know which columns it adds`;
506
506
  return "base class not found in the application";
507
507
  }
@@ -519,7 +519,7 @@ function parentsOf(cls) {
519
519
  function resolveClass(name, from, app, project) {
520
520
  const local = from.getClass(name);
521
521
  if (local) return local;
522
- const origin = originOf(name, from);
522
+ const origin = originOf$1(name, from);
523
523
  if (!origin) return null;
524
524
  const target = app.resolveSpecifier(origin.specifier);
525
525
  if (!target) return null;
@@ -528,7 +528,7 @@ function resolveClass(name, from, app, project) {
528
528
  if (origin.exportedName === "default") return file.getClasses().find((candidate) => candidate.isDefaultExport()) ?? null;
529
529
  return file.getClass(origin.exportedName) ?? null;
530
530
  }
531
- function originOf(local, file) {
531
+ function originOf$1(local, file) {
532
532
  for (const declaration of file.getImportDeclarations()) {
533
533
  const specifier = declaration.getModuleSpecifierValue();
534
534
  if (declaration.getDefaultImport()?.getText() === local) return {
@@ -556,11 +556,27 @@ function columnsOf(cls) {
556
556
  for (const property of cls.getProperties()) for (const decorator of property.getDecorators()) {
557
557
  const full = decorator.getFullName();
558
558
  if (full !== "column" && !full.startsWith("column.")) continue;
559
- const isIdentifier = /isPrimary\s*:\s*true/.test(decorator.getExpression().getText());
559
+ const options = decorator.getExpression().getText();
560
+ const isIdentifier = /isPrimary\s*:\s*true/.test(options);
561
+ /**
562
+ * `autoCreate` / `autoUpdate`: the framework stamps it on insert or update.
563
+ * The user neither supplies nor maintains the value, so it is not a DET —
564
+ * counting-decisions §6. Recorded here as a fact about the column; the
565
+ * counting side decides what to do with it.
566
+ */
567
+ const system = /auto(Create|Update)\s*:\s*true/.test(options);
568
+ /**
569
+ * `serializeAs: null`: Lucid never serialises the column, so it cannot leave
570
+ * the boundary on an output. It is still a DET of the data function — the
571
+ * user supplies a password — counting-decisions §6.
572
+ */
573
+ const hidden = /serializeAs\s*:\s*null/.test(options);
560
574
  attributes.push({
561
575
  name: property.getName(),
562
576
  type: property.getTypeNode()?.getText(),
563
577
  isIdentifier,
578
+ ...system ? { system } : {},
579
+ ...hidden ? { hidden } : {},
564
580
  provenance: {
565
581
  file,
566
582
  line: property.getStartLineNumber(),
@@ -1511,6 +1527,12 @@ function createAnalyzer(app, stores, options = {}) {
1511
1527
  * per route, uniformly. Loading everything first trades N rebuilds for one.
1512
1528
  */
1513
1529
  for (const root of app.scanRoots) project.addSourceFilesAtPaths(`${root}/**/*.ts`);
1530
+ /**
1531
+ * Seeders under `database/` as well — not application code, and never followed
1532
+ * from a handler, but an EIF only a seed populates is a fact the report needs
1533
+ * (counting-decisions §11), and `make:seeder` puts them exactly there.
1534
+ */
1535
+ project.addSourceFilesAtPaths(`${toPosix(app.root)}/database/**/seeders/**/*.ts`);
1514
1536
  const storesByName = new Map(stores.map((store) => [store.name, store]));
1515
1537
  const relationsByStore = new Map(stores.map((store) => [store.name, store.relations]));
1516
1538
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
@@ -1584,6 +1606,9 @@ function createAnalyzer(app, stores, options = {}) {
1584
1606
  const accesses = [];
1585
1607
  const followUps = [];
1586
1608
  const unresolved = [];
1609
+ const reads = [];
1610
+ /** calls a strategy claimed: a nested transformer's keys arrive through its body */
1611
+ const followedCalls = /* @__PURE__ */ new Set();
1587
1612
  const validator = validatorFieldsIn(body, file, app);
1588
1613
  const request = requestFieldsIn(body);
1589
1614
  const context = {
@@ -1616,6 +1641,36 @@ function createAnalyzer(app, stores, options = {}) {
1616
1641
  technical
1617
1642
  });
1618
1643
  /**
1644
+ * How the chain reads the store decides what leaves when nothing transforms
1645
+ * it — §6: rows whole, `.select()` columns, or one scalar from `.count()`.
1646
+ * A select list that is not literal is reported, and the store falls back
1647
+ * to every column, which overestimates in the open.
1648
+ *
1649
+ * `related('itens').query().count()` reads the RELATION target, and the
1650
+ * parent only as a receiver; `preload('itens')` reads the target whole.
1651
+ */
1652
+ if (access.mode === "read") {
1653
+ const chain = chainShapeOf(call);
1654
+ const shape = chain.aggregate ? "aggregate" : chain.selected.length > 0 ? "select" : "whole";
1655
+ const read = (store, how, via) => reads.push({
1656
+ store,
1657
+ shape: how,
1658
+ columns: how === "select" ? chain.selected : [],
1659
+ ...via ? { via } : {}
1660
+ });
1661
+ if (access.method === "related" && access.viaRelation) read(access.viaRelation, shape);
1662
+ else {
1663
+ read(access.store, shape);
1664
+ if (access.viaRelation) read(access.viaRelation, "whole", access.store);
1665
+ }
1666
+ for (const problem of chain.unreadable) unresolved.push({
1667
+ file: ref.file,
1668
+ line: problem.line,
1669
+ expression: problem.expression,
1670
+ reason: `select with a column list that is not literal: ${access.store} counts every column`
1671
+ });
1672
+ }
1673
+ /**
1619
1674
  * A relation reached by `preload`/`load` is read; one written through
1620
1675
  * `related('files').create(…)` is written. Assuming read either way made
1621
1676
  * a table maintained only through a relation come out as an EIF.
@@ -1654,6 +1709,7 @@ function createAnalyzer(app, stores, options = {}) {
1654
1709
  by: resolved.by,
1655
1710
  technical
1656
1711
  });
1712
+ followedCalls.add(call);
1657
1713
  continue;
1658
1714
  }
1659
1715
  if (isWorthReporting(call, symbols, imports) && !isNoise(call, owner)) unresolved.push({
@@ -1663,6 +1719,11 @@ function createAnalyzer(app, stores, options = {}) {
1663
1719
  reason: "call that no strategy knew how to follow"
1664
1720
  });
1665
1721
  }
1722
+ /**
1723
+ * Read after the loop: whether a key holds a nested transformer is known only
1724
+ * once the strategies have said which calls they follow.
1725
+ */
1726
+ const output = outputFieldsIn(body, owner, storesByName, (c) => followedCalls.has(c));
1666
1727
  return {
1667
1728
  accesses,
1668
1729
  followUps,
@@ -1671,6 +1732,10 @@ function createAnalyzer(app, stores, options = {}) {
1671
1732
  opaqueValidators: validator.opaque,
1672
1733
  requestFields: request.fields,
1673
1734
  opaqueRequest: request.opaque,
1735
+ outputs: output.outputs,
1736
+ opaqueOutputs: output.opaqueOutputs,
1737
+ transformed: output.resource,
1738
+ reads,
1674
1739
  bodyHash: hashOf(body)
1675
1740
  };
1676
1741
  }
@@ -1689,8 +1754,20 @@ function createAnalyzer(app, stores, options = {}) {
1689
1754
  * transaction reaches the store still decides if it is counted at all; this
1690
1755
  * only decides who maintains it.
1691
1756
  */
1692
- const writtenAnywhere = () => {
1757
+ /**
1758
+ * Both project-wide facts come from one pass, computed once: which stores the
1759
+ * application WRITES (maintenance, §6.5.4) and which it ADDRESSES directly
1760
+ * (grouping, counting-decisions §10). A store reached only through a relation
1761
+ * — `preload('itens')`, `related('itens').create()` — is read or written, but
1762
+ * not addressed: the user never sees it outside its parent.
1763
+ */
1764
+ let projectWide;
1765
+ const scanProject = () => {
1766
+ if (projectWide) return projectWide;
1693
1767
  const written = /* @__PURE__ */ new Set();
1768
+ const addressed = /* @__PURE__ */ new Set();
1769
+ /** written by a seeder: not maintenance, but a fact the report needs (an EIF only a seed populates) */
1770
+ const seeded = /* @__PURE__ */ new Set();
1694
1771
  for (const file of project.getSourceFiles()) {
1695
1772
  /**
1696
1773
  * A seeder's inserts are not the application maintaining a table, and a test
@@ -1702,12 +1779,25 @@ function createAnalyzer(app, stores, options = {}) {
1702
1779
  * depth — because a domain-module layout puts `tests/` and `seeders/` inside
1703
1780
  * `app/`, where the root filter never looks.
1704
1781
  */
1705
- if (!isApplicationCode(app.root, file.getFilePath())) continue;
1782
+ if (!isApplicationCode(app.root, file.getFilePath())) {
1783
+ if (isSeeder(app.root, file.getFilePath())) {
1784
+ const symbols = storeSymbolsFor(file, file, app, storesByName);
1785
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1786
+ const access = symbols.size > 0 ? detectAccess(call, symbols, relationsByStore) : null;
1787
+ if (access?.mode !== "write") continue;
1788
+ seeded.add(access.store);
1789
+ if (access.viaRelation && access.relationWritten) seeded.add(access.viaRelation);
1790
+ }
1791
+ }
1792
+ continue;
1793
+ }
1706
1794
  const symbols = storeSymbolsFor(file, file, app, storesByName);
1707
1795
  if (symbols.size === 0) continue;
1708
1796
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1709
1797
  const access = detectAccess(call, symbols, relationsByStore);
1710
- if (access?.mode !== "write") continue;
1798
+ if (!access) continue;
1799
+ addressed.add(access.store);
1800
+ if (access.mode !== "write") continue;
1711
1801
  written.add(access.store);
1712
1802
  /**
1713
1803
  * `distribution.related('files').create(…)` maintains the related table.
@@ -1717,12 +1807,27 @@ function createAnalyzer(app, stores, options = {}) {
1717
1807
  */
1718
1808
  if (access.viaRelation && access.relationWritten) written.add(access.viaRelation);
1719
1809
  }
1810
+ for (const construction of file.getDescendantsOfKind(SyntaxKind.NewExpression)) {
1811
+ const target = construction.getExpression();
1812
+ const store = Node.isIdentifier(target) ? symbols.get(target.getText()) : void 0;
1813
+ if (store) addressed.add(store);
1814
+ }
1720
1815
  }
1721
- return written;
1816
+ projectWide = {
1817
+ written,
1818
+ addressed,
1819
+ seeded
1820
+ };
1821
+ return projectWide;
1722
1822
  };
1823
+ const writtenAnywhere = () => scanProject().written;
1824
+ const addressedAnywhere = () => scanProject().addressed;
1825
+ const seededAnywhere = () => scanProject().seeded;
1723
1826
  return {
1724
1827
  analyze: (handler) => run(handler),
1725
1828
  writtenAnywhere,
1829
+ addressedAnywhere,
1830
+ seededAnywhere,
1726
1831
  /** how many files the project loaded — used to prove it does not grow */
1727
1832
  fileCount: () => project.getSourceFiles().length
1728
1833
  };
@@ -1733,6 +1838,10 @@ function createAnalyzer(app, stores, options = {}) {
1733
1838
  const opaqueInputFields = /* @__PURE__ */ new Set();
1734
1839
  const requestFields = /* @__PURE__ */ new Set();
1735
1840
  let opaqueRequest = false;
1841
+ const outputFields = /* @__PURE__ */ new Set();
1842
+ const opaqueOutputFields = /* @__PURE__ */ new Set();
1843
+ const transformedStores = /* @__PURE__ */ new Set();
1844
+ const outputReads = /* @__PURE__ */ new Map();
1736
1845
  const trace = [];
1737
1846
  const scope = [];
1738
1847
  const unresolved = [];
@@ -1779,6 +1888,24 @@ function createAnalyzer(app, stores, options = {}) {
1779
1888
  for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
1780
1889
  for (const field of facts.requestFields) requestFields.add(field);
1781
1890
  if (facts.opaqueRequest) opaqueRequest = true;
1891
+ for (const field of facts.outputs) outputFields.add(field);
1892
+ for (const field of facts.opaqueOutputs) opaqueOutputFields.add(field);
1893
+ if (facts.transformed) transformedStores.add(facts.transformed);
1894
+ for (const { store, shape, columns, via } of facts.reads) {
1895
+ const known = outputReads.get(store) ?? {
1896
+ whole: false,
1897
+ selected: /* @__PURE__ */ new Set(),
1898
+ aggregate: false,
1899
+ direct: false,
1900
+ via: /* @__PURE__ */ new Set()
1901
+ };
1902
+ if (shape === "whole") known.whole = true;
1903
+ if (shape === "aggregate") known.aggregate = true;
1904
+ for (const column of columns) known.selected.add(column);
1905
+ if (via) known.via.add(via);
1906
+ else known.direct = true;
1907
+ outputReads.set(store, known);
1908
+ }
1782
1909
  trace.push({
1783
1910
  file: ref.file,
1784
1911
  member: ref.member,
@@ -1807,6 +1934,16 @@ function createAnalyzer(app, stores, options = {}) {
1807
1934
  opaqueInputFields: [...opaqueInputFields].sort(),
1808
1935
  requestFields: [...requestFields].sort(),
1809
1936
  opaqueRequest,
1937
+ outputFields: [...outputFields].sort(),
1938
+ opaqueOutputFields: [...opaqueOutputFields].sort(),
1939
+ transformedStores: [...transformedStores].sort(),
1940
+ outputReads: Object.fromEntries([...outputReads.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([store, read]) => [store, {
1941
+ whole: read.whole,
1942
+ selected: [...read.selected].sort(),
1943
+ aggregate: read.aggregate,
1944
+ direct: read.direct,
1945
+ via: [...read.via].sort()
1946
+ }])),
1810
1947
  trace,
1811
1948
  scope,
1812
1949
  unresolved
@@ -2155,40 +2292,360 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
2155
2292
  return weights[type][complexity];
2156
2293
  }
2157
2294
  //#endregion
2295
+ //#region src/albrecht/opaque.ts
2296
+ /**
2297
+ * DETs the analysis cannot read, and what a person declared about them —
2298
+ * counting-decisions §8 and §9.
2299
+ *
2300
+ * Three shapes are opaque: a JSON column (`ast:surveys.answers`), an open
2301
+ * input object (`validator:answerSurveyValidator.answers`), and a spread a
2302
+ * transformer emits (`transformer:X.<this.resource.serialize()>`). Each counts
2303
+ * 1 DET — a floor, never a zero — and is marked `(opaque)` in the rationale.
2304
+ *
2305
+ * A declaration is about the ORIGIN of the placeholder, not about a function,
2306
+ * and it applies to every function that carries the DET: the data function and
2307
+ * each transaction that takes or shows the column. Keyed by function it had to
2308
+ * be written twice and still missed the third place, so the same column was
2309
+ * worth two numbers in one count — and matching by bare name meant reviewing
2310
+ * `Attachment.metadata` reviewed every `metadata` column of every table.
2311
+ */
2312
+ const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2313
+ const isOpaqueType = (type) => type !== void 0 && OPAQUE_TYPE.test(type);
2314
+ const OPAQUE = " (opaque)";
2315
+ const REVIEWED = " (opaque, reviewed)";
2316
+ /** the origin of a placeholder, spelled the way the configuration keys it */
2317
+ function originOf(source, storeOfTable) {
2318
+ if (!source.endsWith(OPAQUE)) return null;
2319
+ const body = source.slice(0, -9);
2320
+ const colon = body.indexOf(":");
2321
+ const prefix = body.slice(0, colon);
2322
+ const rest = body.slice(colon + 1);
2323
+ switch (prefix) {
2324
+ case "ast":
2325
+ case "generated-schema": {
2326
+ const dot = rest.indexOf(".");
2327
+ const table = rest.slice(0, dot);
2328
+ return `${storeOfTable.get(table) ?? table}.${rest.slice(dot + 1)}`;
2329
+ }
2330
+ case "output":
2331
+ case "validator":
2332
+ case "transformer": return rest;
2333
+ default: return null;
2334
+ }
2335
+ }
2336
+ /**
2337
+ * Applies the declarations to every function carrying the origin they name.
2338
+ *
2339
+ * schemas the placeholder's 1 DET becomes the schema's leaves, and the line
2340
+ * says which schema stood in
2341
+ * reviewed the placeholder stays 1, marked reviewed; the warning stops
2342
+ *
2343
+ * A declaration keyed by the physical table (`surveys.answers`) is accepted
2344
+ * as well as one keyed by the model (`Survey.answers`): the count prints
2345
+ * the model, `fp:explain` prints the table, and a person copies from either.
2346
+ */
2347
+ function applyOpaque(functions, options) {
2348
+ const storeOfTable = new Map(options.stores.map((store) => [store.table ?? store.name, store.name]));
2349
+ /** declarations by the origin the rationale will produce */
2350
+ const declared = /* @__PURE__ */ new Map();
2351
+ for (const [key, declaration] of Object.entries(options.declarations)) {
2352
+ const dot = key.indexOf(".");
2353
+ const head = dot === -1 ? key : key.slice(0, dot);
2354
+ const normalised = dot === -1 ? key : `${storeOfTable.get(head) ?? head}.${key.slice(dot + 1)}`;
2355
+ declared.set(normalised, {
2356
+ key,
2357
+ declaration
2358
+ });
2359
+ }
2360
+ const answered = /* @__PURE__ */ new Map();
2361
+ const used = /* @__PURE__ */ new Set();
2362
+ const warnings = [];
2363
+ const warned = /* @__PURE__ */ new Set();
2364
+ const resolveSchemas = (key, names) => {
2365
+ const resolved = names.map((name) => options.schemas.get(name)).filter((schema) => schema !== void 0);
2366
+ for (const name of names) {
2367
+ if (options.schemas.has(name) || warned.has(`${key}:${name}`)) continue;
2368
+ warned.add(`${key}:${name}`);
2369
+ warnings.push(`opaque declaration "${key}" 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.`);
2370
+ }
2371
+ if (resolved.length === 0) return null;
2372
+ /** unioned by leaf path: a field two templates share is one DET */
2373
+ const leaves = new Set(resolved.flatMap((schema) => schema.leaves));
2374
+ return {
2375
+ name: resolved.map((schema) => schema.name).join(" + "),
2376
+ fields: leaves.size
2377
+ };
2378
+ };
2379
+ const applied = functions.map((fn) => {
2380
+ let det = fn.det;
2381
+ const sources = [];
2382
+ const overrides = [...fn.rationale.overrides ?? []];
2383
+ const recorded = /* @__PURE__ */ new Set();
2384
+ for (const source of fn.rationale.detSources) {
2385
+ const origin = originOf(source, storeOfTable);
2386
+ const found = origin ? declared.get(origin) : void 0;
2387
+ if (!origin || !found) {
2388
+ sources.push(source);
2389
+ continue;
2390
+ }
2391
+ const { key, declaration } = found;
2392
+ used.add(origin);
2393
+ const body = source.slice(0, -9);
2394
+ if (declaration.schemas) {
2395
+ const schema = resolveSchemas(key, [declaration.schemas].flat());
2396
+ if (!schema) {
2397
+ sources.push(source);
2398
+ continue;
2399
+ }
2400
+ det += schema.fields - 1;
2401
+ sources.push(`${body} → ${schema.name} (${schema.fields} fields)`);
2402
+ answered.set(origin, "replaced");
2403
+ if (!recorded.has(key)) {
2404
+ recorded.add(key);
2405
+ overrides.push({
2406
+ by: `config:opaque.${key} (from ${schema.name}: ${schema.fields} fields)`,
2407
+ reason: declaration.reason,
2408
+ fields: ["det"]
2409
+ });
2410
+ }
2411
+ continue;
2412
+ }
2413
+ if (declaration.reviewed) {
2414
+ sources.push(body + REVIEWED);
2415
+ answered.set(origin, "reviewed");
2416
+ if (!recorded.has(key)) {
2417
+ recorded.add(key);
2418
+ overrides.push({
2419
+ by: `config:opaque.${key}`,
2420
+ reason: declaration.reason,
2421
+ fields: []
2422
+ });
2423
+ }
2424
+ continue;
2425
+ }
2426
+ sources.push(source);
2427
+ }
2428
+ if (det === fn.det && overrides.length === (fn.rationale.overrides?.length ?? 0)) return fn;
2429
+ const complexity = complexityOf(fn.type, fn.refs, det, options.tables);
2430
+ return {
2431
+ ...fn,
2432
+ det,
2433
+ complexity,
2434
+ points: pointsOf(fn.type, complexity, options.weights),
2435
+ rationale: {
2436
+ ...fn.rationale,
2437
+ detSources: sources,
2438
+ overrides
2439
+ }
2440
+ };
2441
+ });
2442
+ for (const [origin, { key }] of declared) {
2443
+ if (used.has(origin)) continue;
2444
+ warnings.push(`opaque declaration "${key}" matches no DET the analysis found opaque: it had no effect. The origins it can answer are the ones \`fp:count\` lists — \`Store.column\` or \`validator.field\`.`);
2445
+ }
2446
+ return {
2447
+ functions: applied,
2448
+ answered,
2449
+ warnings
2450
+ };
2451
+ }
2452
+ /**
2453
+ * The floors still standing, by origin — the one blind spot this package used
2454
+ * to keep to itself.
2455
+ *
2456
+ * Grouped by origin because that is what a declaration answers: one line for
2457
+ * `Form.definition` however many transactions show it. A transformer's spread has
2458
+ * its own warning and is not repeated here. Only what is unanswered is a request
2459
+ * to do something; what was answered is counted at the end so the fact is
2460
+ * recorded rather than erased.
2461
+ */
2462
+ function opaqueWarnings(input) {
2463
+ const storeOfTable = new Map(input.stores.map((store) => [store.table ?? store.name, store.name]));
2464
+ const typeOf = /* @__PURE__ */ new Map();
2465
+ for (const store of input.stores) for (const attribute of store.attributes) if (attribute.type) typeOf.set(`${store.name}.${attribute.name}`, attribute.type);
2466
+ /** how many transactions reach each store, so the reader can judge a column's weight */
2467
+ const reached = /* @__PURE__ */ new Map();
2468
+ for (const entry of input.entryPoints) for (const store of input.behaviors.get(entry.id)?.touches ?? []) reached.set(store, (reached.get(store) ?? 0) + 1);
2469
+ const floors = /* @__PURE__ */ new Map();
2470
+ for (const fn of input.functions) for (const source of fn.rationale.detSources) {
2471
+ const origin = originOf(source, storeOfTable);
2472
+ if (!origin || source.startsWith("transformer:")) continue;
2473
+ const kind = source.startsWith("validator:") ? "input object" : "column";
2474
+ const floor = floors.get(origin) ?? {
2475
+ kind,
2476
+ carriers: []
2477
+ };
2478
+ if (!floor.carriers.includes(fn.name)) floor.carriers.push(fn.name);
2479
+ floors.set(origin, floor);
2480
+ }
2481
+ const lines = [...floors.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([origin, floor]) => {
2482
+ if (floor.kind === "input object") return ` ${origin} — input object on ${floor.carriers.join(", ")}`;
2483
+ const store = origin.slice(0, origin.indexOf("."));
2484
+ const type = typeOf.get(origin);
2485
+ return ` ${origin}${type ? ` (${type})` : ""} — column on ${store}, reached by ${reached.get(store) ?? 0} transaction(s)`;
2486
+ });
2487
+ const replaced = [...input.answered.values()].filter((how) => how === "replaced").length;
2488
+ const reviewed = [...input.answered.values()].filter((how) => how === "reviewed").length;
2489
+ const settled = replaced + reviewed === 0 ? [] : [` (already answered: ` + [...replaced > 0 ? [`${replaced} replaced by a schema`] : [], ...reviewed > 0 ? [`${reviewed} reviewed`] : []].join(", ") + `)`];
2490
+ if (lines.length === 0) return settled;
2491
+ return [
2492
+ `${lines.length} DET(s) 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 \`opaque.<origin>.schemas\`; where 1 is the right answer, record it with \`opaque.<origin>.reviewed\` — counting-decisions §8:`,
2493
+ ...lines,
2494
+ ...settled
2495
+ ];
2496
+ }
2497
+ //#endregion
2158
2498
  //#region src/albrecht/data_functions.ts
2159
2499
  /**
2160
- * A column whose shape says nothing about what it holds. Marked in the rationale
2161
- * because `detFromSchema` replaces exactly this placeholder, and because a reader
2162
- * deserves to know which of the DETs is a floor rather than a count.
2500
+ * A store `C` is a RET of `P` when, and only when:
2501
+ *
2502
+ * 1. `P` declares `hasMany` / `hasOne` -> `C` (collected as `subgroups`);
2503
+ * 2. no application code addresses `C` directly — the user only ever reaches
2504
+ * it through `P`, so under the CPM it is not a logical file of its own;
2505
+ * 3. exactly one `P` satisfies (1). More than one: `C` stays apart, reported.
2506
+ *
2507
+ * Cascade delete was measured and rejected as the signal: on a real application
2508
+ * 11 of 13 cascades pointed at the tenant table. Usage is the rule the rest of
2509
+ * the count already runs on.
2510
+ */
2511
+ function groupStores(stores, options) {
2512
+ const rootOf = /* @__PURE__ */ new Map();
2513
+ const members = /* @__PURE__ */ new Map();
2514
+ const linkColumns = /* @__PURE__ */ new Map();
2515
+ const warnings = [];
2516
+ const byName = new Map(stores.map((store) => [store.name, store]));
2517
+ for (const store of stores) {
2518
+ rootOf.set(store.name, store.name);
2519
+ members.set(store.name, [store.name]);
2520
+ }
2521
+ const strategy = options.grouping;
2522
+ if (strategy === "none") return {
2523
+ strategy,
2524
+ rootOf,
2525
+ members,
2526
+ linkColumns,
2527
+ warnings
2528
+ };
2529
+ /** child -> the parents declaring a composition relation to it */
2530
+ const parentsOf = /* @__PURE__ */ new Map();
2531
+ for (const store of stores) for (const child of store.subgroups) {
2532
+ if (!byName.has(child)) continue;
2533
+ parentsOf.set(child, [...parentsOf.get(child) ?? [], store.name]);
2534
+ }
2535
+ const parentChosen = /* @__PURE__ */ new Map();
2536
+ for (const [child, parents] of [...parentsOf.entries()].sort(([a], [b]) => a.localeCompare(b))) {
2537
+ if (options.addressedAnywhere.has(child)) continue;
2538
+ if (parents.length > 1) {
2539
+ warnings.push(`not grouped: ${child} is a composition child of ${parents.sort().join(" and ")} and no application code addresses it directly. Which parent it belongs to is not derivable from the code, so it stays its own data function.`);
2540
+ continue;
2541
+ }
2542
+ parentChosen.set(child, parents[0]);
2543
+ }
2544
+ /** follow parent -> parent up to a store that is its own root; a cycle stops at the child */
2545
+ const rootFor = (child) => {
2546
+ let current = child;
2547
+ const seen = /* @__PURE__ */ new Set();
2548
+ while (parentChosen.has(current) && !seen.has(current)) {
2549
+ seen.add(current);
2550
+ current = parentChosen.get(current);
2551
+ }
2552
+ return seen.has(current) ? child : current;
2553
+ };
2554
+ for (const [child, parent] of parentChosen) {
2555
+ const root = rootFor(child);
2556
+ if (root === child) continue;
2557
+ rootOf.set(child, root);
2558
+ members.get(root).push(child);
2559
+ members.delete(child);
2560
+ linkColumns.set(child, foreignKeysTo(byName.get(child), parent));
2561
+ warnings.push(`grouped: ${child} is a RET of ${root} — ${parent} declares hasMany/hasOne to it, and no application code addresses it directly (counting-decisions §10)`);
2562
+ }
2563
+ for (const [root, list] of members) members.set(root, [root, ...list.filter((member) => member !== root).sort()]);
2564
+ return {
2565
+ strategy,
2566
+ rootOf,
2567
+ members,
2568
+ linkColumns,
2569
+ warnings
2570
+ };
2571
+ }
2572
+ /**
2573
+ * The child's foreign keys to its parent, by Lucid's convention: the
2574
+ * `belongsTo` property plus `Id`. Inside one logical file that key is the
2575
+ * subgroup's link, not an attribute the user recognises. A key to a DIFFERENT
2576
+ * data function still counts, as IFPUG requires.
2163
2577
  */
2164
- const OPAQUE_TYPE$1 = /^(object|any|unknown|Record<|Json|JSON)/;
2578
+ function foreignKeysTo(child, parent) {
2579
+ const names = new Set(child.attributes.map((attribute) => attribute.name));
2580
+ const keys = /* @__PURE__ */ new Set();
2581
+ for (const [property, target] of Object.entries(child.relations)) {
2582
+ if (target !== parent) continue;
2583
+ const key = `${property}Id`;
2584
+ if (names.has(key)) keys.add(key);
2585
+ }
2586
+ return keys;
2587
+ }
2588
+ /** the DET attributes of one store: not the key, not a system stamp, not a link to its parent */
2589
+ function detAttributesOf(store, links = /* @__PURE__ */ new Set()) {
2590
+ return store.attributes.filter((attribute) => !attribute.isIdentifier && !attribute.system && !links.has(attribute.name));
2591
+ }
2165
2592
  function countDataFunctions(stores, usage, options) {
2166
2593
  const counted = [];
2594
+ const byName = new Map(stores.map((store) => [store.name, store]));
2595
+ const { rootOf, members, linkColumns } = options.grouping;
2167
2596
  for (const store of stores) {
2168
- const use = usage.get(store.name);
2169
- if (!use?.used) continue;
2597
+ if (rootOf.get(store.name) !== store.name) continue;
2598
+ const group = (members.get(store.name) ?? [store.name]).map((name) => byName.get(name)).filter((member) => member !== void 0);
2599
+ /**
2600
+ * Usage and maintenance are properties of the GROUP: a transaction that
2601
+ * reaches or writes the detail reaches or writes the logical file.
2602
+ */
2603
+ const use = group.reduce((total, member) => {
2604
+ const each = usage.get(member.name);
2605
+ return {
2606
+ used: total.used || each?.used === true,
2607
+ written: total.written || each?.written === true
2608
+ };
2609
+ }, {
2610
+ used: false,
2611
+ written: false
2612
+ });
2613
+ if (!use.used) continue;
2170
2614
  /**
2171
- * DETs exclude the technical identifier.
2615
+ * DETs exclude the technical identifier, the system timestamps, and — for a
2616
+ * child folded in — its link to the parent.
2172
2617
  *
2173
2618
  * IFPUG defines a DET as a "user recognizable" attribute, and an
2174
2619
  * auto-increment surrogate key is not something the user recognises.
2175
- * Counting it would inflate every data function by one.
2620
+ * Counting it would inflate every data function by one. A column the
2621
+ * framework stamps (`autoCreate` / `autoUpdate`) is the same kind of field —
2622
+ * counting-decisions §6.
2176
2623
  */
2177
- const detAttributes = store.attributes.filter((attribute) => !attribute.isIdentifier);
2178
- const det = detAttributes.length;
2179
- const refs = options.retStrategy === "composition" ? 1 + store.subgroups.length : 1;
2624
+ const detSources = [];
2625
+ let det = 0;
2626
+ for (const member of group) for (const attribute of detAttributesOf(member, linkColumns.get(member.name))) {
2627
+ det++;
2628
+ detSources.push(`${member.columnSource}:${member.table ?? member.name}.${attribute.name}` + (isOpaqueType(attribute.type) ? " (opaque)" : ""));
2629
+ }
2630
+ const refs = group.length;
2180
2631
  /**
2181
2632
  * Maintained by the application, or by another system?
2182
2633
  *
2183
2634
  * A write reachable from an entry point is the common case. A write from a
2184
- * job or a seeder maintains the store just as much — AFP §6.5.4 asks who
2185
- * maintains it, not which route does.
2635
+ * job maintains the store just as much — AFP §6.5.4 asks who maintains it,
2636
+ * not which route does.
2186
2637
  */
2187
- const maintained = use.written || options.writtenAnywhere.has(store.name);
2188
- const type = options.externallyMaintained.has(store.name) || !maintained ? "EIF" : "ILF";
2638
+ const maintained = use.written || group.some((member) => options.writtenAnywhere.has(member.name));
2639
+ const declaredExternal = group.some((member) => options.externallyMaintained.has(member.name));
2640
+ const type = declaredExternal || !maintained ? "EIF" : "ILF";
2189
2641
  const complexity = complexityOf(type, refs, det, options.tables);
2190
2642
  counted.push({
2191
- id: `data:${store.name}`,
2643
+ /**
2644
+ * Identity is the physical table — counting-decisions §5 — never the class.
2645
+ * Renaming a model is implementation; keyed by the class it billed as a
2646
+ * deletion plus an addition for zero functional change.
2647
+ */
2648
+ id: `data:${store.table ?? store.name}`,
2192
2649
  name: store.name,
2193
2650
  module: store.module,
2194
2651
  type,
@@ -2197,9 +2654,12 @@ function countDataFunctions(stores, usage, options) {
2197
2654
  complexity,
2198
2655
  points: pointsOf(type, complexity, options.weights),
2199
2656
  rationale: {
2200
- 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",
2201
- detSources: detAttributes.map((attribute) => `${store.columnSource}:${store.table ?? store.name}.${attribute.name}` + (attribute.type && OPAQUE_TYPE$1.test(attribute.type) ? " (opaque)" : "")),
2202
- refSources: options.retStrategy === "composition" ? ["1 (main group)", ...store.subgroups.map((s) => `composition:${s}`)] : ["1 (constant: a logical subgroup is not derivable from code)"]
2657
+ rule: declaredExternal ? "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",
2658
+ detSources,
2659
+ refSources: [options.grouping.strategy === "none" ? `1 (grouping disabled: every table is its own data function)` : `1 (main group: ${store.table ?? store.name})`, ...group.slice(1).map((member) => {
2660
+ const links = [...linkColumns.get(member.name) ?? []].join(", ") || "none found";
2661
+ return `subgroup:${member.name} — a composition child no application code addresses directly; its link to the parent (${links}) is not a DET`;
2662
+ })]
2203
2663
  }
2204
2664
  });
2205
2665
  }
@@ -2220,7 +2680,19 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
2220
2680
  */
2221
2681
  if (touched.length === 0) continue;
2222
2682
  const type = behavior.writes ? "EI" : "EO";
2223
- const refs = touched.length;
2683
+ /**
2684
+ * FTR counts logical files, not tables: the master and the detail folded
2685
+ * into it are one. `reaches:Pedido (via ItemPedido)` keeps the path visible.
2686
+ */
2687
+ const { rootOf } = options.grouping;
2688
+ const viaOf = /* @__PURE__ */ new Map();
2689
+ for (const store of touched) {
2690
+ const root = rootOf.get(store) ?? store;
2691
+ const via = viaOf.get(root) ?? [];
2692
+ if (root !== store) via.push(store);
2693
+ viaOf.set(root, via);
2694
+ }
2695
+ const refs = viaOf.size;
2224
2696
  const { det, sources } = detsFor(entry, behavior, touched, type, options);
2225
2697
  const complexity = complexityOf(type, refs, det, options.tables);
2226
2698
  counted.push({
@@ -2236,7 +2708,7 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
2236
2708
  rationale: {
2237
2709
  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)",
2238
2710
  detSources: sources,
2239
- refSources: touched.map((store) => `reaches:${store}`),
2711
+ refSources: [...viaOf.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([root, via]) => `reaches:${root}${via.length ? ` (via ${via.join(", ")})` : ""}`),
2240
2712
  trace: behavior.trace.map((step) => ({
2241
2713
  ...step,
2242
2714
  file: relativeTo(options.root, step.file)
@@ -2274,7 +2746,8 @@ function scopeHashOf(behavior) {
2274
2746
  * With no `.select()` and no visible transformer, the output fields are the
2275
2747
  * whole table, which **overestimates**. That is the trade AFP makes on purpose,
2276
2748
  * favouring repeatability over fidelity; the origin is recorded in `Rationale`
2277
- * so `fp:calibrate` can measure the bias.
2749
+ * (`transformer:` / `select:` / `output:`) so `fp:calibrate` can measure the
2750
+ * bias per origin.
2278
2751
  */
2279
2752
  function detsFor(entry, behavior, touched, type, options) {
2280
2753
  const sources = [];
@@ -2304,9 +2777,73 @@ function detsFor(entry, behavior, touched, type, options) {
2304
2777
  * paid for twice.
2305
2778
  */
2306
2779
  for (const field of behavior.requestFields) add(field, `request:${field}`);
2307
- if (type === "EO" || type === "EQ") for (const store of touched) {
2308
- const columns = options.countedStores.get(store).attributes.filter((attribute) => !attribute.isIdentifier);
2309
- for (const column of columns) add(`${store}.${column.name}`, `output:${store}.${column.name}`);
2780
+ if (type === "EO" || type === "EQ") {
2781
+ /**
2782
+ * counting-decisions §6, per store, in order of what is visible:
2783
+ *
2784
+ * transformer covers ITS resource: the keys leave, the columns do not.
2785
+ * A store read beside it and passed raw is not covered.
2786
+ * aggregate `.count()` / `.exists()`: one derived scalar leaves — 1 DET,
2787
+ * whatever else is known about the store
2788
+ * whole rows leave: every column
2789
+ * select only the columns named
2790
+ * unknown reached some other way (a hook, a relation): every column
2791
+ *
2792
+ * An unreadable spread in a transformer is a placeholder at 1 DET, marked
2793
+ * `(opaque)` like an open input object, and the counter reports it.
2794
+ */
2795
+ const opaqueOutputs = new Set(behavior.opaqueOutputFields);
2796
+ /**
2797
+ * Covered: the stores a transformer is for, and the stores preloaded ONLY
2798
+ * through a covered one — `Livro.query().preload('autor')` handed to
2799
+ * `RecenteTransformer<Livro>` loads the author for the transformer, which
2800
+ * emits whatever of it leaves. A store read by a chain of its own is shown
2801
+ * for itself and is never covered this way. Iterated to a fixpoint: a
2802
+ * relation of a relation.
2803
+ */
2804
+ const covered = new Set(behavior.transformedStores);
2805
+ for (let changed = true; changed;) {
2806
+ changed = false;
2807
+ for (const [store, read] of Object.entries(behavior.outputReads)) {
2808
+ if (covered.has(store) || read.direct || read.via.length === 0) continue;
2809
+ if (!read.via.every((parent) => covered.has(parent))) continue;
2810
+ covered.add(store);
2811
+ changed = true;
2812
+ }
2813
+ }
2814
+ for (const field of behavior.outputFields) add(field, `transformer:${field}${opaqueOutputs.has(field) ? " (opaque)" : ""}`);
2815
+ for (const store of touched) {
2816
+ const read = behavior.outputReads[store];
2817
+ if (read?.aggregate) add(`${store}.<aggregate>`, `aggregate:${store} (a count or an existence check: one scalar)`);
2818
+ if (covered.has(store)) continue;
2819
+ if (read && read.aggregate && !read.whole && read.selected.length === 0) continue;
2820
+ /**
2821
+ * The key and the system timestamps are not DETs however they leave —
2822
+ * selected by name or as part of the whole table. Same ground as on the
2823
+ * data function: the user neither supplies nor recognises them (§6). A
2824
+ * hidden column is a DET of the file and never of an output.
2825
+ */
2826
+ const attributes = options.countedStores.get(store).attributes;
2827
+ const excluded = new Set([...attributes.filter((a) => a.isIdentifier || a.system || a.hidden).map((a) => a.name), ...options.grouping.linkColumns.get(store) ?? []]);
2828
+ /**
2829
+ * A JSON column leaving the boundary is as unreadable here as on the data
2830
+ * function: 1 DET, marked, so a declaration about the column (§8) reaches the
2831
+ * transactions that show it and not only the store.
2832
+ */
2833
+ const opaqueOf = new Map(attributes.map((a) => [a.name, isOpaqueType(a.type)]));
2834
+ const mark = (column) => opaqueOf.get(column) ? " (opaque)" : "";
2835
+ if (read && !read.whole && read.selected.length > 0) {
2836
+ for (const column of read.selected) {
2837
+ if (excluded.has(column)) continue;
2838
+ add(`${store}.${column}`, `select:${store}.${column}${mark(column)}`);
2839
+ }
2840
+ continue;
2841
+ }
2842
+ for (const column of attributes) {
2843
+ if (excluded.has(column.name)) continue;
2844
+ add(`${store}.${column.name}`, `output:${store}.${column.name}${mark(column.name)}`);
2845
+ }
2846
+ }
2310
2847
  }
2311
2848
  let det = counted.size + options.messageDet;
2312
2849
  if (options.messageDet > 0) sources.push("message:1");
@@ -2318,20 +2855,17 @@ function detsFor(entry, behavior, touched, type, options) {
2318
2855
  //#endregion
2319
2856
  //#region src/albrecht/technical_filter.ts
2320
2857
  /**
2321
- * Temporary and technical data filter — AFP §6.5.2.1.1.
2858
+ * Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3)
2859
+ * plus the one AdonisJS asks for.
2322
2860
  *
2323
- * "Database tables identified as temporary or technical shall be marked as
2324
- * such to be presented in the final report, and shall be ignored in the rest
2325
- * of this process."
2326
- *
2327
- * Returns the reason when a table is technical, `null` otherwise: the report
2328
- * must say WHY something was excluded, not merely that it was.
2329
- */
2330
- /**
2331
- * Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3).
2861
+ * The standard treats these as user-provided inputs, so `boundary.technicalPatterns`
2862
+ * REPLACES this list when set — a team that finds `.+types?` catching its
2863
+ * business data drops it there — and `boundary.business` restores one table.
2332
2864
  *
2333
- * The standard treats these as user-provided inputs, so they stay overridable
2334
- * through the boundary configuration.
2865
+ * `token` is not in the spec's list and is here because the framework's own
2866
+ * tables are: `auth_access_tokens`, `remember_me_tokens`, `password_reset_tokens`.
2867
+ * A token is the machinery of authentication, not data the user maintains, and
2868
+ * on two applications it came out as an ILF at 7 PF each.
2335
2869
  */
2336
2870
  const DEFAULT_TECHNICAL_PATTERNS = [
2337
2871
  {
@@ -2349,11 +2883,18 @@ const DEFAULT_TECHNICAL_PATTERNS = [
2349
2883
  {
2350
2884
  label: "template entity",
2351
2885
  pattern: /^(.*template.*)$/i
2886
+ },
2887
+ {
2888
+ label: "token entity",
2889
+ pattern: /^(.*tokens?.*)$/i
2352
2890
  }
2353
2891
  ];
2354
2892
  function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
2355
2893
  const table = store.table ?? store.name;
2356
- for (const { label, pattern } of patterns) if (pattern.test(table)) return `${label} (AFP §6.5.2.1.3: ${pattern.source})`;
2894
+ for (const { label, pattern } of patterns) {
2895
+ const regex = typeof pattern === "string" ? new RegExp(pattern, "i") : pattern;
2896
+ if (regex.test(table)) return `${label} (AFP §6.5.2.1.3: ${regex.source})`;
2897
+ }
2357
2898
  return null;
2358
2899
  }
2359
2900
  //#endregion
@@ -2378,15 +2919,19 @@ const RULESET = "afp";
2378
2919
  * that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
2379
2920
  * across the whole project rather than from routes alone, a job followed into
2380
2921
  * `process`, an event followed into its listeners, and `request.input(…)` counted
2381
- * as a DET — and three more in 1.2.0: an open input object counting 1 instead of 0,
2922
+ * as a DET — three more in 1.2.0: an open input object counting 1 instead of 0,
2382
2923
  * `detFromSchema` no longer subtracting a placeholder that was not there, and a
2383
- * write through `related(…)` maintaining the related table.
2924
+ * write through `related(…)` maintaining the related table — and six in 1.5.0:
2925
+ * output DETs read from transformers, selects and aggregates instead of every
2926
+ * column; system timestamps and `serializeAs: null` columns leaving the DETs;
2927
+ * master-detail folded into one data function; identity by table; token tables
2928
+ * technical; and opaque declarations reaching every function carrying the origin.
2384
2929
  *
2385
2930
  * Without the bump, a baseline saved by the previous version compares cleanly
2386
2931
  * against this one and bills the tool's own improvement as work done. The guard
2387
2932
  * exists for exactly that, and only this constant arms it.
2388
2933
  */
2389
- const RULESET_VERSION = "1.4.0";
2934
+ const RULESET_VERSION = "1.5.0";
2390
2935
  function count(input, options = {}) {
2391
2936
  const warnings = [];
2392
2937
  const usage = usageOf(input);
@@ -2405,7 +2950,7 @@ function count(input, options = {}) {
2405
2950
  warnings.push(`excluded by boundary configuration: ${store.name}`);
2406
2951
  return false;
2407
2952
  }
2408
- const technical = isTechnical(store);
2953
+ const technical = isTechnical(store, options.boundary?.technicalPatterns);
2409
2954
  if (!technical) return true;
2410
2955
  /**
2411
2956
  * The naming filter is a heuristic over names, so it catches business data
@@ -2431,50 +2976,79 @@ function count(input, options = {}) {
2431
2976
  warnings.push(`technical, excluded: ${store.name} (${technical})`);
2432
2977
  return false;
2433
2978
  });
2979
+ /**
2980
+ * A configuration the code does not honour is worse than none: whoever set it
2981
+ * believes something changed. `retStrategy` left the type in 0.6.0 — but a
2982
+ * configuration file is loaded without types, so an old one still arrives here
2983
+ * and has to be told.
2984
+ */
2985
+ if (options.retStrategy !== void 0) warnings.push("`retStrategy` is no longer read: RET comes from how the application uses each table (counting-decisions §10), configurable as `dataFunctions.grouping: 'usage' | 'none'`. Remove the key.");
2986
+ /**
2987
+ * 3. how the stores fold into data functions — §10 — then the data functions.
2988
+ *
2989
+ * Grouping by usage needs the project-wide pass. Without it an empty set would
2990
+ * read as "nobody addresses this table" and fold every composition child into
2991
+ * its parent — the absence of a fact is not the fact. The pipeline always
2992
+ * provides it; a direct caller that does not is told, and gets no grouping.
2993
+ */
2994
+ const strategy = options.dataFunctions?.grouping ?? "usage";
2995
+ if (strategy === "usage" && input.addressedAnywhere === void 0) warnings.push("grouping by usage needs the project-wide pass (`addressedAnywhere`) and none was provided: every table is its own data function in this count.");
2996
+ const grouping = groupStores(countable, {
2997
+ grouping: input.addressedAnywhere === void 0 ? "none" : strategy,
2998
+ addressedAnywhere: input.addressedAnywhere ?? /* @__PURE__ */ new Set()
2999
+ });
3000
+ warnings.push(...grouping.warnings);
2434
3001
  const dataFunctions = countDataFunctions(countable, usage, {
3002
+ grouping,
2435
3003
  writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
2436
- retStrategy: options.retStrategy ?? "constant",
2437
3004
  externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
2438
3005
  tables,
2439
3006
  weights
2440
3007
  });
2441
- const countedStores = new Map(dataFunctions.map((fn) => [fn.name, countable.find((store) => store.name === fn.name)]));
3008
+ /**
3009
+ * 4. transactional functions, over the stores that actually count — a child
3010
+ * folded into a counted root counts too: a transaction reaching it reaches the
3011
+ * group, and its columns are the group's output.
3012
+ */
3013
+ const countedRoots = new Set(dataFunctions.map((fn) => fn.name));
3014
+ const countedStores = new Map(countable.filter((store) => countedRoots.has(grouping.rootOf.get(store.name) ?? store.name)).map((store) => [store.name, store]));
2442
3015
  const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
2443
3016
  const transactionalFunctions = countTransactionalFunctions(input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? "")), input.behaviors, {
2444
3017
  countedStores,
3018
+ grouping,
2445
3019
  root: input.app.root,
2446
3020
  messageDet: options.messageDet ?? 0,
2447
3021
  tables,
2448
3022
  weights
2449
3023
  });
2450
3024
  /**
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.
3025
+ * What the analysis could not read, answered by ORIGIN — counting-decisions §8.
2465
3026
  *
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.
3027
+ * A declaration is about a column or a validator field, and it applies to every
3028
+ * function that carries the DET: the data function and each transaction that
3029
+ * takes or shows it. Keyed by function it was declared twice and still missed
3030
+ * the third place, so the same column was worth two numbers in one count.
2470
3031
  */
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 ?? {}
3032
+ const opaque = applyOpaque([...dataFunctions, ...transactionalFunctions], {
3033
+ declarations: options.opaque ?? {},
3034
+ stores: countable,
3035
+ schemas: input.jsonSchemas ?? /* @__PURE__ */ new Map(),
3036
+ tables,
3037
+ weights
3038
+ });
3039
+ warnings.push(...opaque.warnings);
3040
+ const functions = applyOverrides(opaque.functions, options.overrides ?? {}, tables, weights, warnings);
3041
+ warnings.push(...opaqueWarnings({
3042
+ functions,
3043
+ stores: countable,
3044
+ entryPoints: input.entryPoints,
3045
+ behaviors: input.behaviors,
3046
+ answered: opaque.answered
2476
3047
  }));
2477
3048
  warnings.push(...unreadableInputWarnings(input));
3049
+ warnings.push(...unreadableOutputWarnings(input));
3050
+ warnings.push(...lookAlikeWarnings(functions));
3051
+ warnings.push(...seededOnlyWarnings(functions, grouping.members, input.seededAnywhere));
2478
3052
  return {
2479
3053
  ruleset: "afp",
2480
3054
  rulesetVersion: RULESET_VERSION,
@@ -2484,160 +3058,57 @@ function count(input, options = {}) {
2484
3058
  };
2485
3059
  }
2486
3060
  /**
2487
- * Columns whose content static analysis cannot read — counting-decisions §8.
2488
- *
2489
- * A JSON column holding a form the user fills counts as 1 DET, because the
2490
- * schema is runtime data. That is the documented trade, and until now it was
2491
- * documented ONLY: the count said nothing, which is the one known blind spot
2492
- * this package reported nowhere. It reports an unresolved call, a technical
2493
- * table, an unresolved mixin and a handler-less route — and stayed silent here.
2494
- *
2495
- * Only columns on a store some transaction reaches are named. An untouched
2496
- * `metadata` column changes no number, and warning about it would be the noise
2497
- * that teaches people to stop reading the confidence block.
2498
- */
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.
3061
+ * Transactions that look like the same elementary process.
3062
+ *
3063
+ * The CPM counts identical processing logic once. `GET /perfil` and
3064
+ * `GET /perfil/editar` on a real application walk the same queries and the same
3065
+ * transformers, touch the same stores and emit the same DETs, and were 7 FP each.
3066
+ * Whether the second is a screen the user needs or a second URL for the same one
3067
+ * is not derivable from code, so both stay counted and the pair is named with
3068
+ * the FP at stake — a request to decide, answered with `boundary.ignoreEntryPoints`.
3069
+ *
3070
+ * The key is deliberately narrow: same type, same stores, same DET sources AND
3071
+ * the same bodies below the entry point. Two fat controllers that merely read the
3072
+ * same table are not flagged — with nothing followed, nothing says the logic is
3073
+ * the same.
2529
3074
  */
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
- });
3075
+ function lookAlikeWarnings(functions) {
3076
+ const groups = /* @__PURE__ */ new Map();
3077
+ for (const fn of functions) {
3078
+ if (!fn.id.startsWith("tx:")) continue;
3079
+ const below = (fn.rationale.trace ?? []).filter((step) => step.depth > 0).map((step) => `${step.file}#${step.member ?? "*"}`).sort();
3080
+ if (below.length === 0) continue;
3081
+ const key = [
3082
+ fn.type,
3083
+ [...fn.rationale.refSources].sort().join(","),
3084
+ [...fn.rationale.detSources].sort().join(","),
3085
+ below.join(",")
3086
+ ].join("|");
3087
+ groups.set(key, [...groups.get(key) ?? [], fn]);
3088
+ }
3089
+ const alike = [...groups.values()].filter((group) => group.length > 1);
3090
+ if (alike.length === 0) return [];
3091
+ return [`${alike.length} group(s) of transactions share the same stores, the same DETs and the same bodies below the controller — the CPM counts identical processing logic once. Whether the second is a screen of its own is not derivable from the code: decide, and record it with \`boundary.ignoreEntryPoints\`:`, ...alike.map((group) => {
3092
+ const names = group.map((fn) => fn.name).sort();
3093
+ const atStake = group.slice(1).reduce((total, fn) => total + fn.points, 0);
3094
+ return ` ${names.join(" ≡ ")} (${atStake} FP at stake)`;
3095
+ })];
2538
3096
  }
2539
- /** a column whose shape says nothing about what it holds */
2540
- const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
2541
3097
  /**
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
3098
+ * EIFs that only a seeder writes.
3099
+ *
3100
+ * After 0.5.0 a seeder's inserts are not maintenance, so a table only the seed
3101
+ * populates is "used but not maintained" — an EIF. That is right for a table
3102
+ * that mirrors data another system maintains in production, and wrong for a
3103
+ * `roles` table: reference data the team maintains is code data under the CPM,
3104
+ * and is not counted at all. The code cannot tell the two apart, and should not
3105
+ * try; it names them and says what each answer costs.
2559
3106
  */
2560
- function opaqueWarnings(stores, input, state) {
2561
- const reached = /* @__PURE__ */ new Map();
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);
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
- };
2575
- for (const store of stores) {
2576
- if (!reached.get(store.name)) continue;
2577
- for (const attribute of store.attributes) {
2578
- if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
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})`);
2584
- }
2585
- }
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);
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}`);
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];
2635
- return [
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
2640
- ];
3107
+ function seededOnlyWarnings(functions, members, seeded) {
3108
+ if (!seeded || seeded.size === 0) return [];
3109
+ const named = functions.filter((fn) => fn.type === "EIF" && fn.rationale.rule.includes("used but not maintained") && (members.get(fn.name) ?? [fn.name]).some((member) => seeded.has(member)));
3110
+ if (named.length === 0) return [];
3111
+ return [`${named.length} EIF(s) are written by a seeder and by nothing else in the application. Reference data the team maintains is code data (CPM) and is not counted — exclude it with \`boundary.infrastructure\`; data another system maintains in production is a legitimate EIF — keep it. The code cannot tell which:`, ...named.map((fn) => ` ${fn.name} (${fn.points} FP)`)];
2641
3112
  }
2642
3113
  /**
2643
3114
  * Transactions that read the request in a way that enumerates nothing.
@@ -2676,100 +3147,63 @@ function unreadableInputWarnings(input) {
2676
3147
  ];
2677
3148
  }
2678
3149
  /**
2679
- * Replaces what the analysis found with what a person declared.
3150
+ * Transformers that emit something the analysis cannot read.
2680
3151
  *
2681
- * Only for facts static analysis cannot reach — a JSON column whose schema
2682
- * lives in the database, per counting-decisions §8. The declared number is
2683
- * reproducible because it comes from a versioned file, and auditable because it
2684
- * travels with its justification into the rationale, which `fp:explain` prints.
2685
- *
2686
- * An override naming no function is a warning, never silence: a typo in the key
2687
- * would otherwise mean the declaration did nothing and nobody was told.
3152
+ * `...this.resource.serialize()`, `...this.extras`: whatever the model has, or
3153
+ * whatever was handed in. Each counts 1 DET — a floor, the same as an open input
3154
+ * object — and the transaction's output is understated by however many fields
3155
+ * the spread carries. Reported with the expression, because the fix is in the
3156
+ * transformer: name the fields, or `pick` them.
3157
+ */
3158
+ function unreadableOutputWarnings(input) {
3159
+ const blind = input.entryPoints.map((entry) => ({
3160
+ entry,
3161
+ behavior: input.behaviors.get(entry.id)
3162
+ })).filter(({ behavior }) => (behavior?.opaqueOutputFields.length ?? 0) > 0);
3163
+ if (blind.length === 0) return [];
3164
+ return [
3165
+ `${blind.length} transaction(s) pass through a transformer that spreads something the analysis cannot read, counted as 1 DET each — a FLOOR. This UNDERSTATES the output; the fix is in the transformer (\`this.pick(...)\` or named keys), not a configuration:`,
3166
+ ...blind.slice(0, 10).map(({ entry, behavior }) => ` ${entry.trigger} ${entry.signature}: ${behavior.opaqueOutputFields.join(", ")}`),
3167
+ ...blind.length > 10 ? [` … and ${blind.length - 10} more`] : []
3168
+ ];
3169
+ }
3170
+ /**
3171
+ * Replaces what the analysis found with a NUMBER a person declared — DET or RET
3172
+ * of one function.
3173
+ *
3174
+ * Only for facts static analysis cannot reach, and only when naming the origin
3175
+ * (`opaque`) is not possible: a declared number is reproducible because it comes
3176
+ * from a versioned file, and auditable because it travels with its justification
3177
+ * into the rationale, which `fp:explain` prints — but it freezes the moment the
3178
+ * form grows. An override naming no function is a warning, never silence.
2688
3179
  */
2689
- function applyOverrides(functions, overrides, schemas, tables, weights, warnings, reviewed) {
3180
+ function applyOverrides(functions, overrides, tables, weights, warnings) {
2690
3181
  const keys = Object.keys(overrides);
2691
3182
  if (keys.length === 0) return functions;
3183
+ /**
3184
+ * A configuration the code does not honour is worse than none. The two keys
3185
+ * that used to live here moved to `opaque`, by origin, in 0.6.0 and left the
3186
+ * type — but a configuration file is loaded without types, so an old one still
3187
+ * arrives here believing something happened.
3188
+ */
3189
+ for (const [name, override] of Object.entries(overrides)) {
3190
+ const legacy = override;
3191
+ const moved = [...legacy.detFromSchema ? ["detFromSchema"] : [], ...legacy.opaqueReviewed ? ["opaqueReviewed"] : []];
3192
+ if (moved.length === 0) continue;
3193
+ warnings.push(`override "${name}" uses \`${moved.join("` and `")}\`, which moved to \`opaque.<Store.column | validator.field>\` in 0.6.0 and is no longer read here: it had no effect. Declare the origin, and it applies to every function carrying it.`);
3194
+ }
2692
3195
  const used = /* @__PURE__ */ new Set();
2693
3196
  const applied = functions.map((fn) => {
2694
3197
  const override = overrides[fn.name];
2695
3198
  if (!override) return fn;
2696
3199
  used.add(fn.name);
2697
3200
  const fields = [];
2698
- if (override.det !== void 0 || override.detFromSchema) fields.push("det");
3201
+ if (override.det !== void 0) fields.push("det");
2699
3202
  if (override.refs !== void 0) fields.push("refs");
2700
- let det = override.det ?? fn.det;
2701
- let by = `config:overrides.${fn.name}`;
2702
- /**
2703
- * Read from the schema rather than declared as a number.
2704
- *
2705
- * A frozen number goes stale the moment someone adds a field: the count
2706
- * would not move and `fp:diff` would report no change for real functional
2707
- * growth. Naming the schema keeps the number coming from the code, and the
2708
- * only thing maintained by hand is the mapping — which changes when a form
2709
- * is born, not when a field is.
2710
- */
2711
- if (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.`);
2731
- if (schema) {
2732
- /**
2733
- * Replaces the opaque placeholder — the one the rationale marks — rather
2734
- * than assuming there is one and that it is worth 1.
2735
- *
2736
- * That assumption was wrong twice over. An open `vine.object` counted
2737
- * ZERO, not 1, so subtracting 1 removed a field the analysis had read
2738
- * correctly: 86 DETs where 87 was right. And a function with no opaque
2739
- * DET at all was silently charged the subtraction too.
2740
- */
2741
- const placeholders = fn.rationale.detSources.filter((source) => source.endsWith("(opaque)"));
2742
- det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
2743
- by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
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.`);
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
- }
2759
- }
3203
+ if (fields.length === 0) return fn;
3204
+ const det = override.det ?? fn.det;
2760
3205
  const refs = override.refs ?? fn.refs;
2761
3206
  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);
2773
3207
  return {
2774
3208
  ...fn,
2775
3209
  det,
@@ -2778,9 +3212,8 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
2778
3212
  points: pointsOf(fn.type, complexity, weights),
2779
3213
  rationale: {
2780
3214
  ...fn.rationale,
2781
- detSources: marked,
2782
3215
  overrides: [...fn.rationale.overrides ?? [], {
2783
- by,
3216
+ by: `config:overrides.${fn.name}`,
2784
3217
  reason: override.reason,
2785
3218
  fields
2786
3219
  }]
@@ -3029,7 +3462,22 @@ async function analyze(root, options = {}) {
3029
3462
  }
3030
3463
  })),
3031
3464
  opaqueRequest: behavior.opaqueRequest,
3032
- outputFields: [],
3465
+ outputFields: behavior.outputFields.map((name) => ({
3466
+ name,
3467
+ provenance: {
3468
+ file: emit(app.root),
3469
+ by: "transformer"
3470
+ }
3471
+ })),
3472
+ opaqueOutputFields: behavior.opaqueOutputFields.map((name) => ({
3473
+ name,
3474
+ provenance: {
3475
+ file: emit(app.root),
3476
+ by: "transformer"
3477
+ }
3478
+ })),
3479
+ transformedStores: behavior.transformedStores,
3480
+ outputReads: behavior.outputReads,
3033
3481
  trace: behavior.trace.map((step) => ({
3034
3482
  ...step,
3035
3483
  file: emit(step.file)
@@ -3057,11 +3505,13 @@ async function analyze(root, options = {}) {
3057
3505
  entryPoints,
3058
3506
  behaviors,
3059
3507
  jsonSchemas,
3060
- writtenAnywhere: analyzer.writtenAnywhere()
3508
+ writtenAnywhere: analyzer.writtenAnywhere(),
3509
+ addressedAnywhere: analyzer.addressedAnywhere(),
3510
+ seededAnywhere: analyzer.seededAnywhere()
3061
3511
  }, options),
3062
3512
  source
3063
3513
  }
3064
3514
  };
3065
3515
  }
3066
3516
  //#endregion
3067
- export { RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };
3517
+ export { DEFAULT_TECHNICAL_PATTERNS as a, RULESET_VERSION as i, analyze as n, RULESET as r, CoverageTooLowError as t };