@filipebraida/adonis-function-points 0.7.0 → 0.9.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 +132 -3
- package/README.md +1 -1
- package/build/commands/main.js +6 -6
- package/build/{fp_calibrate-3TxGdS1b.js → fp_calibrate-DJspMFHc.js} +1 -1
- package/build/{fp_count-arGLnVlY.js → fp_count-Bjviq8dz.js} +1 -1
- package/build/{fp_diff-DBBvzq5x.js → fp_diff-BSK1kKLQ.js} +1 -1
- package/build/{fp_explain-aNFApwiT.js → fp_explain-CrFcMeXU.js} +1 -1
- package/build/{fp_inventory-DIjKIC9t.js → fp_inventory-B4Pv8Thz.js} +1 -1
- package/build/{fp_metrics-BpU61waG.js → fp_metrics-DoS5riGr.js} +1 -1
- package/build/index.js +2 -2
- package/build/{pipeline-DO2301fV.js → pipeline-DqPrDZfD.js} +1135 -73
- package/build/{resolvers-DaU4uAqT.js → resolvers-CneCj3sT.js} +59 -11
- package/build/{runners-Dm7cWGa-.js → runners-BuZNr-FE.js} +14 -4
- package/build/src/albrecht/counter.d.ts +15 -3
- package/build/src/cli.js +2 -2
- package/build/src/inventory/graph/call_graph.d.ts +7 -0
- package/build/src/inventory/graph/deliveries.d.ts +13 -3
- package/build/src/inventory/graph/output_fields.d.ts +2 -2
- package/build/src/inventory/graph/pages.d.ts +44 -0
- package/build/src/inventory/resolvers/index.js +1 -1
- package/build/src/inventory/resolvers/local_function.d.ts +3 -3
- package/build/src/inventory/sources/event_bindings.d.ts +3 -0
- package/build/src/pipeline.js +1 -1
- package/build/src/reporters/table.d.ts +3 -1
- package/build/src/types.d.ts +18 -0
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { _ as relativeTo, a as chainShapeOf, c as unwrap$1, d as collectEventBindings, f as detectAccess, g as isSeeder, h as isApplicationCode, i as resolveCall, l as DISPATCH_METHODS, m as rootSymbolOf, o as mappedLiteralOf, p as hooksFiredBy, r as isTechnicalWrite, s as outputFieldsIn, t as BUILTIN_CALL_RESOLVERS, u as EXECUTION_METHODS, v as samePath, y as toPosix } from "./resolvers-
|
|
1
|
+
import { _ as relativeTo, a as chainShapeOf, c as unwrap$1, d as collectEventBindings, f as detectAccess, g as isSeeder, h as isApplicationCode, i as resolveCall, l as DISPATCH_METHODS, m as rootSymbolOf, o as mappedLiteralOf, p as hooksFiredBy, r as isTechnicalWrite, s as outputFieldsIn, t as BUILTIN_CALL_RESOLVERS, u as EXECUTION_METHODS, v as samePath, y as toPosix } from "./resolvers-CneCj3sT.js";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
3
|
+
import path, { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
8
|
//#region src/inventory/app_context.ts
|
|
9
9
|
/** folders naming an artefact TYPE, in either layout */
|
|
10
10
|
const ARTIFACT_KINDS = new Set([
|
|
@@ -969,7 +969,7 @@ const problemAt = (call, expression, reason) => ({
|
|
|
969
969
|
/**
|
|
970
970
|
* Ace commands as entry points — counting-decisions §5, plan 0.7 §C.
|
|
971
971
|
*
|
|
972
|
-
* IFPUG counts batch processes an operator starts. `node ace
|
|
972
|
+
* IFPUG counts batch processes an operator starts. `node ace articles:import`
|
|
973
973
|
* reads a feed and writes news: an EI exactly like a `POST`, and until this the
|
|
974
974
|
* collector emitted only `kind: 'http'`, although the type had `command` and §5
|
|
975
975
|
* had decided its identity since 0.1.0.
|
|
@@ -1036,7 +1036,7 @@ function baseCommandNameIn(file) {
|
|
|
1036
1036
|
}
|
|
1037
1037
|
return null;
|
|
1038
1038
|
}
|
|
1039
|
-
/** `static commandName = '
|
|
1039
|
+
/** `static commandName = 'articles:import'` — a literal; a computed name is nobody's identity */
|
|
1040
1040
|
function commandNameOf(cls) {
|
|
1041
1041
|
const property = cls.getStaticProperty("commandName");
|
|
1042
1042
|
if (!property || !Node.isPropertyDeclaration(property)) return null;
|
|
@@ -1190,7 +1190,8 @@ const FRAMEWORK_SERVICES = new Set([
|
|
|
1190
1190
|
"bouncer",
|
|
1191
1191
|
"ui",
|
|
1192
1192
|
"colors",
|
|
1193
|
-
"app"
|
|
1193
|
+
"app",
|
|
1194
|
+
"prompt"
|
|
1194
1195
|
]);
|
|
1195
1196
|
/** Is this call one that cannot reach a data store? */
|
|
1196
1197
|
function isNoise(call, owner) {
|
|
@@ -1288,22 +1289,24 @@ const PRINTERS = new Set([
|
|
|
1288
1289
|
"console"
|
|
1289
1290
|
]);
|
|
1290
1291
|
/** `x.data`, `x.rows` on a paginated / wrapped result hand the collection on */
|
|
1291
|
-
const PASSES_THROUGH = new Set([
|
|
1292
|
+
const PASSES_THROUGH$1 = new Set([
|
|
1292
1293
|
"data",
|
|
1293
1294
|
"rows",
|
|
1294
1295
|
"all",
|
|
1295
1296
|
"toJSON",
|
|
1296
1297
|
"serialize"
|
|
1297
1298
|
]);
|
|
1298
|
-
/**
|
|
1299
|
+
/**
|
|
1300
|
+
* Keys under which a paginator or a wrapper hands its rows on: picking one of these
|
|
1301
|
+
* is not picking one value. Framework and JavaScript conventions only — a key named
|
|
1302
|
+
* in one application's language is that application's, not a rule.
|
|
1303
|
+
*/
|
|
1299
1304
|
const PASSES_ROWS = new Set([
|
|
1300
1305
|
"data",
|
|
1301
1306
|
"rows",
|
|
1302
1307
|
"items",
|
|
1303
1308
|
"results",
|
|
1304
|
-
"
|
|
1305
|
-
"linhas",
|
|
1306
|
-
"itens"
|
|
1309
|
+
"records"
|
|
1307
1310
|
]);
|
|
1308
1311
|
/** properties of a result that are one value, not its rows */
|
|
1309
1312
|
const SCALAR_PROPS = new Set([
|
|
@@ -1436,8 +1439,15 @@ function deliveriesIn(ctx) {
|
|
|
1436
1439
|
const method = callee.getName();
|
|
1437
1440
|
const receiver = lastSegmentOf(callee.getExpression());
|
|
1438
1441
|
let payload;
|
|
1439
|
-
|
|
1440
|
-
|
|
1442
|
+
let via;
|
|
1443
|
+
if (RENDERERS.has(receiver) && RENDER_METHODS.has(method)) {
|
|
1444
|
+
payload = call.getArguments()[1];
|
|
1445
|
+
const name = unwrap$1(call.getArguments()[0]);
|
|
1446
|
+
if (name && Node.isStringLiteral(name)) via = {
|
|
1447
|
+
engine: receiver === "view" ? "edge" : "inertia",
|
|
1448
|
+
page: name.getLiteralValue()
|
|
1449
|
+
};
|
|
1450
|
+
} else if (receiver === "response" && RESPONSE_METHODS.has(method)) payload = call.getArguments()[0];
|
|
1441
1451
|
else if (isPrinter(callee.getExpression(), ctx.body)) {
|
|
1442
1452
|
any = true;
|
|
1443
1453
|
seen.add(call);
|
|
@@ -1447,7 +1457,9 @@ function deliveriesIn(ctx) {
|
|
|
1447
1457
|
any = true;
|
|
1448
1458
|
if (!payload) continue;
|
|
1449
1459
|
seen.add(call);
|
|
1460
|
+
const before = deliveries.length;
|
|
1450
1461
|
classify(unwrap$1(payload), "", ctx, deliveries, 0);
|
|
1462
|
+
if (via) for (let i = before; i < deliveries.length; i++) deliveries[i].via = via;
|
|
1451
1463
|
}
|
|
1452
1464
|
for (const statement of ctx.body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
|
|
1453
1465
|
if (statement.getFirstAncestor((node) => Node.isArrowFunction(node) || Node.isFunctionExpression(node) || Node.isMethodDeclaration(node) || Node.isFunctionDeclaration(node)) !== ctx.body) continue;
|
|
@@ -1530,14 +1542,14 @@ function classify(value, path, ctx, out, depth) {
|
|
|
1530
1542
|
*/
|
|
1531
1543
|
if (!path && (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) || Node.isNumericLiteral(value))) return;
|
|
1532
1544
|
if (Node.isObjectLiteralExpression(value)) {
|
|
1533
|
-
for (const property of value.getProperties()) if (Node.isShorthandPropertyAssignment(property)) classify(property.getNameNode(), join(path, property.getName()), ctx, out, depth + 1);
|
|
1545
|
+
for (const property of value.getProperties()) if (Node.isShorthandPropertyAssignment(property)) classify(property.getNameNode(), join$1(path, property.getName()), ctx, out, depth + 1);
|
|
1534
1546
|
else if (Node.isPropertyAssignment(property)) {
|
|
1535
1547
|
const name = Node.isComputedPropertyName(property.getNameNode()) ? "*" : property.getName().replace(/^['"]|['"]$/g, "");
|
|
1536
|
-
classify(unwrap$1(property.getInitializer()), join(path, name), ctx, out, depth + 1);
|
|
1548
|
+
classify(unwrap$1(property.getInitializer()), join$1(path, name), ctx, out, depth + 1);
|
|
1537
1549
|
} else if (Node.isSpreadAssignment(property)) classify(unwrap$1(property.getExpression()), path, ctx, out, depth + 1);
|
|
1538
1550
|
else out.push({
|
|
1539
1551
|
kind: "scalar",
|
|
1540
|
-
path: join(path, property.getName?.() ?? "*")
|
|
1552
|
+
path: join$1(path, property.getName?.() ?? "*")
|
|
1541
1553
|
});
|
|
1542
1554
|
return;
|
|
1543
1555
|
}
|
|
@@ -1649,7 +1661,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
|
|
|
1649
1661
|
classifyMapped(value, callee.getExpression(), path, ctx, out, depth);
|
|
1650
1662
|
return;
|
|
1651
1663
|
}
|
|
1652
|
-
if (Node.isPropertyAccessExpression(callee) && PASSES_THROUGH.has(method) && value.getArguments().length === 0) {
|
|
1664
|
+
if (Node.isPropertyAccessExpression(callee) && PASSES_THROUGH$1.has(method) && value.getArguments().length === 0) {
|
|
1653
1665
|
classify(unwrap$1(callee.getExpression()), path, ctx, out, depth + 1);
|
|
1654
1666
|
return;
|
|
1655
1667
|
}
|
|
@@ -1660,7 +1672,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
|
|
|
1660
1672
|
});
|
|
1661
1673
|
else for (const key of PAGINATOR_META) out.push({
|
|
1662
1674
|
kind: "scalar",
|
|
1663
|
-
path: join(path, key)
|
|
1675
|
+
path: join$1(path, key)
|
|
1664
1676
|
});
|
|
1665
1677
|
return;
|
|
1666
1678
|
}
|
|
@@ -1754,10 +1766,15 @@ function classifyCall(value, path, ctx, out, depth, pick) {
|
|
|
1754
1766
|
if (Node.isPropertyAccessExpression(callee)) {
|
|
1755
1767
|
const root = chainRootOf(callee.getExpression());
|
|
1756
1768
|
if (root && ctx.symbols.has(root)) {
|
|
1757
|
-
out.push({
|
|
1769
|
+
if (readsField(callee.getExpression())) out.push({
|
|
1758
1770
|
kind: "scalar",
|
|
1759
1771
|
path
|
|
1760
1772
|
});
|
|
1773
|
+
else out.push({
|
|
1774
|
+
kind: "store",
|
|
1775
|
+
store: ctx.symbols.get(root),
|
|
1776
|
+
path
|
|
1777
|
+
});
|
|
1761
1778
|
return;
|
|
1762
1779
|
}
|
|
1763
1780
|
if (root && root !== "this") {
|
|
@@ -1776,7 +1793,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
|
|
|
1776
1793
|
/**
|
|
1777
1794
|
* `rows.map(…).join('\n')` in a body whose parameter is `rows`: the whole input
|
|
1778
1795
|
* transformed, so the input says what leaves — the caller's arguments decide,
|
|
1779
|
-
* and the body itself says nothing. `
|
|
1796
|
+
* and the body itself says nothing. `article.publicadaEm?.toISO()` on that
|
|
1780
1797
|
* parameter is one FIELD of it, and stays one value below.
|
|
1781
1798
|
*/
|
|
1782
1799
|
if (Node.isPropertyAccessExpression(callee) && transformsParameter(callee, ctx.body)) {
|
|
@@ -1789,7 +1806,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
|
|
|
1789
1806
|
}
|
|
1790
1807
|
if (Node.isPropertyAccessExpression(callee)) {
|
|
1791
1808
|
/**
|
|
1792
|
-
* `
|
|
1809
|
+
* `notice.enviadoEm!.toISODate()`, `(a ?? b).toRFC2822()`: a FIELD read off
|
|
1793
1810
|
* the chain, or an expression, then a method — one value. A chain rooted at
|
|
1794
1811
|
* `this` (`this.service.find()`) is a call into a service, and not this.
|
|
1795
1812
|
*/
|
|
@@ -1824,7 +1841,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
|
|
|
1824
1841
|
}
|
|
1825
1842
|
/**
|
|
1826
1843
|
* `xs.map(cb)` with no literal in the callback: what the callback RETURNS,
|
|
1827
|
-
* once — a function passed by reference (`rows.map(
|
|
1844
|
+
* once — a function passed by reference (`rows.map(toRow)`) is a call to
|
|
1828
1845
|
* that function over the rows; an expression body (`(m) => new T(m).toObject()`)
|
|
1829
1846
|
* is classified as if it were the value; anything else is one repeating attribute.
|
|
1830
1847
|
*/
|
|
@@ -1863,7 +1880,7 @@ function callbackValueOf(callback) {
|
|
|
1863
1880
|
}
|
|
1864
1881
|
/**
|
|
1865
1882
|
* Does this chain read a property (not a method) or hold an expression before
|
|
1866
|
-
* the method is applied? `
|
|
1883
|
+
* the method is applied? `notice.enviadoEm.toISO()` does; `rows.map(f).join()`
|
|
1867
1884
|
* does not; a chain rooted at `this` is a service, and does not.
|
|
1868
1885
|
*/
|
|
1869
1886
|
function readsField(node) {
|
|
@@ -1892,7 +1909,7 @@ function readsField(node) {
|
|
|
1892
1909
|
}
|
|
1893
1910
|
/**
|
|
1894
1911
|
* Is this chain a method (or methods) applied to a plain parameter of the body,
|
|
1895
|
-
* with no field read in between? `rows.map(f).join(s)` is; `
|
|
1912
|
+
* with no field read in between? `rows.map(f).join(s)` is; `article.capa?.toISO()`
|
|
1896
1913
|
* reads a field first and is not.
|
|
1897
1914
|
*/
|
|
1898
1915
|
function transformsParameter(callee, body) {
|
|
@@ -1942,7 +1959,7 @@ function returnsPrimitive(call) {
|
|
|
1942
1959
|
}
|
|
1943
1960
|
}
|
|
1944
1961
|
/**
|
|
1945
|
-
* `x.data`, `
|
|
1962
|
+
* `x.data`, `result.rows`, `rows[0]`: a part of a value the body holds.
|
|
1946
1963
|
*
|
|
1947
1964
|
* on a variable bound to a followed call that key of what the call returns
|
|
1948
1965
|
* on a store-bound variable the store (`rows[0]`, `.data`) or a field (scalar)
|
|
@@ -1957,7 +1974,7 @@ function classifyAccess(value, path, ctx, out, depth) {
|
|
|
1957
1974
|
*/
|
|
1958
1975
|
const property = Node.isPropertyAccessExpression(value) ? value.getName() : keyOfElementAccess(value);
|
|
1959
1976
|
if (root && ctx.symbols.has(root)) {
|
|
1960
|
-
if (!property || PASSES_THROUGH.has(property)) out.push({
|
|
1977
|
+
if (!property || PASSES_THROUGH$1.has(property)) out.push({
|
|
1961
1978
|
kind: "store",
|
|
1962
1979
|
store: ctx.symbols.get(root),
|
|
1963
1980
|
path
|
|
@@ -1979,10 +1996,10 @@ function classifyAccess(value, path, ctx, out, depth) {
|
|
|
1979
1996
|
return;
|
|
1980
1997
|
}
|
|
1981
1998
|
/**
|
|
1982
|
-
* `
|
|
1999
|
+
* `result.rows`: one key of what the call returns; `meta.pagina` on a
|
|
1983
2000
|
* destructured `meta`: the key under the key; `rows[0]`, `.data`: the whole.
|
|
1984
2001
|
*/
|
|
1985
|
-
const own = property && !PASSES_THROUGH.has(property) ? property : void 0;
|
|
2002
|
+
const own = property && !PASSES_THROUGH$1.has(property) ? property : void 0;
|
|
1986
2003
|
const pick = [bound.pick, own].filter(Boolean).join(".") || void 0;
|
|
1987
2004
|
if (own && !ctx.followed.has(bound.initializer)) {
|
|
1988
2005
|
out.push({
|
|
@@ -2020,7 +2037,7 @@ function keyOfElementAccess(value) {
|
|
|
2020
2037
|
if (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) return argument.getLiteralValue();
|
|
2021
2038
|
return "*";
|
|
2022
2039
|
}
|
|
2023
|
-
const join = (prefix, name) => prefix ? `${prefix}.${name}` : name;
|
|
2040
|
+
const join$1 = (prefix, name) => prefix ? `${prefix}.${name}` : name;
|
|
2024
2041
|
/** `await x` binds x: the value, not the promise */
|
|
2025
2042
|
function unwrapAwait$1(node) {
|
|
2026
2043
|
let current = unwrap$1(node);
|
|
@@ -2073,6 +2090,518 @@ function isEchoBinding(name, body) {
|
|
|
2073
2090
|
}
|
|
2074
2091
|
const isRequestLike = (node) => lastSegmentOf(node) === "request";
|
|
2075
2092
|
//#endregion
|
|
2093
|
+
//#region src/inventory/graph/pages.ts
|
|
2094
|
+
/** array methods whose callback receives one row */
|
|
2095
|
+
const ITERATES_ROWS$1 = new Set([
|
|
2096
|
+
"map",
|
|
2097
|
+
"forEach",
|
|
2098
|
+
"filter",
|
|
2099
|
+
"find",
|
|
2100
|
+
"findLast",
|
|
2101
|
+
"some",
|
|
2102
|
+
"every",
|
|
2103
|
+
"flatMap"
|
|
2104
|
+
]);
|
|
2105
|
+
/** array methods that hand back one row, or the same rows */
|
|
2106
|
+
const SAME_ROWS = new Set([
|
|
2107
|
+
"filter",
|
|
2108
|
+
"slice",
|
|
2109
|
+
"sort",
|
|
2110
|
+
"toSorted",
|
|
2111
|
+
"reverse",
|
|
2112
|
+
"concat",
|
|
2113
|
+
"flat",
|
|
2114
|
+
"find",
|
|
2115
|
+
"findLast",
|
|
2116
|
+
"at"
|
|
2117
|
+
]);
|
|
2118
|
+
/** wrappers whose property hands the rows on */
|
|
2119
|
+
const PASSES_THROUGH = new Set([
|
|
2120
|
+
"data",
|
|
2121
|
+
"rows",
|
|
2122
|
+
"all"
|
|
2123
|
+
]);
|
|
2124
|
+
/** how deep the reader follows a row into child components */
|
|
2125
|
+
const MAX_COMPONENT_DEPTH = 1;
|
|
2126
|
+
function readPages(deliveries, env) {
|
|
2127
|
+
const reading = {
|
|
2128
|
+
columns: /* @__PURE__ */ new Map(),
|
|
2129
|
+
unreadable: /* @__PURE__ */ new Map(),
|
|
2130
|
+
unknownMembers: /* @__PURE__ */ new Map()
|
|
2131
|
+
};
|
|
2132
|
+
if (deliveries.length === 0) return reading;
|
|
2133
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
2134
|
+
for (const delivery of deliveries) {
|
|
2135
|
+
const key = `${delivery.engine}:${delivery.page}`;
|
|
2136
|
+
byPage.set(key, [...byPage.get(key) ?? [], delivery]);
|
|
2137
|
+
}
|
|
2138
|
+
for (const group of byPage.values()) {
|
|
2139
|
+
const { engine, page } = group[0];
|
|
2140
|
+
const candidates = engine === "inertia" ? inertiaPageFiles(env.root, page) : edgeFiles(env.root, page);
|
|
2141
|
+
if (candidates.length !== 1) {
|
|
2142
|
+
const reason = candidates.length === 0 ? `page "${page}" not found under the ${engine === "inertia" ? "pages" : "views"} conventions` : `two files answer to the page "${page}": ${candidates.map((c) => toPosix(c).replace(`${toPosix(env.root)}/`, "")).join(", ")}`;
|
|
2143
|
+
for (const delivery of group) markUnreadable(reading, delivery.store, reason);
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* One prop carrying several stores — a query object whose unreadable result
|
|
2148
|
+
* made every store it read leave under `atuacao` — cannot be told apart by the
|
|
2149
|
+
* page: `atuacao.total` belongs to none of them. Whole, all of them, and said so.
|
|
2150
|
+
*/
|
|
2151
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2152
|
+
for (const d of group) byPath.set(d.path, new Set([...byPath.get(d.path) ?? [], d.store]));
|
|
2153
|
+
const readable = group.filter((d) => {
|
|
2154
|
+
const stores = byPath.get(d.path);
|
|
2155
|
+
if (stores.size === 1) return true;
|
|
2156
|
+
markUnreadable(reading, d.store, `page "${page}": prop \`${d.path || "(props)"}\` carries ${stores.size} stores (${[...stores].join(", ")}) — what the page reads off it belongs to no one of them`);
|
|
2157
|
+
return false;
|
|
2158
|
+
});
|
|
2159
|
+
if (readable.length === 0) continue;
|
|
2160
|
+
const reader = new PageReader(env, reading);
|
|
2161
|
+
if (engine === "inertia") reader.readTsx(candidates[0], readable);
|
|
2162
|
+
else reader.readEdge(candidates[0], readable);
|
|
2163
|
+
/**
|
|
2164
|
+
* The page was read and no column of the store came out of it: either the page
|
|
2165
|
+
* never touches the rows, or it reads members that are not columns — a row the
|
|
2166
|
+
* controller serialised on the way, or computed getters. Whole, and said so.
|
|
2167
|
+
*/
|
|
2168
|
+
for (const { store } of readable) {
|
|
2169
|
+
if (reading.columns.has(store) || reading.unreadable.has(store)) continue;
|
|
2170
|
+
const members = [...reading.unknownMembers.get(store) ?? []];
|
|
2171
|
+
markUnreadable(reading, store, members.length > 0 ? `page "${page}" reads ${members.slice(0, 4).map((m) => `\`${m}\``).join(", ")} off ${store}, none of them a column of it` : `page "${page}" never reads ${store}`);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return reading;
|
|
2175
|
+
}
|
|
2176
|
+
function markUnreadable(reading, store, reason) {
|
|
2177
|
+
if (!reading.unreadable.has(store)) reading.unreadable.set(store, reason);
|
|
2178
|
+
}
|
|
2179
|
+
const SKIPPED_DIRS = new Set([
|
|
2180
|
+
"node_modules",
|
|
2181
|
+
"dist",
|
|
2182
|
+
"build",
|
|
2183
|
+
".git",
|
|
2184
|
+
"coverage",
|
|
2185
|
+
"tmp",
|
|
2186
|
+
".adonisjs"
|
|
2187
|
+
]);
|
|
2188
|
+
/**
|
|
2189
|
+
* `inertia.render('livros/index')` → `inertia/pages/livros/index.tsx` (the default), or any
|
|
2190
|
+
* `pages/` directory under the root holding that path — a module-scoped one included. The `resolve` function of the front-end is not
|
|
2191
|
+
* run — a convention is read, a function is not.
|
|
2192
|
+
*/
|
|
2193
|
+
function inertiaPageFiles(root, page) {
|
|
2194
|
+
const found = /* @__PURE__ */ new Set();
|
|
2195
|
+
/**
|
|
2196
|
+
* `orders/show` → `<…>/orders/<…>/pages/show.tsx`: a front-end that keeps one `pages/`
|
|
2197
|
+
* directory per module names the module in the page name's first segment. Read
|
|
2198
|
+
* structurally — a `pages/` directory whose path carries that segment — never as a
|
|
2199
|
+
* fixed layout: the default is `inertia/pages/<name>`, and anything else is one
|
|
2200
|
+
* application's convention.
|
|
2201
|
+
*/
|
|
2202
|
+
const [first, ...rest] = page.split("/");
|
|
2203
|
+
for (const dir of pagesDirectories(root)) {
|
|
2204
|
+
const scopedToModule = rest.length > 0 && toPosix(dir).split("/").includes(first);
|
|
2205
|
+
for (const extension of [
|
|
2206
|
+
".tsx",
|
|
2207
|
+
".jsx",
|
|
2208
|
+
".vue",
|
|
2209
|
+
".svelte"
|
|
2210
|
+
]) {
|
|
2211
|
+
const file = join(dir, `${page}${extension}`);
|
|
2212
|
+
if (existsSync(file)) found.add(file);
|
|
2213
|
+
const index = join(dir, page, `index${extension}`);
|
|
2214
|
+
if (existsSync(index)) found.add(index);
|
|
2215
|
+
if (scopedToModule) {
|
|
2216
|
+
const scoped = join(dir, `${rest.join("/")}${extension}`);
|
|
2217
|
+
if (existsSync(scoped)) found.add(scoped);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return [...found].sort();
|
|
2222
|
+
}
|
|
2223
|
+
let pagesDirsCache = /* @__PURE__ */ new Map();
|
|
2224
|
+
function pagesDirectories(root) {
|
|
2225
|
+
const cached = pagesDirsCache.get(root);
|
|
2226
|
+
if (cached) return cached;
|
|
2227
|
+
const dirs = [];
|
|
2228
|
+
const walk = (dir, depth) => {
|
|
2229
|
+
if (depth > 6) return;
|
|
2230
|
+
let entries;
|
|
2231
|
+
try {
|
|
2232
|
+
entries = readdirSync(dir);
|
|
2233
|
+
} catch {
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
for (const entry of entries) {
|
|
2237
|
+
if (SKIPPED_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
2238
|
+
const full = join(dir, entry);
|
|
2239
|
+
let isDirectory = false;
|
|
2240
|
+
try {
|
|
2241
|
+
isDirectory = statSync(full).isDirectory();
|
|
2242
|
+
} catch {
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
if (!isDirectory) continue;
|
|
2246
|
+
if (entry === "pages") dirs.push(full);
|
|
2247
|
+
else walk(full, depth + 1);
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
walk(root, 0);
|
|
2251
|
+
pagesDirsCache.set(root, dirs);
|
|
2252
|
+
return dirs;
|
|
2253
|
+
}
|
|
2254
|
+
/** `view.render('catalogo')` → `resources/views/catalogo.edge` */
|
|
2255
|
+
function edgeFiles(root, page) {
|
|
2256
|
+
const file = join(root, "resources", "views", `${page.replace(/\./g, "/")}.edge`);
|
|
2257
|
+
return existsSync(file) ? [file] : [];
|
|
2258
|
+
}
|
|
2259
|
+
/** tsconfig `paths` of the application, for the page's own imports (`~/components/x`) */
|
|
2260
|
+
function aliasesOf(root) {
|
|
2261
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
2262
|
+
for (const config of [join(root, "tsconfig.json"), join(root, "inertia", "tsconfig.json")]) {
|
|
2263
|
+
if (!existsSync(config)) continue;
|
|
2264
|
+
try {
|
|
2265
|
+
const text = readFileSync(config, "utf8").replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "");
|
|
2266
|
+
const paths = JSON.parse(text)?.compilerOptions?.paths;
|
|
2267
|
+
for (const [alias, targets] of Object.entries(paths ?? {})) {
|
|
2268
|
+
const target = targets[0];
|
|
2269
|
+
if (!target) continue;
|
|
2270
|
+
aliases.set(alias.replace(/\*$/, ""), resolve(dirname(config), target.replace(/\*$/, "")));
|
|
2271
|
+
}
|
|
2272
|
+
} catch {}
|
|
2273
|
+
}
|
|
2274
|
+
if (!aliases.has("~/")) aliases.set("~/", join(root, "inertia"));
|
|
2275
|
+
return aliases;
|
|
2276
|
+
}
|
|
2277
|
+
var PageReader = class {
|
|
2278
|
+
aliases;
|
|
2279
|
+
constructor(env, reading) {
|
|
2280
|
+
this.env = env;
|
|
2281
|
+
this.reading = reading;
|
|
2282
|
+
this.aliases = aliasesOf(env.root);
|
|
2283
|
+
}
|
|
2284
|
+
readTsx(file, deliveries) {
|
|
2285
|
+
const source = this.env.project.addSourceFileAtPathIfExists(file);
|
|
2286
|
+
const component = source ? defaultComponentOf(source) : null;
|
|
2287
|
+
if (!source || !component) {
|
|
2288
|
+
for (const d of deliveries) this.fail(d.store, `page "${deliveries[0].page}" has no default-exported component the reader can open`);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const scope = this.bindProps(component, deliveries.map((d) => ({
|
|
2292
|
+
name: d.path.split(".")[0],
|
|
2293
|
+
entity: {
|
|
2294
|
+
store: d.store,
|
|
2295
|
+
rest: d.path.split(".").slice(1)
|
|
2296
|
+
}
|
|
2297
|
+
})));
|
|
2298
|
+
this.readComponent(component, scope, 0, deliveries[0].page);
|
|
2299
|
+
}
|
|
2300
|
+
/** the component's first parameter: `{ livros }`, `{ livros: rows }`, or `props` */
|
|
2301
|
+
bindProps(component, props) {
|
|
2302
|
+
const scope = /* @__PURE__ */ new Map();
|
|
2303
|
+
if (!Node.isParametered(component)) return scope;
|
|
2304
|
+
const parameter = component.getParameters()[0];
|
|
2305
|
+
if (!parameter) return scope;
|
|
2306
|
+
const nameNode = parameter.getNameNode();
|
|
2307
|
+
if (Node.isObjectBindingPattern(nameNode)) for (const element of nameNode.getElements()) {
|
|
2308
|
+
const property = element.getPropertyNameNode()?.getText() ?? element.getName();
|
|
2309
|
+
const prop = props.find((p) => p.name === property);
|
|
2310
|
+
if (prop) scope.set(element.getName(), prop.entity);
|
|
2311
|
+
}
|
|
2312
|
+
else if (Node.isIdentifier(nameNode)) for (const prop of props) scope.set(`${nameNode.getText()}.${prop.name}`, prop.entity);
|
|
2313
|
+
return scope;
|
|
2314
|
+
}
|
|
2315
|
+
readComponent(component, scope, depth, page) {
|
|
2316
|
+
const body = Node.isParametered(component) && "getBody" in component ? component.getBody() ?? component : component;
|
|
2317
|
+
const file = component.getSourceFile();
|
|
2318
|
+
/** the entity an expression holds, recording a column when the expression IS one */
|
|
2319
|
+
const entityOf = (node, visiting = 0) => {
|
|
2320
|
+
if (!node || visiting > 12) return null;
|
|
2321
|
+
let current = node;
|
|
2322
|
+
while (Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current) || Node.isAsExpression(current) || Node.isAwaitExpression(current)) current = current.getExpression();
|
|
2323
|
+
if (Node.isIdentifier(current)) return scope.get(current.getText()) ?? null;
|
|
2324
|
+
if (Node.isPropertyAccessExpression(current)) {
|
|
2325
|
+
const receiver = current.getExpression();
|
|
2326
|
+
if (Node.isIdentifier(receiver)) {
|
|
2327
|
+
const asPath = scope.get(`${receiver.getText()}.${current.getName()}`);
|
|
2328
|
+
if (asPath) return asPath;
|
|
2329
|
+
}
|
|
2330
|
+
const holder = entityOf(receiver, visiting + 1);
|
|
2331
|
+
if (!holder) return null;
|
|
2332
|
+
return this.member(holder, current.getName());
|
|
2333
|
+
}
|
|
2334
|
+
if (Node.isElementAccessExpression(current)) {
|
|
2335
|
+
const holder = entityOf(current.getExpression(), visiting + 1);
|
|
2336
|
+
if (!holder) return null;
|
|
2337
|
+
const argument = current.getArgumentExpression();
|
|
2338
|
+
if (argument && Node.isStringLiteral(argument)) return this.member(holder, argument.getLiteralValue());
|
|
2339
|
+
return holder;
|
|
2340
|
+
}
|
|
2341
|
+
if (Node.isCallExpression(current)) {
|
|
2342
|
+
const callee = current.getExpression();
|
|
2343
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
2344
|
+
const holder = entityOf(callee.getExpression(), visiting + 1);
|
|
2345
|
+
if (holder && holder.rest.length === 0 && SAME_ROWS.has(callee.getName())) return holder;
|
|
2346
|
+
if (holder && holder.rest.length === 0 && ITERATES_ROWS$1.has(callee.getName())) return null;
|
|
2347
|
+
if (holder && holder.rest.length === 0 && callee.getName() === "length") return null;
|
|
2348
|
+
if (holder) return null;
|
|
2349
|
+
}
|
|
2350
|
+
return null;
|
|
2351
|
+
}
|
|
2352
|
+
if (Node.isConditionalExpression(current)) {
|
|
2353
|
+
entityOf(current.getCondition(), visiting + 1);
|
|
2354
|
+
return entityOf(current.getWhenTrue(), visiting + 1) ?? entityOf(current.getWhenFalse(), visiting + 1);
|
|
2355
|
+
}
|
|
2356
|
+
if (Node.isBinaryExpression(current)) {
|
|
2357
|
+
const operator = current.getOperatorToken().getKind();
|
|
2358
|
+
if (operator === SyntaxKind.AmpersandAmpersandToken) {
|
|
2359
|
+
entityOf(current.getLeft(), visiting + 1);
|
|
2360
|
+
return entityOf(current.getRight(), visiting + 1);
|
|
2361
|
+
}
|
|
2362
|
+
if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) return entityOf(current.getLeft(), visiting + 1) ?? entityOf(current.getRight(), visiting + 1);
|
|
2363
|
+
entityOf(current.getLeft(), visiting + 1);
|
|
2364
|
+
entityOf(current.getRight(), visiting + 1);
|
|
2365
|
+
return null;
|
|
2366
|
+
}
|
|
2367
|
+
if (Node.isTemplateExpression(current)) {
|
|
2368
|
+
for (const span of current.getTemplateSpans()) entityOf(span.getExpression(), visiting + 1);
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
return null;
|
|
2372
|
+
};
|
|
2373
|
+
const escape = (entity, reason) => {
|
|
2374
|
+
const store = entity.rest.length === 0 ? entity.store : entity.store;
|
|
2375
|
+
this.fail(store, `page "${page}": ${reason}`);
|
|
2376
|
+
};
|
|
2377
|
+
body.forEachDescendant((node) => {
|
|
2378
|
+
if (Node.isVariableDeclaration(node)) {
|
|
2379
|
+
const initializer = node.getInitializer();
|
|
2380
|
+
const nameNode = node.getNameNode();
|
|
2381
|
+
if (!initializer) return;
|
|
2382
|
+
const entity = entityOf(initializer);
|
|
2383
|
+
if (!entity) return;
|
|
2384
|
+
if (Node.isIdentifier(nameNode)) scope.set(nameNode.getText(), entity);
|
|
2385
|
+
else if (Node.isObjectBindingPattern(nameNode)) for (const element of nameNode.getElements()) {
|
|
2386
|
+
const property = element.getPropertyNameNode()?.getText() ?? element.getName();
|
|
2387
|
+
const member = this.member(entity, property);
|
|
2388
|
+
if (member) scope.set(element.getName(), member);
|
|
2389
|
+
}
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
if (Node.isCallExpression(node)) {
|
|
2393
|
+
const callee = node.getExpression();
|
|
2394
|
+
if (Node.isPropertyAccessExpression(callee) && ITERATES_ROWS$1.has(callee.getName())) {
|
|
2395
|
+
const holder = entityOf(callee.getExpression());
|
|
2396
|
+
const callback = node.getArguments()[0];
|
|
2397
|
+
if (holder && holder.rest.length === 0 && callback && (Node.isArrowFunction(callback) || Node.isFunctionExpression(callback))) {
|
|
2398
|
+
const parameter = callback.getParameters()[0]?.getNameNode();
|
|
2399
|
+
if (parameter && Node.isIdentifier(parameter)) scope.set(parameter.getText(), holder);
|
|
2400
|
+
}
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
for (const argument of node.getArguments()) {
|
|
2404
|
+
const entity = entityOf(argument);
|
|
2405
|
+
if (entity && !Node.isPropertyAccessExpression(unwrapParens(argument))) escape(entity, `\`${node.getExpression().getText()}(…)\` receives the rows and the reader cannot follow a function`);
|
|
2406
|
+
else if (entity) escape(entity, `\`${node.getExpression().getText()}(…)\` receives the rows and the reader cannot follow a function`);
|
|
2407
|
+
}
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
if (Node.isForOfStatement(node)) {
|
|
2411
|
+
const declared = node.getInitializer();
|
|
2412
|
+
const entity = entityOf(node.getExpression());
|
|
2413
|
+
if (entity && Node.isVariableDeclarationList(declared)) {
|
|
2414
|
+
const nameNode = declared.getDeclarations()[0]?.getNameNode();
|
|
2415
|
+
if (nameNode && Node.isIdentifier(nameNode)) scope.set(nameNode.getText(), entity);
|
|
2416
|
+
}
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
if (Node.isSpreadAssignment(node) || Node.isJsxSpreadAttribute(node) || Node.isSpreadElement(node)) {
|
|
2420
|
+
const entity = entityOf(node.getExpression());
|
|
2421
|
+
if (entity) escape(entity, "a spread hands the whole row on");
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (Node.isJsxExpression(node)) {
|
|
2425
|
+
const expression = node.getExpression();
|
|
2426
|
+
const parent = node.getParent();
|
|
2427
|
+
if (!expression || Node.isJsxAttribute(parent)) return;
|
|
2428
|
+
const entity = entityOf(expression);
|
|
2429
|
+
if (entity) escape(entity, `\`{${expression.getText().replace(/\s+/g, " ").slice(0, 60)}}\` renders the row itself`);
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
if (Node.isJsxAttribute(node)) {
|
|
2433
|
+
const initializer = node.getInitializer();
|
|
2434
|
+
const entity = entityOf(initializer && Node.isJsxExpression(initializer) ? initializer.getExpression() : void 0);
|
|
2435
|
+
if (!entity) return;
|
|
2436
|
+
const element = node.getFirstAncestor((n) => Node.isJsxOpeningElement(n) || Node.isJsxSelfClosingElement(n));
|
|
2437
|
+
const tag = element && (Node.isJsxOpeningElement(element) || Node.isJsxSelfClosingElement(element)) ? element.getTagNameNode().getText() : "";
|
|
2438
|
+
if (!/^[A-Z]/.test(tag)) return;
|
|
2439
|
+
if (depth >= MAX_COMPONENT_DEPTH) {
|
|
2440
|
+
escape(entity, `\`<${tag} ${node.getNameNode().getText()}={…} />\` is a second level of components; the reader follows one`);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
const child = this.componentOf(tag, file);
|
|
2444
|
+
if (!child) {
|
|
2445
|
+
escape(entity, `\`<${tag} />\` is not a component of the application the reader can open`);
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
const childScope = this.bindProps(child, [{
|
|
2449
|
+
name: node.getNameNode().getText(),
|
|
2450
|
+
entity
|
|
2451
|
+
}]);
|
|
2452
|
+
this.readComponent(child, childScope, depth + 1, page);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
if (Node.isReturnStatement(node) && depth === 0) {
|
|
2456
|
+
const expression = node.getExpression();
|
|
2457
|
+
if (expression && !Node.isJsxElement(expression) && !Node.isJsxFragment(expression) && !Node.isJsxSelfClosingElement(expression) && !Node.isParenthesizedExpression(expression)) {
|
|
2458
|
+
const entity = entityOf(expression);
|
|
2459
|
+
if (entity) escape(entity, `\`return ${expression.getText().replace(/\s+/g, " ").slice(0, 60)}\` hands the rows back as they came`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
/** `entity.name`: one column (recorded), a relation (the target), a wrapper key, or nothing */
|
|
2465
|
+
member(entity, name) {
|
|
2466
|
+
if (entity.rest.length > 0) return entity.rest[0] === name ? {
|
|
2467
|
+
store: entity.store,
|
|
2468
|
+
rest: entity.rest.slice(1)
|
|
2469
|
+
} : null;
|
|
2470
|
+
if (PASSES_THROUGH.has(name)) return entity;
|
|
2471
|
+
if (this.env.stores.get(entity.store)?.attributes.some((a) => a.name === name)) {
|
|
2472
|
+
this.column(entity.store, name);
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
2475
|
+
const target = this.env.relations.get(entity.store)?.[name];
|
|
2476
|
+
if (target) return {
|
|
2477
|
+
store: target,
|
|
2478
|
+
rest: []
|
|
2479
|
+
};
|
|
2480
|
+
if (![
|
|
2481
|
+
"length",
|
|
2482
|
+
"id",
|
|
2483
|
+
"map",
|
|
2484
|
+
"filter",
|
|
2485
|
+
"find",
|
|
2486
|
+
"some",
|
|
2487
|
+
"every",
|
|
2488
|
+
"forEach",
|
|
2489
|
+
"slice",
|
|
2490
|
+
"sort"
|
|
2491
|
+
].includes(name)) {
|
|
2492
|
+
const members = this.reading.unknownMembers.get(entity.store) ?? /* @__PURE__ */ new Set();
|
|
2493
|
+
members.add(name);
|
|
2494
|
+
this.reading.unknownMembers.set(entity.store, members);
|
|
2495
|
+
}
|
|
2496
|
+
return null;
|
|
2497
|
+
}
|
|
2498
|
+
/** the component a JSX tag names, in this file or through its imports (tsconfig paths, relative) */
|
|
2499
|
+
componentOf(tag, file) {
|
|
2500
|
+
const local = file.getFunction(tag) ?? functionVariable(file, tag);
|
|
2501
|
+
if (local) return local;
|
|
2502
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
2503
|
+
if (![declaration.getDefaultImport()?.getText(), ...declaration.getNamedImports().map((n) => n.getAliasNode()?.getText() ?? n.getName())].includes(tag)) continue;
|
|
2504
|
+
const target = this.resolveImport(declaration.getModuleSpecifierValue(), file.getFilePath());
|
|
2505
|
+
if (!target) return null;
|
|
2506
|
+
const source = this.env.project.addSourceFileAtPathIfExists(target);
|
|
2507
|
+
if (!source) return null;
|
|
2508
|
+
const exported = declaration.getNamedImports().find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === tag)?.getName() ?? tag;
|
|
2509
|
+
return source.getFunction(exported) ?? functionVariable(source, exported) ?? (declaration.getDefaultImport()?.getText() === tag ? defaultComponentOf(source) : null);
|
|
2510
|
+
}
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
resolveImport(specifier, from) {
|
|
2514
|
+
let base = this.env.resolveSpecifier(specifier);
|
|
2515
|
+
if (base) base = base.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
2516
|
+
if (!base && specifier.startsWith(".")) base = resolve(dirname(from), specifier);
|
|
2517
|
+
else for (const [alias, dir] of this.aliases) if (specifier.startsWith(alias)) base = join(dir, specifier.slice(alias.length));
|
|
2518
|
+
if (!base) return null;
|
|
2519
|
+
for (const candidate of [
|
|
2520
|
+
base,
|
|
2521
|
+
`${base}.tsx`,
|
|
2522
|
+
`${base}.ts`,
|
|
2523
|
+
`${base}.jsx`,
|
|
2524
|
+
join(base, "index.tsx"),
|
|
2525
|
+
join(base, "index.ts")
|
|
2526
|
+
]) if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
readEdge(file, deliveries, depth = 0, scope) {
|
|
2530
|
+
const text = readFileSync(file, "utf8");
|
|
2531
|
+
const page = deliveries[0].page;
|
|
2532
|
+
const bound = scope ?? /* @__PURE__ */ new Map();
|
|
2533
|
+
if (!scope) for (const d of deliveries) bound.set(d.path.split(".")[0], {
|
|
2534
|
+
store: d.store,
|
|
2535
|
+
rest: d.path.split(".").slice(1)
|
|
2536
|
+
});
|
|
2537
|
+
const entityOf = (expression) => {
|
|
2538
|
+
const parts = expression.trim().replace(/\?\./g, ".").split(".");
|
|
2539
|
+
let entity = bound.get(parts[0]) ?? null;
|
|
2540
|
+
for (const part of parts.slice(1)) {
|
|
2541
|
+
if (!entity) return null;
|
|
2542
|
+
entity = this.member(entity, part.replace(/\(.*$/, ""));
|
|
2543
|
+
}
|
|
2544
|
+
return entity;
|
|
2545
|
+
};
|
|
2546
|
+
const read = (expression) => {
|
|
2547
|
+
for (const chain of expression.match(/[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+/g) ?? []) {
|
|
2548
|
+
const entity = entityOf(chain);
|
|
2549
|
+
if (entity) this.fail(entity.store, `page "${page}": \`${chain}\` is a row handed on, not a column`);
|
|
2550
|
+
}
|
|
2551
|
+
const bare = expression.trim();
|
|
2552
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare) && bound.has(bare)) this.fail(bound.get(bare).store, `page "${page}": \`{{ ${bare} }}\` renders the rows themselves`);
|
|
2553
|
+
};
|
|
2554
|
+
for (const match of text.matchAll(/@each\(\s*\(?\s*([A-Za-z_$][\w$]*)(?:\s*,\s*[A-Za-z_$][\w$]*)?\s*\)?\s+in\s+([^)]+)\)/g)) {
|
|
2555
|
+
const entity = entityOf(match[2]);
|
|
2556
|
+
if (entity) bound.set(match[1], entity);
|
|
2557
|
+
}
|
|
2558
|
+
for (const match of text.matchAll(/\{\{\{?\s*([\s\S]*?)\s*\}?\}\}/g)) read(match[1]);
|
|
2559
|
+
for (const match of text.matchAll(/@(?:if|elseif|unless)\(([^)]*)\)/g)) read(match[1]);
|
|
2560
|
+
for (const match of text.matchAll(/@!?(?:include|component|layout)\(\s*['"]([^'"]+)['"]/g)) {
|
|
2561
|
+
const included = join(this.env.root, "resources", "views", `${match[1].replace(/\./g, "/")}.edge`);
|
|
2562
|
+
if (!existsSync(included)) continue;
|
|
2563
|
+
if (depth >= MAX_COMPONENT_DEPTH) {
|
|
2564
|
+
for (const entity of bound.values()) this.fail(entity.store, `page "${page}": \`@${match[0].slice(1).split("(")[0]}('${match[1]}')\` is a second level; the reader follows one`);
|
|
2565
|
+
continue;
|
|
2566
|
+
}
|
|
2567
|
+
this.readEdge(included, deliveries, depth + 1, new Map(bound));
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
column(store, name) {
|
|
2571
|
+
const columns = this.reading.columns.get(store) ?? /* @__PURE__ */ new Set();
|
|
2572
|
+
columns.add(name);
|
|
2573
|
+
this.reading.columns.set(store, columns);
|
|
2574
|
+
}
|
|
2575
|
+
fail(store, reason) {
|
|
2576
|
+
markUnreadable(this.reading, store, reason);
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
function unwrapParens(node) {
|
|
2580
|
+
let current = node;
|
|
2581
|
+
while (Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) current = current.getExpression();
|
|
2582
|
+
return current;
|
|
2583
|
+
}
|
|
2584
|
+
/** `export default function Page(…)`, or `export default Page` naming a function or an arrow */
|
|
2585
|
+
function defaultComponentOf(file) {
|
|
2586
|
+
const declared = file.getFunctions().find((f) => f.isDefaultExport());
|
|
2587
|
+
if (declared) return declared;
|
|
2588
|
+
for (const assignment of file.getExportAssignments()) {
|
|
2589
|
+
const expression = assignment.getExpression();
|
|
2590
|
+
if (Node.isIdentifier(expression)) return file.getFunction(expression.getText()) ?? functionVariable(file, expression.getText());
|
|
2591
|
+
if (Node.isArrowFunction(expression) || Node.isFunctionExpression(expression)) return expression;
|
|
2592
|
+
if (Node.isCallExpression(expression)) {
|
|
2593
|
+
const inner = expression.getArguments()[0];
|
|
2594
|
+
if (inner && Node.isIdentifier(inner)) return file.getFunction(inner.getText()) ?? functionVariable(file, inner.getText());
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
return null;
|
|
2598
|
+
}
|
|
2599
|
+
/** `const Page = (props) => …` / `function (…) {}` */
|
|
2600
|
+
function functionVariable(file, name) {
|
|
2601
|
+
const initializer = file.getVariableDeclaration(name)?.getInitializer();
|
|
2602
|
+
return initializer && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) ? initializer : null;
|
|
2603
|
+
}
|
|
2604
|
+
//#endregion
|
|
2076
2605
|
//#region src/inventory/graph/call_graph.ts
|
|
2077
2606
|
/**
|
|
2078
2607
|
* Fields declared by the validators used in this body.
|
|
@@ -2427,16 +2956,236 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2427
2956
|
factsCache.set(key, facts);
|
|
2428
2957
|
return facts;
|
|
2429
2958
|
};
|
|
2959
|
+
/** store name by the posix path of the model that declares it */
|
|
2960
|
+
const storeByFile = /* @__PURE__ */ new Map();
|
|
2961
|
+
for (const [name, store] of storesByName) storeByFile.set(toPosix(store.provenance.file), name);
|
|
2962
|
+
/**
|
|
2963
|
+
* The guard's user: `auth.user`, `auth.getUserOrFail()` are rows of the model
|
|
2964
|
+
* `config/auth.ts` names in its provider (`model: () => import('#models/user')`).
|
|
2965
|
+
* Read, never assumed — an application with no such config binds nothing.
|
|
2966
|
+
*/
|
|
2967
|
+
const authUserStore = (() => {
|
|
2968
|
+
const config = project.addSourceFileAtPathIfExists(`${toPosix(app.root)}/config/auth.ts`);
|
|
2969
|
+
if (!config) return null;
|
|
2970
|
+
for (const call of config.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
2971
|
+
if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
|
|
2972
|
+
if (call.getFirstAncestorByKind(SyntaxKind.PropertyAssignment)?.getName() !== "model") continue;
|
|
2973
|
+
const specifier = call.getArguments()[0];
|
|
2974
|
+
if (!specifier || !Node.isStringLiteral(specifier)) continue;
|
|
2975
|
+
const target = app.resolveSpecifier(specifier.getLiteralValue());
|
|
2976
|
+
const store = target ? storeByFile.get(toPosix(target)) : void 0;
|
|
2977
|
+
if (store) return store;
|
|
2978
|
+
}
|
|
2979
|
+
return null;
|
|
2980
|
+
})();
|
|
2981
|
+
/**
|
|
2982
|
+
* What a body's locals ARE — plan 0.8 §A. `storeSymbolsFor` binds what the
|
|
2983
|
+
* parameters and the static imports say; this pass, run in source order before
|
|
2984
|
+
* the accesses are detected, binds what the body's own statements say:
|
|
2985
|
+
*
|
|
2986
|
+
* const s = await this.sessoes.ativa(d) the followed body's return type,
|
|
2987
|
+
* or its returns when unannotated
|
|
2988
|
+
* const n = id ? await N.find(id) : new N() a conditional, both branches N
|
|
2989
|
+
* const p = (await P.first()) ?? new P() a default, both operands P
|
|
2990
|
+
* const u = auth.getUserOrFail() the guard's model, per config/auth.ts
|
|
2991
|
+
* const pasta = documento.pasta a relation read off a loaded row
|
|
2992
|
+
* for (const d of pasta.documentos) a row of the relation's target
|
|
2993
|
+
* rows.map(async (d) => …) the callback's parameter, a row
|
|
2994
|
+
*
|
|
2995
|
+
* Every one is a reading of what the code declares; none is a guess. A `save()`
|
|
2996
|
+
* on a local none of this can type is reported as unresolved — not dropped.
|
|
2997
|
+
*/
|
|
2998
|
+
function bindLocals(body, symbols, context, strategies) {
|
|
2999
|
+
const returnedStoreOf = (call, from = context, depth = 0) => {
|
|
3000
|
+
if (depth > 2) return null;
|
|
3001
|
+
const resolved = resolveCall(call, from, strategies);
|
|
3002
|
+
if (!resolved || resolved.refs.length === 0) return null;
|
|
3003
|
+
const found = /* @__PURE__ */ new Set();
|
|
3004
|
+
for (const target of resolved.refs) {
|
|
3005
|
+
const source = sourceFile(target.file);
|
|
3006
|
+
const resolvedBody = source ? findBody(source, target) : null;
|
|
3007
|
+
if (!resolvedBody) continue;
|
|
3008
|
+
const annotation = Node.isReturnTyped(resolvedBody) ? resolvedBody.getReturnTypeNode()?.getText() : void 0;
|
|
3009
|
+
if (annotation) {
|
|
3010
|
+
const store = storeNamedBy(annotation, storesByName);
|
|
3011
|
+
if (store) found.add(store);
|
|
3012
|
+
else return null;
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
const own = storeSymbolsFor(resolvedBody, source, app, storesByName, relationsByStore);
|
|
3016
|
+
const returns = resolvedBody.getDescendantsOfKind(SyntaxKind.ReturnStatement).filter((r) => r.getFirstAncestor((n) => Node.isMethodDeclaration(n) || Node.isFunctionDeclaration(n) || Node.isArrowFunction(n) || Node.isFunctionExpression(n)) === resolvedBody);
|
|
3017
|
+
if (returns.length === 0) return null;
|
|
3018
|
+
for (const statement of returns) {
|
|
3019
|
+
const value = statement.getExpression();
|
|
3020
|
+
if (!value) return null;
|
|
3021
|
+
const unwrapped = unwrapAwait(value);
|
|
3022
|
+
if (unwrapped.getKind() === SyntaxKind.NullKeyword) continue;
|
|
3023
|
+
if (Node.isCallExpression(unwrapped) && !detectAccess(unwrapped, own, relationsByStore)) {
|
|
3024
|
+
const store = returnedStoreOf(unwrapped, {
|
|
3025
|
+
...context,
|
|
3026
|
+
file: source,
|
|
3027
|
+
...importsFor(source),
|
|
3028
|
+
injected: injectedFor(resolvedBody.getFirstAncestorByKind(SyntaxKind.ClassDeclaration), source, app)
|
|
3029
|
+
}, depth + 1);
|
|
3030
|
+
if (!store) return null;
|
|
3031
|
+
found.add(store);
|
|
3032
|
+
continue;
|
|
3033
|
+
}
|
|
3034
|
+
const store = Node.isNewExpression(unwrapped) ? storesByName.has(unwrapped.getExpression().getText()) ? unwrapped.getExpression().getText() : null : own.get(rootSymbolOf(unwrapped) ?? "") ?? (storesByName.has(rootSymbolOf(unwrapped) ?? "") ? rootSymbolOf(unwrapped) : null);
|
|
3035
|
+
if (!store) return null;
|
|
3036
|
+
found.add(store);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
return found.size === 1 ? [...found][0] : null;
|
|
3040
|
+
};
|
|
3041
|
+
/**
|
|
3042
|
+
* `order.pendingItems({ client })`: a method declared on the model class, returning
|
|
3043
|
+
* `Item.query()…`. The model file is application code, and the method's annotation
|
|
3044
|
+
* (`typeof Item` names the store) or its returns say what it hands back (plan 0.9 §B).
|
|
3045
|
+
*/
|
|
3046
|
+
const modelMethodStore = (callee, scope) => {
|
|
3047
|
+
const root = rootSymbolOf(callee.getExpression());
|
|
3048
|
+
const store = root ? scope.get(root) : void 0;
|
|
3049
|
+
if (!store) return null;
|
|
3050
|
+
const declared = storesByName.get(store);
|
|
3051
|
+
const method = (declared ? sourceFile(declared.provenance.file) : null)?.getClasses().flatMap((c) => c.getMethods()).find((m) => m.getName() === callee.getName());
|
|
3052
|
+
if (!method) return null;
|
|
3053
|
+
const annotation = method.getReturnTypeNode()?.getText();
|
|
3054
|
+
if (annotation) {
|
|
3055
|
+
const named = annotation.match(/typeof\s+([A-Za-z_]\w*)/)?.[1];
|
|
3056
|
+
return storeNamedBy(named ?? annotation, storesByName);
|
|
3057
|
+
}
|
|
3058
|
+
const found = /* @__PURE__ */ new Set();
|
|
3059
|
+
for (const statement of method.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
|
|
3060
|
+
const value = statement.getExpression();
|
|
3061
|
+
if (!value) return null;
|
|
3062
|
+
const returnedRoot = rootSymbolOf(unwrapAwait(value));
|
|
3063
|
+
if (returnedRoot && storesByName.has(returnedRoot)) found.add(returnedRoot);
|
|
3064
|
+
else return null;
|
|
3065
|
+
}
|
|
3066
|
+
return found.size === 1 ? [...found][0] : null;
|
|
3067
|
+
};
|
|
3068
|
+
const isAuthUser = (node) => {
|
|
3069
|
+
if (!authUserStore) return false;
|
|
3070
|
+
const chain = Node.isCallExpression(node) ? node.getExpression() : node;
|
|
3071
|
+
if (!Node.isPropertyAccessExpression(chain)) return false;
|
|
3072
|
+
if (rootSymbolOf(chain) !== "auth" && lastSegmentText(chain.getExpression()) !== "auth") return false;
|
|
3073
|
+
const last = chain.getName();
|
|
3074
|
+
return last === "user" || last === "getUserOrFail" || last === "authenticate";
|
|
3075
|
+
};
|
|
3076
|
+
/** the store a value is rows (or one row) of, by what the code says — or nothing */
|
|
3077
|
+
const storeOfValue = (value, depth = 0) => {
|
|
3078
|
+
if (!value || depth > 6) return null;
|
|
3079
|
+
const node = unwrapAwait(value);
|
|
3080
|
+
if (node.getKind() === SyntaxKind.NullKeyword) return null;
|
|
3081
|
+
if (Node.isIdentifier(node)) {
|
|
3082
|
+
if (node.getText() === "undefined") return null;
|
|
3083
|
+
return symbols.get(node.getText()) ?? null;
|
|
3084
|
+
}
|
|
3085
|
+
if (Node.isNewExpression(node)) {
|
|
3086
|
+
const name = node.getExpression().getText();
|
|
3087
|
+
return storesByName.has(name) ? name : null;
|
|
3088
|
+
}
|
|
3089
|
+
if (Node.isConditionalExpression(node)) return sameStore(storeOfValue(node.getWhenTrue(), depth + 1), storeOfValue(node.getWhenFalse(), depth + 1), node.getWhenTrue(), node.getWhenFalse());
|
|
3090
|
+
if (Node.isBinaryExpression(node)) {
|
|
3091
|
+
const operator = node.getOperatorToken().getKind();
|
|
3092
|
+
if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) return sameStore(storeOfValue(node.getLeft(), depth + 1), storeOfValue(node.getRight(), depth + 1), node.getLeft(), node.getRight());
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
if (Node.isElementAccessExpression(node)) return storeOfValue(node.getExpression(), depth + 1);
|
|
3096
|
+
if (isAuthUser(node)) return authUserStore;
|
|
3097
|
+
if (Node.isPropertyAccessExpression(node)) return storeOfExpression(node, symbols, relationsByStore);
|
|
3098
|
+
if (Node.isCallExpression(node)) {
|
|
3099
|
+
const callee = node.getExpression();
|
|
3100
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
3101
|
+
/**
|
|
3102
|
+
* `q.forUpdate()`, `q.where(…)`: a query-builder chain hands the same rows on —
|
|
3103
|
+
* read from the innermost call outwards, so a model's method at the root of
|
|
3104
|
+
* the chain is seen before Lucid's API is assumed for the whole chain. An
|
|
3105
|
+
* aggregate (`.count()`) hands back a number.
|
|
3106
|
+
*/
|
|
3107
|
+
const receiver = unwrapAwait(callee.getExpression());
|
|
3108
|
+
if (Node.isCallExpression(receiver)) {
|
|
3109
|
+
if (AGGREGATES.has(callee.getName())) return null;
|
|
3110
|
+
const inner = storeOfValue(receiver, depth + 1);
|
|
3111
|
+
if (inner) return inner;
|
|
3112
|
+
}
|
|
3113
|
+
const fromModel = modelMethodStore(callee, symbols);
|
|
3114
|
+
if (fromModel) return fromModel;
|
|
3115
|
+
}
|
|
3116
|
+
const access = detectAccess(node, symbols, relationsByStore);
|
|
3117
|
+
if (access) return access.method === "related" && access.viaRelation ? access.viaRelation : access.store;
|
|
3118
|
+
if (Node.isPropertyAccessExpression(callee) && ONE_OF_ROWS.has(callee.getName())) return storeOfValue(callee.getExpression(), depth + 1);
|
|
3119
|
+
return returnedStoreOf(node);
|
|
3120
|
+
}
|
|
3121
|
+
return null;
|
|
3122
|
+
};
|
|
3123
|
+
const isNullish = (node) => {
|
|
3124
|
+
const inner = unwrapAwait(node);
|
|
3125
|
+
return inner.getKind() === SyntaxKind.NullKeyword || Node.isIdentifier(inner) && inner.getText() === "undefined";
|
|
3126
|
+
};
|
|
3127
|
+
const sameStore = (a, b, left, right) => {
|
|
3128
|
+
if (a && b) return a === b ? a : null;
|
|
3129
|
+
if (a && isNullish(right)) return a;
|
|
3130
|
+
if (b && isNullish(left)) return b;
|
|
3131
|
+
return null;
|
|
3132
|
+
};
|
|
3133
|
+
body.forEachDescendant((node) => {
|
|
3134
|
+
if (Node.isVariableDeclaration(node)) {
|
|
3135
|
+
const initializer = node.getInitializer();
|
|
3136
|
+
const nameNode = node.getNameNode();
|
|
3137
|
+
if (!initializer) return;
|
|
3138
|
+
if (Node.isIdentifier(nameNode)) {
|
|
3139
|
+
/**
|
|
3140
|
+
* Re-read even when the parameter pass already bound the name by the chain's
|
|
3141
|
+
* root: `const items = await order.pendingItems().forUpdate()` is rooted at
|
|
3142
|
+
* `order` and holds `Item` rows — what the model's method returns wins over
|
|
3143
|
+
* where the chain started.
|
|
3144
|
+
*/
|
|
3145
|
+
const store = storeOfValue(initializer);
|
|
3146
|
+
if (store) symbols.set(nameNode.getText(), store);
|
|
3147
|
+
}
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
if (Node.isForOfStatement(node)) {
|
|
3151
|
+
const declared = node.getInitializer();
|
|
3152
|
+
if (!Node.isVariableDeclarationList(declared)) return;
|
|
3153
|
+
const nameNode = declared.getDeclarations()[0]?.getNameNode();
|
|
3154
|
+
if (!nameNode || !Node.isIdentifier(nameNode)) return;
|
|
3155
|
+
const store = storeOfValue(node.getExpression());
|
|
3156
|
+
if (store) symbols.set(nameNode.getText(), store);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (Node.isCallExpression(node)) {
|
|
3160
|
+
const callee = node.getExpression();
|
|
3161
|
+
if (!Node.isPropertyAccessExpression(callee) || !ITERATES_ROWS.has(callee.getName())) return;
|
|
3162
|
+
const callback = node.getArguments()[0];
|
|
3163
|
+
if (!callback || !(Node.isArrowFunction(callback) || Node.isFunctionExpression(callback))) return;
|
|
3164
|
+
const parameter = callback.getParameters()[0]?.getNameNode();
|
|
3165
|
+
if (!parameter || !Node.isIdentifier(parameter)) return;
|
|
3166
|
+
const store = storeOfValue(callee.getExpression());
|
|
3167
|
+
if (store) symbols.set(parameter.getText(), store);
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
2430
3171
|
function computeFacts(ref) {
|
|
2431
3172
|
const file = sourceFile(ref.file);
|
|
2432
3173
|
if (!file) return null;
|
|
2433
3174
|
const body = findBody(file, ref);
|
|
2434
3175
|
if (!body) return null;
|
|
2435
3176
|
const { imports, exportedAs } = importsFor(file);
|
|
3177
|
+
/** locals imported from packages, not from the application: their objects are not stores */
|
|
3178
|
+
const packageImports = /* @__PURE__ */ new Set();
|
|
3179
|
+
for (const declaration of file.getImportDeclarations()) {
|
|
3180
|
+
if (app.resolveSpecifier(declaration.getModuleSpecifierValue())) continue;
|
|
3181
|
+
const defaultImport = declaration.getDefaultImport()?.getText();
|
|
3182
|
+
if (defaultImport) packageImports.add(defaultImport);
|
|
3183
|
+
for (const named of declaration.getNamedImports()) packageImports.add(named.getAliasNode()?.getText() ?? named.getName());
|
|
3184
|
+
}
|
|
2436
3185
|
/** the class this body belongs to: how `this.something` resolves */
|
|
2437
3186
|
const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
|
2438
3187
|
const injected = injectedFor(owner, file, app);
|
|
2439
|
-
const symbols = storeSymbolsFor(body, file, app, storesByName);
|
|
3188
|
+
const symbols = storeSymbolsFor(body, file, app, storesByName, relationsByStore);
|
|
2440
3189
|
const accesses = [];
|
|
2441
3190
|
const followUps = [];
|
|
2442
3191
|
const unresolved = [];
|
|
@@ -2457,6 +3206,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2457
3206
|
resolveSpecifier: app.resolveSpecifier,
|
|
2458
3207
|
sourceFile
|
|
2459
3208
|
};
|
|
3209
|
+
bindLocals(body, symbols, context, resolvers);
|
|
2460
3210
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
2461
3211
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
2462
3212
|
if (access) {
|
|
@@ -2481,8 +3231,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2481
3231
|
* A select list that is not literal is reported, and the store falls back
|
|
2482
3232
|
* to every column, which overestimates in the open.
|
|
2483
3233
|
*
|
|
2484
|
-
* `related('
|
|
2485
|
-
* parent only as a receiver; `preload('
|
|
3234
|
+
* `related('items').query().count()` reads the RELATION target, and the
|
|
3235
|
+
* parent only as a receiver; `preload('items')` reads the target whole.
|
|
2486
3236
|
*/
|
|
2487
3237
|
if (access.mode === "read") {
|
|
2488
3238
|
const chain = chainShapeOf(call);
|
|
@@ -2552,6 +3302,21 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2552
3302
|
followedCalls.set(call, resolved.refs);
|
|
2553
3303
|
continue;
|
|
2554
3304
|
}
|
|
3305
|
+
/**
|
|
3306
|
+
* `alvo.save()` on a receiver nobody could type. Not a write the count can
|
|
3307
|
+
* attribute — and not silence either: the reviewed application had six EIs
|
|
3308
|
+
* counted as EOs under a coverage of 99.5%, because a write on an unknown
|
|
3309
|
+
* local was dropped without a word. It lowers coverage and is named.
|
|
3310
|
+
*/
|
|
3311
|
+
if (isUnreadableWrite(call, symbols, imports, packageImports, body) && !isNoise(call, owner)) {
|
|
3312
|
+
unresolved.push({
|
|
3313
|
+
file: ref.file,
|
|
3314
|
+
line: call.getStartLineNumber(),
|
|
3315
|
+
expression: call.getExpression().getText().replace(/\s+/g, ""),
|
|
3316
|
+
reason: "write on a receiver whose type the analysis cannot read"
|
|
3317
|
+
});
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
2555
3320
|
if (isWorthReporting(call, symbols, imports) && !isNoise(call, owner)) unresolved.push({
|
|
2556
3321
|
file: ref.file,
|
|
2557
3322
|
line: call.getStartLineNumber(),
|
|
@@ -2608,7 +3373,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2608
3373
|
* Both project-wide facts come from one pass, computed once: which stores the
|
|
2609
3374
|
* application WRITES (maintenance, §6.5.4) and which it ADDRESSES directly
|
|
2610
3375
|
* (grouping, counting-decisions §10). A store reached only through a relation
|
|
2611
|
-
* — `preload('
|
|
3376
|
+
* — `preload('items')`, `related('items').create()` — is read or written, but
|
|
2612
3377
|
* not addressed: the user never sees it outside its parent.
|
|
2613
3378
|
*/
|
|
2614
3379
|
let projectWide;
|
|
@@ -2631,7 +3396,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2631
3396
|
*/
|
|
2632
3397
|
if (!isApplicationCode(app.root, file.getFilePath())) {
|
|
2633
3398
|
if (isSeeder(app.root, file.getFilePath())) {
|
|
2634
|
-
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
3399
|
+
const symbols = storeSymbolsFor(file, file, app, storesByName, relationsByStore);
|
|
2635
3400
|
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
2636
3401
|
const access = symbols.size > 0 ? detectAccess(call, symbols, relationsByStore) : null;
|
|
2637
3402
|
if (access?.mode !== "write") continue;
|
|
@@ -2641,7 +3406,7 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2641
3406
|
}
|
|
2642
3407
|
continue;
|
|
2643
3408
|
}
|
|
2644
|
-
const symbols = storeSymbolsFor(file, file, app, storesByName);
|
|
3409
|
+
const symbols = storeSymbolsFor(file, file, app, storesByName, relationsByStore);
|
|
2645
3410
|
if (symbols.size === 0) continue;
|
|
2646
3411
|
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
2647
3412
|
const access = detectAccess(call, symbols, relationsByStore);
|
|
@@ -2695,6 +3460,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2695
3460
|
const deliveredFields = /* @__PURE__ */ new Set();
|
|
2696
3461
|
const deliveredOpaque = /* @__PURE__ */ new Set();
|
|
2697
3462
|
const deliveredStores = /* @__PURE__ */ new Set();
|
|
3463
|
+
/** stores handed raw to a page: what the page shows of them is read after the walk */
|
|
3464
|
+
const rawDeliveries = [];
|
|
2698
3465
|
let anyDelivery = false;
|
|
2699
3466
|
const outputReads = /* @__PURE__ */ new Map();
|
|
2700
3467
|
const trace = [];
|
|
@@ -2725,6 +3492,11 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2725
3492
|
switch (item.kind) {
|
|
2726
3493
|
case "store":
|
|
2727
3494
|
deliveredStores.add(item.store);
|
|
3495
|
+
if (item.via) rawDeliveries.push({
|
|
3496
|
+
...item.via,
|
|
3497
|
+
store: item.store,
|
|
3498
|
+
path: item.path
|
|
3499
|
+
});
|
|
2728
3500
|
return;
|
|
2729
3501
|
case "scalar":
|
|
2730
3502
|
deliveredFields.add(item.path || "<value>");
|
|
@@ -2748,8 +3520,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2748
3520
|
}
|
|
2749
3521
|
const returned = facts.deliveries.returns.filter((r) => !item.pick || r.path === item.pick || r.path.startsWith(`${item.pick}.`));
|
|
2750
3522
|
/**
|
|
2751
|
-
* `
|
|
2752
|
-
*
|
|
3523
|
+
* `member.curso` where the body returns the row itself (`return
|
|
3524
|
+
* Member.query()…first()`, path ''): the pick lands INSIDE a returned
|
|
2753
3525
|
* value — one field of a store is one value; one key of a returned call
|
|
2754
3526
|
* is that call picked deeper.
|
|
2755
3527
|
*/
|
|
@@ -2760,7 +3532,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2760
3532
|
if (r.kind === "call") deliver({
|
|
2761
3533
|
...r,
|
|
2762
3534
|
path: item.path,
|
|
2763
|
-
pick: rest
|
|
3535
|
+
pick: rest,
|
|
3536
|
+
via: item.via
|
|
2764
3537
|
}, depth + 1, seen);
|
|
2765
3538
|
else if (r.kind === "store" || r.kind === "scalar") deliveredFields.add(item.path || "<value>");
|
|
2766
3539
|
}
|
|
@@ -2780,7 +3553,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2780
3553
|
const path = [item.path, rest].filter(Boolean).join(".");
|
|
2781
3554
|
deliver({
|
|
2782
3555
|
...r,
|
|
2783
|
-
path
|
|
3556
|
+
path,
|
|
3557
|
+
via: item.via
|
|
2784
3558
|
}, depth + 1, seen);
|
|
2785
3559
|
}
|
|
2786
3560
|
resolved = true;
|
|
@@ -2789,7 +3563,14 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2789
3563
|
if (item.pick) continue;
|
|
2790
3564
|
const read = storesReadBy(ref, depth + 1);
|
|
2791
3565
|
if (read.size > 0) {
|
|
2792
|
-
for (const store of read)
|
|
3566
|
+
for (const store of read) {
|
|
3567
|
+
deliveredStores.add(store);
|
|
3568
|
+
if (item.via) rawDeliveries.push({
|
|
3569
|
+
...item.via,
|
|
3570
|
+
store,
|
|
3571
|
+
path: item.path
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
2793
3574
|
resolved = true;
|
|
2794
3575
|
}
|
|
2795
3576
|
}
|
|
@@ -2809,7 +3590,10 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2809
3590
|
* received, so the rows' stores leave. Nothing handed in: opaque.
|
|
2810
3591
|
*/
|
|
2811
3592
|
if (item.args.length > 0) {
|
|
2812
|
-
for (const argument of item.args) deliver(
|
|
3593
|
+
for (const argument of item.args) deliver({
|
|
3594
|
+
...argument,
|
|
3595
|
+
via: item.via
|
|
3596
|
+
}, depth, seen);
|
|
2813
3597
|
return;
|
|
2814
3598
|
}
|
|
2815
3599
|
deliveredOpaque.add(`${item.path ? `${item.path}.` : ""}<${item.expression}>`);
|
|
@@ -2903,6 +3687,18 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2903
3687
|
}
|
|
2904
3688
|
};
|
|
2905
3689
|
visit(handler, 0);
|
|
3690
|
+
/**
|
|
3691
|
+
* What each page shows of the stores handed to it raw — read once the walk is
|
|
3692
|
+
* done, because a query object's rows reach the page through the delivery of a
|
|
3693
|
+
* call, not of a variable (plan 0.8 §D).
|
|
3694
|
+
*/
|
|
3695
|
+
const pages = readPages(rawDeliveries.filter((d) => !transformedStores.has(d.store)), {
|
|
3696
|
+
root: app.root,
|
|
3697
|
+
project,
|
|
3698
|
+
stores: storesByName,
|
|
3699
|
+
relations: relationsByStore,
|
|
3700
|
+
resolveSpecifier: app.resolveSpecifier
|
|
3701
|
+
});
|
|
2906
3702
|
return {
|
|
2907
3703
|
writes,
|
|
2908
3704
|
touches: [...touches].sort(),
|
|
@@ -2921,6 +3717,8 @@ function createAnalyzer(app, stores, options = {}) {
|
|
|
2921
3717
|
opaqueFields: [...deliveredOpaque].sort(),
|
|
2922
3718
|
stores: [...deliveredStores].sort()
|
|
2923
3719
|
},
|
|
3720
|
+
pageReads: Object.fromEntries([...pages.columns.entries()].map(([store, columns]) => [store, [...columns].sort()])),
|
|
3721
|
+
unreadablePages: Object.fromEntries(pages.unreadable),
|
|
2924
3722
|
outputReads: Object.fromEntries([...outputReads.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([store, read]) => [store, {
|
|
2925
3723
|
whole: read.whole,
|
|
2926
3724
|
selected: [...read.selected].sort(),
|
|
@@ -2972,7 +3770,7 @@ function findBody(file, ref) {
|
|
|
2972
3770
|
* from them: `const invite = await Invite.findOrFail(...)` makes `invite.save()`
|
|
2973
3771
|
* count as a write to `Invite`.
|
|
2974
3772
|
*/
|
|
2975
|
-
function storeSymbolsFor(body, file, app, stores) {
|
|
3773
|
+
function storeSymbolsFor(body, file, app, stores, relations = /* @__PURE__ */ new Map()) {
|
|
2976
3774
|
const symbols = /* @__PURE__ */ new Map();
|
|
2977
3775
|
for (const declaration of file.getImportDeclarations()) {
|
|
2978
3776
|
if (!app.resolveSpecifier(declaration.getModuleSpecifierValue())) continue;
|
|
@@ -2984,7 +3782,7 @@ function storeSymbolsFor(body, file, app, stores) {
|
|
|
2984
3782
|
}
|
|
2985
3783
|
}
|
|
2986
3784
|
/**
|
|
2987
|
-
* `const { default:
|
|
3785
|
+
* `const { default: Article } = await import('#articles/models/article')`: a
|
|
2988
3786
|
* model imported INSIDE the body — an ace command does this to keep the app
|
|
2989
3787
|
* from booting for `--help`. The store is the same; only the binding moved.
|
|
2990
3788
|
*/
|
|
@@ -3007,7 +3805,24 @@ function storeSymbolsFor(body, file, app, stores) {
|
|
|
3007
3805
|
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
3008
3806
|
const initializer = declaration.getInitializer();
|
|
3009
3807
|
const name = declaration.getNameNode();
|
|
3010
|
-
if (!
|
|
3808
|
+
if (!Node.isIdentifier(name)) continue;
|
|
3809
|
+
const declared = storeNamedBy(declaration.getTypeNode()?.getText(), stores);
|
|
3810
|
+
if (declared) {
|
|
3811
|
+
symbols.set(name.getText(), declared);
|
|
3812
|
+
continue;
|
|
3813
|
+
}
|
|
3814
|
+
if (!initializer) continue;
|
|
3815
|
+
/**
|
|
3816
|
+
* `const pasta = documento.pasta`: a RELATION read off a row is the relation's
|
|
3817
|
+
* target, not the row's store — `pasta.save()` had been billed to `Documento`.
|
|
3818
|
+
* A plain field (`documento.nome`) is no store at all.
|
|
3819
|
+
*/
|
|
3820
|
+
const plain = unwrapAwait(initializer);
|
|
3821
|
+
if (Node.isPropertyAccessExpression(plain)) {
|
|
3822
|
+
const target = storeOfExpression(plain, symbols, relations);
|
|
3823
|
+
if (target) symbols.set(name.getText(), target);
|
|
3824
|
+
continue;
|
|
3825
|
+
}
|
|
3011
3826
|
const root = rootSymbolOf(initializer);
|
|
3012
3827
|
const store = root ? symbols.get(root) : void 0;
|
|
3013
3828
|
if (store) symbols.set(name.getText(), store);
|
|
@@ -3015,8 +3830,8 @@ function storeSymbolsFor(body, file, app, stores) {
|
|
|
3015
3830
|
if (Node.isMethodDeclaration(body) || Node.isFunctionDeclaration(body)) for (const parameter of body.getParameters()) {
|
|
3016
3831
|
const typeNode = parameter.getTypeNode();
|
|
3017
3832
|
const nameNode = parameter.getNameNode();
|
|
3018
|
-
const typeName = typeNode?.getText();
|
|
3019
|
-
if (typeName &&
|
|
3833
|
+
const typeName = storeNamedBy(typeNode?.getText(), stores);
|
|
3834
|
+
if (typeName && Node.isIdentifier(nameNode)) {
|
|
3020
3835
|
symbols.set(nameNode.getText(), typeName);
|
|
3021
3836
|
continue;
|
|
3022
3837
|
}
|
|
@@ -3027,32 +3842,56 @@ function storeSymbolsFor(body, file, app, stores) {
|
|
|
3027
3842
|
* Registers the PATH `input.invite`, because that is how the write
|
|
3028
3843
|
* appears: `input.invite.save()`.
|
|
3029
3844
|
*/
|
|
3030
|
-
if (typeNode && Node.isIdentifier(nameNode)) {
|
|
3031
|
-
|
|
3845
|
+
if (typeNode && Node.isIdentifier(nameNode)) for (const [property, propertyType] of membersOfType(typeNode, file, app)) {
|
|
3846
|
+
const named = storeNamedBy(propertyType, stores);
|
|
3847
|
+
if (named) symbols.set(`${nameNode.getText()}.${property}`, named);
|
|
3032
3848
|
}
|
|
3033
3849
|
/**
|
|
3034
|
-
* Destructured form: `handle({ invite }: { invite: Invite })
|
|
3035
|
-
*
|
|
3036
|
-
*
|
|
3037
|
-
*
|
|
3038
|
-
*
|
|
3039
|
-
* instead of an EI.
|
|
3850
|
+
* Destructured form: `handle({ invite }: { invite: Invite })`, and
|
|
3851
|
+
* `handle({ document, name }: RenameDocumentInput)` with the interface named
|
|
3852
|
+
* — in this file or imported. The second is the dominant shape on a reviewed
|
|
3853
|
+
* application, and only the inline literal was read: every `document.save()`
|
|
3854
|
+
* behind it was invisible, and the transaction an EO (plan 0.8 §A).
|
|
3040
3855
|
*/
|
|
3041
3856
|
const binding = nameNode.asKind(SyntaxKind.ObjectBindingPattern);
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
const
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
if (memberType) propertyTypes.set(member.getName(), memberType);
|
|
3857
|
+
if (!binding || !typeNode) continue;
|
|
3858
|
+
const propertyTypes = membersOfType(typeNode, file, app);
|
|
3859
|
+
for (const element of binding.getElements()) {
|
|
3860
|
+
const property = element.getPropertyNameNode()?.getText() ?? element.getName();
|
|
3861
|
+
const resolved = storeNamedBy(propertyTypes.get(property), stores);
|
|
3862
|
+
if (resolved) symbols.set(element.getName(), resolved);
|
|
3049
3863
|
}
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* `const { order } = input` where `input.order` is a registered path:
|
|
3867
|
+
* each element inherits the store of its path. Read AFTER the parameters, which
|
|
3868
|
+
* is where the paths come from.
|
|
3869
|
+
*/
|
|
3870
|
+
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
3871
|
+
const binding = declaration.getNameNode();
|
|
3872
|
+
const initializer = declaration.getInitializer();
|
|
3873
|
+
if (!Node.isObjectBindingPattern(binding) || !initializer) continue;
|
|
3874
|
+
const root = Node.isIdentifier(initializer) ? initializer.getText() : null;
|
|
3875
|
+
if (!root) continue;
|
|
3050
3876
|
for (const element of binding.getElements()) {
|
|
3051
3877
|
const property = element.getPropertyNameNode()?.getText() ?? element.getName();
|
|
3052
|
-
const
|
|
3053
|
-
if (
|
|
3878
|
+
const store = symbols.get(`${root}.${property}`);
|
|
3879
|
+
if (store) symbols.set(element.getName(), store);
|
|
3054
3880
|
}
|
|
3055
3881
|
}
|
|
3882
|
+
/**
|
|
3883
|
+
* `for (const documento of pasta.documentos)`, `for (const row of rows)`: the
|
|
3884
|
+
* loop variable is a row of the relation's target, or of the rows' store. A
|
|
3885
|
+
* `delete()` inside such a loop was invisible; read last, after the parameters that name the parent — plan 0.8 §A.
|
|
3886
|
+
*/
|
|
3887
|
+
for (const loop of body.getDescendantsOfKind(SyntaxKind.ForOfStatement)) {
|
|
3888
|
+
const declared = loop.getInitializer();
|
|
3889
|
+
if (!Node.isVariableDeclarationList(declared)) continue;
|
|
3890
|
+
const nameNode = declared.getDeclarations()[0]?.getNameNode();
|
|
3891
|
+
if (!nameNode || !Node.isIdentifier(nameNode)) continue;
|
|
3892
|
+
const store = storeOfExpression(unwrapAwait(loop.getExpression()), symbols, relations);
|
|
3893
|
+
if (store) symbols.set(nameNode.getText(), store);
|
|
3894
|
+
}
|
|
3056
3895
|
return symbols;
|
|
3057
3896
|
}
|
|
3058
3897
|
/**
|
|
@@ -3116,6 +3955,78 @@ function resolveTypeToFile(typeName, file, app) {
|
|
|
3116
3955
|
* Accepts an inline type literal and a named type declared in this file or
|
|
3117
3956
|
* imported from the application. Anything else yields empty — no guessing.
|
|
3118
3957
|
*/
|
|
3958
|
+
/**
|
|
3959
|
+
* The store a type annotation names, or nothing: `Sessao`, `Sessao | null`,
|
|
3960
|
+
* `Promise<Sessao | null>`, `Sessao[]`, `Promise<Sessao[]>`. Two different stores
|
|
3961
|
+
* in one union name nothing — a guess is not a binding.
|
|
3962
|
+
*/
|
|
3963
|
+
function storeNamedBy(typeText, stores) {
|
|
3964
|
+
if (!typeText) return null;
|
|
3965
|
+
let text = typeText.trim();
|
|
3966
|
+
const promise = text.match(/^Promise<([\s\S]*)>$/);
|
|
3967
|
+
if (promise) text = promise[1];
|
|
3968
|
+
const named = new Set(text.split("|").map((part) => part.trim().replace(/\[\]$/, "").replace(/^Array<(.*)>$/, "$1").trim()).filter((part) => part && part !== "null" && part !== "undefined"));
|
|
3969
|
+
if (named.size !== 1) return null;
|
|
3970
|
+
const [only] = named;
|
|
3971
|
+
return stores.has(only) ? only : null;
|
|
3972
|
+
}
|
|
3973
|
+
/** array methods that hand back one row, or the same rows, of the receiver */
|
|
3974
|
+
const ONE_OF_ROWS = new Set([
|
|
3975
|
+
"find",
|
|
3976
|
+
"findLast",
|
|
3977
|
+
"at",
|
|
3978
|
+
"filter",
|
|
3979
|
+
"slice",
|
|
3980
|
+
"sort",
|
|
3981
|
+
"toSorted",
|
|
3982
|
+
"reverse",
|
|
3983
|
+
"toReversed",
|
|
3984
|
+
"concat",
|
|
3985
|
+
"flat",
|
|
3986
|
+
"first",
|
|
3987
|
+
"last"
|
|
3988
|
+
]);
|
|
3989
|
+
/** query-builder terminals that hand back one number or one yes/no, never rows */
|
|
3990
|
+
const AGGREGATES = new Set([
|
|
3991
|
+
"count",
|
|
3992
|
+
"sum",
|
|
3993
|
+
"avg",
|
|
3994
|
+
"min",
|
|
3995
|
+
"max",
|
|
3996
|
+
"exists",
|
|
3997
|
+
"pojo",
|
|
3998
|
+
"pluck"
|
|
3999
|
+
]);
|
|
4000
|
+
/** array methods whose callback receives one row of the receiver */
|
|
4001
|
+
const ITERATES_ROWS = new Set([
|
|
4002
|
+
"map",
|
|
4003
|
+
"forEach",
|
|
4004
|
+
"filter",
|
|
4005
|
+
"find",
|
|
4006
|
+
"findLast",
|
|
4007
|
+
"some",
|
|
4008
|
+
"every",
|
|
4009
|
+
"flatMap",
|
|
4010
|
+
"reduce"
|
|
4011
|
+
]);
|
|
4012
|
+
const lastSegmentText = (node) => Node.isPropertyAccessExpression(node) ? node.getName() : node.getText();
|
|
4013
|
+
/** the store an expression is rows of: a store symbol, or `parent.relation` with the relation declared */
|
|
4014
|
+
function storeOfExpression(expression, symbols, relations) {
|
|
4015
|
+
if (!expression) return null;
|
|
4016
|
+
if (Node.isIdentifier(expression)) return symbols.get(expression.getText()) ?? null;
|
|
4017
|
+
if (Node.isPropertyAccessExpression(expression)) {
|
|
4018
|
+
const receiver = expression.getExpression();
|
|
4019
|
+
if (Node.isIdentifier(receiver)) {
|
|
4020
|
+
const byPath = symbols.get(`${receiver.getText()}.${expression.getName()}`);
|
|
4021
|
+
if (byPath) return byPath;
|
|
4022
|
+
}
|
|
4023
|
+
const parent = symbols.get(rootSymbolOf(receiver) ?? "");
|
|
4024
|
+
if (!parent) return null;
|
|
4025
|
+
return relations.get(parent)?.[expression.getName()] ?? null;
|
|
4026
|
+
}
|
|
4027
|
+
const root = rootSymbolOf(expression);
|
|
4028
|
+
return root ? symbols.get(root) ?? null : null;
|
|
4029
|
+
}
|
|
3119
4030
|
function membersOfType(typeNode, file, app) {
|
|
3120
4031
|
const members = /* @__PURE__ */ new Map();
|
|
3121
4032
|
const collect = (node) => {
|
|
@@ -3170,8 +4081,8 @@ function importMapsOf(file, app) {
|
|
|
3170
4081
|
}
|
|
3171
4082
|
}
|
|
3172
4083
|
/**
|
|
3173
|
-
* `const { default:
|
|
3174
|
-
* `const {
|
|
4084
|
+
* `const { default: SyncCatalog } = await import('#catalog/actions/sync_catalog')`
|
|
4085
|
+
* `const { runInProgress } = await import('#catalog/services/runs')`
|
|
3175
4086
|
*
|
|
3176
4087
|
* A module imported INSIDE a body — the shape ace commands use so `--help` does
|
|
3177
4088
|
* not boot the application. The binding moved; the body it names did not, and
|
|
@@ -3230,6 +4141,77 @@ function isWorthReporting(call, symbols, imports) {
|
|
|
3230
4141
|
if (root === "this") return true;
|
|
3231
4142
|
return imports.has(root);
|
|
3232
4143
|
}
|
|
4144
|
+
/** Lucid's persistence on an instance, with no arguments: `x.save()`, `x.delete()` — a `Map#delete(key)` has one */
|
|
4145
|
+
const INSTANCE_WRITES = new Set([
|
|
4146
|
+
"save",
|
|
4147
|
+
"delete",
|
|
4148
|
+
"forceDelete"
|
|
4149
|
+
]);
|
|
4150
|
+
/** persistence through a relation or a merge: `x.related('y').create(…)`, `x.merge(p).save()` */
|
|
4151
|
+
const CHAINED_WRITES = new Set([
|
|
4152
|
+
"create",
|
|
4153
|
+
"createMany",
|
|
4154
|
+
"save",
|
|
4155
|
+
"saveMany",
|
|
4156
|
+
"attach",
|
|
4157
|
+
"detach",
|
|
4158
|
+
"sync",
|
|
4159
|
+
"updateOrCreate",
|
|
4160
|
+
"firstOrCreate",
|
|
4161
|
+
"delete"
|
|
4162
|
+
]);
|
|
4163
|
+
const WRITE_CHAINS = new Set([
|
|
4164
|
+
"related",
|
|
4165
|
+
"merge",
|
|
4166
|
+
"fill",
|
|
4167
|
+
"useTransaction"
|
|
4168
|
+
]);
|
|
4169
|
+
/**
|
|
4170
|
+
* A persistence call on a receiver the body cannot type: a local that is neither a
|
|
4171
|
+
* store, nor an import, nor `this`, nor the result of a call on one of those.
|
|
4172
|
+
* Reported as unresolved — the count must not stay silent where an EI may hide.
|
|
4173
|
+
*/
|
|
4174
|
+
function isUnreadableWrite(call, symbols, imports, packageImports, body) {
|
|
4175
|
+
const expression = call.getExpression();
|
|
4176
|
+
if (!Node.isPropertyAccessExpression(expression)) return false;
|
|
4177
|
+
const method = expression.getName();
|
|
4178
|
+
const receiver = unwrapAwait(expression.getExpression());
|
|
4179
|
+
if (Node.isCallExpression(receiver)) {
|
|
4180
|
+
const inner = receiver.getExpression();
|
|
4181
|
+
if (!Node.isPropertyAccessExpression(inner) || !WRITE_CHAINS.has(inner.getName())) return false;
|
|
4182
|
+
if (!CHAINED_WRITES.has(method)) return false;
|
|
4183
|
+
} else if (!INSTANCE_WRITES.has(method) || call.getArguments().length > 0) return false;
|
|
4184
|
+
const root = rootSymbolOf(expression.getExpression());
|
|
4185
|
+
if (!root || root === "this") return false;
|
|
4186
|
+
if (symbols.has(root) || imports.has(root) || packageImports.has(root)) return false;
|
|
4187
|
+
if (!Node.isIdentifier(unwrapAwait(rootNodeOf(expression.getExpression())))) return false;
|
|
4188
|
+
/**
|
|
4189
|
+
* `const pdfDoc = await PDFDocument.create()` from `pdf-lib`, then `pdfDoc.save()`:
|
|
4190
|
+
* a package's object with a method called `save`. Its declaration says where it
|
|
4191
|
+
* came from, and it is not a store — nothing to report.
|
|
4192
|
+
*/
|
|
4193
|
+
for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
4194
|
+
const nameNode = declaration.getNameNode();
|
|
4195
|
+
if (!Node.isIdentifier(nameNode) || nameNode.getText() !== root) continue;
|
|
4196
|
+
const initializer = declaration.getInitializer();
|
|
4197
|
+
const origin = initializer ? rootSymbolOf(unwrapAwait(initializer)) : null;
|
|
4198
|
+
if (origin && packageImports.has(origin)) return false;
|
|
4199
|
+
}
|
|
4200
|
+
return true;
|
|
4201
|
+
}
|
|
4202
|
+
/** the leftmost node of a chain */
|
|
4203
|
+
function rootNodeOf(node) {
|
|
4204
|
+
let current = node;
|
|
4205
|
+
for (let depth = 0; depth < 40; depth++) {
|
|
4206
|
+
const next = unwrapAwait(current);
|
|
4207
|
+
if (Node.isPropertyAccessExpression(next) || Node.isCallExpression(next) || Node.isElementAccessExpression(next)) {
|
|
4208
|
+
current = next.getExpression();
|
|
4209
|
+
continue;
|
|
4210
|
+
}
|
|
4211
|
+
return next;
|
|
4212
|
+
}
|
|
4213
|
+
return current;
|
|
4214
|
+
}
|
|
3233
4215
|
/** `(await x())` and `x()` are the same receiver for this purpose. */
|
|
3234
4216
|
function unwrapAwait(node) {
|
|
3235
4217
|
let current = node;
|
|
@@ -4051,6 +5033,19 @@ function detsFor(entry, behavior, touched, type, options) {
|
|
|
4051
5033
|
}
|
|
4052
5034
|
continue;
|
|
4053
5035
|
}
|
|
5036
|
+
/**
|
|
5037
|
+
* Handed raw to a page the reader could open: the columns the page reads off
|
|
5038
|
+
* the rows are what the user sees (§6, plan 0.8 §D). A page it could not read
|
|
5039
|
+
* leaves the store whole below, and the count says why.
|
|
5040
|
+
*/
|
|
5041
|
+
const shown = behavior.pageReads?.[store];
|
|
5042
|
+
if (shown && !behavior.unreadablePages?.[store]) {
|
|
5043
|
+
for (const column of shown) {
|
|
5044
|
+
if (excluded.has(column)) continue;
|
|
5045
|
+
add(`${store}.${column}`, `page:${store}.${column}${mark(column)}`);
|
|
5046
|
+
}
|
|
5047
|
+
continue;
|
|
5048
|
+
}
|
|
4054
5049
|
for (const column of attributes) {
|
|
4055
5050
|
if (excluded.has(column.name)) continue;
|
|
4056
5051
|
add(`${store}.${column.name}`, `output:${store}.${column.name}${mark(column.name)}`);
|
|
@@ -4141,13 +5136,19 @@ const RULESET = "afp";
|
|
|
4141
5136
|
* Three in 1.6.0: an output's DETs are what the transaction DELIVERS (the render
|
|
4142
5137
|
* props, the response payload, what a command prints) read back to their origin;
|
|
4143
5138
|
* a function of the same file and a `.map(fn)` by reference are followed, so
|
|
4144
|
-
* FTRs move; and ace commands are transactions, with flags as input.
|
|
5139
|
+
* FTRs move; and ace commands are transactions, with flags as input. One in
|
|
5140
|
+
* 1.7.0, wide: a write binds to what the variable IS — a destructured named
|
|
5141
|
+
* interface, a followed method's return, a relation off a row, a loop over rows,
|
|
5142
|
+
* the guard's user, a service the container made — and a write nobody can type is
|
|
5143
|
+
* an unresolved call instead of silence. Two in 1.8.0: a method the model
|
|
5144
|
+
* declares names the store its caller writes, and a listener written inline on a
|
|
5145
|
+
* string event is followed like a listener class.
|
|
4145
5146
|
*
|
|
4146
5147
|
* Without the bump, a baseline saved by the previous version compares cleanly
|
|
4147
5148
|
* against this one and bills the tool's own improvement as work done. The guard
|
|
4148
5149
|
* exists for exactly that, and only this constant arms it.
|
|
4149
5150
|
*/
|
|
4150
|
-
const RULESET_VERSION = "1.
|
|
5151
|
+
const RULESET_VERSION = "1.8.0";
|
|
4151
5152
|
function count(input, options = {}) {
|
|
4152
5153
|
const warnings = [];
|
|
4153
5154
|
const usage = usageOf(input);
|
|
@@ -4267,6 +5268,7 @@ function count(input, options = {}) {
|
|
|
4267
5268
|
warnings.push(...unreadableDeliveryWarnings(input));
|
|
4268
5269
|
warnings.push(...commandWarnings(entryPoints, transactionalFunctions));
|
|
4269
5270
|
warnings.push(...undispatchedJobWarnings(input));
|
|
5271
|
+
warnings.push(...unreadablePageWarnings(input));
|
|
4270
5272
|
warnings.push(...lookAlikeWarnings(functions));
|
|
4271
5273
|
warnings.push(...seededOnlyWarnings(functions, grouping.members, input.seededAnywhere));
|
|
4272
5274
|
return {
|
|
@@ -4299,6 +5301,27 @@ function unreadableDeliveryWarnings(input) {
|
|
|
4299
5301
|
];
|
|
4300
5302
|
}
|
|
4301
5303
|
/**
|
|
5304
|
+
* Pages the reader could not open for a store handed to them raw — plan 0.8 §D.
|
|
5305
|
+
*
|
|
5306
|
+
* The store leaves whole, as it always did; this says which page, and why: a
|
|
5307
|
+
* second level of components, a spread, a package's component, two files answering
|
|
5308
|
+
* to one name. Overestimating in the open — the fix is usually in the page.
|
|
5309
|
+
*/
|
|
5310
|
+
function unreadablePageWarnings(input) {
|
|
5311
|
+
const lines = [];
|
|
5312
|
+
for (const entry of input.entryPoints) {
|
|
5313
|
+
const behavior = input.behaviors.get(entry.id);
|
|
5314
|
+
if (!behavior || behavior.writes) continue;
|
|
5315
|
+
for (const [store, reason] of Object.entries(behavior.unreadablePages ?? {})) lines.push(` ${entry.trigger} ${entry.signature}: ${store} leaves whole — ${reason}`);
|
|
5316
|
+
}
|
|
5317
|
+
if (lines.length === 0) return [];
|
|
5318
|
+
return [
|
|
5319
|
+
`${lines.length} store(s) handed raw to a page the analysis could not read: every column counted. What the page shows would be less:`,
|
|
5320
|
+
...lines.slice(0, 12),
|
|
5321
|
+
...lines.length > 12 ? [` … and ${lines.length - 12} more`] : []
|
|
5322
|
+
];
|
|
5323
|
+
}
|
|
5324
|
+
/**
|
|
4302
5325
|
* Jobs no transaction reaches — plan 0.7 §D.
|
|
4303
5326
|
*
|
|
4304
5327
|
* A job dispatched by a handler is part of that handler's transaction (§9). One
|
|
@@ -4582,20 +5605,32 @@ function totalsOf(functions) {
|
|
|
4582
5605
|
* reader must see that without having to go looking.
|
|
4583
5606
|
*/
|
|
4584
5607
|
function confidenceOf(input, warnings) {
|
|
4585
|
-
let unresolvedCalls = 0;
|
|
4586
5608
|
let withoutHandler = 0;
|
|
5609
|
+
const sites = /* @__PURE__ */ new Map();
|
|
5610
|
+
for (const site of input.unresolved ?? []) sites.set(`${site.file}:${site.line}:${site.expression}`, { ...site });
|
|
4587
5611
|
for (const entry of input.entryPoints) {
|
|
4588
5612
|
const behavior = input.behaviors.get(entry.id);
|
|
4589
5613
|
if (!behavior) {
|
|
4590
5614
|
withoutHandler++;
|
|
4591
5615
|
continue;
|
|
4592
5616
|
}
|
|
4593
|
-
|
|
5617
|
+
if (input.unresolved) continue;
|
|
5618
|
+
for (const call of behavior.unresolved) {
|
|
5619
|
+
const key = `${call.file}:${call.line}:${call.expression}`;
|
|
5620
|
+
const site = sites.get(key);
|
|
5621
|
+
if (site) site.transactions++;
|
|
5622
|
+
else sites.set(key, {
|
|
5623
|
+
...call,
|
|
5624
|
+
transactions: 1
|
|
5625
|
+
});
|
|
5626
|
+
}
|
|
4594
5627
|
}
|
|
5628
|
+
const unresolved = [...sites.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
4595
5629
|
return {
|
|
4596
|
-
unresolvedCalls,
|
|
5630
|
+
unresolvedCalls: unresolved.length,
|
|
4597
5631
|
entryPointsWithoutHandler: withoutHandler,
|
|
4598
|
-
warnings
|
|
5632
|
+
warnings,
|
|
5633
|
+
unresolved
|
|
4599
5634
|
};
|
|
4600
5635
|
}
|
|
4601
5636
|
//#endregion
|
|
@@ -4672,7 +5707,27 @@ async function analyze(root, options = {}) {
|
|
|
4672
5707
|
});
|
|
4673
5708
|
const behaviors = new Map(entryPoints.filter((entry) => entry.handler).map((entry) => [entry.id, analyzer.analyze(entry.handler)]));
|
|
4674
5709
|
const resolved = [...behaviors.values()].filter((behavior) => behavior.unresolved.length === 0).length;
|
|
4675
|
-
|
|
5710
|
+
/**
|
|
5711
|
+
* One site, however many transactions reach it: a body five routes walk is one gap
|
|
5712
|
+
* to close, not five. The count shows the same number — a reviewing team found 10
|
|
5713
|
+
* in one report and 13 in the other and could act on neither.
|
|
5714
|
+
*/
|
|
5715
|
+
const sites = /* @__PURE__ */ new Map();
|
|
5716
|
+
for (const problem of [...storeProblems, ...routeProblems]) sites.set(`${problem.file}:${problem.line}:${problem.expression}`, {
|
|
5717
|
+
...problem,
|
|
5718
|
+
transactions: 0
|
|
5719
|
+
});
|
|
5720
|
+
for (const behavior of behaviors.values()) for (const call of behavior.unresolved) {
|
|
5721
|
+
const key = `${call.file}:${call.line}:${call.expression}`;
|
|
5722
|
+
const site = sites.get(key);
|
|
5723
|
+
if (site) site.transactions++;
|
|
5724
|
+
else sites.set(key, {
|
|
5725
|
+
...call,
|
|
5726
|
+
transactions: 1
|
|
5727
|
+
});
|
|
5728
|
+
}
|
|
5729
|
+
const unresolvedSites = [...sites.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
5730
|
+
const unresolvedCalls = unresolvedSites.length;
|
|
4676
5731
|
/**
|
|
4677
5732
|
* Every path that LEAVES is relative to the application root.
|
|
4678
5733
|
*
|
|
@@ -4768,6 +5823,8 @@ async function analyze(root, options = {}) {
|
|
|
4768
5823
|
})),
|
|
4769
5824
|
transformedStores: behavior.transformedStores,
|
|
4770
5825
|
delivered: behavior.delivered,
|
|
5826
|
+
pageReads: behavior.pageReads,
|
|
5827
|
+
unreadablePages: behavior.unreadablePages,
|
|
4771
5828
|
outputReads: behavior.outputReads,
|
|
4772
5829
|
trace: behavior.trace.map((step) => ({
|
|
4773
5830
|
...step,
|
|
@@ -4783,7 +5840,11 @@ async function analyze(root, options = {}) {
|
|
|
4783
5840
|
entryPointsResolved: resolved,
|
|
4784
5841
|
unresolvedCalls,
|
|
4785
5842
|
ratio: entryPoints.length === 0 ? 1 : resolved / entryPoints.length
|
|
4786
|
-
}
|
|
5843
|
+
},
|
|
5844
|
+
unresolved: unresolvedSites.map((site) => ({
|
|
5845
|
+
...site,
|
|
5846
|
+
file: emit(site.file)
|
|
5847
|
+
}))
|
|
4787
5848
|
};
|
|
4788
5849
|
const minimum = options.minCoverage ?? 0;
|
|
4789
5850
|
if (inventory.coverage.ratio < minimum) throw new CoverageTooLowError(inventory.coverage.ratio, minimum);
|
|
@@ -4797,6 +5858,7 @@ async function analyze(root, options = {}) {
|
|
|
4797
5858
|
behaviors,
|
|
4798
5859
|
jsonSchemas,
|
|
4799
5860
|
jobs: collectJobs(app),
|
|
5861
|
+
unresolved: inventory.unresolved,
|
|
4800
5862
|
writtenAnywhere: analyzer.writtenAnywhere(),
|
|
4801
5863
|
addressedAnywhere: analyzer.addressedAnywhere(),
|
|
4802
5864
|
seededAnywhere: analyzer.seededAnywhere()
|