@filipebraida/adonis-function-points 0.7.0 → 0.8.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-DhJO-qvQ.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([
@@ -1288,7 +1288,7 @@ const PRINTERS = new Set([
1288
1288
  "console"
1289
1289
  ]);
1290
1290
  /** `x.data`, `x.rows` on a paginated / wrapped result hand the collection on */
1291
- const PASSES_THROUGH = new Set([
1291
+ const PASSES_THROUGH$1 = new Set([
1292
1292
  "data",
1293
1293
  "rows",
1294
1294
  "all",
@@ -1436,8 +1436,15 @@ function deliveriesIn(ctx) {
1436
1436
  const method = callee.getName();
1437
1437
  const receiver = lastSegmentOf(callee.getExpression());
1438
1438
  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];
1439
+ let via;
1440
+ if (RENDERERS.has(receiver) && RENDER_METHODS.has(method)) {
1441
+ payload = call.getArguments()[1];
1442
+ const name = unwrap$1(call.getArguments()[0]);
1443
+ if (name && Node.isStringLiteral(name)) via = {
1444
+ engine: receiver === "view" ? "edge" : "inertia",
1445
+ page: name.getLiteralValue()
1446
+ };
1447
+ } else if (receiver === "response" && RESPONSE_METHODS.has(method)) payload = call.getArguments()[0];
1441
1448
  else if (isPrinter(callee.getExpression(), ctx.body)) {
1442
1449
  any = true;
1443
1450
  seen.add(call);
@@ -1447,7 +1454,9 @@ function deliveriesIn(ctx) {
1447
1454
  any = true;
1448
1455
  if (!payload) continue;
1449
1456
  seen.add(call);
1457
+ const before = deliveries.length;
1450
1458
  classify(unwrap$1(payload), "", ctx, deliveries, 0);
1459
+ if (via) for (let i = before; i < deliveries.length; i++) deliveries[i].via = via;
1451
1460
  }
1452
1461
  for (const statement of ctx.body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
1453
1462
  if (statement.getFirstAncestor((node) => Node.isArrowFunction(node) || Node.isFunctionExpression(node) || Node.isMethodDeclaration(node) || Node.isFunctionDeclaration(node)) !== ctx.body) continue;
@@ -1530,14 +1539,14 @@ function classify(value, path, ctx, out, depth) {
1530
1539
  */
1531
1540
  if (!path && (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) || Node.isNumericLiteral(value))) return;
1532
1541
  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);
1542
+ for (const property of value.getProperties()) if (Node.isShorthandPropertyAssignment(property)) classify(property.getNameNode(), join$1(path, property.getName()), ctx, out, depth + 1);
1534
1543
  else if (Node.isPropertyAssignment(property)) {
1535
1544
  const name = Node.isComputedPropertyName(property.getNameNode()) ? "*" : property.getName().replace(/^['"]|['"]$/g, "");
1536
- classify(unwrap$1(property.getInitializer()), join(path, name), ctx, out, depth + 1);
1545
+ classify(unwrap$1(property.getInitializer()), join$1(path, name), ctx, out, depth + 1);
1537
1546
  } else if (Node.isSpreadAssignment(property)) classify(unwrap$1(property.getExpression()), path, ctx, out, depth + 1);
1538
1547
  else out.push({
1539
1548
  kind: "scalar",
1540
- path: join(path, property.getName?.() ?? "*")
1549
+ path: join$1(path, property.getName?.() ?? "*")
1541
1550
  });
1542
1551
  return;
1543
1552
  }
@@ -1649,7 +1658,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
1649
1658
  classifyMapped(value, callee.getExpression(), path, ctx, out, depth);
1650
1659
  return;
1651
1660
  }
1652
- if (Node.isPropertyAccessExpression(callee) && PASSES_THROUGH.has(method) && value.getArguments().length === 0) {
1661
+ if (Node.isPropertyAccessExpression(callee) && PASSES_THROUGH$1.has(method) && value.getArguments().length === 0) {
1653
1662
  classify(unwrap$1(callee.getExpression()), path, ctx, out, depth + 1);
1654
1663
  return;
1655
1664
  }
@@ -1660,7 +1669,7 @@ function classifyCall(value, path, ctx, out, depth, pick) {
1660
1669
  });
1661
1670
  else for (const key of PAGINATOR_META) out.push({
1662
1671
  kind: "scalar",
1663
- path: join(path, key)
1672
+ path: join$1(path, key)
1664
1673
  });
1665
1674
  return;
1666
1675
  }
@@ -1754,10 +1763,15 @@ function classifyCall(value, path, ctx, out, depth, pick) {
1754
1763
  if (Node.isPropertyAccessExpression(callee)) {
1755
1764
  const root = chainRootOf(callee.getExpression());
1756
1765
  if (root && ctx.symbols.has(root)) {
1757
- out.push({
1766
+ if (readsField(callee.getExpression())) out.push({
1758
1767
  kind: "scalar",
1759
1768
  path
1760
1769
  });
1770
+ else out.push({
1771
+ kind: "store",
1772
+ store: ctx.symbols.get(root),
1773
+ path
1774
+ });
1761
1775
  return;
1762
1776
  }
1763
1777
  if (root && root !== "this") {
@@ -1957,7 +1971,7 @@ function classifyAccess(value, path, ctx, out, depth) {
1957
1971
  */
1958
1972
  const property = Node.isPropertyAccessExpression(value) ? value.getName() : keyOfElementAccess(value);
1959
1973
  if (root && ctx.symbols.has(root)) {
1960
- if (!property || PASSES_THROUGH.has(property)) out.push({
1974
+ if (!property || PASSES_THROUGH$1.has(property)) out.push({
1961
1975
  kind: "store",
1962
1976
  store: ctx.symbols.get(root),
1963
1977
  path
@@ -1982,7 +1996,7 @@ function classifyAccess(value, path, ctx, out, depth) {
1982
1996
  * `resultado.linhas`: one key of what the call returns; `meta.pagina` on a
1983
1997
  * destructured `meta`: the key under the key; `rows[0]`, `.data`: the whole.
1984
1998
  */
1985
- const own = property && !PASSES_THROUGH.has(property) ? property : void 0;
1999
+ const own = property && !PASSES_THROUGH$1.has(property) ? property : void 0;
1986
2000
  const pick = [bound.pick, own].filter(Boolean).join(".") || void 0;
1987
2001
  if (own && !ctx.followed.has(bound.initializer)) {
1988
2002
  out.push({
@@ -2020,7 +2034,7 @@ function keyOfElementAccess(value) {
2020
2034
  if (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) return argument.getLiteralValue();
2021
2035
  return "*";
2022
2036
  }
2023
- const join = (prefix, name) => prefix ? `${prefix}.${name}` : name;
2037
+ const join$1 = (prefix, name) => prefix ? `${prefix}.${name}` : name;
2024
2038
  /** `await x` binds x: the value, not the promise */
2025
2039
  function unwrapAwait$1(node) {
2026
2040
  let current = unwrap$1(node);
@@ -2073,6 +2087,514 @@ function isEchoBinding(name, body) {
2073
2087
  }
2074
2088
  const isRequestLike = (node) => lastSegmentOf(node) === "request";
2075
2089
  //#endregion
2090
+ //#region src/inventory/graph/pages.ts
2091
+ /** array methods whose callback receives one row */
2092
+ const ITERATES_ROWS$1 = new Set([
2093
+ "map",
2094
+ "forEach",
2095
+ "filter",
2096
+ "find",
2097
+ "findLast",
2098
+ "some",
2099
+ "every",
2100
+ "flatMap"
2101
+ ]);
2102
+ /** array methods that hand back one row, or the same rows */
2103
+ const SAME_ROWS = new Set([
2104
+ "filter",
2105
+ "slice",
2106
+ "sort",
2107
+ "toSorted",
2108
+ "reverse",
2109
+ "concat",
2110
+ "flat",
2111
+ "find",
2112
+ "findLast",
2113
+ "at"
2114
+ ]);
2115
+ /** wrappers whose property hands the rows on */
2116
+ const PASSES_THROUGH = new Set([
2117
+ "data",
2118
+ "rows",
2119
+ "all"
2120
+ ]);
2121
+ /** how deep the reader follows a row into child components */
2122
+ const MAX_COMPONENT_DEPTH = 1;
2123
+ function readPages(deliveries, env) {
2124
+ const reading = {
2125
+ columns: /* @__PURE__ */ new Map(),
2126
+ unreadable: /* @__PURE__ */ new Map(),
2127
+ unknownMembers: /* @__PURE__ */ new Map()
2128
+ };
2129
+ if (deliveries.length === 0) return reading;
2130
+ const byPage = /* @__PURE__ */ new Map();
2131
+ for (const delivery of deliveries) {
2132
+ const key = `${delivery.engine}:${delivery.page}`;
2133
+ byPage.set(key, [...byPage.get(key) ?? [], delivery]);
2134
+ }
2135
+ for (const group of byPage.values()) {
2136
+ const { engine, page } = group[0];
2137
+ const candidates = engine === "inertia" ? inertiaPageFiles(env.root, page) : edgeFiles(env.root, page);
2138
+ if (candidates.length !== 1) {
2139
+ 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(", ")}`;
2140
+ for (const delivery of group) markUnreadable(reading, delivery.store, reason);
2141
+ continue;
2142
+ }
2143
+ /**
2144
+ * One prop carrying several stores — a query object whose unreadable result
2145
+ * made every store it read leave under `atuacao` — cannot be told apart by the
2146
+ * page: `atuacao.total` belongs to none of them. Whole, all of them, and said so.
2147
+ */
2148
+ const byPath = /* @__PURE__ */ new Map();
2149
+ for (const d of group) byPath.set(d.path, new Set([...byPath.get(d.path) ?? [], d.store]));
2150
+ const readable = group.filter((d) => {
2151
+ const stores = byPath.get(d.path);
2152
+ if (stores.size === 1) return true;
2153
+ 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`);
2154
+ return false;
2155
+ });
2156
+ if (readable.length === 0) continue;
2157
+ const reader = new PageReader(env, reading);
2158
+ if (engine === "inertia") reader.readTsx(candidates[0], readable);
2159
+ else reader.readEdge(candidates[0], readable);
2160
+ /**
2161
+ * The page was read and no column of the store came out of it: either the page
2162
+ * never touches the rows, or it reads members that are not columns — a row the
2163
+ * controller serialised on the way, or computed getters. Whole, and said so.
2164
+ */
2165
+ for (const { store } of readable) {
2166
+ if (reading.columns.has(store) || reading.unreadable.has(store)) continue;
2167
+ const members = [...reading.unknownMembers.get(store) ?? []];
2168
+ 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}`);
2169
+ }
2170
+ }
2171
+ return reading;
2172
+ }
2173
+ function markUnreadable(reading, store, reason) {
2174
+ if (!reading.unreadable.has(store)) reading.unreadable.set(store, reason);
2175
+ }
2176
+ const SKIPPED_DIRS = new Set([
2177
+ "node_modules",
2178
+ "dist",
2179
+ "build",
2180
+ ".git",
2181
+ "coverage",
2182
+ "tmp",
2183
+ ".adonisjs"
2184
+ ]);
2185
+ /**
2186
+ * `inertia.render('livros/index')` → `inertia/pages/livros/index.tsx` (the default),
2187
+ * `app/<first>/ui/pages/<rest>.tsx` (a domain-module layout), or any `pages/` directory
2188
+ * under the root holding that path. The `resolve` function of the front-end is not
2189
+ * run — a convention is read, a function is not.
2190
+ */
2191
+ function inertiaPageFiles(root, page) {
2192
+ const found = /* @__PURE__ */ new Set();
2193
+ const [first, ...rest] = page.split("/");
2194
+ if (rest.length > 0) for (const extension of [
2195
+ ".tsx",
2196
+ ".jsx",
2197
+ ".vue",
2198
+ ".svelte"
2199
+ ]) {
2200
+ const file = join(root, "app", first, "ui", "pages", `${rest.join("/")}${extension}`);
2201
+ if (existsSync(file)) found.add(file);
2202
+ }
2203
+ for (const dir of pagesDirectories(root)) for (const extension of [
2204
+ ".tsx",
2205
+ ".jsx",
2206
+ ".vue",
2207
+ ".svelte"
2208
+ ]) {
2209
+ const file = join(dir, `${page}${extension}`);
2210
+ if (existsSync(file)) found.add(file);
2211
+ const index = join(dir, page, `index${extension}`);
2212
+ if (existsSync(index)) found.add(index);
2213
+ }
2214
+ return [...found].sort();
2215
+ }
2216
+ let pagesDirsCache = /* @__PURE__ */ new Map();
2217
+ function pagesDirectories(root) {
2218
+ const cached = pagesDirsCache.get(root);
2219
+ if (cached) return cached;
2220
+ const dirs = [];
2221
+ const walk = (dir, depth) => {
2222
+ if (depth > 6) return;
2223
+ let entries;
2224
+ try {
2225
+ entries = readdirSync(dir);
2226
+ } catch {
2227
+ return;
2228
+ }
2229
+ for (const entry of entries) {
2230
+ if (SKIPPED_DIRS.has(entry) || entry.startsWith(".")) continue;
2231
+ const full = join(dir, entry);
2232
+ let isDirectory = false;
2233
+ try {
2234
+ isDirectory = statSync(full).isDirectory();
2235
+ } catch {
2236
+ continue;
2237
+ }
2238
+ if (!isDirectory) continue;
2239
+ if (entry === "pages") dirs.push(full);
2240
+ else walk(full, depth + 1);
2241
+ }
2242
+ };
2243
+ walk(root, 0);
2244
+ pagesDirsCache.set(root, dirs);
2245
+ return dirs;
2246
+ }
2247
+ /** `view.render('catalogo')` → `resources/views/catalogo.edge` */
2248
+ function edgeFiles(root, page) {
2249
+ const file = join(root, "resources", "views", `${page.replace(/\./g, "/")}.edge`);
2250
+ return existsSync(file) ? [file] : [];
2251
+ }
2252
+ /** tsconfig `paths` of the application, for the page's own imports (`~/components/x`) */
2253
+ function aliasesOf(root) {
2254
+ const aliases = /* @__PURE__ */ new Map();
2255
+ for (const config of [join(root, "tsconfig.json"), join(root, "inertia", "tsconfig.json")]) {
2256
+ if (!existsSync(config)) continue;
2257
+ try {
2258
+ const text = readFileSync(config, "utf8").replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "");
2259
+ const paths = JSON.parse(text)?.compilerOptions?.paths;
2260
+ for (const [alias, targets] of Object.entries(paths ?? {})) {
2261
+ const target = targets[0];
2262
+ if (!target) continue;
2263
+ aliases.set(alias.replace(/\*$/, ""), resolve(dirname(config), target.replace(/\*$/, "")));
2264
+ }
2265
+ } catch {}
2266
+ }
2267
+ if (!aliases.has("~/")) aliases.set("~/", join(root, "inertia"));
2268
+ return aliases;
2269
+ }
2270
+ var PageReader = class {
2271
+ aliases;
2272
+ constructor(env, reading) {
2273
+ this.env = env;
2274
+ this.reading = reading;
2275
+ this.aliases = aliasesOf(env.root);
2276
+ }
2277
+ readTsx(file, deliveries) {
2278
+ const source = this.env.project.addSourceFileAtPathIfExists(file);
2279
+ const component = source ? defaultComponentOf(source) : null;
2280
+ if (!source || !component) {
2281
+ for (const d of deliveries) this.fail(d.store, `page "${deliveries[0].page}" has no default-exported component the reader can open`);
2282
+ return;
2283
+ }
2284
+ const scope = this.bindProps(component, deliveries.map((d) => ({
2285
+ name: d.path.split(".")[0],
2286
+ entity: {
2287
+ store: d.store,
2288
+ rest: d.path.split(".").slice(1)
2289
+ }
2290
+ })));
2291
+ this.readComponent(component, scope, 0, deliveries[0].page);
2292
+ }
2293
+ /** the component's first parameter: `{ livros }`, `{ livros: rows }`, or `props` */
2294
+ bindProps(component, props) {
2295
+ const scope = /* @__PURE__ */ new Map();
2296
+ if (!Node.isParametered(component)) return scope;
2297
+ const parameter = component.getParameters()[0];
2298
+ if (!parameter) return scope;
2299
+ const nameNode = parameter.getNameNode();
2300
+ if (Node.isObjectBindingPattern(nameNode)) for (const element of nameNode.getElements()) {
2301
+ const property = element.getPropertyNameNode()?.getText() ?? element.getName();
2302
+ const prop = props.find((p) => p.name === property);
2303
+ if (prop) scope.set(element.getName(), prop.entity);
2304
+ }
2305
+ else if (Node.isIdentifier(nameNode)) for (const prop of props) scope.set(`${nameNode.getText()}.${prop.name}`, prop.entity);
2306
+ return scope;
2307
+ }
2308
+ readComponent(component, scope, depth, page) {
2309
+ const body = Node.isParametered(component) && "getBody" in component ? component.getBody() ?? component : component;
2310
+ const file = component.getSourceFile();
2311
+ /** the entity an expression holds, recording a column when the expression IS one */
2312
+ const entityOf = (node, visiting = 0) => {
2313
+ if (!node || visiting > 12) return null;
2314
+ let current = node;
2315
+ while (Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current) || Node.isAsExpression(current) || Node.isAwaitExpression(current)) current = current.getExpression();
2316
+ if (Node.isIdentifier(current)) return scope.get(current.getText()) ?? null;
2317
+ if (Node.isPropertyAccessExpression(current)) {
2318
+ const receiver = current.getExpression();
2319
+ if (Node.isIdentifier(receiver)) {
2320
+ const asPath = scope.get(`${receiver.getText()}.${current.getName()}`);
2321
+ if (asPath) return asPath;
2322
+ }
2323
+ const holder = entityOf(receiver, visiting + 1);
2324
+ if (!holder) return null;
2325
+ return this.member(holder, current.getName());
2326
+ }
2327
+ if (Node.isElementAccessExpression(current)) {
2328
+ const holder = entityOf(current.getExpression(), visiting + 1);
2329
+ if (!holder) return null;
2330
+ const argument = current.getArgumentExpression();
2331
+ if (argument && Node.isStringLiteral(argument)) return this.member(holder, argument.getLiteralValue());
2332
+ return holder;
2333
+ }
2334
+ if (Node.isCallExpression(current)) {
2335
+ const callee = current.getExpression();
2336
+ if (Node.isPropertyAccessExpression(callee)) {
2337
+ const holder = entityOf(callee.getExpression(), visiting + 1);
2338
+ if (holder && holder.rest.length === 0 && SAME_ROWS.has(callee.getName())) return holder;
2339
+ if (holder && holder.rest.length === 0 && ITERATES_ROWS$1.has(callee.getName())) return null;
2340
+ if (holder && holder.rest.length === 0 && callee.getName() === "length") return null;
2341
+ if (holder) return null;
2342
+ }
2343
+ return null;
2344
+ }
2345
+ if (Node.isConditionalExpression(current)) {
2346
+ entityOf(current.getCondition(), visiting + 1);
2347
+ return entityOf(current.getWhenTrue(), visiting + 1) ?? entityOf(current.getWhenFalse(), visiting + 1);
2348
+ }
2349
+ if (Node.isBinaryExpression(current)) {
2350
+ const operator = current.getOperatorToken().getKind();
2351
+ if (operator === SyntaxKind.AmpersandAmpersandToken) {
2352
+ entityOf(current.getLeft(), visiting + 1);
2353
+ return entityOf(current.getRight(), visiting + 1);
2354
+ }
2355
+ if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) return entityOf(current.getLeft(), visiting + 1) ?? entityOf(current.getRight(), visiting + 1);
2356
+ entityOf(current.getLeft(), visiting + 1);
2357
+ entityOf(current.getRight(), visiting + 1);
2358
+ return null;
2359
+ }
2360
+ if (Node.isTemplateExpression(current)) {
2361
+ for (const span of current.getTemplateSpans()) entityOf(span.getExpression(), visiting + 1);
2362
+ return null;
2363
+ }
2364
+ return null;
2365
+ };
2366
+ const escape = (entity, reason) => {
2367
+ const store = entity.rest.length === 0 ? entity.store : entity.store;
2368
+ this.fail(store, `page "${page}": ${reason}`);
2369
+ };
2370
+ body.forEachDescendant((node) => {
2371
+ if (Node.isVariableDeclaration(node)) {
2372
+ const initializer = node.getInitializer();
2373
+ const nameNode = node.getNameNode();
2374
+ if (!initializer) return;
2375
+ const entity = entityOf(initializer);
2376
+ if (!entity) return;
2377
+ if (Node.isIdentifier(nameNode)) scope.set(nameNode.getText(), entity);
2378
+ else if (Node.isObjectBindingPattern(nameNode)) for (const element of nameNode.getElements()) {
2379
+ const property = element.getPropertyNameNode()?.getText() ?? element.getName();
2380
+ const member = this.member(entity, property);
2381
+ if (member) scope.set(element.getName(), member);
2382
+ }
2383
+ return;
2384
+ }
2385
+ if (Node.isCallExpression(node)) {
2386
+ const callee = node.getExpression();
2387
+ if (Node.isPropertyAccessExpression(callee) && ITERATES_ROWS$1.has(callee.getName())) {
2388
+ const holder = entityOf(callee.getExpression());
2389
+ const callback = node.getArguments()[0];
2390
+ if (holder && holder.rest.length === 0 && callback && (Node.isArrowFunction(callback) || Node.isFunctionExpression(callback))) {
2391
+ const parameter = callback.getParameters()[0]?.getNameNode();
2392
+ if (parameter && Node.isIdentifier(parameter)) scope.set(parameter.getText(), holder);
2393
+ }
2394
+ return;
2395
+ }
2396
+ for (const argument of node.getArguments()) {
2397
+ const entity = entityOf(argument);
2398
+ if (entity && !Node.isPropertyAccessExpression(unwrapParens(argument))) escape(entity, `\`${node.getExpression().getText()}(…)\` receives the rows and the reader cannot follow a function`);
2399
+ else if (entity) escape(entity, `\`${node.getExpression().getText()}(…)\` receives the rows and the reader cannot follow a function`);
2400
+ }
2401
+ return;
2402
+ }
2403
+ if (Node.isForOfStatement(node)) {
2404
+ const declared = node.getInitializer();
2405
+ const entity = entityOf(node.getExpression());
2406
+ if (entity && Node.isVariableDeclarationList(declared)) {
2407
+ const nameNode = declared.getDeclarations()[0]?.getNameNode();
2408
+ if (nameNode && Node.isIdentifier(nameNode)) scope.set(nameNode.getText(), entity);
2409
+ }
2410
+ return;
2411
+ }
2412
+ if (Node.isSpreadAssignment(node) || Node.isJsxSpreadAttribute(node) || Node.isSpreadElement(node)) {
2413
+ const entity = entityOf(node.getExpression());
2414
+ if (entity) escape(entity, "a spread hands the whole row on");
2415
+ return;
2416
+ }
2417
+ if (Node.isJsxExpression(node)) {
2418
+ const expression = node.getExpression();
2419
+ const parent = node.getParent();
2420
+ if (!expression || Node.isJsxAttribute(parent)) return;
2421
+ const entity = entityOf(expression);
2422
+ if (entity) escape(entity, `\`{${expression.getText().replace(/\s+/g, " ").slice(0, 60)}}\` renders the row itself`);
2423
+ return;
2424
+ }
2425
+ if (Node.isJsxAttribute(node)) {
2426
+ const initializer = node.getInitializer();
2427
+ const entity = entityOf(initializer && Node.isJsxExpression(initializer) ? initializer.getExpression() : void 0);
2428
+ if (!entity) return;
2429
+ const element = node.getFirstAncestor((n) => Node.isJsxOpeningElement(n) || Node.isJsxSelfClosingElement(n));
2430
+ const tag = element && (Node.isJsxOpeningElement(element) || Node.isJsxSelfClosingElement(element)) ? element.getTagNameNode().getText() : "";
2431
+ if (!/^[A-Z]/.test(tag)) return;
2432
+ if (depth >= MAX_COMPONENT_DEPTH) {
2433
+ escape(entity, `\`<${tag} ${node.getNameNode().getText()}={…} />\` is a second level of components; the reader follows one`);
2434
+ return;
2435
+ }
2436
+ const child = this.componentOf(tag, file);
2437
+ if (!child) {
2438
+ escape(entity, `\`<${tag} />\` is not a component of the application the reader can open`);
2439
+ return;
2440
+ }
2441
+ const childScope = this.bindProps(child, [{
2442
+ name: node.getNameNode().getText(),
2443
+ entity
2444
+ }]);
2445
+ this.readComponent(child, childScope, depth + 1, page);
2446
+ return;
2447
+ }
2448
+ if (Node.isReturnStatement(node) && depth === 0) {
2449
+ const expression = node.getExpression();
2450
+ if (expression && !Node.isJsxElement(expression) && !Node.isJsxFragment(expression) && !Node.isJsxSelfClosingElement(expression) && !Node.isParenthesizedExpression(expression)) {
2451
+ const entity = entityOf(expression);
2452
+ if (entity) escape(entity, `\`return ${expression.getText().replace(/\s+/g, " ").slice(0, 60)}\` hands the rows back as they came`);
2453
+ }
2454
+ }
2455
+ });
2456
+ }
2457
+ /** `entity.name`: one column (recorded), a relation (the target), a wrapper key, or nothing */
2458
+ member(entity, name) {
2459
+ if (entity.rest.length > 0) return entity.rest[0] === name ? {
2460
+ store: entity.store,
2461
+ rest: entity.rest.slice(1)
2462
+ } : null;
2463
+ if (PASSES_THROUGH.has(name)) return entity;
2464
+ if (this.env.stores.get(entity.store)?.attributes.some((a) => a.name === name)) {
2465
+ this.column(entity.store, name);
2466
+ return null;
2467
+ }
2468
+ const target = this.env.relations.get(entity.store)?.[name];
2469
+ if (target) return {
2470
+ store: target,
2471
+ rest: []
2472
+ };
2473
+ if (![
2474
+ "length",
2475
+ "id",
2476
+ "map",
2477
+ "filter",
2478
+ "find",
2479
+ "some",
2480
+ "every",
2481
+ "forEach",
2482
+ "slice",
2483
+ "sort"
2484
+ ].includes(name)) {
2485
+ const members = this.reading.unknownMembers.get(entity.store) ?? /* @__PURE__ */ new Set();
2486
+ members.add(name);
2487
+ this.reading.unknownMembers.set(entity.store, members);
2488
+ }
2489
+ return null;
2490
+ }
2491
+ /** the component a JSX tag names, in this file or through its imports (tsconfig paths, relative) */
2492
+ componentOf(tag, file) {
2493
+ const local = file.getFunction(tag) ?? functionVariable(file, tag);
2494
+ if (local) return local;
2495
+ for (const declaration of file.getImportDeclarations()) {
2496
+ if (![declaration.getDefaultImport()?.getText(), ...declaration.getNamedImports().map((n) => n.getAliasNode()?.getText() ?? n.getName())].includes(tag)) continue;
2497
+ const target = this.resolveImport(declaration.getModuleSpecifierValue(), file.getFilePath());
2498
+ if (!target) return null;
2499
+ const source = this.env.project.addSourceFileAtPathIfExists(target);
2500
+ if (!source) return null;
2501
+ const exported = declaration.getNamedImports().find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === tag)?.getName() ?? tag;
2502
+ return source.getFunction(exported) ?? functionVariable(source, exported) ?? (declaration.getDefaultImport()?.getText() === tag ? defaultComponentOf(source) : null);
2503
+ }
2504
+ return null;
2505
+ }
2506
+ resolveImport(specifier, from) {
2507
+ let base = this.env.resolveSpecifier(specifier);
2508
+ if (base) base = base.replace(/\.(ts|tsx|js|jsx)$/, "");
2509
+ if (!base && specifier.startsWith(".")) base = resolve(dirname(from), specifier);
2510
+ else for (const [alias, dir] of this.aliases) if (specifier.startsWith(alias)) base = join(dir, specifier.slice(alias.length));
2511
+ if (!base) return null;
2512
+ for (const candidate of [
2513
+ base,
2514
+ `${base}.tsx`,
2515
+ `${base}.ts`,
2516
+ `${base}.jsx`,
2517
+ join(base, "index.tsx"),
2518
+ join(base, "index.ts")
2519
+ ]) if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
2520
+ return null;
2521
+ }
2522
+ readEdge(file, deliveries, depth = 0, scope) {
2523
+ const text = readFileSync(file, "utf8");
2524
+ const page = deliveries[0].page;
2525
+ const bound = scope ?? /* @__PURE__ */ new Map();
2526
+ if (!scope) for (const d of deliveries) bound.set(d.path.split(".")[0], {
2527
+ store: d.store,
2528
+ rest: d.path.split(".").slice(1)
2529
+ });
2530
+ const entityOf = (expression) => {
2531
+ const parts = expression.trim().replace(/\?\./g, ".").split(".");
2532
+ let entity = bound.get(parts[0]) ?? null;
2533
+ for (const part of parts.slice(1)) {
2534
+ if (!entity) return null;
2535
+ entity = this.member(entity, part.replace(/\(.*$/, ""));
2536
+ }
2537
+ return entity;
2538
+ };
2539
+ const read = (expression) => {
2540
+ for (const chain of expression.match(/[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+/g) ?? []) {
2541
+ const entity = entityOf(chain);
2542
+ if (entity) this.fail(entity.store, `page "${page}": \`${chain}\` is a row handed on, not a column`);
2543
+ }
2544
+ const bare = expression.trim();
2545
+ if (/^[A-Za-z_$][\w$]*$/.test(bare) && bound.has(bare)) this.fail(bound.get(bare).store, `page "${page}": \`{{ ${bare} }}\` renders the rows themselves`);
2546
+ };
2547
+ for (const match of text.matchAll(/@each\(\s*\(?\s*([A-Za-z_$][\w$]*)(?:\s*,\s*[A-Za-z_$][\w$]*)?\s*\)?\s+in\s+([^)]+)\)/g)) {
2548
+ const entity = entityOf(match[2]);
2549
+ if (entity) bound.set(match[1], entity);
2550
+ }
2551
+ for (const match of text.matchAll(/\{\{\{?\s*([\s\S]*?)\s*\}?\}\}/g)) read(match[1]);
2552
+ for (const match of text.matchAll(/@(?:if|elseif|unless)\(([^)]*)\)/g)) read(match[1]);
2553
+ for (const match of text.matchAll(/@!?(?:include|component|layout)\(\s*['"]([^'"]+)['"]/g)) {
2554
+ const included = join(this.env.root, "resources", "views", `${match[1].replace(/\./g, "/")}.edge`);
2555
+ if (!existsSync(included)) continue;
2556
+ if (depth >= MAX_COMPONENT_DEPTH) {
2557
+ 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`);
2558
+ continue;
2559
+ }
2560
+ this.readEdge(included, deliveries, depth + 1, new Map(bound));
2561
+ }
2562
+ }
2563
+ column(store, name) {
2564
+ const columns = this.reading.columns.get(store) ?? /* @__PURE__ */ new Set();
2565
+ columns.add(name);
2566
+ this.reading.columns.set(store, columns);
2567
+ }
2568
+ fail(store, reason) {
2569
+ markUnreadable(this.reading, store, reason);
2570
+ }
2571
+ };
2572
+ function unwrapParens(node) {
2573
+ let current = node;
2574
+ while (Node.isParenthesizedExpression(current) || Node.isNonNullExpression(current)) current = current.getExpression();
2575
+ return current;
2576
+ }
2577
+ /** `export default function Page(…)`, or `export default Page` naming a function or an arrow */
2578
+ function defaultComponentOf(file) {
2579
+ const declared = file.getFunctions().find((f) => f.isDefaultExport());
2580
+ if (declared) return declared;
2581
+ for (const assignment of file.getExportAssignments()) {
2582
+ const expression = assignment.getExpression();
2583
+ if (Node.isIdentifier(expression)) return file.getFunction(expression.getText()) ?? functionVariable(file, expression.getText());
2584
+ if (Node.isArrowFunction(expression) || Node.isFunctionExpression(expression)) return expression;
2585
+ if (Node.isCallExpression(expression)) {
2586
+ const inner = expression.getArguments()[0];
2587
+ if (inner && Node.isIdentifier(inner)) return file.getFunction(inner.getText()) ?? functionVariable(file, inner.getText());
2588
+ }
2589
+ }
2590
+ return null;
2591
+ }
2592
+ /** `const Page = (props) => …` / `function (…) {}` */
2593
+ function functionVariable(file, name) {
2594
+ const initializer = file.getVariableDeclaration(name)?.getInitializer();
2595
+ return initializer && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) ? initializer : null;
2596
+ }
2597
+ //#endregion
2076
2598
  //#region src/inventory/graph/call_graph.ts
2077
2599
  /**
2078
2600
  * Fields declared by the validators used in this body.
@@ -2427,16 +2949,188 @@ function createAnalyzer(app, stores, options = {}) {
2427
2949
  factsCache.set(key, facts);
2428
2950
  return facts;
2429
2951
  };
2952
+ /** store name by the posix path of the model that declares it */
2953
+ const storeByFile = /* @__PURE__ */ new Map();
2954
+ for (const [name, store] of storesByName) storeByFile.set(toPosix(store.provenance.file), name);
2955
+ /**
2956
+ * The guard's user: `auth.user`, `auth.getUserOrFail()` are rows of the model
2957
+ * `config/auth.ts` names in its provider (`model: () => import('#models/user')`).
2958
+ * Read, never assumed — an application with no such config binds nothing.
2959
+ */
2960
+ const authUserStore = (() => {
2961
+ const config = project.addSourceFileAtPathIfExists(`${toPosix(app.root)}/config/auth.ts`);
2962
+ if (!config) return null;
2963
+ for (const call of config.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2964
+ if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
2965
+ if (call.getFirstAncestorByKind(SyntaxKind.PropertyAssignment)?.getName() !== "model") continue;
2966
+ const specifier = call.getArguments()[0];
2967
+ if (!specifier || !Node.isStringLiteral(specifier)) continue;
2968
+ const target = app.resolveSpecifier(specifier.getLiteralValue());
2969
+ const store = target ? storeByFile.get(toPosix(target)) : void 0;
2970
+ if (store) return store;
2971
+ }
2972
+ return null;
2973
+ })();
2974
+ /**
2975
+ * What a body's locals ARE — plan 0.8 §A. `storeSymbolsFor` binds what the
2976
+ * parameters and the static imports say; this pass, run in source order before
2977
+ * the accesses are detected, binds what the body's own statements say:
2978
+ *
2979
+ * const s = await this.sessoes.ativa(d) the followed body's return type,
2980
+ * or its returns when unannotated
2981
+ * const n = id ? await N.find(id) : new N() a conditional, both branches N
2982
+ * const p = (await P.first()) ?? new P() a default, both operands P
2983
+ * const u = auth.getUserOrFail() the guard's model, per config/auth.ts
2984
+ * const pasta = documento.pasta a relation read off a loaded row
2985
+ * for (const d of pasta.documentos) a row of the relation's target
2986
+ * rows.map(async (d) => …) the callback's parameter, a row
2987
+ *
2988
+ * Every one is a reading of what the code declares; none is a guess. A `save()`
2989
+ * on a local none of this can type is reported as unresolved — not dropped.
2990
+ */
2991
+ function bindLocals(body, symbols, context, strategies) {
2992
+ const returnedStoreOf = (call, from = context, depth = 0) => {
2993
+ if (depth > 2) return null;
2994
+ const resolved = resolveCall(call, from, strategies);
2995
+ if (!resolved || resolved.refs.length === 0) return null;
2996
+ const found = /* @__PURE__ */ new Set();
2997
+ for (const target of resolved.refs) {
2998
+ const source = sourceFile(target.file);
2999
+ const resolvedBody = source ? findBody(source, target) : null;
3000
+ if (!resolvedBody) continue;
3001
+ const annotation = Node.isReturnTyped(resolvedBody) ? resolvedBody.getReturnTypeNode()?.getText() : void 0;
3002
+ if (annotation) {
3003
+ const store = storeNamedBy(annotation, storesByName);
3004
+ if (store) found.add(store);
3005
+ else return null;
3006
+ continue;
3007
+ }
3008
+ const own = storeSymbolsFor(resolvedBody, source, app, storesByName, relationsByStore);
3009
+ const returns = resolvedBody.getDescendantsOfKind(SyntaxKind.ReturnStatement).filter((r) => r.getFirstAncestor((n) => Node.isMethodDeclaration(n) || Node.isFunctionDeclaration(n) || Node.isArrowFunction(n) || Node.isFunctionExpression(n)) === resolvedBody);
3010
+ if (returns.length === 0) return null;
3011
+ for (const statement of returns) {
3012
+ const value = statement.getExpression();
3013
+ if (!value) return null;
3014
+ const unwrapped = unwrapAwait(value);
3015
+ if (unwrapped.getKind() === SyntaxKind.NullKeyword) continue;
3016
+ if (Node.isCallExpression(unwrapped) && !detectAccess(unwrapped, own, relationsByStore)) {
3017
+ const store = returnedStoreOf(unwrapped, {
3018
+ ...context,
3019
+ file: source,
3020
+ ...importsFor(source),
3021
+ injected: injectedFor(resolvedBody.getFirstAncestorByKind(SyntaxKind.ClassDeclaration), source, app)
3022
+ }, depth + 1);
3023
+ if (!store) return null;
3024
+ found.add(store);
3025
+ continue;
3026
+ }
3027
+ 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);
3028
+ if (!store) return null;
3029
+ found.add(store);
3030
+ }
3031
+ }
3032
+ return found.size === 1 ? [...found][0] : null;
3033
+ };
3034
+ const isAuthUser = (node) => {
3035
+ if (!authUserStore) return false;
3036
+ const chain = Node.isCallExpression(node) ? node.getExpression() : node;
3037
+ if (!Node.isPropertyAccessExpression(chain)) return false;
3038
+ if (rootSymbolOf(chain) !== "auth" && lastSegmentText(chain.getExpression()) !== "auth") return false;
3039
+ const last = chain.getName();
3040
+ return last === "user" || last === "getUserOrFail" || last === "authenticate";
3041
+ };
3042
+ /** the store a value is rows (or one row) of, by what the code says — or nothing */
3043
+ const storeOfValue = (value, depth = 0) => {
3044
+ if (!value || depth > 6) return null;
3045
+ const node = unwrapAwait(value);
3046
+ if (node.getKind() === SyntaxKind.NullKeyword) return null;
3047
+ if (Node.isIdentifier(node)) {
3048
+ if (node.getText() === "undefined") return null;
3049
+ return symbols.get(node.getText()) ?? null;
3050
+ }
3051
+ if (Node.isNewExpression(node)) {
3052
+ const name = node.getExpression().getText();
3053
+ return storesByName.has(name) ? name : null;
3054
+ }
3055
+ if (Node.isConditionalExpression(node)) return sameStore(storeOfValue(node.getWhenTrue(), depth + 1), storeOfValue(node.getWhenFalse(), depth + 1), node.getWhenTrue(), node.getWhenFalse());
3056
+ if (Node.isBinaryExpression(node)) {
3057
+ const operator = node.getOperatorToken().getKind();
3058
+ if (operator === SyntaxKind.QuestionQuestionToken || operator === SyntaxKind.BarBarToken) return sameStore(storeOfValue(node.getLeft(), depth + 1), storeOfValue(node.getRight(), depth + 1), node.getLeft(), node.getRight());
3059
+ return null;
3060
+ }
3061
+ if (Node.isElementAccessExpression(node)) return storeOfValue(node.getExpression(), depth + 1);
3062
+ if (isAuthUser(node)) return authUserStore;
3063
+ if (Node.isPropertyAccessExpression(node)) return storeOfExpression(node, symbols, relationsByStore);
3064
+ if (Node.isCallExpression(node)) {
3065
+ const access = detectAccess(node, symbols, relationsByStore);
3066
+ if (access) return access.method === "related" && access.viaRelation ? access.viaRelation : access.store;
3067
+ const callee = node.getExpression();
3068
+ if (Node.isPropertyAccessExpression(callee) && ONE_OF_ROWS.has(callee.getName())) return storeOfValue(callee.getExpression(), depth + 1);
3069
+ return returnedStoreOf(node);
3070
+ }
3071
+ return null;
3072
+ };
3073
+ const isNullish = (node) => {
3074
+ const inner = unwrapAwait(node);
3075
+ return inner.getKind() === SyntaxKind.NullKeyword || Node.isIdentifier(inner) && inner.getText() === "undefined";
3076
+ };
3077
+ const sameStore = (a, b, left, right) => {
3078
+ if (a && b) return a === b ? a : null;
3079
+ if (a && isNullish(right)) return a;
3080
+ if (b && isNullish(left)) return b;
3081
+ return null;
3082
+ };
3083
+ body.forEachDescendant((node) => {
3084
+ if (Node.isVariableDeclaration(node)) {
3085
+ const initializer = node.getInitializer();
3086
+ const nameNode = node.getNameNode();
3087
+ if (!initializer) return;
3088
+ if (Node.isIdentifier(nameNode)) {
3089
+ if (symbols.has(nameNode.getText())) return;
3090
+ const store = storeOfValue(initializer);
3091
+ if (store) symbols.set(nameNode.getText(), store);
3092
+ }
3093
+ return;
3094
+ }
3095
+ if (Node.isForOfStatement(node)) {
3096
+ const declared = node.getInitializer();
3097
+ if (!Node.isVariableDeclarationList(declared)) return;
3098
+ const nameNode = declared.getDeclarations()[0]?.getNameNode();
3099
+ if (!nameNode || !Node.isIdentifier(nameNode) || symbols.has(nameNode.getText())) return;
3100
+ const store = storeOfValue(node.getExpression());
3101
+ if (store) symbols.set(nameNode.getText(), store);
3102
+ return;
3103
+ }
3104
+ if (Node.isCallExpression(node)) {
3105
+ const callee = node.getExpression();
3106
+ if (!Node.isPropertyAccessExpression(callee) || !ITERATES_ROWS.has(callee.getName())) return;
3107
+ const callback = node.getArguments()[0];
3108
+ if (!callback || !(Node.isArrowFunction(callback) || Node.isFunctionExpression(callback))) return;
3109
+ const parameter = callback.getParameters()[0]?.getNameNode();
3110
+ if (!parameter || !Node.isIdentifier(parameter) || symbols.has(parameter.getText())) return;
3111
+ const store = storeOfValue(callee.getExpression());
3112
+ if (store) symbols.set(parameter.getText(), store);
3113
+ }
3114
+ });
3115
+ }
2430
3116
  function computeFacts(ref) {
2431
3117
  const file = sourceFile(ref.file);
2432
3118
  if (!file) return null;
2433
3119
  const body = findBody(file, ref);
2434
3120
  if (!body) return null;
2435
3121
  const { imports, exportedAs } = importsFor(file);
3122
+ /** locals imported from packages, not from the application: their objects are not stores */
3123
+ const packageImports = /* @__PURE__ */ new Set();
3124
+ for (const declaration of file.getImportDeclarations()) {
3125
+ if (app.resolveSpecifier(declaration.getModuleSpecifierValue())) continue;
3126
+ const defaultImport = declaration.getDefaultImport()?.getText();
3127
+ if (defaultImport) packageImports.add(defaultImport);
3128
+ for (const named of declaration.getNamedImports()) packageImports.add(named.getAliasNode()?.getText() ?? named.getName());
3129
+ }
2436
3130
  /** the class this body belongs to: how `this.something` resolves */
2437
3131
  const owner = body.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
2438
3132
  const injected = injectedFor(owner, file, app);
2439
- const symbols = storeSymbolsFor(body, file, app, storesByName);
3133
+ const symbols = storeSymbolsFor(body, file, app, storesByName, relationsByStore);
2440
3134
  const accesses = [];
2441
3135
  const followUps = [];
2442
3136
  const unresolved = [];
@@ -2457,6 +3151,7 @@ function createAnalyzer(app, stores, options = {}) {
2457
3151
  resolveSpecifier: app.resolveSpecifier,
2458
3152
  sourceFile
2459
3153
  };
3154
+ bindLocals(body, symbols, context, resolvers);
2460
3155
  for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2461
3156
  const access = detectAccess(call, symbols, relationsByStore);
2462
3157
  if (access) {
@@ -2552,6 +3247,21 @@ function createAnalyzer(app, stores, options = {}) {
2552
3247
  followedCalls.set(call, resolved.refs);
2553
3248
  continue;
2554
3249
  }
3250
+ /**
3251
+ * `alvo.save()` on a receiver nobody could type. Not a write the count can
3252
+ * attribute — and not silence either: the reviewed application had six EIs
3253
+ * counted as EOs under a coverage of 99.5%, because a write on an unknown
3254
+ * local was dropped without a word. It lowers coverage and is named.
3255
+ */
3256
+ if (isUnreadableWrite(call, symbols, imports, packageImports, body) && !isNoise(call, owner)) {
3257
+ unresolved.push({
3258
+ file: ref.file,
3259
+ line: call.getStartLineNumber(),
3260
+ expression: call.getExpression().getText().replace(/\s+/g, ""),
3261
+ reason: "write on a receiver whose type the analysis cannot read"
3262
+ });
3263
+ continue;
3264
+ }
2555
3265
  if (isWorthReporting(call, symbols, imports) && !isNoise(call, owner)) unresolved.push({
2556
3266
  file: ref.file,
2557
3267
  line: call.getStartLineNumber(),
@@ -2631,7 +3341,7 @@ function createAnalyzer(app, stores, options = {}) {
2631
3341
  */
2632
3342
  if (!isApplicationCode(app.root, file.getFilePath())) {
2633
3343
  if (isSeeder(app.root, file.getFilePath())) {
2634
- const symbols = storeSymbolsFor(file, file, app, storesByName);
3344
+ const symbols = storeSymbolsFor(file, file, app, storesByName, relationsByStore);
2635
3345
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2636
3346
  const access = symbols.size > 0 ? detectAccess(call, symbols, relationsByStore) : null;
2637
3347
  if (access?.mode !== "write") continue;
@@ -2641,7 +3351,7 @@ function createAnalyzer(app, stores, options = {}) {
2641
3351
  }
2642
3352
  continue;
2643
3353
  }
2644
- const symbols = storeSymbolsFor(file, file, app, storesByName);
3354
+ const symbols = storeSymbolsFor(file, file, app, storesByName, relationsByStore);
2645
3355
  if (symbols.size === 0) continue;
2646
3356
  for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2647
3357
  const access = detectAccess(call, symbols, relationsByStore);
@@ -2695,6 +3405,8 @@ function createAnalyzer(app, stores, options = {}) {
2695
3405
  const deliveredFields = /* @__PURE__ */ new Set();
2696
3406
  const deliveredOpaque = /* @__PURE__ */ new Set();
2697
3407
  const deliveredStores = /* @__PURE__ */ new Set();
3408
+ /** stores handed raw to a page: what the page shows of them is read after the walk */
3409
+ const rawDeliveries = [];
2698
3410
  let anyDelivery = false;
2699
3411
  const outputReads = /* @__PURE__ */ new Map();
2700
3412
  const trace = [];
@@ -2725,6 +3437,11 @@ function createAnalyzer(app, stores, options = {}) {
2725
3437
  switch (item.kind) {
2726
3438
  case "store":
2727
3439
  deliveredStores.add(item.store);
3440
+ if (item.via) rawDeliveries.push({
3441
+ ...item.via,
3442
+ store: item.store,
3443
+ path: item.path
3444
+ });
2728
3445
  return;
2729
3446
  case "scalar":
2730
3447
  deliveredFields.add(item.path || "<value>");
@@ -2760,7 +3477,8 @@ function createAnalyzer(app, stores, options = {}) {
2760
3477
  if (r.kind === "call") deliver({
2761
3478
  ...r,
2762
3479
  path: item.path,
2763
- pick: rest
3480
+ pick: rest,
3481
+ via: item.via
2764
3482
  }, depth + 1, seen);
2765
3483
  else if (r.kind === "store" || r.kind === "scalar") deliveredFields.add(item.path || "<value>");
2766
3484
  }
@@ -2780,7 +3498,8 @@ function createAnalyzer(app, stores, options = {}) {
2780
3498
  const path = [item.path, rest].filter(Boolean).join(".");
2781
3499
  deliver({
2782
3500
  ...r,
2783
- path
3501
+ path,
3502
+ via: item.via
2784
3503
  }, depth + 1, seen);
2785
3504
  }
2786
3505
  resolved = true;
@@ -2789,7 +3508,14 @@ function createAnalyzer(app, stores, options = {}) {
2789
3508
  if (item.pick) continue;
2790
3509
  const read = storesReadBy(ref, depth + 1);
2791
3510
  if (read.size > 0) {
2792
- for (const store of read) deliveredStores.add(store);
3511
+ for (const store of read) {
3512
+ deliveredStores.add(store);
3513
+ if (item.via) rawDeliveries.push({
3514
+ ...item.via,
3515
+ store,
3516
+ path: item.path
3517
+ });
3518
+ }
2793
3519
  resolved = true;
2794
3520
  }
2795
3521
  }
@@ -2809,7 +3535,10 @@ function createAnalyzer(app, stores, options = {}) {
2809
3535
  * received, so the rows' stores leave. Nothing handed in: opaque.
2810
3536
  */
2811
3537
  if (item.args.length > 0) {
2812
- for (const argument of item.args) deliver(argument, depth, seen);
3538
+ for (const argument of item.args) deliver({
3539
+ ...argument,
3540
+ via: item.via
3541
+ }, depth, seen);
2813
3542
  return;
2814
3543
  }
2815
3544
  deliveredOpaque.add(`${item.path ? `${item.path}.` : ""}<${item.expression}>`);
@@ -2903,6 +3632,18 @@ function createAnalyzer(app, stores, options = {}) {
2903
3632
  }
2904
3633
  };
2905
3634
  visit(handler, 0);
3635
+ /**
3636
+ * What each page shows of the stores handed to it raw — read once the walk is
3637
+ * done, because a query object's rows reach the page through the delivery of a
3638
+ * call, not of a variable (plan 0.8 §D).
3639
+ */
3640
+ const pages = readPages(rawDeliveries.filter((d) => !transformedStores.has(d.store)), {
3641
+ root: app.root,
3642
+ project,
3643
+ stores: storesByName,
3644
+ relations: relationsByStore,
3645
+ resolveSpecifier: app.resolveSpecifier
3646
+ });
2906
3647
  return {
2907
3648
  writes,
2908
3649
  touches: [...touches].sort(),
@@ -2921,6 +3662,8 @@ function createAnalyzer(app, stores, options = {}) {
2921
3662
  opaqueFields: [...deliveredOpaque].sort(),
2922
3663
  stores: [...deliveredStores].sort()
2923
3664
  },
3665
+ pageReads: Object.fromEntries([...pages.columns.entries()].map(([store, columns]) => [store, [...columns].sort()])),
3666
+ unreadablePages: Object.fromEntries(pages.unreadable),
2924
3667
  outputReads: Object.fromEntries([...outputReads.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([store, read]) => [store, {
2925
3668
  whole: read.whole,
2926
3669
  selected: [...read.selected].sort(),
@@ -2972,7 +3715,7 @@ function findBody(file, ref) {
2972
3715
  * from them: `const invite = await Invite.findOrFail(...)` makes `invite.save()`
2973
3716
  * count as a write to `Invite`.
2974
3717
  */
2975
- function storeSymbolsFor(body, file, app, stores) {
3718
+ function storeSymbolsFor(body, file, app, stores, relations = /* @__PURE__ */ new Map()) {
2976
3719
  const symbols = /* @__PURE__ */ new Map();
2977
3720
  for (const declaration of file.getImportDeclarations()) {
2978
3721
  if (!app.resolveSpecifier(declaration.getModuleSpecifierValue())) continue;
@@ -3007,7 +3750,24 @@ function storeSymbolsFor(body, file, app, stores) {
3007
3750
  for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
3008
3751
  const initializer = declaration.getInitializer();
3009
3752
  const name = declaration.getNameNode();
3010
- if (!initializer || !Node.isIdentifier(name)) continue;
3753
+ if (!Node.isIdentifier(name)) continue;
3754
+ const declared = storeNamedBy(declaration.getTypeNode()?.getText(), stores);
3755
+ if (declared) {
3756
+ symbols.set(name.getText(), declared);
3757
+ continue;
3758
+ }
3759
+ if (!initializer) continue;
3760
+ /**
3761
+ * `const pasta = documento.pasta`: a RELATION read off a row is the relation's
3762
+ * target, not the row's store — `pasta.save()` had been billed to `Documento`.
3763
+ * A plain field (`documento.nome`) is no store at all.
3764
+ */
3765
+ const plain = unwrapAwait(initializer);
3766
+ if (Node.isPropertyAccessExpression(plain)) {
3767
+ const target = storeOfExpression(plain, symbols, relations);
3768
+ if (target) symbols.set(name.getText(), target);
3769
+ continue;
3770
+ }
3011
3771
  const root = rootSymbolOf(initializer);
3012
3772
  const store = root ? symbols.get(root) : void 0;
3013
3773
  if (store) symbols.set(name.getText(), store);
@@ -3015,8 +3775,8 @@ function storeSymbolsFor(body, file, app, stores) {
3015
3775
  if (Node.isMethodDeclaration(body) || Node.isFunctionDeclaration(body)) for (const parameter of body.getParameters()) {
3016
3776
  const typeNode = parameter.getTypeNode();
3017
3777
  const nameNode = parameter.getNameNode();
3018
- const typeName = typeNode?.getText();
3019
- if (typeName && stores.has(typeName) && Node.isIdentifier(nameNode)) {
3778
+ const typeName = storeNamedBy(typeNode?.getText(), stores);
3779
+ if (typeName && Node.isIdentifier(nameNode)) {
3020
3780
  symbols.set(nameNode.getText(), typeName);
3021
3781
  continue;
3022
3782
  }
@@ -3027,32 +3787,56 @@ function storeSymbolsFor(body, file, app, stores) {
3027
3787
  * Registers the PATH `input.invite`, because that is how the write
3028
3788
  * appears: `input.invite.save()`.
3029
3789
  */
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);
3790
+ if (typeNode && Node.isIdentifier(nameNode)) for (const [property, propertyType] of membersOfType(typeNode, file, app)) {
3791
+ const named = storeNamedBy(propertyType, stores);
3792
+ if (named) symbols.set(`${nameNode.getText()}.${property}`, named);
3032
3793
  }
3033
3794
  /**
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.
3795
+ * Destructured form: `handle({ invite }: { invite: Invite })`, and
3796
+ * `handle({ document, name }: RenameDocumentInput)` with the interface named
3797
+ * — in this file or imported. The second is the dominant shape on a reviewed
3798
+ * application, and only the inline literal was read: every `document.save()`
3799
+ * behind it was invisible, and the transaction an EO (plan 0.8 §A).
3040
3800
  */
3041
3801
  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);
3802
+ if (!binding || !typeNode) continue;
3803
+ const propertyTypes = membersOfType(typeNode, file, app);
3804
+ for (const element of binding.getElements()) {
3805
+ const property = element.getPropertyNameNode()?.getText() ?? element.getName();
3806
+ const resolved = storeNamedBy(propertyTypes.get(property), stores);
3807
+ if (resolved) symbols.set(element.getName(), resolved);
3049
3808
  }
3809
+ }
3810
+ /**
3811
+ * `const { preIntake } = input` where `input.preIntake` is a registered path:
3812
+ * each element inherits the store of its path. Read AFTER the parameters, which
3813
+ * is where the paths come from.
3814
+ */
3815
+ for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
3816
+ const binding = declaration.getNameNode();
3817
+ const initializer = declaration.getInitializer();
3818
+ if (!Node.isObjectBindingPattern(binding) || !initializer) continue;
3819
+ const root = Node.isIdentifier(initializer) ? initializer.getText() : null;
3820
+ if (!root) continue;
3050
3821
  for (const element of binding.getElements()) {
3051
3822
  const property = element.getPropertyNameNode()?.getText() ?? element.getName();
3052
- const resolved = propertyTypes.get(property);
3053
- if (resolved && stores.has(resolved)) symbols.set(element.getName(), resolved);
3823
+ const store = symbols.get(`${root}.${property}`);
3824
+ if (store) symbols.set(element.getName(), store);
3054
3825
  }
3055
3826
  }
3827
+ /**
3828
+ * `for (const documento of pasta.documentos)`, `for (const row of rows)`: the
3829
+ * loop variable is a row of the relation's target, or of the rows' store. A
3830
+ * `delete()` inside such a loop was invisible; read last, after the parameters that name the parent — plan 0.8 §A.
3831
+ */
3832
+ for (const loop of body.getDescendantsOfKind(SyntaxKind.ForOfStatement)) {
3833
+ const declared = loop.getInitializer();
3834
+ if (!Node.isVariableDeclarationList(declared)) continue;
3835
+ const nameNode = declared.getDeclarations()[0]?.getNameNode();
3836
+ if (!nameNode || !Node.isIdentifier(nameNode)) continue;
3837
+ const store = storeOfExpression(unwrapAwait(loop.getExpression()), symbols, relations);
3838
+ if (store) symbols.set(nameNode.getText(), store);
3839
+ }
3056
3840
  return symbols;
3057
3841
  }
3058
3842
  /**
@@ -3116,6 +3900,67 @@ function resolveTypeToFile(typeName, file, app) {
3116
3900
  * Accepts an inline type literal and a named type declared in this file or
3117
3901
  * imported from the application. Anything else yields empty — no guessing.
3118
3902
  */
3903
+ /**
3904
+ * The store a type annotation names, or nothing: `Sessao`, `Sessao | null`,
3905
+ * `Promise<Sessao | null>`, `Sessao[]`, `Promise<Sessao[]>`. Two different stores
3906
+ * in one union name nothing — a guess is not a binding.
3907
+ */
3908
+ function storeNamedBy(typeText, stores) {
3909
+ if (!typeText) return null;
3910
+ let text = typeText.trim();
3911
+ const promise = text.match(/^Promise<([\s\S]*)>$/);
3912
+ if (promise) text = promise[1];
3913
+ const named = new Set(text.split("|").map((part) => part.trim().replace(/\[\]$/, "").replace(/^Array<(.*)>$/, "$1").trim()).filter((part) => part && part !== "null" && part !== "undefined"));
3914
+ if (named.size !== 1) return null;
3915
+ const [only] = named;
3916
+ return stores.has(only) ? only : null;
3917
+ }
3918
+ /** array methods that hand back one row, or the same rows, of the receiver */
3919
+ const ONE_OF_ROWS = new Set([
3920
+ "find",
3921
+ "findLast",
3922
+ "at",
3923
+ "filter",
3924
+ "slice",
3925
+ "sort",
3926
+ "toSorted",
3927
+ "reverse",
3928
+ "toReversed",
3929
+ "concat",
3930
+ "flat",
3931
+ "first",
3932
+ "last"
3933
+ ]);
3934
+ /** array methods whose callback receives one row of the receiver */
3935
+ const ITERATES_ROWS = new Set([
3936
+ "map",
3937
+ "forEach",
3938
+ "filter",
3939
+ "find",
3940
+ "findLast",
3941
+ "some",
3942
+ "every",
3943
+ "flatMap",
3944
+ "reduce"
3945
+ ]);
3946
+ const lastSegmentText = (node) => Node.isPropertyAccessExpression(node) ? node.getName() : node.getText();
3947
+ /** the store an expression is rows of: a store symbol, or `parent.relation` with the relation declared */
3948
+ function storeOfExpression(expression, symbols, relations) {
3949
+ if (!expression) return null;
3950
+ if (Node.isIdentifier(expression)) return symbols.get(expression.getText()) ?? null;
3951
+ if (Node.isPropertyAccessExpression(expression)) {
3952
+ const receiver = expression.getExpression();
3953
+ if (Node.isIdentifier(receiver)) {
3954
+ const byPath = symbols.get(`${receiver.getText()}.${expression.getName()}`);
3955
+ if (byPath) return byPath;
3956
+ }
3957
+ const parent = symbols.get(rootSymbolOf(receiver) ?? "");
3958
+ if (!parent) return null;
3959
+ return relations.get(parent)?.[expression.getName()] ?? null;
3960
+ }
3961
+ const root = rootSymbolOf(expression);
3962
+ return root ? symbols.get(root) ?? null : null;
3963
+ }
3119
3964
  function membersOfType(typeNode, file, app) {
3120
3965
  const members = /* @__PURE__ */ new Map();
3121
3966
  const collect = (node) => {
@@ -3230,6 +4075,77 @@ function isWorthReporting(call, symbols, imports) {
3230
4075
  if (root === "this") return true;
3231
4076
  return imports.has(root);
3232
4077
  }
4078
+ /** Lucid's persistence on an instance, with no arguments: `x.save()`, `x.delete()` — a `Map#delete(key)` has one */
4079
+ const INSTANCE_WRITES = new Set([
4080
+ "save",
4081
+ "delete",
4082
+ "forceDelete"
4083
+ ]);
4084
+ /** persistence through a relation or a merge: `x.related('y').create(…)`, `x.merge(p).save()` */
4085
+ const CHAINED_WRITES = new Set([
4086
+ "create",
4087
+ "createMany",
4088
+ "save",
4089
+ "saveMany",
4090
+ "attach",
4091
+ "detach",
4092
+ "sync",
4093
+ "updateOrCreate",
4094
+ "firstOrCreate",
4095
+ "delete"
4096
+ ]);
4097
+ const WRITE_CHAINS = new Set([
4098
+ "related",
4099
+ "merge",
4100
+ "fill",
4101
+ "useTransaction"
4102
+ ]);
4103
+ /**
4104
+ * A persistence call on a receiver the body cannot type: a local that is neither a
4105
+ * store, nor an import, nor `this`, nor the result of a call on one of those.
4106
+ * Reported as unresolved — the count must not stay silent where an EI may hide.
4107
+ */
4108
+ function isUnreadableWrite(call, symbols, imports, packageImports, body) {
4109
+ const expression = call.getExpression();
4110
+ if (!Node.isPropertyAccessExpression(expression)) return false;
4111
+ const method = expression.getName();
4112
+ const receiver = unwrapAwait(expression.getExpression());
4113
+ if (Node.isCallExpression(receiver)) {
4114
+ const inner = receiver.getExpression();
4115
+ if (!Node.isPropertyAccessExpression(inner) || !WRITE_CHAINS.has(inner.getName())) return false;
4116
+ if (!CHAINED_WRITES.has(method)) return false;
4117
+ } else if (!INSTANCE_WRITES.has(method) || call.getArguments().length > 0) return false;
4118
+ const root = rootSymbolOf(expression.getExpression());
4119
+ if (!root || root === "this") return false;
4120
+ if (symbols.has(root) || imports.has(root) || packageImports.has(root)) return false;
4121
+ if (!Node.isIdentifier(unwrapAwait(rootNodeOf(expression.getExpression())))) return false;
4122
+ /**
4123
+ * `const pdfDoc = await PDFDocument.create()` from `pdf-lib`, then `pdfDoc.save()`:
4124
+ * a package's object with a method called `save`. Its declaration says where it
4125
+ * came from, and it is not a store — nothing to report.
4126
+ */
4127
+ for (const declaration of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
4128
+ const nameNode = declaration.getNameNode();
4129
+ if (!Node.isIdentifier(nameNode) || nameNode.getText() !== root) continue;
4130
+ const initializer = declaration.getInitializer();
4131
+ const origin = initializer ? rootSymbolOf(unwrapAwait(initializer)) : null;
4132
+ if (origin && packageImports.has(origin)) return false;
4133
+ }
4134
+ return true;
4135
+ }
4136
+ /** the leftmost node of a chain */
4137
+ function rootNodeOf(node) {
4138
+ let current = node;
4139
+ for (let depth = 0; depth < 40; depth++) {
4140
+ const next = unwrapAwait(current);
4141
+ if (Node.isPropertyAccessExpression(next) || Node.isCallExpression(next) || Node.isElementAccessExpression(next)) {
4142
+ current = next.getExpression();
4143
+ continue;
4144
+ }
4145
+ return next;
4146
+ }
4147
+ return current;
4148
+ }
3233
4149
  /** `(await x())` and `x()` are the same receiver for this purpose. */
3234
4150
  function unwrapAwait(node) {
3235
4151
  let current = node;
@@ -4051,6 +4967,19 @@ function detsFor(entry, behavior, touched, type, options) {
4051
4967
  }
4052
4968
  continue;
4053
4969
  }
4970
+ /**
4971
+ * Handed raw to a page the reader could open: the columns the page reads off
4972
+ * the rows are what the user sees (§6, plan 0.8 §D). A page it could not read
4973
+ * leaves the store whole below, and the count says why.
4974
+ */
4975
+ const shown = behavior.pageReads?.[store];
4976
+ if (shown && !behavior.unreadablePages?.[store]) {
4977
+ for (const column of shown) {
4978
+ if (excluded.has(column)) continue;
4979
+ add(`${store}.${column}`, `page:${store}.${column}${mark(column)}`);
4980
+ }
4981
+ continue;
4982
+ }
4054
4983
  for (const column of attributes) {
4055
4984
  if (excluded.has(column.name)) continue;
4056
4985
  add(`${store}.${column.name}`, `output:${store}.${column.name}${mark(column.name)}`);
@@ -4141,13 +5070,17 @@ const RULESET = "afp";
4141
5070
  * Three in 1.6.0: an output's DETs are what the transaction DELIVERS (the render
4142
5071
  * props, the response payload, what a command prints) read back to their origin;
4143
5072
  * 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.
5073
+ * FTRs move; and ace commands are transactions, with flags as input. One in
5074
+ * 1.7.0, wide: a write binds to what the variable IS — a destructured named
5075
+ * interface, a followed method's return, a relation off a row, a loop over rows,
5076
+ * the guard's user, a service the container made — and a write nobody can type is
5077
+ * an unresolved call instead of silence.
4145
5078
  *
4146
5079
  * Without the bump, a baseline saved by the previous version compares cleanly
4147
5080
  * against this one and bills the tool's own improvement as work done. The guard
4148
5081
  * exists for exactly that, and only this constant arms it.
4149
5082
  */
4150
- const RULESET_VERSION = "1.6.0";
5083
+ const RULESET_VERSION = "1.7.0";
4151
5084
  function count(input, options = {}) {
4152
5085
  const warnings = [];
4153
5086
  const usage = usageOf(input);
@@ -4267,6 +5200,7 @@ function count(input, options = {}) {
4267
5200
  warnings.push(...unreadableDeliveryWarnings(input));
4268
5201
  warnings.push(...commandWarnings(entryPoints, transactionalFunctions));
4269
5202
  warnings.push(...undispatchedJobWarnings(input));
5203
+ warnings.push(...unreadablePageWarnings(input));
4270
5204
  warnings.push(...lookAlikeWarnings(functions));
4271
5205
  warnings.push(...seededOnlyWarnings(functions, grouping.members, input.seededAnywhere));
4272
5206
  return {
@@ -4299,6 +5233,27 @@ function unreadableDeliveryWarnings(input) {
4299
5233
  ];
4300
5234
  }
4301
5235
  /**
5236
+ * Pages the reader could not open for a store handed to them raw — plan 0.8 §D.
5237
+ *
5238
+ * The store leaves whole, as it always did; this says which page, and why: a
5239
+ * second level of components, a spread, a package's component, two files answering
5240
+ * to one name. Overestimating in the open — the fix is usually in the page.
5241
+ */
5242
+ function unreadablePageWarnings(input) {
5243
+ const lines = [];
5244
+ for (const entry of input.entryPoints) {
5245
+ const behavior = input.behaviors.get(entry.id);
5246
+ if (!behavior || behavior.writes) continue;
5247
+ for (const [store, reason] of Object.entries(behavior.unreadablePages ?? {})) lines.push(` ${entry.trigger} ${entry.signature}: ${store} leaves whole — ${reason}`);
5248
+ }
5249
+ if (lines.length === 0) return [];
5250
+ return [
5251
+ `${lines.length} store(s) handed raw to a page the analysis could not read: every column counted. What the page shows would be less:`,
5252
+ ...lines.slice(0, 12),
5253
+ ...lines.length > 12 ? [` … and ${lines.length - 12} more`] : []
5254
+ ];
5255
+ }
5256
+ /**
4302
5257
  * Jobs no transaction reaches — plan 0.7 §D.
4303
5258
  *
4304
5259
  * A job dispatched by a handler is part of that handler's transaction (§9). One
@@ -4768,6 +5723,8 @@ async function analyze(root, options = {}) {
4768
5723
  })),
4769
5724
  transformedStores: behavior.transformedStores,
4770
5725
  delivered: behavior.delivered,
5726
+ pageReads: behavior.pageReads,
5727
+ unreadablePages: behavior.unreadablePages,
4771
5728
  outputReads: behavior.outputReads,
4772
5729
  trace: behavior.trace.map((step) => ({
4773
5730
  ...step,