@filipebraida/adonis-function-points 0.4.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.
- package/CHANGELOG.md +155 -0
- package/README.md +101 -281
- package/build/{calibration-8eV8CEix.js → calibration-DVIf8hcE.js} +42 -3
- package/build/commands/main.js +6 -6
- package/build/{fp_calibrate-DLZP5bUp.js → fp_calibrate-EAuAtdbq.js} +1 -1
- package/build/{fp_count-DNSwaLUD.js → fp_count-CZ0cUUBQ.js} +1 -1
- package/build/{fp_diff-CCKxqGKh.js → fp_diff-BTg_LX0r.js} +1 -1
- package/build/{fp_explain-Dpiby5Qx.js → fp_explain-D6QvDLKQ.js} +1 -1
- package/build/{fp_inventory-DHwZzEQf.js → fp_inventory-C43fU39x.js} +1 -1
- package/build/{fp_metrics-M84qLYaE.js → fp_metrics-DEMPk4xC.js} +1 -1
- package/build/index.d.ts +8 -4
- package/build/index.js +4 -4
- package/build/{pipeline-Dm9KvUvF.js → pipeline-Cq4dNTNE.js} +841 -264
- package/build/{resolvers-vMahHkAd.js → resolvers-DlKJOZnk.js} +373 -63
- package/build/{runners-DetZGfh5.js → runners-FYmPIPub.js} +12 -4
- package/build/src/albrecht/counter.d.ts +31 -5
- package/build/src/albrecht/data_functions.d.ts +49 -3
- package/build/src/albrecht/diff.d.ts +27 -0
- package/build/src/albrecht/index.d.ts +1 -0
- package/build/src/albrecht/opaque.d.ts +90 -0
- package/build/src/albrecht/technical_filter.d.ts +18 -11
- package/build/src/albrecht/transactional_functions.d.ts +7 -0
- package/build/src/cli.js +2 -2
- package/build/src/define_config.d.ts +55 -57
- package/build/src/inventory/graph/call_graph.d.ts +39 -0
- package/build/src/inventory/graph/output_fields.d.ts +99 -0
- package/build/src/inventory/paths.d.ts +3 -0
- package/build/src/inventory/resolvers/index.d.ts +28 -0
- package/build/src/inventory/resolvers/index.js +2 -2
- package/build/src/inventory/resolvers/types.d.ts +19 -0
- package/build/src/pipeline.js +1 -1
- package/build/src/types.d.ts +32 -1
- package/build/stubs/config.stub +29 -16
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
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
|
|
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,16 +1606,71 @@ 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);
|
|
1614
|
+
const context = {
|
|
1615
|
+
file,
|
|
1616
|
+
depth: 0,
|
|
1617
|
+
imports,
|
|
1618
|
+
exportedAs,
|
|
1619
|
+
injected,
|
|
1620
|
+
eventBindings,
|
|
1621
|
+
dataStoresBySymbol: storesByName,
|
|
1622
|
+
resolveSpecifier: app.resolveSpecifier,
|
|
1623
|
+
sourceFile
|
|
1624
|
+
};
|
|
1589
1625
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1590
1626
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
1591
1627
|
if (access) {
|
|
1628
|
+
/**
|
|
1629
|
+
* Asked about a DIRECT write too, not only about a call a resolver follows.
|
|
1630
|
+
*
|
|
1631
|
+
* `Notification.query().…update({ status: 'read' })` in a `show` handler is
|
|
1632
|
+
* exactly the shape this exists for, and the write IS the call — there is no
|
|
1633
|
+
* method to declare. Asking in one place only would have covered the service
|
|
1634
|
+
* call and missed the query builder beside it, which is the same fact written
|
|
1635
|
+
* differently.
|
|
1636
|
+
*/
|
|
1637
|
+
const technical = access.mode === "write" && isTechnicalWrite(call, context, resolvers);
|
|
1592
1638
|
accesses.push({
|
|
1593
1639
|
store: access.store,
|
|
1594
|
-
write: access.mode === "write"
|
|
1640
|
+
write: access.mode === "write",
|
|
1641
|
+
technical
|
|
1595
1642
|
});
|
|
1596
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
|
+
/**
|
|
1597
1674
|
* A relation reached by `preload`/`load` is read; one written through
|
|
1598
1675
|
* `related('files').create(…)` is written. Assuming read either way made
|
|
1599
1676
|
* a table maintained only through a relation come out as an EIF.
|
|
@@ -1619,22 +1696,20 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1619
1696
|
* shape must not be CLAIMED, not merely not reported.
|
|
1620
1697
|
*/
|
|
1621
1698
|
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);
|
|
1699
|
+
const resolved = resolveCall(call, context, resolvers);
|
|
1633
1700
|
if (resolved) {
|
|
1701
|
+
/**
|
|
1702
|
+
* Declared about the CALL, so everything reached through it is incidental too:
|
|
1703
|
+
* `persistOrganizationVisit` is called from several screens and saying it once
|
|
1704
|
+
* covers all of them.
|
|
1705
|
+
*/
|
|
1706
|
+
const technical = isTechnicalWrite(call, context, resolvers);
|
|
1634
1707
|
for (const next of resolved.refs) followUps.push({
|
|
1635
1708
|
ref: next,
|
|
1636
|
-
by: resolved.by
|
|
1709
|
+
by: resolved.by,
|
|
1710
|
+
technical
|
|
1637
1711
|
});
|
|
1712
|
+
followedCalls.add(call);
|
|
1638
1713
|
continue;
|
|
1639
1714
|
}
|
|
1640
1715
|
if (isWorthReporting(call, symbols, imports) && !isNoise(call, owner)) unresolved.push({
|
|
@@ -1644,6 +1719,11 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1644
1719
|
reason: "call that no strategy knew how to follow"
|
|
1645
1720
|
});
|
|
1646
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));
|
|
1647
1727
|
return {
|
|
1648
1728
|
accesses,
|
|
1649
1729
|
followUps,
|
|
@@ -1652,6 +1732,10 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1652
1732
|
opaqueValidators: validator.opaque,
|
|
1653
1733
|
requestFields: request.fields,
|
|
1654
1734
|
opaqueRequest: request.opaque,
|
|
1735
|
+
outputs: output.outputs,
|
|
1736
|
+
opaqueOutputs: output.opaqueOutputs,
|
|
1737
|
+
transformed: output.resource,
|
|
1738
|
+
reads,
|
|
1655
1739
|
bodyHash: hashOf(body)
|
|
1656
1740
|
};
|
|
1657
1741
|
}
|
|
@@ -1670,14 +1754,50 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1670
1754
|
* transaction reaches the store still decides if it is counted at all; this
|
|
1671
1755
|
* only decides who maintains it.
|
|
1672
1756
|
*/
|
|
1673
|
-
|
|
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;
|
|
1674
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();
|
|
1675
1771
|
for (const file of project.getSourceFiles()) {
|
|
1772
|
+
/**
|
|
1773
|
+
* A seeder's inserts are not the application maintaining a table, and a test
|
|
1774
|
+
* factory's are not either. Counting them made every reference table an ILF:
|
|
1775
|
+
* the CPM puts data maintained by the development team at an EIF at most, and
|
|
1776
|
+
* code data outside the count entirely.
|
|
1777
|
+
*
|
|
1778
|
+
* This is the same notion `scanRoots` applies at the root, applied at any
|
|
1779
|
+
* depth — because a domain-module layout puts `tests/` and `seeders/` inside
|
|
1780
|
+
* `app/`, where the root filter never looks.
|
|
1781
|
+
*/
|
|
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
|
+
}
|
|
1676
1794
|
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
1677
1795
|
if (symbols.size === 0) continue;
|
|
1678
1796
|
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1679
1797
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
1680
|
-
if (access
|
|
1798
|
+
if (!access) continue;
|
|
1799
|
+
addressed.add(access.store);
|
|
1800
|
+
if (access.mode !== "write") continue;
|
|
1681
1801
|
written.add(access.store);
|
|
1682
1802
|
/**
|
|
1683
1803
|
* `distribution.related('files').create(…)` maintains the related table.
|
|
@@ -1687,27 +1807,47 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1687
1807
|
*/
|
|
1688
1808
|
if (access.viaRelation && access.relationWritten) written.add(access.viaRelation);
|
|
1689
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
|
+
}
|
|
1690
1815
|
}
|
|
1691
|
-
|
|
1816
|
+
projectWide = {
|
|
1817
|
+
written,
|
|
1818
|
+
addressed,
|
|
1819
|
+
seeded
|
|
1820
|
+
};
|
|
1821
|
+
return projectWide;
|
|
1692
1822
|
};
|
|
1823
|
+
const writtenAnywhere = () => scanProject().written;
|
|
1824
|
+
const addressedAnywhere = () => scanProject().addressed;
|
|
1825
|
+
const seededAnywhere = () => scanProject().seeded;
|
|
1693
1826
|
return {
|
|
1694
1827
|
analyze: (handler) => run(handler),
|
|
1695
1828
|
writtenAnywhere,
|
|
1829
|
+
addressedAnywhere,
|
|
1830
|
+
seededAnywhere,
|
|
1696
1831
|
/** how many files the project loaded — used to prove it does not grow */
|
|
1697
1832
|
fileCount: () => project.getSourceFiles().length
|
|
1698
1833
|
};
|
|
1699
1834
|
function run(handler) {
|
|
1700
1835
|
const touches = /* @__PURE__ */ new Set();
|
|
1836
|
+
const writtenStores = /* @__PURE__ */ new Set();
|
|
1701
1837
|
const inputFields = /* @__PURE__ */ new Set();
|
|
1702
1838
|
const opaqueInputFields = /* @__PURE__ */ new Set();
|
|
1703
1839
|
const requestFields = /* @__PURE__ */ new Set();
|
|
1704
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();
|
|
1705
1845
|
const trace = [];
|
|
1706
1846
|
const scope = [];
|
|
1707
1847
|
const unresolved = [];
|
|
1708
1848
|
const visited = /* @__PURE__ */ new Set();
|
|
1709
1849
|
let writes = false;
|
|
1710
|
-
const visit = (ref, depth) => {
|
|
1850
|
+
const visit = (ref, depth, technical = false) => {
|
|
1711
1851
|
const key = `${ref.file}#${ref.member ?? ref.line ?? "*"}`;
|
|
1712
1852
|
if (visited.has(key) || depth > maxDepth) return;
|
|
1713
1853
|
visited.add(key);
|
|
@@ -1732,16 +1872,40 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1732
1872
|
let bodyWrites = false;
|
|
1733
1873
|
for (const access of facts.accesses) {
|
|
1734
1874
|
touches.add(access.store);
|
|
1735
|
-
if (access.write)
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1875
|
+
if (!access.write) continue;
|
|
1876
|
+
/**
|
|
1877
|
+
* The store is maintained either way — a visit table really is written by this
|
|
1878
|
+
* application, so it stays an ILF and stays an FTR. What a technical write does
|
|
1879
|
+
* not do is decide what the transaction is FOR: §6.5.3 would read a `GET` that
|
|
1880
|
+
* notes the visit as an EI, and the CPM asks about primary intent.
|
|
1881
|
+
*/
|
|
1882
|
+
writtenStores.add(access.store);
|
|
1883
|
+
bodyWrites = true;
|
|
1884
|
+
if (!technical && !access.technical) writes = true;
|
|
1739
1885
|
}
|
|
1740
1886
|
unresolved.push(...facts.unresolved);
|
|
1741
1887
|
for (const field of facts.validators) inputFields.add(field);
|
|
1742
1888
|
for (const field of facts.opaqueValidators) opaqueInputFields.add(field);
|
|
1743
1889
|
for (const field of facts.requestFields) requestFields.add(field);
|
|
1744
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
|
+
}
|
|
1745
1909
|
trace.push({
|
|
1746
1910
|
file: ref.file,
|
|
1747
1911
|
member: ref.member,
|
|
@@ -1757,7 +1921,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1757
1921
|
if (depth >= maxDepth) return;
|
|
1758
1922
|
for (const followUp of facts.followUps) {
|
|
1759
1923
|
const before = trace.length;
|
|
1760
|
-
visit(followUp.ref, depth + 1);
|
|
1924
|
+
visit(followUp.ref, depth + 1, technical || followUp.technical === true);
|
|
1761
1925
|
if (trace.length > before) trace[before].by = followUp.by;
|
|
1762
1926
|
}
|
|
1763
1927
|
};
|
|
@@ -1765,10 +1929,21 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
1765
1929
|
return {
|
|
1766
1930
|
writes,
|
|
1767
1931
|
touches: [...touches].sort(),
|
|
1932
|
+
writtenStores: [...writtenStores].sort(),
|
|
1768
1933
|
inputFields: [...inputFields].sort(),
|
|
1769
1934
|
opaqueInputFields: [...opaqueInputFields].sort(),
|
|
1770
1935
|
requestFields: [...requestFields].sort(),
|
|
1771
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
|
+
}])),
|
|
1772
1947
|
trace,
|
|
1773
1948
|
scope,
|
|
1774
1949
|
unresolved
|
|
@@ -2117,40 +2292,360 @@ function pointsOf(type, complexity, weights = DEFAULT_WEIGHTS) {
|
|
|
2117
2292
|
return weights[type][complexity];
|
|
2118
2293
|
}
|
|
2119
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
|
|
2120
2498
|
//#region src/albrecht/data_functions.ts
|
|
2121
2499
|
/**
|
|
2122
|
-
* A
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
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.
|
|
2125
2510
|
*/
|
|
2126
|
-
|
|
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.
|
|
2577
|
+
*/
|
|
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
|
+
}
|
|
2127
2592
|
function countDataFunctions(stores, usage, options) {
|
|
2128
2593
|
const counted = [];
|
|
2594
|
+
const byName = new Map(stores.map((store) => [store.name, store]));
|
|
2595
|
+
const { rootOf, members, linkColumns } = options.grouping;
|
|
2129
2596
|
for (const store of stores) {
|
|
2130
|
-
|
|
2131
|
-
|
|
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);
|
|
2132
2599
|
/**
|
|
2133
|
-
*
|
|
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;
|
|
2614
|
+
/**
|
|
2615
|
+
* DETs exclude the technical identifier, the system timestamps, and — for a
|
|
2616
|
+
* child folded in — its link to the parent.
|
|
2134
2617
|
*
|
|
2135
2618
|
* IFPUG defines a DET as a "user recognizable" attribute, and an
|
|
2136
2619
|
* auto-increment surrogate key is not something the user recognises.
|
|
2137
|
-
* 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.
|
|
2138
2623
|
*/
|
|
2139
|
-
const
|
|
2140
|
-
|
|
2141
|
-
const
|
|
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;
|
|
2142
2631
|
/**
|
|
2143
2632
|
* Maintained by the application, or by another system?
|
|
2144
2633
|
*
|
|
2145
2634
|
* A write reachable from an entry point is the common case. A write from a
|
|
2146
|
-
* job
|
|
2147
|
-
*
|
|
2635
|
+
* job maintains the store just as much — AFP §6.5.4 asks who maintains it,
|
|
2636
|
+
* not which route does.
|
|
2148
2637
|
*/
|
|
2149
|
-
const maintained = use.written || options.writtenAnywhere.has(
|
|
2150
|
-
const
|
|
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";
|
|
2151
2641
|
const complexity = complexityOf(type, refs, det, options.tables);
|
|
2152
2642
|
counted.push({
|
|
2153
|
-
|
|
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}`,
|
|
2154
2649
|
name: store.name,
|
|
2155
2650
|
module: store.module,
|
|
2156
2651
|
type,
|
|
@@ -2159,9 +2654,12 @@ function countDataFunctions(stores, usage, options) {
|
|
|
2159
2654
|
complexity,
|
|
2160
2655
|
points: pointsOf(type, complexity, options.weights),
|
|
2161
2656
|
rationale: {
|
|
2162
|
-
rule:
|
|
2163
|
-
detSources
|
|
2164
|
-
refSources: options.
|
|
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
|
+
})]
|
|
2165
2663
|
}
|
|
2166
2664
|
});
|
|
2167
2665
|
}
|
|
@@ -2182,7 +2680,19 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
|
|
|
2182
2680
|
*/
|
|
2183
2681
|
if (touched.length === 0) continue;
|
|
2184
2682
|
const type = behavior.writes ? "EI" : "EO";
|
|
2185
|
-
|
|
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;
|
|
2186
2696
|
const { det, sources } = detsFor(entry, behavior, touched, type, options);
|
|
2187
2697
|
const complexity = complexityOf(type, refs, det, options.tables);
|
|
2188
2698
|
counted.push({
|
|
@@ -2198,7 +2708,7 @@ function countTransactionalFunctions(entryPoints, behaviors, options) {
|
|
|
2198
2708
|
rationale: {
|
|
2199
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)",
|
|
2200
2710
|
detSources: sources,
|
|
2201
|
-
refSources:
|
|
2711
|
+
refSources: [...viaOf.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([root, via]) => `reaches:${root}${via.length ? ` (via ${via.join(", ")})` : ""}`),
|
|
2202
2712
|
trace: behavior.trace.map((step) => ({
|
|
2203
2713
|
...step,
|
|
2204
2714
|
file: relativeTo(options.root, step.file)
|
|
@@ -2236,7 +2746,8 @@ function scopeHashOf(behavior) {
|
|
|
2236
2746
|
* With no `.select()` and no visible transformer, the output fields are the
|
|
2237
2747
|
* whole table, which **overestimates**. That is the trade AFP makes on purpose,
|
|
2238
2748
|
* favouring repeatability over fidelity; the origin is recorded in `Rationale`
|
|
2239
|
-
* so `fp:calibrate` can measure the
|
|
2749
|
+
* (`transformer:` / `select:` / `output:`) so `fp:calibrate` can measure the
|
|
2750
|
+
* bias per origin.
|
|
2240
2751
|
*/
|
|
2241
2752
|
function detsFor(entry, behavior, touched, type, options) {
|
|
2242
2753
|
const sources = [];
|
|
@@ -2266,9 +2777,73 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
2266
2777
|
* paid for twice.
|
|
2267
2778
|
*/
|
|
2268
2779
|
for (const field of behavior.requestFields) add(field, `request:${field}`);
|
|
2269
|
-
if (type === "EO" || type === "EQ")
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
+
}
|
|
2272
2847
|
}
|
|
2273
2848
|
let det = counted.size + options.messageDet;
|
|
2274
2849
|
if (options.messageDet > 0) sources.push("message:1");
|
|
@@ -2280,20 +2855,17 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
2280
2855
|
//#endregion
|
|
2281
2856
|
//#region src/albrecht/technical_filter.ts
|
|
2282
2857
|
/**
|
|
2283
|
-
*
|
|
2858
|
+
* Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3)
|
|
2859
|
+
* plus the one AdonisJS asks for.
|
|
2284
2860
|
*
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
2287
|
-
*
|
|
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.
|
|
2288
2864
|
*
|
|
2289
|
-
*
|
|
2290
|
-
*
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
* Naming conventions, with the defaults given by the spec itself (§6.5.2.1.3).
|
|
2294
|
-
*
|
|
2295
|
-
* The standard treats these as user-provided inputs, so they stay overridable
|
|
2296
|
-
* 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.
|
|
2297
2869
|
*/
|
|
2298
2870
|
const DEFAULT_TECHNICAL_PATTERNS = [
|
|
2299
2871
|
{
|
|
@@ -2311,11 +2883,18 @@ const DEFAULT_TECHNICAL_PATTERNS = [
|
|
|
2311
2883
|
{
|
|
2312
2884
|
label: "template entity",
|
|
2313
2885
|
pattern: /^(.*template.*)$/i
|
|
2886
|
+
},
|
|
2887
|
+
{
|
|
2888
|
+
label: "token entity",
|
|
2889
|
+
pattern: /^(.*tokens?.*)$/i
|
|
2314
2890
|
}
|
|
2315
2891
|
];
|
|
2316
2892
|
function isTechnical(store, patterns = DEFAULT_TECHNICAL_PATTERNS) {
|
|
2317
2893
|
const table = store.table ?? store.name;
|
|
2318
|
-
for (const { label, pattern } of patterns)
|
|
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
|
+
}
|
|
2319
2898
|
return null;
|
|
2320
2899
|
}
|
|
2321
2900
|
//#endregion
|
|
@@ -2340,15 +2919,19 @@ const RULESET = "afp";
|
|
|
2340
2919
|
* that is easy to forget. Four such changes landed in 1.1.0 — maintenance read
|
|
2341
2920
|
* across the whole project rather than from routes alone, a job followed into
|
|
2342
2921
|
* `process`, an event followed into its listeners, and `request.input(…)` counted
|
|
2343
|
-
* as a DET —
|
|
2922
|
+
* as a DET — three more in 1.2.0: an open input object counting 1 instead of 0,
|
|
2344
2923
|
* `detFromSchema` no longer subtracting a placeholder that was not there, and a
|
|
2345
|
-
* 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.
|
|
2346
2929
|
*
|
|
2347
2930
|
* Without the bump, a baseline saved by the previous version compares cleanly
|
|
2348
2931
|
* against this one and bills the tool's own improvement as work done. The guard
|
|
2349
2932
|
* exists for exactly that, and only this constant arms it.
|
|
2350
2933
|
*/
|
|
2351
|
-
const RULESET_VERSION = "1.
|
|
2934
|
+
const RULESET_VERSION = "1.5.0";
|
|
2352
2935
|
function count(input, options = {}) {
|
|
2353
2936
|
const warnings = [];
|
|
2354
2937
|
const usage = usageOf(input);
|
|
@@ -2367,7 +2950,7 @@ function count(input, options = {}) {
|
|
|
2367
2950
|
warnings.push(`excluded by boundary configuration: ${store.name}`);
|
|
2368
2951
|
return false;
|
|
2369
2952
|
}
|
|
2370
|
-
const technical = isTechnical(store);
|
|
2953
|
+
const technical = isTechnical(store, options.boundary?.technicalPatterns);
|
|
2371
2954
|
if (!technical) return true;
|
|
2372
2955
|
/**
|
|
2373
2956
|
* The naming filter is a heuristic over names, so it catches business data
|
|
@@ -2377,55 +2960,95 @@ function count(input, options = {}) {
|
|
|
2377
2960
|
* quietly counting one more store.
|
|
2378
2961
|
*/
|
|
2379
2962
|
if (business.has(store.name) || business.has(store.table ?? "")) {
|
|
2380
|
-
|
|
2963
|
+
/**
|
|
2964
|
+
* Whether any transaction WRITES it, said out loud.
|
|
2965
|
+
*
|
|
2966
|
+
* `business` is meant for data the user maintains, and it accepted without
|
|
2967
|
+
* comment a table nothing in the application writes. That is how a team put two
|
|
2968
|
+
* read-only lookup tables in it believing they had a CRUD — the routes were
|
|
2969
|
+
* `.only(['index', 'show'])`. The declaration is still honoured, because only a
|
|
2970
|
+
* person knows, but the fact that contradicts it is now in the report.
|
|
2971
|
+
*/
|
|
2972
|
+
const maintained = usage.get(store.name)?.written === true || (input.writtenAnywhere?.has(store.name) ?? false);
|
|
2973
|
+
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
2974
|
return true;
|
|
2382
2975
|
}
|
|
2383
2976
|
warnings.push(`technical, excluded: ${store.name} (${technical})`);
|
|
2384
2977
|
return false;
|
|
2385
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);
|
|
2386
3001
|
const dataFunctions = countDataFunctions(countable, usage, {
|
|
3002
|
+
grouping,
|
|
2387
3003
|
writtenAnywhere: input.writtenAnywhere ?? /* @__PURE__ */ new Set(),
|
|
2388
|
-
retStrategy: options.retStrategy ?? "constant",
|
|
2389
3004
|
externallyMaintained: new Set(options.boundary?.externallyMaintained ?? []),
|
|
2390
3005
|
tables,
|
|
2391
3006
|
weights
|
|
2392
3007
|
});
|
|
2393
|
-
|
|
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]));
|
|
2394
3015
|
const ignored = new Set(options.boundary?.ignoreEntryPoints ?? []);
|
|
2395
3016
|
const transactionalFunctions = countTransactionalFunctions(input.entryPoints.filter((entry) => !ignored.has(entry.identity) && !ignored.has(entry.name ?? "")), input.behaviors, {
|
|
2396
3017
|
countedStores,
|
|
3018
|
+
grouping,
|
|
2397
3019
|
root: input.app.root,
|
|
2398
3020
|
messageDet: options.messageDet ?? 0,
|
|
2399
3021
|
tables,
|
|
2400
3022
|
weights
|
|
2401
3023
|
});
|
|
2402
3024
|
/**
|
|
2403
|
-
*
|
|
3025
|
+
* What the analysis could not read, answered by ORIGIN — counting-decisions §8.
|
|
2404
3026
|
*
|
|
2405
|
-
*
|
|
2406
|
-
*
|
|
2407
|
-
*
|
|
2408
|
-
*
|
|
2409
|
-
* reports. It silences nothing else: the count does not move, and how many were
|
|
2410
|
-
* reviewed is still printed.
|
|
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.
|
|
2411
3031
|
*/
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
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
|
|
2427
3047
|
}));
|
|
2428
3048
|
warnings.push(...unreadableInputWarnings(input));
|
|
3049
|
+
warnings.push(...unreadableOutputWarnings(input));
|
|
3050
|
+
warnings.push(...lookAlikeWarnings(functions));
|
|
3051
|
+
warnings.push(...seededOnlyWarnings(functions, grouping.members, input.seededAnywhere));
|
|
2429
3052
|
return {
|
|
2430
3053
|
ruleset: "afp",
|
|
2431
3054
|
rulesetVersion: RULESET_VERSION,
|
|
@@ -2435,106 +3058,57 @@ function count(input, options = {}) {
|
|
|
2435
3058
|
};
|
|
2436
3059
|
}
|
|
2437
3060
|
/**
|
|
2438
|
-
*
|
|
2439
|
-
*
|
|
2440
|
-
*
|
|
2441
|
-
*
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
2444
|
-
*
|
|
2445
|
-
*
|
|
2446
|
-
*
|
|
2447
|
-
*
|
|
2448
|
-
*
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
* Opaque DETs someone has declared reviewed, in both spellings a person might use.
|
|
2452
|
-
*
|
|
2453
|
-
* Qualified (`Petition.schema`) is unambiguous; bare (`schema`) is what someone reads
|
|
2454
|
-
* off the warning line.
|
|
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.
|
|
2455
3074
|
*/
|
|
2456
|
-
function
|
|
2457
|
-
const
|
|
2458
|
-
for (const
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
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
|
+
})];
|
|
2463
3096
|
}
|
|
2464
|
-
/** a column whose shape says nothing about what it holds */
|
|
2465
|
-
const OPAQUE_TYPE = /^(object|any|unknown|Record<|Json|JSON)/;
|
|
2466
3097
|
/**
|
|
2467
|
-
*
|
|
2468
|
-
*
|
|
2469
|
-
*
|
|
2470
|
-
* is
|
|
2471
|
-
*
|
|
2472
|
-
*
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2475
|
-
* whose floor `detFromSchema` had already replaced, which reads as "go and map this"
|
|
2476
|
-
* about something already mapped. That misreading actually happened, to the author of
|
|
2477
|
-
* this code, reading someone else's report.
|
|
2478
|
-
*
|
|
2479
|
-
* Three states per function, and only the third is a request to do something:
|
|
2480
|
-
*
|
|
2481
|
-
* replaced a `detFromSchema` override stands in for one of them
|
|
2482
|
-
* reviewed someone looked and 1 is the right answer
|
|
2483
|
-
* floor still unanswered
|
|
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.
|
|
2484
3106
|
*/
|
|
2485
|
-
function
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
const found = byFunction.get(name) ?? {
|
|
2491
|
-
kind,
|
|
2492
|
-
floor: [],
|
|
2493
|
-
reviewed: 0,
|
|
2494
|
-
transactions
|
|
2495
|
-
};
|
|
2496
|
-
byFunction.set(name, found);
|
|
2497
|
-
return found;
|
|
2498
|
-
};
|
|
2499
|
-
for (const store of stores) {
|
|
2500
|
-
if (!reached.get(store.name)) continue;
|
|
2501
|
-
for (const attribute of store.attributes) {
|
|
2502
|
-
if (!attribute.type || !OPAQUE_TYPE.test(attribute.type)) continue;
|
|
2503
|
-
const entry = tally(store.name, "column", reached.get(store.name) ?? 0);
|
|
2504
|
-
if (state.reviewed.has(`${store.name}.${attribute.name}`) || state.reviewed.has(attribute.name)) entry.reviewed += 1;
|
|
2505
|
-
else entry.floor.push(`${attribute.name} (${attribute.type})`);
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
for (const point of input.entryPoints) for (const field of input.behaviors.get(point.id)?.opaqueInputFields ?? []) {
|
|
2509
|
-
const entry = tally(point.identity, "input object", 1);
|
|
2510
|
-
if (state.reviewed.has(field) || state.reviewed.has(`${point.identity}.${field}`)) entry.reviewed += 1;
|
|
2511
|
-
else entry.floor.push(field);
|
|
2512
|
-
}
|
|
2513
|
-
const lines = [];
|
|
2514
|
-
let answered = 0;
|
|
2515
|
-
for (const [name, entry] of byFunction) {
|
|
2516
|
-
/** a declared schema stands in for exactly one placeholder — §8, and the override warns when there are more */
|
|
2517
|
-
const replaced = state.declared.has(name) && entry.floor.length > 0 ? 1 : 0;
|
|
2518
|
-
const remaining = entry.floor.slice(replaced);
|
|
2519
|
-
if (remaining.length === 0) {
|
|
2520
|
-
answered += 1;
|
|
2521
|
-
continue;
|
|
2522
|
-
}
|
|
2523
|
-
const answeredHere = [...replaced > 0 ? [`${replaced} replaced by override`] : [], ...entry.reviewed > 0 ? [`${entry.reviewed} reviewed`] : []];
|
|
2524
|
-
/**
|
|
2525
|
-
* How many transactions reach the store, so the reader can judge whether the
|
|
2526
|
-
* floor is worth answering. A blob nothing touches changes no number.
|
|
2527
|
-
*/
|
|
2528
|
-
const reach = entry.kind === "column" ? `, reached by ${entry.transactions} transaction(s)` : "";
|
|
2529
|
-
lines.push(` ${name} — ${remaining.length} ${entry.kind}(s) at 1 DET${reach}` + (answeredHere.length > 0 ? ` (${answeredHere.join(", ")} already)` : "") + `: ${remaining.map((f) => entry.kind === "column" ? `${name}.${f}` : f).join(", ")}`);
|
|
2530
|
-
}
|
|
2531
|
-
const settled = answered === 0 ? [] : [` (${answered} more function(s) whose opaque DETs are all accounted for)`];
|
|
2532
|
-
if (lines.length === 0) return settled;
|
|
2533
|
-
return [
|
|
2534
|
-
`${lines.length} function(s) with a DET the analysis cannot read, counted as 1 each — a FLOOR, not a measurement. Where the fields are declared in the source, name that schema with \`overrides.detFromSchema\`; where 1 is the right answer, record it with \`overrides.<fn>.opaqueReviewed\` — counting-decisions §8:`,
|
|
2535
|
-
...lines,
|
|
2536
|
-
...settled
|
|
2537
|
-
];
|
|
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)`)];
|
|
2538
3112
|
}
|
|
2539
3113
|
/**
|
|
2540
3114
|
* Transactions that read the request in a way that enumerates nothing.
|
|
@@ -2573,87 +3147,63 @@ function unreadableInputWarnings(input) {
|
|
|
2573
3147
|
];
|
|
2574
3148
|
}
|
|
2575
3149
|
/**
|
|
2576
|
-
*
|
|
3150
|
+
* Transformers that emit something the analysis cannot read.
|
|
2577
3151
|
*
|
|
2578
|
-
*
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2581
|
-
*
|
|
2582
|
-
*
|
|
2583
|
-
* An override naming no function is a warning, never silence: a typo in the key
|
|
2584
|
-
* 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.
|
|
2585
3157
|
*/
|
|
2586
|
-
function
|
|
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.
|
|
3179
|
+
*/
|
|
3180
|
+
function applyOverrides(functions, overrides, tables, weights, warnings) {
|
|
2587
3181
|
const keys = Object.keys(overrides);
|
|
2588
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
|
+
}
|
|
2589
3195
|
const used = /* @__PURE__ */ new Set();
|
|
2590
3196
|
const applied = functions.map((fn) => {
|
|
2591
3197
|
const override = overrides[fn.name];
|
|
2592
3198
|
if (!override) return fn;
|
|
2593
3199
|
used.add(fn.name);
|
|
2594
3200
|
const fields = [];
|
|
2595
|
-
if (override.det !== void 0
|
|
3201
|
+
if (override.det !== void 0) fields.push("det");
|
|
2596
3202
|
if (override.refs !== void 0) fields.push("refs");
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
/**
|
|
2600
|
-
* Read from the schema rather than declared as a number.
|
|
2601
|
-
*
|
|
2602
|
-
* A frozen number goes stale the moment someone adds a field: the count
|
|
2603
|
-
* would not move and `fp:diff` would report no change for real functional
|
|
2604
|
-
* growth. Naming the schema keeps the number coming from the code, and the
|
|
2605
|
-
* only thing maintained by hand is the mapping — which changes when a form
|
|
2606
|
-
* is born, not when a field is.
|
|
2607
|
-
*/
|
|
2608
|
-
if (override.detFromSchema) {
|
|
2609
|
-
/**
|
|
2610
|
-
* One name or several, unioned by leaf path.
|
|
2611
|
-
*
|
|
2612
|
-
* An ILF's DETs are the fields the user recognises in the file, and an
|
|
2613
|
-
* application with one schema per template recognises all of them. A field
|
|
2614
|
-
* two templates share is one DET, so the union is over paths rather than a
|
|
2615
|
-
* sum of counts.
|
|
2616
|
-
*/
|
|
2617
|
-
const named = [override.detFromSchema].flat();
|
|
2618
|
-
const resolved = named.map((name) => schemas.get(name)).filter((s) => s !== void 0);
|
|
2619
|
-
const missing = named.filter((name) => !schemas.has(name));
|
|
2620
|
-
const union = new Set(resolved.flatMap((s) => s.leaves));
|
|
2621
|
-
const schema = resolved.length === 0 ? void 0 : {
|
|
2622
|
-
/** only what resolved: naming a schema that contributed nothing would mislead */
|
|
2623
|
-
name: resolved.map((s) => s.name).join(" + "),
|
|
2624
|
-
fields: union.size,
|
|
2625
|
-
leaves: [...union]
|
|
2626
|
-
};
|
|
2627
|
-
for (const name of missing) warnings.push(`override for "${fn.name}" names schema "${name}", which is not declared anywhere in the code: it contributed nothing. A renamed or moved schema breaks the mapping, and this says so rather than counting on silently.`);
|
|
2628
|
-
if (schema) {
|
|
2629
|
-
/**
|
|
2630
|
-
* Replaces the opaque placeholder — the one the rationale marks — rather
|
|
2631
|
-
* than assuming there is one and that it is worth 1.
|
|
2632
|
-
*
|
|
2633
|
-
* That assumption was wrong twice over. An open `vine.object` counted
|
|
2634
|
-
* ZERO, not 1, so subtracting 1 removed a field the analysis had read
|
|
2635
|
-
* correctly: 86 DETs where 87 was right. And a function with no opaque
|
|
2636
|
-
* DET at all was silently charged the subtraction too.
|
|
2637
|
-
*/
|
|
2638
|
-
const placeholders = fn.rationale.detSources.filter((source) => source.endsWith("(opaque)"));
|
|
2639
|
-
det = Math.max(fn.det - Math.min(placeholders.length, 1), 0) + schema.fields;
|
|
2640
|
-
by = `config:overrides.${fn.name} (from ${schema.name}: ${schema.fields} fields)`;
|
|
2641
|
-
if (placeholders.length === 0) warnings.push(`override for "${fn.name}" names schema "${schema.name}", but this function has no opaque DET for it to stand in for: the ${schema.fields} fields were ADDED to the ${fn.det} already counted. Check the override is on the right function.`);
|
|
2642
|
-
else if (placeholders.length > 1) warnings.push(`override for "${fn.name}" names one schema and the function has ${placeholders.length} opaque DETs (${placeholders.join(", ")}). Only one was replaced; the others still count 1 each.`);
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
3203
|
+
if (fields.length === 0) return fn;
|
|
3204
|
+
const det = override.det ?? fn.det;
|
|
2645
3205
|
const refs = override.refs ?? fn.refs;
|
|
2646
3206
|
const complexity = complexityOf(fn.type, refs, det, tables);
|
|
2647
|
-
/**
|
|
2648
|
-
* Only a DECLARED NUMBER is an override in the rationale.
|
|
2649
|
-
*
|
|
2650
|
-
* `fp:count` prints what share of the total came from a person, and that line is
|
|
2651
|
-
* the reason the mechanism is acceptable at all. An `opaqueReviewed`-only entry
|
|
2652
|
-
* declares no number, and recording it here read as "1 function, 7 FP, 35% of the
|
|
2653
|
-
* total declared by override" — misrepresenting the one number that exists to keep
|
|
2654
|
-
* this honest. The review is recorded in the warning, which is where it belongs.
|
|
2655
|
-
*/
|
|
2656
|
-
if (fields.length === 0) return fn;
|
|
2657
3207
|
return {
|
|
2658
3208
|
...fn,
|
|
2659
3209
|
det,
|
|
@@ -2663,7 +3213,7 @@ function applyOverrides(functions, overrides, schemas, tables, weights, warnings
|
|
|
2663
3213
|
rationale: {
|
|
2664
3214
|
...fn.rationale,
|
|
2665
3215
|
overrides: [...fn.rationale.overrides ?? [], {
|
|
2666
|
-
by
|
|
3216
|
+
by: `config:overrides.${fn.name}`,
|
|
2667
3217
|
reason: override.reason,
|
|
2668
3218
|
fields
|
|
2669
3219
|
}]
|
|
@@ -2690,7 +3240,16 @@ function usageOf(input) {
|
|
|
2690
3240
|
};
|
|
2691
3241
|
usage.set(store, {
|
|
2692
3242
|
used: true,
|
|
2693
|
-
|
|
3243
|
+
/**
|
|
3244
|
+
* Per STORE, not per transaction.
|
|
3245
|
+
*
|
|
3246
|
+
* `behavior.writes` decides EI against EO and says nothing about which of
|
|
3247
|
+
* the tables was written. Read as "this store is maintained", a reference
|
|
3248
|
+
* table merely READ by a route that writes something else became an ILF —
|
|
3249
|
+
* and on a production application that left exactly one EIF in the whole
|
|
3250
|
+
* count, which should have been the signal.
|
|
3251
|
+
*/
|
|
3252
|
+
written: current.written || behavior.writtenStores.includes(store)
|
|
2694
3253
|
});
|
|
2695
3254
|
}
|
|
2696
3255
|
}
|
|
@@ -2880,6 +3439,7 @@ async function analyze(root, options = {}) {
|
|
|
2880
3439
|
entryPointId,
|
|
2881
3440
|
writes: behavior.writes,
|
|
2882
3441
|
touches: behavior.touches,
|
|
3442
|
+
writtenStores: behavior.writtenStores,
|
|
2883
3443
|
inputFields: behavior.inputFields.map((name) => ({
|
|
2884
3444
|
name,
|
|
2885
3445
|
provenance: {
|
|
@@ -2902,7 +3462,22 @@ async function analyze(root, options = {}) {
|
|
|
2902
3462
|
}
|
|
2903
3463
|
})),
|
|
2904
3464
|
opaqueRequest: behavior.opaqueRequest,
|
|
2905
|
-
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,
|
|
2906
3481
|
trace: behavior.trace.map((step) => ({
|
|
2907
3482
|
...step,
|
|
2908
3483
|
file: emit(step.file)
|
|
@@ -2930,11 +3505,13 @@ async function analyze(root, options = {}) {
|
|
|
2930
3505
|
entryPoints,
|
|
2931
3506
|
behaviors,
|
|
2932
3507
|
jsonSchemas,
|
|
2933
|
-
writtenAnywhere: analyzer.writtenAnywhere()
|
|
3508
|
+
writtenAnywhere: analyzer.writtenAnywhere(),
|
|
3509
|
+
addressedAnywhere: analyzer.addressedAnywhere(),
|
|
3510
|
+
seededAnywhere: analyzer.seededAnywhere()
|
|
2934
3511
|
}, options),
|
|
2935
3512
|
source
|
|
2936
3513
|
}
|
|
2937
3514
|
};
|
|
2938
3515
|
}
|
|
2939
3516
|
//#endregion
|
|
2940
|
-
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 };
|