@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.
@@ -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-DaU4uAqT.js";
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 noticias:importar`
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 = 'noticias:importar'` — a literal; a computed name is nobody's identity */
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
- /** keys of a result that carry its rows: picking one of these is not picking one value */
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
- "list",
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
- if (RENDERERS.has(receiver) && RENDER_METHODS.has(method)) payload = call.getArguments()[1];
1440
- else if (receiver === "response" && RESPONSE_METHODS.has(method)) payload = call.getArguments()[0];
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. `noticia.publicadaEm?.toISO()` on that
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
- * `comunicado.enviadoEm!.toISODate()`, `(a ?? b).toRFC2822()`: a FIELD read off
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(paraLinha)`) is a call to
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? `comunicado.enviadoEm.toISO()` does; `rows.map(f).join()`
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; `noticia.capa?.toISO()`
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`, `resultado.linhas`, `rows[0]`: a part of a value the body holds.
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
- * `resultado.linhas`: one key of what the call returns; `meta.pagina` on a
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('itens').query().count()` reads the RELATION target, and the
2485
- * parent only as a receiver; `preload('itens')` reads the target whole.
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('itens')`, `related('itens').create()` — is read or written, but
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
- * `egresso.curso` where the body returns the row itself (`return
2752
- * Egresso.query()…first()`, path ''): the pick lands INSIDE a returned
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) deliveredStores.add(store);
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(argument, depth, seen);
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: Noticia } = await import('#noticias/models/noticia')`: a
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 (!initializer || !Node.isIdentifier(name)) continue;
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 && stores.has(typeName) && Node.isIdentifier(nameNode)) {
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
- for (const [property, propertyType] of membersOfType(typeNode, file, app)) if (stores.has(propertyType)) symbols.set(`${nameNode.getText()}.${property}`, propertyType);
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
- * This is the dominant shape in the action-object pattern — the action
3037
- * receives a named payload. Without it, `invite.save()` inside the action
3038
- * does not count as a write, and the whole transaction becomes an EO
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
- const literal = typeNode?.asKind(SyntaxKind.TypeLiteral);
3043
- if (!binding || !literal) continue;
3044
- const propertyTypes = /* @__PURE__ */ new Map();
3045
- for (const member of literal.getMembers()) {
3046
- if (!Node.isPropertySignature(member)) continue;
3047
- const memberType = member.getTypeNode()?.getText();
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 resolved = propertyTypes.get(property);
3053
- if (resolved && stores.has(resolved)) symbols.set(element.getName(), resolved);
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: SincronizarBulk } = await import('#inpi/actions/sincronizar_bulk')`
3174
- * `const { execucaoEmAndamento } = await import('#inpi/services/execucao')`
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.6.0";
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
- unresolvedCalls += behavior.unresolved.length;
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
- const unresolvedCalls = storeProblems.length + routeProblems.length + [...behaviors.values()].reduce((total, behavior) => total + behavior.unresolved.length, 0);
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()