makersclaw 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +73 -7
  2. package/package.json +3 -2
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +65 -9
  117. package/src/install.mjs +360 -21
  118. package/src/lockfile.mjs +468 -0
  119. package/src/pm.mjs +473 -0
  120. package/src/registry.mjs +125 -1
  121. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  122. package/templates/next/browser/app/layout.jsx +94 -0
  123. package/templates/next/browser/app/page.jsx +60 -0
  124. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  125. package/templates/next/browser/lib/actions.js +16 -0
  126. package/templates/next/browser/lib/data.js +43 -0
  127. package/templates/next/files/README.md +818 -82
  128. package/templates/next/files/app/claw-alive/route.js +2 -2
  129. package/templates/next/files/app/frame.js +134 -9
  130. package/templates/next/files/app/layout.jsx +2 -2
  131. package/templates/next/files/check/run.mjs +1491 -53
  132. package/templates/next/files/components/ui/item.jsx +2 -2
  133. package/templates/next/files/components/ui/notice.jsx +1 -1
  134. package/templates/next/files/components/ui/separator.jsx +1 -1
  135. package/templates/next/files/lib/actions.js +171 -52
  136. package/templates/next/files/lib/brain-server.js +96 -0
  137. package/templates/next/files/lib/brain.js +413 -0
  138. package/templates/next/files/lib/claw.js +11 -0
  139. package/templates/next/files/lib/data.js +120 -30
  140. package/templates/next/files/lib/db.js +97 -17
  141. package/templates/next/files/lib/door.js +680 -0
  142. package/templates/next/files/next.config.mjs +85 -9
  143. package/templates/next/files/package.json +3 -3
  144. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  145. package/templates/next/files/surfaces.js +3 -2
  146. package/templates/next/pnpm-lock.yaml +5870 -0
  147. package/templates/next/template.json +13 -6
  148. package/templates/next/files/schema.sql +0 -22
@@ -3,52 +3,97 @@
3
3
  // exit 1 on the first thing that does not. No dependency: node's own fs,
4
4
  // crypto, fetch and child_process.
5
5
  //
6
- // Static rules read the source; the smoke test builds the app and loads
7
- // every surface. Every static rule exists because the failure it names
6
+ // Source rules read the source; the smoke test builds the app and loads
7
+ // every surface. Every source rule exists because the failure it names
8
8
  // was seen in a real build: a hex in a surface (the app stopped following
9
9
  // the workspace theme), a hand-rolled <select> that could not be used, an
10
10
  // example INSERTed into a shared table, a manifest entry whose file was
11
11
  // never written, an icon name that rendered nothing, a tweet card painted
12
12
  // in the workspace's tokens (unrecognisable as a tweet — the one thing
13
13
  // the app was for), a kit file "improved" per app, a button whose server
14
- // action the app's own agent had no way to know about.
14
+ // action the app's own agent had no way to know about. Rules 9 and 11
15
+ // came before their failures: 0.7.0 moves an app to its own schema, and
16
+ // every example the Builder copied named public (tables); and the brain's
17
+ // door refuses a kind the app did not declare only on the member's
18
+ // screen, long after the build (brain). Number 10 is browser mode's rule
19
+ // (claw's STATIC-SPEC §3): a browser app is files and a door, and the
20
+ // build refuses its server code late and unclearly, or not at all.
15
21
  //
16
- // npm run check static rules, then smoke
17
- // npm run check -- --static static rules only
22
+ // npm run check source rules, then smoke
23
+ // npm run check -- --no-build source rules only: no build, no smoke
24
+ // (--static is its old name, still read)
25
+ // node check/run.mjs --actions
26
+ // { "actions": [...], "tables": [...] }: the
27
+ // actions the screens run and the tables they
28
+ // write (rule 8), for the platform's ship
18
29
  //
19
30
  // The smoke test builds for itself, and only when a source file is newer
20
- // than its last build. Where: in the sandbox the app folder is
21
- // `ubuntu:claw 2755` — the terminal user can read it, not write in it —
22
- // so the sources are copied to a folder of its own (`~/.claw-check/<app>`,
23
- // node_modules symlinked back) and built there. On a writable checkout
24
- // (local dev) it builds in place, into `.next-check` (CLAW_DIST_DIR,
25
- // next.config.mjs). Never into `.next`: that is the dev server's, and a
26
- // second build there dies with EACCES on .next/trace. The founder's live
27
- // preview keeps running, untouched, while the check runs beside it.
31
+ // than its last build. Where: in place when the app folder is writable,
32
+ // into `.next-check` (CLAW_DIST_DIR, next.config.mjs). Where it is not (a
33
+ // sandbox tree made `2755` before claw opened app trees to its `claw`
34
+ // group, tree-access.ts), the sources are copied to a folder of its own
35
+ // (`~/.claw-check/<app>`, node_modules symlinked back) and built there.
36
+ // A browser app always takes the copy (below).
37
+ //
38
+ // Never into `.next`: it is not the check's. In a working copy it is the
39
+ // preview dev server's; in a live app it holds the production build the
40
+ // platform made and serves, and whose .next/BUILD_ID it reads as proof of
41
+ // a build. The founder's live preview keeps running, untouched, while the
42
+ // check runs beside it. What Next 16 changed (16.3.7, 2026-09-29): `next
43
+ // dev` keeps its own files in `.next/dev`, and a build by the same user
44
+ // beside a running dev server works and leaves `dev/` alone. Under claw's
45
+ // umask 002 a `.next` the dev server makes is 775, so the EACCES on
46
+ // .next/trace that Next 15 hit across users should not happen in today's
47
+ // sandbox (not tried with two Linux users). It comes back wherever the
48
+ // builder cannot write `.next` (`chmod 555` on it: EACCES on .next/trace),
49
+ // which is where one Linux user per app, claw's planned walls, leads.
50
+ //
51
+ // A browser app ("mode": "browser") is exported, not started. Its smoke
52
+ // always builds in the copy folder (`~/.claw-check/<app>`), with the
53
+ // default distDir and the check's own environment, .env left out: under
54
+ // export a custom distDir is the export's folder and the build itself
55
+ // still goes to `.next`, so a build in place would empty the tree's
56
+ // `.next`. It serves the copy's out/ from a file server of its own, by
57
+ // the platform router's rules, and loads / and every surface: no `next
58
+ // start`, no /claw-alive (browserSmoke, below). What that proves: every
59
+ // page prerenders. Not that a surface renders with data: the export
60
+ // holds each surface's loading line, and the rows come in the browser.
61
+ //
62
+ // A copy's node_modules is a symlink back to the app's, and Turbopack
63
+ // reads nothing outside its root, so the copy's build is told a root
64
+ // that holds both (CLAW_TURBOPACK_ROOT, commonRoot below).
28
65
  //
29
66
  // Output is meant to be read by the platform agent in one glance: keep
30
67
  // it under twenty lines, name file:line, say what to do instead.
31
68
 
32
- import { accessSync, constants, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync } from "node:fs";
69
+ import { accessSync, constants, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
33
70
  import { createHash } from "node:crypto";
34
71
  import { spawn, spawnSync } from "node:child_process";
72
+ import { createServer as createHttpServer } from "node:http";
35
73
  import { createServer } from "node:net";
36
- import { basename, dirname, join, relative } from "node:path";
74
+ import { basename, dirname, join, posix, relative, sep } from "node:path";
37
75
  import { homedir, tmpdir } from "node:os";
38
76
  import { fileURLToPath } from "node:url";
39
77
 
40
78
  // The app dir: this file's parent, unless the platform runs a canonical
41
79
  // copy of it from elsewhere against an app (CLAW_APP_DIR).
42
80
  const ROOT = process.env.CLAW_APP_DIR || join(fileURLToPath(import.meta.url), "..", "..");
43
- const STATIC_ONLY = process.argv.includes("--static");
81
+ // --no-build: the source rules alone. --static is the same flag's old
82
+ // name (static as in reading the source, not a mode), kept because the
83
+ // platform and scripts pass it.
84
+ const NO_BUILD = process.argv.includes("--no-build") || process.argv.includes("--static");
85
+ const ACTIONS_ONLY = process.argv.includes("--actions");
44
86
 
45
87
  // The kit, by exact file: what the template ships under components/ui and
46
88
  // components/parts. Anything else in those folders is the agent's, and the
47
89
  // `kit` rule refuses it (a shared piece goes in components/, a picture in
48
90
  // components/depictions/). The list is the template's; `makersclaw
49
91
  // update` lays both folders again.
50
- // Exact names AND extensions: Next resolves .js before .jsx, so a
51
- // components/ui/depiction.js beside depiction.jsx would win unseen.
92
+ // Exact names AND extensions: a file of the same name with an extension
93
+ // the bundler tries first wins unseen. Next 15's webpack tried .js before
94
+ // .jsx (a components/ui/depiction.js beside depiction.jsx won); Next 16's
95
+ // Turbopack tries .jsx before .js (seen on 16.3.7), so the kit's own file
96
+ // wins there, but the rule holds for every bundler.
52
97
  const KIT = /^components\/(?:ui\/(?:cx|index)\.js|ui\/(?:radio|toggle|button-group|avatar|breadcrumb|pagination|button|collapsible|item|table|depiction|feedback|stat|distribution|swatch|steps|separator|scroll-area|field|icon|menu|context-menu|popover|calendar|date-picker|tooltip|notice|sheet|tabs|slider|input-group|image|file-input|input-otp|command|combobox|rating|toast|carousel|resizable)\.jsx|parts\/(?:index|sample)\.js|parts\/(?:fixture|record-switcher|live-work)\.jsx)$/;
53
98
 
54
99
  // The pins: sha256 of every fixed file, written by `makersclaw init` and
@@ -99,12 +144,12 @@ const SKIP = [
99
144
  /^check\//,
100
145
  KIT,
101
146
  /^app\/globals\.css$/,
102
- /^lib\/(db|data|actions|claw|platform|hooks|sample)\.js$/,
147
+ /^lib\/(db|data|actions|claw|platform|hooks|sample|door|brain|brain-server)\.js$/,
103
148
  /^app\/(layout|page|error|global-error|not-found|error-relay|auto-refresh|escape-relay)\.jsx$/,
104
149
  /^app\/frame\.js$/,
105
150
  /^app\/claw-alive\/route\.js$/,
106
151
  /^app\/s\/\[key\]\/page\.jsx$/,
107
- /^middleware\.js$/,
152
+ /^proxy\.js$/,
108
153
  /^next\.config\.mjs$/,
109
154
  /^postcss\.config\.mjs$/,
110
155
  /^public\//,
@@ -194,6 +239,460 @@ function stripComments(text) {
194
239
  /** JSX attributes up to the tag's `>`: strings and balanced braces may hold a `>` or `=>`. */
195
240
  const ATTRS = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'{])*`;
196
241
 
242
+ // The app's makersclaw.json, read once for rules 8, 9 and 11. `broken` is
243
+ // the parse error of a file that is not JSON.
244
+ const MANIFEST = (() => {
245
+ const path = join(ROOT, "makersclaw.json");
246
+ if (!existsSync(path)) return { manifest: null, broken: null };
247
+ try {
248
+ return { manifest: JSON.parse(readFileSync(path, "utf8")), broken: null };
249
+ } catch (e) {
250
+ return { manifest: null, broken: e.message };
251
+ }
252
+ })();
253
+
254
+ /**
255
+ * The tables and views the app's own schema makes, for rule 9's last
256
+ * clause: { names, from }, or null where no list is here. The first
257
+ * found wins:
258
+ * 1. CLAW_APP_TABLES, a comma list the platform passes at ship, read on
259
+ * the app lane. It comes first because after an app's first ship its
260
+ * Builder makes tables with `sql`, and no file holds those. Set and
261
+ * empty: the app makes none. A name given with its schema
262
+ * (app_crm.deals) counts by its table.
263
+ * 2. ../install/schema.sql: a play folder's, what its install ran.
264
+ * 3. ../../schema.sql: a store package's (packages/<key>/files/app).
265
+ * From a file, every `create table` and `create view` (or replace,
266
+ * materialized, unlogged, temporary, if not exists), by the name's last
267
+ * part: a package from before 0.7.0 still writes `public.<t>` there.
268
+ */
269
+ const APP_TABLES = (() => {
270
+ const env = process.env.CLAW_APP_TABLES;
271
+ if (env !== undefined) return { names: new Set(env.split(",").map((n) => n.trim().split(".").pop().trim()).filter(Boolean)), from: "CLAW_APP_TABLES" };
272
+ for (const rel of ["../install/schema.sql", "../../schema.sql"]) {
273
+ let sql;
274
+ try {
275
+ sql = readFileSync(join(ROOT, rel), "utf8");
276
+ } catch {
277
+ continue;
278
+ }
279
+ return { names: tablesMade(sql), from: rel };
280
+ }
281
+ return null;
282
+ })();
283
+ /** The tables and views a schema.sql creates. A quoted name keeps its
284
+ * case; an unquoted one folds to lower case, as Postgres folds it. */
285
+ function tablesMade(sql) {
286
+ const code = sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ");
287
+ const PART = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)`;
288
+ const CREATE = new RegExp(
289
+ String.raw`\bcreate\s+(?:or\s+replace\s+)?(?:(?:global\s+|local\s+)?(?:temp|temporary)\s+|unlogged\s+)?(?:materialized\s+|recursive\s+)?(?:table|view)\s+(?:if\s+not\s+exists\s+)?((?:${PART}\s*\.\s*)*${PART})`,
290
+ "gi",
291
+ );
292
+ const names = new Set();
293
+ for (const m of code.matchAll(CREATE)) {
294
+ const last = m[1].match(new RegExp(PART, "g")).pop();
295
+ names.add(last.startsWith('"') ? last.slice(1, -1).replace(/""/g, '"') : last.toLowerCase());
296
+ }
297
+ return names;
298
+ }
299
+
300
+ // Reading names out of the app's JavaScript, for rules 8, 9 and 11. The
301
+ // check has no parser (it depends on nothing), so these read the shapes
302
+ // an app writes: a string literal, a constant of the same file, an
303
+ // object literal's properties.
304
+ const ID = String.raw`[A-Za-z_][\w$]*`;
305
+ // A string literal whose value can be read (no ${…} inside): lit(n) puts
306
+ // its quote in group n and its value in group n + 1.
307
+ const LIT = String.raw`(["'\`])((?:(?!\1)[^\\\n$]|\\.|\$(?!\{))*)\1`;
308
+ const lit = (n) => LIT.replaceAll("\\1", `\\${n}`);
309
+ /**
310
+ * A file's string constants (`const SOURCE = "deals"`), as a lookup:
311
+ * `constAt(name, at)` is the { value, index } of the one a use at `at`
312
+ * sees, the declaration in the innermost block that holds the use (the
313
+ * file's own, outside every block, holds every use). None: the name is
314
+ * something the code decides at run time (a prop, a parameter, another
315
+ * function's constant), and undefined. Until 0.7.0's review the first
316
+ * declaration anywhere was taken, so a `const kind = "error"` inside one
317
+ * component was read as the `{ kind }` prop of another.
318
+ */
319
+ function stringConsts(code) {
320
+ // Every { … } of the code, strings skipped: [open, close].
321
+ const blocks = [];
322
+ const open = [];
323
+ for (let i = 0; i < code.length; i++) {
324
+ const c = code[i];
325
+ if (c === '"' || c === "'" || c === "`") i = endOfString(code, i);
326
+ else if (c === "{") open.push(i);
327
+ else if (c === "}" && open.length) blocks.push([open.pop(), i]);
328
+ }
329
+ for (const o of open) blocks.push([o, code.length]);
330
+ const holds = (b, at) => b[0] < at && at < b[1];
331
+ const decls = new Map(); // name → [{ value, index, from, block }]: `from` opens its block (-1 and null: the file)
332
+ for (const m of code.matchAll(new RegExp(String.raw`\bconst\s+(${ID})\s*=\s*${lit(2)}`, "g"))) {
333
+ const from = blocks.filter((b) => holds(b, m.index)).reduce((a, b) => Math.max(a, b[0]), -1);
334
+ const block = from < 0 ? null : blocks.find((b) => b[0] === from);
335
+ if (!decls.has(m[1])) decls.set(m[1], []);
336
+ decls.get(m[1]).push({ value: m[3], index: m.index, from, block });
337
+ }
338
+ return (name, at) => {
339
+ let seen;
340
+ for (const d of decls.get(name) ?? []) {
341
+ if ((d.block === null || holds(d.block, at)) && (!seen || d.from > seen.from)) seen = d;
342
+ }
343
+ return seen && { value: seen.value, index: seen.index };
344
+ };
345
+ }
346
+ /** The index of the quote that closes the string opened at `i` (a
347
+ * template's ${…} skipped whole). A quote left open ends at its line. */
348
+ function endOfString(code, i) {
349
+ const q = code[i];
350
+ for (let j = i + 1; j < code.length; j++) {
351
+ const c = code[j];
352
+ if (c === "\\") j++;
353
+ else if (q === "`" && c === "$" && code[j + 1] === "{") j = closeOf(code, j + 1);
354
+ else if (c === q || (q !== "`" && c === "\n")) return j;
355
+ }
356
+ return code.length;
357
+ }
358
+ /** Every string and template literal of the code, outermost only (a
359
+ * template's ${…} is read as part of it): [{ start, end }], the indexes
360
+ * of its quotes. A ' or " right after a letter or a digit opens nothing:
361
+ * it is JSX text's apostrophe ("Don't"), since code never puts a string
362
+ * there. A quote inside a regular expression is read as one, as the
363
+ * other walks here read it. */
364
+ function literalsOf(code) {
365
+ const out = [];
366
+ for (let i = 0; i < code.length; i++) {
367
+ const c = code[i];
368
+ if (c !== '"' && c !== "'" && c !== "`") continue;
369
+ if (c !== "`" && /[A-Za-z0-9]/.test(code[i - 1] ?? "")) continue;
370
+ const end = endOfString(code, i);
371
+ out.push({ start: i, end });
372
+ i = end;
373
+ }
374
+ return out;
375
+ }
376
+ /** The string literals of `code` that are SQL by where they go, for
377
+ * rules 8 and 9: handed to query() (`query(…)`, `client.query(…)`),
378
+ * directly or through a const of the file, or written as a sql`…`
379
+ * template. A Set of their opening quote's index. */
380
+ function handedToQuery(code) {
381
+ const handed = new Set();
382
+ const declaredAt = (name, before) => {
383
+ let at = null;
384
+ for (const m of code.matchAll(new RegExp(String.raw`\b(?:const|let|var)\s+${esc(name)}\s*=\s*(?=["'\`])`, "g"))) if (m.index < before) at = m.index + m[0].length;
385
+ return at;
386
+ };
387
+ for (const m of code.matchAll(new RegExp(String.raw`(?<![\w$])(?:${ID}\s*\??\.\s*)?query\s*(?:\?\.\s*)?\(\s*`, "g"))) {
388
+ const at = m.index + m[0].length;
389
+ if (/["'`]/.test(code[at] ?? "")) handed.add(at);
390
+ else {
391
+ const id = new RegExp(String.raw`^(${ID})\s*[,)]`).exec(code.slice(at));
392
+ const decl = id && declaredAt(id[1], m.index);
393
+ if (decl !== null && decl !== undefined) handed.add(decl);
394
+ }
395
+ }
396
+ for (const m of code.matchAll(/(?<![\w$.])sql\s*(?=`)/g)) handed.add(m.index + m[0].length);
397
+ return handed;
398
+ }
399
+ /**
400
+ * The code's strings as rule 9 reads SQL: each run of string literals
401
+ * joined with + is one text, as JavaScript joins it (`"select d.id " +
402
+ * "from deals d " + "join notes n …"`). Found in 0.7.0's review: each
403
+ * literal was read alone, so a table in any piece after the first
404
+ * passed. A name or a call between two pieces (`+ id +`) joins them
405
+ * through a character no name can hold. [{ text, at, handed }]: `handed`
406
+ * when a piece goes to query() (handedToQuery); `at(i)` is where the
407
+ * text's i-th character is in the code. The text is blanked where no
408
+ * table is named, every index kept: the SQL's own strings ('…'), its
409
+ * -- and block comments, and a \n, \r or \t written as an escape.
410
+ */
411
+ function sqlTexts(code) {
412
+ const handed = handedToQuery(code);
413
+ const GLUE = new RegExp(String.raw`^\s*\+\s*${ID}(?:\s*\??\.\s*${ID})*(?:\s*\([^()"'\`]*\))?\s*\+\s*$`);
414
+ const runs = [];
415
+ let run = null;
416
+ for (const { start, end } of literalsOf(code)) {
417
+ const gap = run ? code.slice(run.end + 1, start) : "";
418
+ const joined = run !== null && /^\s*\+\s*$/.test(gap);
419
+ const glued = run !== null && !joined && GLUE.test(gap);
420
+ if (!joined && !glued) runs.push((run = { pieces: [], text: "", handed: false }));
421
+ if (glued) run.text += "\u0001";
422
+ run.pieces.push({ from: run.text.length, start });
423
+ run.text += code.slice(start + 1, end);
424
+ run.end = end;
425
+ if (handed.has(start)) run.handed = true;
426
+ }
427
+ return runs.map(({ pieces, text, handed }) => ({
428
+ handed,
429
+ // A string holding a backslash is left to the next quote, as before
430
+ // this reader: E'a\' from x' then blanks ' from x'.
431
+ text: text.replace(/'(?:[^'\\\n]|'')*'|--(?:[^\n\\]|\\(?![nr]))*|\/\*[\s\S]*?\*\/|\\[nrt]/g, (s) => " ".repeat(s.length)),
432
+ at: (i) => {
433
+ const p = pieces.findLast((q) => q.from <= i);
434
+ return p.start + 1 + (i - p.from);
435
+ },
436
+ }));
437
+ }
438
+ /** The index of the bracket that closes the one at `open`. */
439
+ function closeOf(code, open) {
440
+ let depth = 0;
441
+ for (let i = open; i < code.length; i++) {
442
+ const c = code[i];
443
+ if (c === '"' || c === "'" || c === "`") i = endOfString(code, i);
444
+ else if (c === "(" || c === "[" || c === "{") depth++;
445
+ else if ((c === ")" || c === "]" || c === "}") && --depth === 0) return i;
446
+ }
447
+ return code.length;
448
+ }
449
+ /**
450
+ * Every property of every object literal in `text`: { key, value, at,
451
+ * depth, path }. `value` is its text (a shorthand's is its own name),
452
+ * `at` where its key starts, `depth` the braces around it (1: an object
453
+ * that is the text itself, a call's argument), `path` the keys of the
454
+ * properties whose values hold it, outermost first: in
455
+ * `{ writes: [{ kind: "deal", owner: { kind: "member" } }] }` the first
456
+ * kind's path is ["writes"], the second's ["writes", "owner"]. Anything
457
+ * that is not `key: value` or a shorthand (a spread, a method, a block's
458
+ * statements) is passed over.
459
+ */
460
+ function objectProps(text) {
461
+ const out = [];
462
+ const frames = []; // one per open bracket: { brace, prop: { key, start, at } | null }
463
+ const KEY = new RegExp(String.raw`\s*(?:(${ID})|"([^"\\\n]*)"|'([^'\\\n]*)')\s*([:,}])`, "y");
464
+ const depth = () => frames.filter((fr) => fr.brace).length;
465
+ const path = () => frames.filter((fr) => fr.brace && fr.prop).map((fr) => fr.prop.key);
466
+ const end = (fr, i) => {
467
+ if (!fr.prop) return;
468
+ out.push({ key: fr.prop.key, value: text.slice(fr.prop.start, i).trim(), at: fr.prop.at, depth: depth(), path: path().slice(0, -1) });
469
+ fr.prop = null;
470
+ };
471
+ // At `i`, just after a { or a , of an object: a key, or not. Returns
472
+ // where the walk goes on.
473
+ const keyAt = (fr, i) => {
474
+ KEY.lastIndex = i;
475
+ const m = KEY.exec(text);
476
+ if (!m) return i;
477
+ const key = m[1] ?? m[2] ?? m[3];
478
+ const at = i + m[0].search(/\S/);
479
+ if (m[4] === ":") {
480
+ fr.prop = { key, start: KEY.lastIndex, at };
481
+ return KEY.lastIndex;
482
+ }
483
+ if (m[1]) out.push({ key, value: key, at, depth: depth(), path: path() });
484
+ return KEY.lastIndex - 1; // the , or } is the walk's
485
+ };
486
+ for (let i = 0; i < text.length; i++) {
487
+ const c = text[i];
488
+ const top = frames[frames.length - 1];
489
+ if (c === '"' || c === "'" || c === "`") i = endOfString(text, i);
490
+ else if (c === "{") {
491
+ const fr = { brace: true, prop: null };
492
+ frames.push(fr);
493
+ i = keyAt(fr, i + 1) - 1;
494
+ } else if (c === "(" || c === "[") frames.push({ brace: false, prop: null });
495
+ else if (c === "}" || c === ")" || c === "]") {
496
+ if (top?.brace) end(top, i);
497
+ frames.pop();
498
+ } else if (c === "," && top?.brace) {
499
+ end(top, i);
500
+ i = keyAt(top, i + 1) - 1;
501
+ }
502
+ }
503
+ return out;
504
+ }
505
+
506
+ // What the app's code hands to the write doors, for rule 8 and
507
+ // `--actions` (0.7.0, claw's STATIC-SPEC §2): the name of every action
508
+ // it runs (runAction) and the table of every one-row write (insertRow,
509
+ // updateRow, moveRow, deleteRow, saveSetting). Each is read from the
510
+ // call's first argument, `{ name }` or `{ source }`, where it is written:
511
+ // a string literal, or a string constant of the same file that the call
512
+ // sees, as rules 9 and 11 read theirs. A surface's own `source:
513
+ // surface.source` is its manifest entry's (surfaces.js). The fixed lib/
514
+ // files that define them are not read (SKIP).
515
+ //
516
+ // A call is found by the function's name however the file reaches it,
517
+ // with `(` or `?.(`: bare; under an alias imported from lib/actions.js or
518
+ // lib/door.js, or from an app file that re-exports them; through a
519
+ // namespace (`import * as doors`, then `doors.runAction`,
520
+ // `doors?.runAction?.(`, `doors["runAction"]`); or through an alias the
521
+ // code makes of one (`const { runAction: go } = doors`, `const go =
522
+ // doors.runAction`, `const go = runAction`). Until 0.7.0's review a
523
+ // `?.(`, a `["…"]`, an alias the code made and a re-export each hid its
524
+ // action or its table from the brief check and from the ship's --actions
525
+ // list.
526
+ const ROW_WRITES = ["insertRow", "updateRow", "moveRow", "deleteRow", "saveSetting"];
527
+ const DOOR_MODULES = ["lib/actions", "lib/door"];
528
+ /** The one bare, lower-case name the data door calls (ACTION_NAME in lib/actions.js). */
529
+ const ACTION_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
530
+ /** A name, escaped for a RegExp (`$` is legal in one). */
531
+ const esc = (name) => name.replace(/\$/g, "\\$");
532
+ /** An app file's module, as an import names it: no extension, no /index. */
533
+ const moduleOf = (path) => path.replace(/\.(?:jsx?|mjs)$/, "").replace(/\/index$/, "");
534
+ /** The module a relative import in the file at `from` names. */
535
+ const importOf = (from, spec) => moduleOf(posix.normalize(posix.join(posix.dirname(from), spec)));
536
+ const jsFiles = files.filter((f) => /\.(?:jsx?|mjs)$/.test(f.path));
537
+ /** The surface files the manifest mounts, each with the `source` of
538
+ * every entry that mounts it: module → [source, or null for none]. */
539
+ function surfaceSources() {
540
+ const out = new Map();
541
+ let text;
542
+ try {
543
+ text = stripComments(readFileSync(join(ROOT, "surfaces.js"), "utf8"));
544
+ } catch {
545
+ return out;
546
+ }
547
+ const imported = new Map();
548
+ for (const m of text.matchAll(/^\s*import\s+(\w+)\s+from\s*["'](\.\/[^"'\n]+)["']/gm)) imported.set(m[1], importOf("surfaces.js", m[2]));
549
+ for (const m of text.matchAll(/\{([^{}]*)\}/g)) {
550
+ const component = /\bcomponent\s*:\s*(\w+)/.exec(m[1])?.[1];
551
+ if (!component || !imported.has(component)) continue;
552
+ const source = new RegExp(String.raw`\bsource\s*:\s*${lit(1)}`).exec(m[1])?.[2]?.trim() || null;
553
+ const mod = imported.get(component);
554
+ if (!out.has(mod)) out.set(mod, []);
555
+ out.get(mod).push(source);
556
+ }
557
+ return out;
558
+ }
559
+ /**
560
+ * → { actions: [{ f, line, name, form }], tables: [{ f, line, fn, table }],
561
+ * unread: [{ f, line, fn }] }. An action's `form` is "object"
562
+ * (runAction({ name: "x" })), "string" (runAction("x"), which runs
563
+ * nothing: runAction takes one object) or "computed" (a name the code
564
+ * decides at run time; `name` is null). `unread` is every one-row write
565
+ * whose table the code decides at run time (a prop, a parameter): the
566
+ * check cannot say which table it writes.
567
+ */
568
+ function writeCalls() {
569
+ const known = new Set([...ROW_WRITES, "runAction"]);
570
+ const all = () => new Map([...known].map((n) => [n, n]));
571
+ // Every module that hands out the doors, with what it exports under
572
+ // which name: lib/actions and lib/door, and an app file that re-exports
573
+ // them (`export { runAction as go } from "../lib/actions"`, `export *
574
+ // from`), until no file adds one.
575
+ const doors = new Map(DOOR_MODULES.map((m) => [m, all()]));
576
+ for (let grew = true; grew; ) {
577
+ grew = false;
578
+ for (const f of jsFiles) {
579
+ for (const m of f.code.matchAll(/^\s*export\s*(\*|\{[^}]*\})\s*from\s*["'](\.[^"'\n]+)["']/gm)) {
580
+ const names = doors.get(importOf(f.path, m[2]));
581
+ if (!names) continue;
582
+ const self = moduleOf(f.path);
583
+ const mine = doors.get(self) ?? new Map();
584
+ doors.set(self, mine);
585
+ const add = (as, fn) => {
586
+ if (mine.has(as)) return;
587
+ mine.set(as, fn);
588
+ grew = true;
589
+ };
590
+ if (m[1] === "*") for (const [n, fn] of names) add(n, fn);
591
+ else {
592
+ for (const part of m[1].slice(1, -1).split(",")) {
593
+ const [name, as] = part.trim().split(/\s+as\s+/);
594
+ if (name && names.has(name.trim())) add((as ?? name).trim(), names.get(name.trim()));
595
+ }
596
+ }
597
+ }
598
+ }
599
+ }
600
+ const mounted = surfaceSources();
601
+ const actions = [];
602
+ const tables = [];
603
+ const unread = [];
604
+ for (const f of jsFiles) {
605
+ const code = f.code;
606
+ const local = all(); // a name in this file → the function it calls
607
+ const spaces = new Map(); // a namespace in this file → its names
608
+ for (const m of code.matchAll(/^\s*import\s+([^;]*?)\s+from\s*["']([^"'\n]+)["']/gm)) {
609
+ if (!m[2].startsWith(".")) continue;
610
+ const names = doors.get(importOf(f.path, m[2]));
611
+ if (!names) continue;
612
+ for (const part of /\{([^}]*)\}/.exec(m[1])?.[1].split(",") ?? []) {
613
+ const [fn, as] = part.trim().split(/\s+as\s+/);
614
+ if (fn && names.has(fn.trim())) local.set((as ?? fn).trim(), names.get(fn.trim()));
615
+ }
616
+ const ns = new RegExp(String.raw`\*\s*as\s+(${ID})`).exec(m[1]);
617
+ if (ns) spaces.set(ns[1], names);
618
+ }
619
+ // The aliases the code makes, twice over so one of another counts.
620
+ for (let pass = 0; pass < 2; pass++) {
621
+ for (const m of code.matchAll(new RegExp(String.raw`\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(${ID})\s*(?=[;\n])`, "g"))) {
622
+ const names = spaces.get(m[2]);
623
+ if (!names) continue;
624
+ for (const part of m[1].split(",")) {
625
+ const p = new RegExp(String.raw`^\s*(${ID})\s*(?::\s*(${ID}))?\s*(?:=[^,]*)?$`).exec(part);
626
+ if (p && names.has(p[1])) local.set(p[2] ?? p[1], names.get(p[1]));
627
+ }
628
+ }
629
+ for (const m of code.matchAll(new RegExp(String.raw`\b(?:const|let|var)\s+(${ID})\s*=\s*(${ID})(?:\s*(?:\?\.|\.)\s*(${ID}))?\s*(?=[;\n])`, "g"))) {
630
+ const [, as, base, member] = m;
631
+ if (member !== undefined) {
632
+ if (spaces.get(base)?.has(member)) local.set(as, spaces.get(base).get(member));
633
+ } else if (spaces.has(base)) spaces.set(as, spaces.get(base));
634
+ else if (local.has(base) && base !== as) local.set(as, local.get(base));
635
+ }
636
+ }
637
+ const found = []; // { fn, open }: `open` is the call's (
638
+ for (const [name, fn] of local) {
639
+ for (const m of code.matchAll(new RegExp(String.raw`(?<![\w$.])${esc(name)}\s*(?:\?\.\s*)?\(`, "g"))) {
640
+ // `function runAction(` is a definition of the app's own, not a call.
641
+ if (/\bfunction\s*\*?\s*$/.test(code.slice(Math.max(0, m.index - 16), m.index))) continue;
642
+ found.push({ fn, open: m.index + m[0].length - 1 });
643
+ }
644
+ }
645
+ for (const [ns, names] of spaces) {
646
+ for (const m of code.matchAll(new RegExp(String.raw`(?<![\w$.])${esc(ns)}\s*(?:(?:\?\.|\.)\s*(${ID})|(?:\?\.)?\s*\[\s*${lit(2)}\s*\])\s*(?:\?\.\s*)?\(`, "g"))) {
647
+ const member = m[1] ?? m[3];
648
+ if (names.has(member)) found.push({ fn: names.get(member), open: m.index + m[0].length - 1 });
649
+ }
650
+ }
651
+ const constAt = stringConsts(code);
652
+ // A literal, or a constant of this file that the use at `at` sees;
653
+ // null for anything the code decides at run time.
654
+ const valueAt = (text, at) => {
655
+ const v = text.trim();
656
+ const m = new RegExp(`^${lit(1)}$`).exec(v);
657
+ if (m) return m[2];
658
+ return new RegExp(`^${ID}$`).test(v) ? (constAt(v, at)?.value ?? null) : null;
659
+ };
660
+ const entries = mounted.get(moduleOf(f.path));
661
+ for (const { fn, open } of found.sort((a, b) => a.open - b.open)) {
662
+ const start = open + 1 + /^\s*/.exec(code.slice(open + 1))[0].length;
663
+ if (fn === "runAction" && /["'`]/.test(code[start] ?? "")) {
664
+ const m = new RegExp(`^${lit(1)}`).exec(code.slice(start));
665
+ actions.push({ f, line: lineOf(code, start), name: m ? m[2] : null, form: m ? "string" : "computed" });
666
+ continue;
667
+ }
668
+ const props = code[start] === "{" ? objectProps(code.slice(start, closeOf(code, start) + 1)).filter((p) => p.depth === 1) : [];
669
+ // The last of a key wins, as in JavaScript.
670
+ const p = props.findLast((q) => q.key === (fn === "runAction" ? "name" : "source"));
671
+ const value = p ? valueAt(p.value, start + p.at) : null;
672
+ const line = lineOf(code, p ? start + p.at : open);
673
+ if (fn === "runAction") actions.push({ f, line, name: value, form: value === null ? "computed" : "object" });
674
+ else if (value !== null && value.trim()) tables.push({ f, line, fn, table: value.trim() });
675
+ else if (p && /^(?:props\s*\??\.\s*)?surface\s*\??\.\s*source$/.test(p.value.trim()) && entries?.length && entries.every(Boolean)) {
676
+ for (const table of new Set(entries)) tables.push({ f, line, fn, table });
677
+ } else unread.push({ f, line, fn });
678
+ }
679
+ }
680
+ return { actions, tables, unread };
681
+ }
682
+
683
+ // `--actions`: what the screens run and write, as JSON, and nothing else,
684
+ // always exit 0. The platform's ship reads it before it builds (claw's
685
+ // STATIC-SPEC §2, "The platform checks at ship"): every action must be a
686
+ // function in the app's schema, which a file cannot show. A check that
687
+ // prints no JSON is one from before 0.7.0, and the ship says so.
688
+ if (ACTIONS_ONLY) {
689
+ const { actions, tables } = writeCalls();
690
+ const names = (xs) => [...new Set(xs)].sort();
691
+ const listed = { actions: names(actions.filter((a) => a.name !== null).map((a) => a.name)), tables: names(tables.map((t) => t.table)) };
692
+ await new Promise((done) => process.stdout.write(`${JSON.stringify(listed)}\n`, done));
693
+ process.exit(0);
694
+ }
695
+
197
696
  // 1. Colour lives in the workspace theme, nowhere in the app — except a
198
697
  // depiction, which wears the colours of the place it depicts (rule 7).
199
698
  {
@@ -344,6 +843,14 @@ const surfaceKeys = [];
344
843
  if (e.isFile() && /^(?:ui|parts|depictions|blocks)\.[^.]+$/.test(e.name)) L.hit(`components/${e.name}`, "shadow", `a file beside the components/${e.name.replace(/\..*$/, "")}/ folder wins module resolution over it; delete it`);
345
844
  }
346
845
  }
846
+ // Next 16 runs proxy.js (fixed) before every request. A middleware file
847
+ // beside it, the name Next 15 used, fails the build; alone it would run
848
+ // in the token check's place. `makersclaw update` removes the template's
849
+ // own from 0.5.x.
850
+ for (const e of existsSync(ROOT) ? readdirSync(ROOT) : []) {
851
+ // A browser app has no proxy.js either; rule 10 (browser) names both files, and why.
852
+ if (MANIFEST.manifest?.mode !== "browser" && /^(?:middleware|proxy)\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(e) && e !== "proxy.js") L.hit(e, "proxy", `Next 16 runs proxy.js, the template's fixed token check; delete ${e}`);
853
+ }
347
854
  // check/ is this script and its pins; a module hidden there is bundled
348
855
  // by Next and read by no rule.
349
856
  if (existsSync(join(ROOT, "check"))) {
@@ -457,17 +964,17 @@ const surfaceKeys = [];
457
964
  if ((m = /\bfetch\(|\bwindow\.|\bdocument\.(?![\w-]+["'\s])|localStorage|sessionStorage|getComputedStyle|matchMedia|\bref=\{|"use server"|revalidate/.exec(code))) L.hit(at(m), "io", "no reads, no writes, no browser: the surface does that beside the frame");
458
965
  }
459
966
 
460
- // (b) where it is framed. The folder has one door: a static default
461
- // import under a Capitalised name. Any other string that names the
462
- // folder (import(), require(), a re-export, a split path) is a way
463
- // round the frame, wherever it sits.
967
+ // (b) where it is framed. The folder has one door: a default import at
968
+ // the top of the file, under a Capitalised name. Any other string
969
+ // that names the folder (import(), require(), a re-export, a split
970
+ // path) is a way round the frame, wherever it sits.
464
971
  const DOOR = /(?<!^\s*import\s+[\w$]+\s+from\s*)["'`][^"'`\n]*\bdepictions\b[^"'`\n]*["'`]/m;
465
972
  scan(DOOR, (f, m, line) => {
466
973
  const slug = /depictions\/([a-z0-9-]+)/.exec(m[0])?.[1];
467
974
  if (slug) claimed.add(slug);
468
975
  L.hit(`${f.path}:${line}`, "door", `${m[0]}: a depiction is reached by one door — import XPost from "…/depictions/x-post" at the top of the file that frames it`);
469
976
  });
470
- scan(/\bimport\s*\(|\brequire\s*\(/, (f, m, line) => L.hit(`${f.path}:${line}`, "dynamic", `${m[0].trim()} — the app is one static bundle; write import X from "…" at the top of the file`));
977
+ scan(/\bimport\s*\(|\brequire\s*\(/, (f, m, line) => L.hit(`${f.path}:${line}`, "dynamic", `${m[0].trim()} — the app is one bundle, every module imported at the top of a file; write import X from "…" at the top of the file`));
471
978
  const IMPORT = /^\s*import\s+([^;]*?)\s+from\s*["']([^"']*depictions\/([^"']+?))(?:\.jsx?)?["']/gm;
472
979
  const FRAME = new RegExp(String.raw`<Depiction\b(${ATTRS})>\s*<([A-Z]\w*)\b${ATTRS}\/>\s*<\/Depiction>`, "g");
473
980
  for (const f of appFiles) {
@@ -565,16 +1072,49 @@ const surfaceKeys = [];
565
1072
  // chat for a board, its agent asked the member to make one, since
566
1073
  // nothing told it how. An app with no brief beside it (a copy on its
567
1074
  // own) is not checked here.
1075
+ //
1076
+ // 0.7.0 grows it for an app on its own tables ("tables": "own"; claw's
1077
+ // STATIC-SPEC §2). Its screens write through two doors only: a one-row
1078
+ // write (insertRow, updateRow, moveRow, deleteRow, saveSetting), or an
1079
+ // action, a function in the app's schema that runAction runs and the
1080
+ // agent runs too (`select close_cycle($1::jsonb)`). So:
1081
+ // 1. No app file writes SQL: an insert into, update … set or delete
1082
+ // from, in any case, in a string of a .js, .jsx or .mjs file (the
1083
+ // fixed lib/ files are skipped, a .sql file is not read), where it
1084
+ // reads as a statement. Both modes, brief or none.
1085
+ // 2. Every action the screens run is in the brief: `close_cycle` and
1086
+ // its call, select close_cycle(. A name the code decides at run time
1087
+ // fails, since no brief (and no ship) can name it; so does
1088
+ // runAction("x"), which runs nothing (it takes one object), and a
1089
+ // name no door calls (one bare, lower-case name). Those three need
1090
+ // no brief.
1091
+ // 3. Every table the screens write through a one-row write is in the
1092
+ // brief: `deals` for source: "deals". The agent's way is one
1093
+ // statement on it.
1094
+ // 4. As before, every server action that writes is in the brief, a
1095
+ // runAction in its body counted as a write.
1096
+ // And no string names the door's path (_claw/data, data/actions/…):
1097
+ // the screens reach it through its functions, which 2 and 3 read.
1098
+ // An app with no tables key, or "public", keeps the rule as it was.
568
1099
  {
569
1100
  const home = dirname(ROOT);
570
- if (!existsSync(join(home, "AGENTS.md"))) ok("agent", "no AGENTS.md beside the app: its actions are not checked here");
571
- else {
572
- const refs = join(home, "references");
573
- const brief = [join(home, "AGENTS.md"), ...(existsSync(refs) ? readdirSync(refs).filter((n) => n.endsWith(".md")).map((n) => join(refs, n)) : [])]
574
- .map((p) => readFileSync(p, "utf8"))
575
- .join("\n");
576
- const WRITES =
577
- /\b(insertRow|updateRow|deleteRow|moveRow|saveSetting)\s*\(|\binsert\s+into\b|\bupdate\s+(?:only\s+)?(?:public\.)?[a-z_][a-z0-9_]*(?:\s+(?:as\s+)?[a-z_][a-z0-9_]*)?\s+set\b|\bdelete\s+from\b/i;
1101
+ const own = MANIFEST.manifest?.tables === "own";
1102
+ const hasBrief = existsSync(join(home, "AGENTS.md"));
1103
+ const refs = join(home, "references");
1104
+ const brief = hasBrief
1105
+ ? [join(home, "AGENTS.md"), ...(existsSync(refs) ? readdirSync(refs).filter((n) => n.endsWith(".md")).map((n) => join(refs, n)) : [])].map((p) => readFileSync(p, "utf8")).join("\n")
1106
+ : "";
1107
+ /** Every exported server action that writes (the names in the brief
1108
+ * aside): { where, name }. `runAction` counts as a write for an own app. */
1109
+ const serverWrites = (withRunAction) => {
1110
+ // An UPDATE's table is read bare or under any schema. Up to 0.6.x
1111
+ // only `public.` was known, the one schema apps had; an app of 0.7.0
1112
+ // names its own tables bare, and one that still wrote `app_crm.deals`
1113
+ // would have hidden the write from this rule while rule 9 refused it.
1114
+ const WRITES = new RegExp(
1115
+ String.raw`\b(insertRow|updateRow|deleteRow|moveRow|saveSetting${withRunAction ? "|runAction" : ""})\s*(?:\?\.\s*)?\(|\binsert\s+into\b|\bupdate\s+(?:only\s+)?(?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*(?:\s+(?:as\s+)?[a-z_][a-z0-9_]*)?\s+set\b|\bdelete\s+from\b`,
1116
+ "i",
1117
+ );
578
1118
  // Every top-level function of a server file, bare or wrapped in a call
579
1119
  // (`export const save = safe(async (…) => …)`), exported by name or in
580
1120
  // an `export { … }` list. One writes when its body writes, or when it
@@ -610,18 +1150,724 @@ const surfaceKeys = [];
610
1150
  if ([...local, ...others].some((n) => mentions(f.body.slice(f.body.indexOf("(")), n))) f.writes = changed = true;
611
1151
  }
612
1152
  }
613
- const writes = fns.filter((f) => f.writes && f.exported).map((f) => ({ where: f.where, name: f.exported }));
1153
+ return fns.filter((f) => f.writes && f.exported).map((f) => ({ where: f.where, name: f.exported }));
1154
+ };
1155
+ /** Clause 4's line, when a server action that writes is not in the brief. */
1156
+ const unnamed = (writes, missing) =>
1157
+ fail(
1158
+ "agent",
1159
+ missing[0].where,
1160
+ `${missing.length} of ${writes.length} server actions that write are not in the agent's brief: ` +
1161
+ `${missing.slice(0, 6).map((w) => w.name).join(", ")}${missing.length > 6 ? ", …" : ""}. Name each in ` +
1162
+ `../references/actions.md, in backticks, with the sql that does the same, so the app's agent can do what the screen does`,
1163
+ );
1164
+
1165
+ if (!own) {
1166
+ if (!hasBrief) ok("agent", "no AGENTS.md beside the app: its actions are not checked here");
1167
+ else {
1168
+ const writes = serverWrites(false);
1169
+ const missing = writes.filter((w) => !brief.includes(`\`${w.name}\``));
1170
+ if (!writes.length) ok("agent", "no server action writes");
1171
+ else if (missing.length) unnamed(writes, missing);
1172
+ else ok("agent", `all ${writes.length} server actions that write are in the agent's brief`);
1173
+ }
1174
+ } else {
1175
+ const L = limiter("agent");
1176
+ const hits = []; // { f, line, what, detail }
1177
+ const DETAIL = "no app file writes SQL. A one-row write is insertRow, updateRow, moveRow, deleteRow or saveSetting; anything else is a function in the app's schema, called with runAction (README §5)";
1178
+ // 1. SQL that writes, where it reads as a statement. Case decides
1179
+ // nothing: Postgres runs `Delete From deals where …` as it runs
1180
+ // `delete from deals where …`. Until 0.7.0's review the words had
1181
+ // to be in one case, which let mixed-case SQL through and refused
1182
+ // lower-case prose ("delete from your board" in JSX text). Now:
1183
+ // - Only inside a string or a template literal (literalsOf): JSX
1184
+ // text, a regular expression that names the words
1185
+ // (/(?:update|delete from|insert into)\s+/, the sql-console's)
1186
+ // and JavaScript's `delete from[key]` are not SQL.
1187
+ // - The words, then the table they write: bare, quoted, under a
1188
+ // schema, or a template's ${…}. A word starts after anything but
1189
+ // a letter, a digit, _ or $, or after a string's \n, \r or \t
1190
+ // ("select 1;\nDELETE FROM deals").
1191
+ // - Then what SQL puts next: `where`, `using` or `returning` after
1192
+ // a delete's table (an alias between); `(`, `values`, `select`,
1193
+ // `default values`, `overriding`, `on conflict` or `returning`
1194
+ // after an insert's; `<column> =` or `(` after an update's
1195
+ // `set`; `;` or a template's ${…} after any.
1196
+ // - Or the statement alone: an insert or a delete that starts the
1197
+ // string (or follows a ;, a newline) and whose table ends it,
1198
+ // `"delete from drafts"`. A JSX attribute's value is a label
1199
+ // then (title="Delete from board"); in code it is refused, with
1200
+ // a word for a label that reads so.
1201
+ // - Or handed to query(): `query(…)`, `client.query(…)`, directly or
1202
+ // through a const of the file, or a sql`…` template. Anything
1203
+ // that names the words and a table then counts.
1204
+ // It misses SQL glued together with + across the words.
1205
+ const NAME = String.raw`(?:\\?"[^"\\\n]+\\?"|\$\{[^}\n]*\}|${ID})`;
1206
+ const TABLE = String.raw`(?:only\s+)?(?:${NAME}\.)?${NAME}`;
1207
+ const AT = String.raw`(?:(?<![\w$])|(?<=\\[nrt]))`;
1208
+ const SQL_WRITE = new RegExp(String.raw`${AT}(?:(insert)\s+into\s+${TABLE}|(delete)\s+from\s+${TABLE}|(update)\s+${TABLE}(?:\s+(?:as\s+)?${ID})?\s+set\b)`, "gi");
1209
+ const GOES_ON = {
1210
+ insert: new RegExp(String.raw`^\s*(?:\(|values\b|select\b|default\s+values\b|overriding\b|on\s+conflict\b|returning\b|;|\$\{)`, "i"),
1211
+ delete: new RegExp(String.raw`^\s*(?:(?:as\s+)?${ID}\s+)?(?:where|using|returning)\b|^\s*(?:;|\$\{)`, "i"),
1212
+ update: new RegExp(String.raw`^\s*(?:\(|${NAME}\s*=(?!=)|;|\$\{)`, "i"),
1213
+ };
1214
+ // And (0.7.0's review) the door by its path: `claw("data/actions/x")`,
1215
+ // `fetch("_claw/data/tables/t/rows")`. A write made so is one no
1216
+ // clause above sees, and the ship's --actions never lists it; a
1217
+ // read made so skips rule 11. So in an own app no string names
1218
+ // `_claw/data`, `data/actions/` or `data/tables/`, and none that
1219
+ // starts `data/` goes to claw(), clawUrl() or clawStream().
1220
+ const DOOR_PATH = /_claw\/data(?:\/|$)|(?:^|\/)data\/(?:actions|tables)\//;
1221
+ for (const f of jsFiles) {
1222
+ const code = f.code;
1223
+ const handed = handedToQuery(code);
1224
+ const toClaw = new Set();
1225
+ for (const m of code.matchAll(/(?<![\w$.])claw(?:Url|Stream)?\s*(?:\?\.\s*)?\(\s*(?=["'`])/g)) toClaw.add(m.index + m[0].length);
1226
+ // An attribute's value: <button title="…"> or title={"…"}.
1227
+ const attribute = (at) => /\s[A-Za-z][\w-]*=\s*\{?\s*$/.test(code.slice(Math.max(0, at - 80), at));
1228
+ for (const { start, end } of literalsOf(code)) {
1229
+ const inner = code.slice(start + 1, end);
1230
+ if (DOOR_PATH.test(inner) || (toClaw.has(start) && /^data\//.test(inner))) {
1231
+ const path = inner.replace(/\s+/g, " ").slice(0, 60);
1232
+ hits.push({ f, line: lineOf(code, start), what: "path", detail: `"${path}": the screens reach the data door through its functions, never by its path: runAction({ name }) for an action, insertRow, updateRow, moveRow, deleteRow or saveSetting for one row, lib/door.js and lib/brain.js for reads, so the brief and the ship see what the app does (README §5)` });
1233
+ }
1234
+ for (const m of inner.matchAll(SQL_WRITE)) {
1235
+ const kind = (m[1] ?? m[2] ?? m[3]).toLowerCase();
1236
+ const rest = inner.slice(m.index + m[0].length);
1237
+ const statement = handed.has(start) || GOES_ON[kind].test(rest);
1238
+ const alone = !statement && kind !== "update" && !m[0].endsWith("}") && /^\s*;?\s*$/.test(rest) && /(?:^|;|\\[nrt]|\n)\s*$/.test(inner.slice(0, m.index)) && !attribute(start);
1239
+ if (!statement && !alone) continue;
1240
+ const sql = m[0].trim().replace(/\s+/g, " ");
1241
+ hits.push({ f, line: lineOf(code, start + 1 + m.index), what: "sql", detail: `\`${sql}\`: ${DETAIL}${alone ? `. If it is a label, word it otherwise ("Remove from board")` : ""}` });
1242
+ }
1243
+ }
1244
+ }
1245
+ // 2 and 3. What the screens hand to the write doors.
1246
+ const calls = writeCalls();
1247
+ const selects = (name) => new RegExp(String.raw`\bselect\s+${name}\s*\(`, "i").test(brief);
1248
+ for (const a of calls.actions) {
1249
+ let detail = null;
1250
+ if (a.name === null) detail = 'runAction\'s name is decided at run time: name the action in the code, so the brief can (runAction({ name: "close_cycle", args }))';
1251
+ else if (a.form === "string") detail = `runAction("${a.name}") runs nothing: it takes one object, runAction({ name: "${a.name}", args })`;
1252
+ else if (!ACTION_NAME.test(a.name)) detail = `runAction runs "${a.name}": an action is one bare, lower-case name (close_cycle), a function of the app's own schema, the one the door calls`;
1253
+ else if (!hasBrief) continue;
1254
+ else if (!brief.includes(`\`${a.name}\``)) detail = `runAction runs ${a.name}, which the agent's brief does not name: add \`${a.name}\` to ../references/actions.md with its call, \`select ${a.name}($1::jsonb)\` and its params, so the app's agent runs the same function`;
1255
+ else if (!selects(a.name)) detail = `the agent's brief names \`${a.name}\` but not its call: write \`select ${a.name}($1::jsonb)\` and its params there, so the app's agent runs the same function the screen does`;
1256
+ if (detail) hits.push({ f: a.f, line: a.line, what: `${a.form} action ${a.name}`, detail });
1257
+ }
1258
+ if (hasBrief) {
1259
+ for (const t of calls.tables) {
1260
+ if (!brief.includes(`\`${t.table}\``)) hits.push({ f: t.f, line: t.line, what: `table ${t.table}`, detail: `${t.fn} writes ${t.table}, which the agent's brief does not name: add \`${t.table}\` to ../references/actions.md with the one statement that does the same on it` });
1261
+ }
1262
+ }
1263
+ // Reported file by file, top to bottom, whichever clause found each.
1264
+ const order = new Map(files.map((f, i) => [f, i]));
1265
+ hits.sort((a, b) => order.get(a.f) - order.get(b.f) || a.line - b.line);
1266
+ for (const h of hits) L.hit(`${h.f.path}:${h.line}`, h.what, h.detail);
1267
+ if (L.count > 4) fail("agent", `${L.count - 4} more`, "writes the agent's brief does not name, SQL that writes in app code, or the door by its path");
1268
+ // 4. Server actions, as before.
1269
+ const writes = hasBrief ? serverWrites(true) : [];
614
1270
  const missing = writes.filter((w) => !brief.includes(`\`${w.name}\``));
615
- if (!writes.length) ok("agent", "no server action writes");
616
- else if (missing.length)
617
- fail(
1271
+ if (missing.length) unnamed(writes, missing);
1272
+ if (!L.count && !missing.length) {
1273
+ const actions = new Set(calls.actions.map((a) => a.name)).size;
1274
+ const tables = new Set(calls.tables.map((t) => t.table)).size;
1275
+ // A write whose table the code decides at run time (a prop, a
1276
+ // parameter) is not checked, and the line says so rather than
1277
+ // claim every table.
1278
+ const n = calls.unread.length;
1279
+ const unread = n ? `; ${n} one-row write${n === 1 ? " names its" : "s name their"} table at run time (${calls.unread[0].f.path}:${calls.unread[0].line}), not checked here` : "";
1280
+ ok(
618
1281
  "agent",
619
- missing[0].where,
620
- `${missing.length} of ${writes.length} server actions that write are not in the agent's brief: ` +
621
- `${missing.slice(0, 6).map((w) => w.name).join(", ")}${missing.length > 6 ? ", …" : ""}. Name each in ` +
622
- `../references/actions.md, in backticks, with the sql that does the same, so the app's agent can do what the screen does`,
1282
+ hasBrief
1283
+ ? `no SQL write in app code; the agent's brief names every action with its call (${actions}), every table written (${tables}) and every server action that writes (${writes.length})${unread}`
1284
+ : "no SQL write in app code; no AGENTS.md beside the app: its actions are not checked here",
623
1285
  );
624
- else ok("agent", `all ${writes.length} server actions that write are in the agent's brief`);
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ /** Is the `from` at `at` one that SQL puts before a column, not a
1291
+ * table? `is [not] distinct from t.col`, and inside the parentheses of
1292
+ * extract(epoch from t.at), substring(… from …), trim(… from …) and
1293
+ * overlay(… from …). Read back to the parenthesis that holds it, within
1294
+ * the SQL's own string (a backtick, a double quote or a ; ends the
1295
+ * walk). Found in 0.7.0's review: an own app with a table named
1296
+ * app_events was refused `extract(epoch from app_events.created_at)`. */
1297
+ function columnFrom(code, at) {
1298
+ if (!/^from\b/i.test(code.slice(at, at + 5))) return false;
1299
+ if (/\bdistinct\s+$/i.test(code.slice(Math.max(0, at - 20), at))) return true;
1300
+ let depth = 0;
1301
+ for (let j = at - 1; j >= 0 && at - j < 400; j--) {
1302
+ const c = code[j];
1303
+ if (c === "`" || c === '"' || c === ";") return false;
1304
+ if (c === ")") depth++;
1305
+ else if (c === "(" && depth-- === 0) return /\b(?:extract|substring|trim|overlay)\s*$/i.test(code.slice(Math.max(0, j - 12), j));
1306
+ }
1307
+ return false;
1308
+ }
1309
+
1310
+ // 9. The app's tables are its own (0.7.0, claw's APPS-SPEC 4.1). When
1311
+ // makersclaw.json says "tables": "own", the app lives in its own
1312
+ // schema (app_<name> in Makersclaw) and names every table of its own
1313
+ // bare: its role's search_path finds them, and it holds nothing in
1314
+ // public. So in the app's code a qualified name is refused when it is:
1315
+ // - public.<t>: the schema every app shared up to 0.6.x. An own app's
1316
+ // role cannot reach it, so the page would fail in the workspace;
1317
+ // - app_<x>.<t>: a schema by name, even the app's own. A second copy of
1318
+ // the app lives in another schema, and another app's tables are not
1319
+ // this one's to reach;
1320
+ // - brain.<t> or brain_api.<t>: the company brain. An app reaches it
1321
+ // through lib/brain.js, where the door knows who asks and logs it;
1322
+ // the brain's functions (`select brain_api.write(…)`) have no
1323
+ // `from`, so any brain_api. name is refused.
1324
+ // claw.<t> and claw_app.<t> (the activity view) are the platform's
1325
+ // doors and pass. The names read, each bare or qualified: a surface's
1326
+ // source, insertRow's and updateRow's first argument (its `source`,
1327
+ // as for every call of lib/actions.js and lib/data.js), and the name
1328
+ // after from, join, into and update in SQL (not a column's from:
1329
+ // columnFrom). A string constant of the same file that the use sees
1330
+ // counts as written in place (`const SOURCE = "…"`, then `source:
1331
+ // SOURCE`). An app laid before 0.7.0 has no tables key and
1332
+ // keeps its public names: this rule does not read them, and neither
1333
+ // does "public". "moved" is refused: it is the platform's mode for a
1334
+ // legacy app whose tables the workspace's move put in its schema
1335
+ // (claw's MIGRATE-SPEC M17), never a manifest's.
1336
+ // And a bare name is one the app's own schema makes (claw's
1337
+ // MIGRATE-SPEC §10 and 7.2), so no bare name leans on something else:
1338
+ // on a moved app's search_path a view in public answers a bare name,
1339
+ // and a rebuild to own would ship code that reads it. The list is
1340
+ // APP_TABLES (CLAW_APP_TABLES, then ../install/schema.sql, then
1341
+ // ../../schema.sql); with none, the ok line says so. Bare names are
1342
+ // read where they are surely tables, since a bare word is also what
1343
+ // a column's value and prose look like:
1344
+ // - a source: every `source` of surfaces.js (a literal or a const the
1345
+ // entry sees, its key quoted or not), and the `source` of a call of
1346
+ // lib/data.js, lib/door.js or lib/actions.js by its name (loadRows,
1347
+ // useRows, insertRow, …), bare or through a namespace, either side
1348
+ // of a ternary. Not `source: "web"` in a row's values, nor another
1349
+ // function's `source`. Not read: an import alias, options held in a
1350
+ // variable (`loadRows(opts)`);
1351
+ // - SQL: strings joined with + read as one (sqlTexts), handed to
1352
+ // query() or a sql`…` template, or starting as a statement (select,
1353
+ // with, insert, update or delete, in one case). In it: the name
1354
+ // after from (and each after its commas), join, insert into, merge
1355
+ // into, update … set, a delete's or a merge's using, and a
1356
+ // statement's table, truncate or copy. Not a function (`from
1357
+ // unnest(…)`), a column's from, a CTE the same text names, a SQL
1358
+ // string ('…') or comment, or pg_catalog's (pg_*). Not read: SQL a
1359
+ // helper takes that does not start as a statement (`run("explain
1360
+ // …")`), SQL built at run time (an array's join). A qualified name
1361
+ // found there is judged as above, with spaces around its dot or
1362
+ // not: forms only SQL's own text shows (a comma's, a using's).
1363
+ {
1364
+ const { manifest, broken } = MANIFEST;
1365
+ const tables = manifest && typeof manifest === "object" ? manifest.tables : undefined;
1366
+ if (broken) fail("tables", "makersclaw.json", `is not JSON (${broken}), so where this app's tables live is unknown; \`makersclaw update\` reads it too`);
1367
+ else if (tables === undefined || tables === "public")
1368
+ ok("tables", tables === "public" ? 'on public (makersclaw.json "tables": "public"): names not checked here' : "no tables key in makersclaw.json (an app from before 0.7.0, on public): names not checked here");
1369
+ else if (tables === "moved")
1370
+ fail("tables", "makersclaw.json", `"tables": "moved" — moved is the platform's mode, never a manifest's: a moved app's makersclaw.json says "public" (or has no tables key) until its rebuild sets "own"`);
1371
+ else if (tables !== "own") fail("tables", "makersclaw.json", `"tables": ${JSON.stringify(tables)} — an app's tables are "own" or "public"; set the one the platform holds for this app`);
1372
+ else {
1373
+ const L = limiter("tables");
1374
+ const why = (schema, table) => {
1375
+ const s = schema.toLowerCase();
1376
+ if (s === "public") return `this app's tables are in its own schema ("tables": "own") and its role reaches nothing in public; write ${table}, bare`;
1377
+ if (/^app_./.test(s)) return `name the app's own tables bare (${table}): a second copy of the app lives in another schema, and another app's tables are not this one's`;
1378
+ if (s === "brain" || s === "brain_api") return "the company brain is reached through lib/brain.js (listRecords, getRecord, writeRecord…), never by its tables";
1379
+ return null; // claw., claw_app., and schemas that are not apps' to keep out of
1380
+ };
1381
+ // A name two readers see (`from public.x`, over the whole code and in
1382
+ // its SQL) is reported once: the limiter keeps one per file and name.
1383
+ const hits = [];
1384
+ const judge = (f, name, index) => {
1385
+ const m = new RegExp(String.raw`^(?:"(${ID})"|(${ID}))\.(?:"(${ID})"|(${ID}))$`).exec(name.trim().replace(/\\"/g, '"'));
1386
+ if (!m) return; // bare, or not a table name at all
1387
+ const schema = m[1] ?? m[2];
1388
+ const table = m[3] ?? m[4];
1389
+ const reason = why(schema, table);
1390
+ if (reason) hits.push({ f, line: lineOf(f.code, index), name: `${schema}.${table}`, reason });
1391
+ };
1392
+ for (const f of appFiles) {
1393
+ if (!/\.(?:jsx?|mjs|cjs|tsx?|mts|cts)$/.test(f.path)) continue;
1394
+ const code = f.code;
1395
+ // The file's string constants, so a name held in one is read where
1396
+ // it is written.
1397
+ const constAt = stringConsts(code);
1398
+ const named = (value, id, index) => {
1399
+ if (value !== undefined) return judge(f, value, index);
1400
+ const c = constAt(id, index);
1401
+ if (c) judge(f, c.value, c.index);
1402
+ };
1403
+ // A source, wherever one is named: a surface's (surfaces.js), and
1404
+ // the first argument of insertRow, updateRow and the rest of
1405
+ // lib/actions.js and lib/data.js, which names its table in
1406
+ // `source`. Written in place (`source: "deals"`, or its key quoted,
1407
+ // `"source": "deals"`), through a constant (`source: SOURCE`), as a
1408
+ // variable (`const source = "deals"`, then `{ source }`) or a prop
1409
+ // (`source="deals"`).
1410
+ for (const m of code.matchAll(new RegExp(String.raw`(?:\bsource|"source"|'source')\s*(?::\s*|=(?![=>])\s*\{?\s*)(?:${lit(1)}|(${ID})(?![\w$]|\s*[.(\[?]))`, "g"))) named(m[2], m[3], m.index);
1411
+ // SQL, wherever it is written: a string, a template over several
1412
+ // lines, pieces joined with +. A JavaScript statement never puts a
1413
+ // name after these words (an import's path is quoted), so the whole
1414
+ // code is read. A quoted identifier counts ("public"."deals"). SQL
1415
+ // is written in one case, so a Capitalised word is prose, not a
1416
+ // schema ("Imported from Public.com"); prose in lower case that
1417
+ // reads like one is refused like SQL. A `from` that SQL puts before
1418
+ // a column is passed over (columnFrom). What only SQL's own strings
1419
+ // show (a comma's table, a using, spaces around the dot) is read
1420
+ // below, in the strings that are SQL.
1421
+ for (const m of code.matchAll(new RegExp(String.raw`\b(?:from|join|into|update)\s+(?:only\s+)?((?:\\?"${ID}\\?"|${ID})\.(?:\\?"${ID}\\?"|${ID}))`, "gi"))) {
1422
+ if (!/^[A-Z][a-z]/.test(m[1]) && !columnFrom(code, m.index)) judge(f, m[1], m.index + m[0].length - m[1].length);
1423
+ }
1424
+ for (const m of code.matchAll(new RegExp(String.raw`\bbrain_api\.(?:"${ID}"|${ID})`, "g"))) judge(f, m[0], m.index);
1425
+ }
1426
+ // The last clause: a bare name is one the app's own schema makes.
1427
+ // With no list here, bare names are not held to one.
1428
+ let bare = () => {};
1429
+ if (APP_TABLES) {
1430
+ const { names, from } = APP_TABLES;
1431
+ const sorted = [...names].sort();
1432
+ const list = sorted.length ? `${sorted.slice(0, 6).join(", ")}${sorted.length > 6 ? ", …" : ""}` : "none";
1433
+ const reason = (name) =>
1434
+ `not a table or view this app's schema makes (${from}: ${list}). A bare name reaches only the app's own schema: in the workspace ` +
1435
+ `${name} fails, or, on a moved app, reads a view in public. Name a table of its own, or reach a company thing through lib/brain.js` +
1436
+ (from === "../install/schema.sql"
1437
+ ? `; a table made since with sql is not in ${from}: the platform's ship checks the live list`
1438
+ : from === "../../schema.sql"
1439
+ ? `; a table the package makes belongs in ${from}`
1440
+ : "");
1441
+ // `fold`: SQL folds an unquoted name to lower case; a source is
1442
+ // quoted as written (lib/db.js ident, the door).
1443
+ bare = (f, raw, index, fold) => {
1444
+ const m = new RegExp(String.raw`^\s*(?:\\?"(${ID})\\?"|(${ID}))\s*$`).exec(raw);
1445
+ if (!m) return; // qualified, or not one name: judge reads it
1446
+ const name = m[1] ?? (fold ? m[2].toLowerCase() : m[2]);
1447
+ if (names.has(name) || /^pg_/.test(name)) return;
1448
+ hits.push({ f, line: lineOf(f.code, index), name, reason: reason(name) });
1449
+ };
1450
+ }
1451
+ // Sources: surfaces.js's entries, and the `source` of a call of the
1452
+ // data layer, by its name (bare, or through a namespace).
1453
+ const TAKES_SOURCE = ["loadRows", "loadRow", "countRows", "countBy", "useRows", "useRow", "useCount", "useCountBy", ...ROW_WRITES];
1454
+ const CALL = new RegExp(String.raw`(?<![\w$])(?:${ID}\s*\??\.\s*)?(?:${TAKES_SOURCE.join("|")})\s*(?:\?\.\s*)?\(\s*(?=\{)`, "g");
1455
+ const VALUE = String.raw`"[^"\\\n]*"|'[^'\\\n]*'|${ID}`;
1456
+ // SQL: a run of strings (sqlTexts) that is SQL by where it goes
1457
+ // (handedToQuery) or by how it starts, its verb in one case.
1458
+ const STATEMENT = new RegExp(
1459
+ String.raw`^\s*\(?\s*(?:(select)\s|(with)\s+(?:recursive\s+)?${ID}\s*(?:\([^()]*\)\s*)?as\s*(?:not\s+)?(?:materialized\s*)?\(|(insert)\s+into\s|(update)\s+(?:only\s+)?(?:\\?"${ID}\\?"|${ID})(?:\s+(?:as\s+)?${ID})?\s+set\s|(delete)\s+from\s)`,
1460
+ "i",
1461
+ );
1462
+ // Where SQL names a table: the words, then one name, or (`list`) a
1463
+ // comma's more (from a, b; using a, b; truncate a, b). A qualified
1464
+ // name may have spaces around its dot (public . deals).
1465
+ const PART = String.raw`\\?"${ID}\\?"|${ID}`;
1466
+ const ONE = String.raw`(?:only\s+)?(?:${PART})(?:\s*\.\s*(?:${PART}))?`;
1467
+ const PLACES = [
1468
+ [/(?<![\w$])from\s+/gi, { list: true, fn: true, from: true }],
1469
+ [/(?<![\w$])join\s+/gi, { fn: true }],
1470
+ [/(?<![\w$])(?:insert|merge)\s+into\s+/gi, {}],
1471
+ [/(?<![\w$])update\s+/gi, { set: true }],
1472
+ [new RegExp(String.raw`(?<![\w$])(?:delete\s+from|merge\s+into)\s+${ONE}(?:\s*\*)?(?:\s+(?:as\s+)?(?!using\b)${ID})?\s+using\s+`, "gi"), { list: true, fn: true }],
1473
+ [/(?:^|[;(]|(?<![\w$])(?:union|intersect|except)(?:\s+(?:all|distinct))?)\s*table\s+/gi, {}],
1474
+ [/(?:^|;)\s*truncate\s+(?:table\s+)?/gi, { list: true }],
1475
+ [/(?:^|;)\s*copy\s+/gi, {}],
1476
+ ];
1477
+ const ITEM = new RegExp(String.raw`(${PART})(?:\s*\.\s*(${PART}))?(?![\w$]|\s*\.)`, "y");
1478
+ const MORE = new RegExp(String.raw`(?:\s*\*)?(?:\s+(?:as\s+)?${ID})?\s*,\s*`, "y");
1479
+ const SET = new RegExp(String.raw`^(?:\s+(?:as\s+)?${ID})?\s+set\b`, "i");
1480
+ const CTE = new RegExp(String.raw`(?:\bwith\s+(?:recursive\s+)?|,\s*)(${ID})\s*(?:\([^()]*\)\s*)?as\s*(?:not\s+)?(?:materialized\s*)?\(`, "gi");
1481
+ const KEYWORDS = new Set(["lateral", "only", "select", "set", "values", "rows", "stdin", "program"]);
1482
+ // The names a SQL text holds: { schema, table, index }, no schema for a
1483
+ // bare one. Not a function (`from unnest(…)`), a column's from, a CTE
1484
+ // the same text names, or a keyword.
1485
+ const namesIn = (text) => {
1486
+ const ctes = new Set([...text.matchAll(CTE)].map((m) => m[1].toLowerCase()));
1487
+ const out = [];
1488
+ for (const [re, how] of PLACES) {
1489
+ for (const m of text.matchAll(re)) {
1490
+ if (how.from && columnFrom(text, m.index)) continue;
1491
+ let pos = m.index + m[0].length;
1492
+ for (;;) {
1493
+ pos += /^only\s+/i.exec(text.slice(pos))?.[0].length ?? 0;
1494
+ ITEM.lastIndex = pos;
1495
+ const it = ITEM.exec(text);
1496
+ if (!it) break;
1497
+ const rest = text.slice(ITEM.lastIndex);
1498
+ if (how.fn && /^\s*\(/.test(rest)) break; // a function: from unnest($1)
1499
+ if (how.set && !SET.test(rest)) break; // for update, on update cascade
1500
+ const plain = (it[2] ?? it[1]).replace(/\\?"/g, "").toLowerCase();
1501
+ if (it[2]) out.push({ schema: it[1], table: it[2], index: pos });
1502
+ else if (KEYWORDS.has(plain)) break;
1503
+ else if (!ctes.has(plain)) out.push({ table: it[1], index: pos });
1504
+ if (!how.list) break;
1505
+ MORE.lastIndex = ITEM.lastIndex;
1506
+ if (!MORE.exec(text)) break;
1507
+ pos = MORE.lastIndex;
1508
+ }
1509
+ }
1510
+ }
1511
+ return out;
1512
+ };
1513
+ for (const f of appFiles) {
1514
+ if (!/\.(?:jsx?|mjs|cjs|tsx?|mts|cts)$/.test(f.path)) continue;
1515
+ const code = f.code;
1516
+ if (APP_TABLES) {
1517
+ const constAt = stringConsts(code);
1518
+ // A literal, or a const of the file the use sees.
1519
+ const valueAt = (v, at) => {
1520
+ const quoted = new RegExp(`^${lit(1)}$`).exec(v);
1521
+ return quoted ? quoted[2] : new RegExp(`^${ID}$`).test(v) ? constAt(v, at)?.value : undefined;
1522
+ };
1523
+ if (f.path === "surfaces.js") {
1524
+ for (const m of code.matchAll(new RegExp(String.raw`(?:\bsource|"source"|'source')\s*:\s*(?:${lit(1)}|(${ID})(?![\w$]|\s*[.(\[?]))`, "g"))) {
1525
+ const value = m[2] ?? constAt(m[3], m.index)?.value;
1526
+ if (value !== undefined) bare(f, value, m.index, false);
1527
+ }
1528
+ }
1529
+ for (const m of code.matchAll(CALL)) {
1530
+ const start = m.index + m[0].length;
1531
+ const p = objectProps(code.slice(start, closeOf(code, start) + 1)).findLast((q) => q.depth === 1 && q.key === "source");
1532
+ if (!p) continue;
1533
+ const at = start + p.at;
1534
+ // Either side of a ternary: source: open ? "deals" : "archive".
1535
+ const either = new RegExp(String.raw`^[^?:"'\`]+\?\s*(${VALUE})\s*:\s*(${VALUE})$`).exec(p.value.trim());
1536
+ for (const v of either ? [either[1], either[2]] : [p.value.trim()]) {
1537
+ const value = valueAt(v, at);
1538
+ if (value !== undefined) bare(f, value, at, false);
1539
+ }
1540
+ }
1541
+ }
1542
+ for (const { text, at, handed } of sqlTexts(code)) {
1543
+ if (!handed) {
1544
+ const s = STATEMENT.exec(text);
1545
+ const verb = s && s.slice(1).find(Boolean);
1546
+ if (!verb || (verb !== verb.toLowerCase() && verb !== verb.toUpperCase())) continue;
1547
+ }
1548
+ for (const n of namesIn(text)) {
1549
+ if (n.schema) judge(f, `${n.schema}.${n.table}`, at(n.index));
1550
+ else bare(f, n.table, at(n.index), true);
1551
+ }
1552
+ }
1553
+ }
1554
+ // Reported file by file, top to bottom, whichever scanner read each.
1555
+ const order = new Map(appFiles.map((f, i) => [f, i]));
1556
+ hits.sort((a, b) => order.get(a.f) - order.get(b.f) || a.line - b.line);
1557
+ for (const h of hits) L.hit(`${h.f.path}:${h.line}`, h.name.toLowerCase(), `${h.name}: ${h.reason}`);
1558
+ if (L.count > 4) fail("tables", `${L.count - 4} more`, "names outside this app's own tables; each is written bare, is one the app's schema makes, or goes through lib/brain.js");
1559
+ if (!L.count) {
1560
+ ok(
1561
+ "tables",
1562
+ APP_TABLES
1563
+ ? `its own schema (makersclaw.json): every table named bare, and each bare name one of the ${APP_TABLES.names.size} its schema makes (${APP_TABLES.from}); claw. and claw_app. are the platform's`
1564
+ : "its own schema (makersclaw.json): every table named bare; claw. and claw_app. are the platform's; no table list here (CLAW_APP_TABLES, ../install/schema.sql, ../../schema.sql), so bare names are not held to one",
1565
+ );
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ // 10. A browser app is files and a door (0.7.0, claw's STATIC-SPEC §3).
1571
+ // Only when makersclaw.json says "mode": "browser", exactly as
1572
+ // next.config.mjs reads it. Its pages are built once into files the
1573
+ // platform serves; nothing of it runs in the VM, and every read and
1574
+ // write goes through the platform's data door (lib/door.js, which
1575
+ // lib/data.js and lib/actions.js re-export). So the app holds:
1576
+ // 1. no "use server" module, and no function marked so: there is no
1577
+ // server to run it;
1578
+ // 2. no import of next/headers, next/cache, next/server, pg or
1579
+ // lib/db (nor lib/brain-server, which browser mode drops);
1580
+ // 3. no route.js or route.ts under app/;
1581
+ // 4. no proxy.js or middleware.js at the root. Under export Next
1582
+ // warns and then ignores it, so its token check would be dead
1583
+ // without a word;
1584
+ // 5. no export of dynamic, revalidate, fetchCache, runtime or
1585
+ // dynamicParams (route segment config) in its code;
1586
+ // 6. every surface file surfaces.js mounts starts with "use client":
1587
+ // the export holds its loading line, the browser renders it;
1588
+ // 7. no process.env.<X> but CLAW_BASE_PATH, CLAW_PRODUCT,
1589
+ // NEXT_PUBLIC_* and NODE_ENV. A prerendered page prints what it
1590
+ // reads into a file anyone who opens the app can fetch; NODE_ENV
1591
+ // is "production" in every build and no secret.
1592
+ // The build refuses 1, 2 and 5 itself, later and less clearly. 3 and
1593
+ // 4 read the folders, not the rules' file list: the template's own
1594
+ // app/claw-alive/route.js and proxy.js are server mode's, and a
1595
+ // browser app that still holds them is told to delete them.
1596
+ {
1597
+ if (MANIFEST.manifest?.mode === "browser") {
1598
+ const L = limiter("browser");
1599
+ const code = files.filter((f) => /\.(?:[mc]?jsx?|[mc]?tsx?)$/.test(f.path));
1600
+ const NO_SERVER = "a browser app has no server to run it: write with lib/actions.js (insertRow, updateRow, runAction…), which the platform's data door answers";
1601
+
1602
+ // 1. "use server", as a file's directive or a function's first line (after the
1603
+ // file's start, `{` or `;`: the last string of an array is not one).
1604
+ scan(/(^|[{;])(\s*)(["'])use server\3\s*(?:;|(?=\n|\}|$))/, (f, m, line) =>
1605
+ L.hit(`${f.path}:${lineOf(f.code, m.index + m[1].length + m[2].length)}`, "use-server", `"use server" — ${NO_SERVER}`), code);
1606
+
1607
+ // 2. Server modules, however the file reaches them.
1608
+ const SERVER_MODULE = {
1609
+ "next/headers": "a browser app's page is a file built once, with no request: who is asking is the door's to know (lib/door.js)",
1610
+ "next/cache": "a browser app's page is a file built once: nothing to revalidate; the door's change stamp refreshes the rows (app/auto-refresh.jsx)",
1611
+ "next/server": "a browser app has no request handlers: the platform's data door answers its reads and writes",
1612
+ pg: "a browser app holds no database connection: read with lib/data.js (useRows…), write with lib/actions.js",
1613
+ "lib/db": "a browser app holds no database connection: read with lib/data.js (useRows…), write with lib/actions.js",
1614
+ "lib/brain-server": "browser mode has no lib/brain-server.js: read the brain with lib/brain.js, from a client component",
1615
+ };
1616
+ const IMPORT_SPEC = /\b(?:import|export)\b(?:(?!\b(?:import|export)\b)[^;"'`])*?\bfrom\s*(["'])([^"'\n]+)\1|\bimport\s*(["'])([^"'\n]+)\3|\b(?:require|import)\s*\(\s*(["'`])([^"'`\n]+)\5\s*\)/;
1617
+ scan(IMPORT_SPEC, (f, m, line) => {
1618
+ const spec = m[2] ?? m[4] ?? m[6];
1619
+ const name = spec.startsWith(".") ? importOf(f.path, spec) : spec.replace(/^(pg)\/.*$/, "$1");
1620
+ if (Object.prototype.hasOwnProperty.call(SERVER_MODULE, name)) L.hit(`${f.path}:${line}`, `import:${name}`, `imports ${spec} — ${SERVER_MODULE[name]}`);
1621
+ }, code);
1622
+
1623
+ // 3. Route handlers under app/, on disk.
1624
+ const appDir = join(ROOT, "app");
1625
+ for (const full of existsSync(appDir) ? walk(appDir) : []) {
1626
+ const rel = relative(ROOT, full).split(sep).join("/");
1627
+ if (!/(?:^|\/)route\.(?:[mc]?jsx?|[mc]?tsx?)$/.test(rel)) continue;
1628
+ if (rel === "app/claw-alive/route.js") L.hit(rel, "route", "server mode's liveness route; browser mode drops it (a browser app's auto-refresh asks the door what changed): delete it");
1629
+ else L.hit(rel, "route", "a browser app has no request handlers: its pages are files, and the platform's data door answers its reads and writes (lib/door.js)");
1630
+ }
1631
+
1632
+ // 4. A proxy or a middleware at the root, on disk.
1633
+ for (const name of readdirSync(ROOT).filter((n) => /^(?:proxy|middleware)\.(?:[mc]?jsx?|[mc]?tsx?)$/.test(n)).sort()) {
1634
+ L.hit(name, "proxy", 'under output: "export" Next warns and then ignores it, so its check would never run; the platform guards a browser app\'s files itself: delete it');
1635
+ }
1636
+
1637
+ // 5. Route segment config, as a declaration or in an export list.
1638
+ const SEGMENT = new Set(["dynamic", "revalidate", "fetchCache", "runtime", "dynamicParams"]);
1639
+ const SEGMENT_DO = "a browser app's every page is a file built once, and route segment config only fails the export: take it out (a route of your own lists its values in generateStaticParams)";
1640
+ scan(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/, (f, m, line) => {
1641
+ if (SEGMENT.has(m[1])) L.hit(`${f.path}:${line}`, `segment:${m[1]}`, `export const ${m[1]} — ${SEGMENT_DO}`);
1642
+ }, code);
1643
+ scan(/\bexport\s*\{([^}]*)\}/, (f, m, line) => {
1644
+ for (const part of m[1].split(",")) {
1645
+ const name = part.trim().split(/\s+as\s+/).pop().trim();
1646
+ if (SEGMENT.has(name)) L.hit(`${f.path}:${line}`, `segment:${name}`, `export { ${part.trim()} } — ${SEGMENT_DO}`);
1647
+ }
1648
+ }, code);
1649
+
1650
+ // 6. Every surface the manifest mounts is a client component.
1651
+ let clientSurfaces = 0;
1652
+ for (const mod of [...surfaceSources().keys()].sort()) {
1653
+ const file = [".jsx", ".js", ".tsx", ".ts", "/index.jsx", "/index.js"].map((ext) => `${mod}${ext}`).find((p) => existsSync(join(ROOT, p)));
1654
+ if (!file) continue; // rule 4 names a surface that is not there
1655
+ if (/^\s*(["'])use client\1/.test(stripComments(readFileSync(join(ROOT, file), "utf8")))) clientSurfaces++;
1656
+ else L.hit(`${file}:1`, "client", 'a browser app\'s surface renders in the browser: put "use client" on its first line (it reads its rows with lib/data.js\'s hooks, and ?row= with useSearchParams)');
1657
+ }
1658
+
1659
+ // 7. The environment, by name only, and only the names a page may print.
1660
+ const ENV_OK = (name) => name === "CLAW_BASE_PATH" || name === "CLAW_PRODUCT" || name === "NODE_ENV" || name.startsWith("NEXT_PUBLIC_");
1661
+ const ENV_DO = "a prerendered page prints what it reads into a file anyone who opens the app can fetch; a browser app reads only CLAW_BASE_PATH, CLAW_PRODUCT and NEXT_PUBLIC_* (a secret stays with the platform)";
1662
+ scan(/\bprocess\s*\??\.\s*env\b(?:\s*\??\.\s*([A-Za-z_$][\w$]*)|\s*(?:\?\.)?\s*\[\s*(?:(["'`])([^"'`\n]*)\2\s*\])?)?/, (f, m, line) => {
1663
+ const where = `${f.path}:${line}`;
1664
+ const named = m[1] ?? m[3];
1665
+ if (named !== undefined) {
1666
+ if (!ENV_OK(named)) L.hit(where, `env:${named}`, `process.env.${named} — ${ENV_DO}`);
1667
+ return;
1668
+ }
1669
+ if (/\[\s*$/.test(m[0])) {
1670
+ L.hit(where, "env:[]", `process.env[…] — a name the code decides at run time; ${ENV_DO}`);
1671
+ return;
1672
+ }
1673
+ // The whole object: destructured by name, or handed on.
1674
+ const before = f.code.slice(0, m.index);
1675
+ const pattern = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=\s*$/.exec(before);
1676
+ if (!pattern) {
1677
+ L.hit(where, "env:*", `process.env, whole — ${ENV_DO}`);
1678
+ return;
1679
+ }
1680
+ for (const part of pattern[1].split(",").map((p) => p.trim()).filter(Boolean)) {
1681
+ const name = part.startsWith("...") ? "..." : part.split(/[:=]/)[0].trim();
1682
+ if (name === "...") L.hit(where, "env:*", `{ ${part} } = process.env takes every variable — ${ENV_DO}`);
1683
+ else if (!ENV_OK(name)) L.hit(where, `env:${name}`, `{ ${name} } = process.env — ${ENV_DO}`);
1684
+ }
1685
+ }, code);
1686
+
1687
+ if (L.count > 4) fail("browser", `${L.count - 4} more`, "more of the same: what a browser app cannot hold (server code, a route, a proxy, segment config, a server surface, an env name a page would print)");
1688
+ if (!L.count) ok("browser", `files and a door: ${clientSurfaces} client surface${clientSurfaces === 1 ? "" : "s"}; no server code, route, proxy or segment config; env only by the names a page may print`);
1689
+ }
1690
+ }
1691
+
1692
+ // 11. The company brain: what the app touches is declared (0.7.0, claw's
1693
+ // CONTRACTS §9). makersclaw.json's `brain` block names the kinds the
1694
+ // app defines (`kinds`), reads and writes; the door hands the app
1695
+ // only what `reads` and `writes` name (writes imply reads, "*" is
1696
+ // every kind) and answers anything else `not_declared`, on the
1697
+ // member's screen. So this rule checks the block's shape (kinds,
1698
+ // reads, writes and links are arrays; each kind has kind, label,
1699
+ // plural and fields), then reads every call of lib/brain.js and
1700
+ // lib/brain-server.js whose kind is written in the code, in place or
1701
+ // as a string constant of the same file that the call sees:
1702
+ // - a read's (listRecords, countRecords, brainLists, and the hooks
1703
+ // useRecords and useRecordCounts: `kind`; searchBrain,
1704
+ // brainChanges and the hook useBrainSearch: `kinds`;
1705
+ // brainRead("<one of them>", { … }), which they all run through) is
1706
+ // in reads or writes;
1707
+ // - a write's (writeRecord, createList: `kind`; writeRecords: each
1708
+ // entry's) is in writes.
1709
+ // A kind held in a variable is not read. The full rules (field types,
1710
+ // keys, each link against the app's tables, "*" only in reviewed
1711
+ // code) are claw's (shared/src/brain-declaration.ts) and run when the
1712
+ // app ships or installs; this rule does not copy them.
1713
+ {
1714
+ const L = limiter("brain");
1715
+ const { manifest, broken } = MANIFEST;
1716
+ const declared = !!manifest && typeof manifest === "object" && !Array.isArray(manifest) && Object.prototype.hasOwnProperty.call(manifest, "brain");
1717
+ const brain = declared ? manifest.brain : undefined;
1718
+ const what = (v) => (v === null ? "null" : Array.isArray(v) ? "an array" : v === "" ? "an empty string" : typeof v === "object" ? "an object" : `a ${typeof v}`);
1719
+ const LISTS = {
1720
+ kinds: '[{ "kind": "contact", "label": "Contact", "plural": "Contacts", "fields": [...] }]',
1721
+ reads: '["company"]',
1722
+ writes: '["contact"]',
1723
+ links: '[{ "table": "tasks", "column": "contact_id", "kind": "contact", "on_erase": "null" }]',
1724
+ };
1725
+
1726
+ // The shape.
1727
+ const shape = [];
1728
+ if (declared) {
1729
+ if (!brain || typeof brain !== "object" || Array.isArray(brain)) shape.push(`"brain" is ${what(brain)}, not an object: "brain": { "kinds": [...], "reads": [...], "writes": [...], "links": [...] }`);
1730
+ else {
1731
+ for (const [key, example] of Object.entries(LISTS)) {
1732
+ if (brain[key] !== undefined && !Array.isArray(brain[key])) shape.push(`brain.${key} is ${what(brain[key])}, not an array: "${key}": ${example}`);
1733
+ }
1734
+ (Array.isArray(brain.kinds) ? brain.kinds : []).forEach((def, i) => {
1735
+ if (!def || typeof def !== "object" || Array.isArray(def)) {
1736
+ shape.push(`brain.kinds[${i}] is ${what(def)}; each kind is an object with kind, label, plural and fields`);
1737
+ return;
1738
+ }
1739
+ const wrong = [];
1740
+ for (const k of ["kind", "label", "plural"]) {
1741
+ if (def[k] === undefined) wrong.push(`no ${k}`);
1742
+ else if (typeof def[k] !== "string" || !def[k].trim()) wrong.push(`its ${k} is ${what(def[k])}`);
1743
+ }
1744
+ if (def.fields === undefined) wrong.push("no fields");
1745
+ else if (!Array.isArray(def.fields)) wrong.push(`its fields are ${what(def.fields)}`);
1746
+ const name = typeof def.kind === "string" && def.kind.trim() ? ` ("${def.kind}")` : "";
1747
+ if (wrong.length) shape.push(`brain.kinds[${i}]${name} has ${wrong.join(", ")}: each kind has kind, label and plural (words) and fields (an array)`);
1748
+ });
1749
+ for (const key of ["reads", "writes"]) {
1750
+ (Array.isArray(brain[key]) ? brain[key] : []).forEach((k, i) => {
1751
+ if (typeof k !== "string" || !k.trim()) shape.push(`brain.${key}[${i}] is ${what(k)}; ${key} names kinds: "${key}": ${LISTS[key]}`);
1752
+ });
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ // The calls: every call of a function imported from lib/brain.js or
1758
+ // lib/brain-server.js (by name, or `import * as brain`), and the kinds
1759
+ // it names.
1760
+ const ONE_READ = new Set(["listRecords", "countRecords", "brainLists", "useRecords", "useRecordCounts"]);
1761
+ const MANY_READ = new Set(["searchBrain", "brainChanges", "useBrainSearch"]);
1762
+ const ONE_WRITE = new Set(["writeRecord", "createList"]);
1763
+ const calls = []; // { f, line, fn, kind, write }
1764
+ for (const f of appFiles) {
1765
+ if (!/\.(?:jsx?|mjs|cjs|tsx?|mts|cts)$/.test(f.path)) continue;
1766
+ const code = f.code;
1767
+ const named = new Map(); // local name → the function it is
1768
+ const spaces = new Set();
1769
+ for (const m of code.matchAll(/^\s*import\s+([^;]*?)\s+from\s*["']([^"'\n]+)["']/gm)) {
1770
+ if (!m[2].startsWith(".")) continue;
1771
+ const target = posix.normalize(posix.join(posix.dirname(f.path), m[2])).replace(/\.js$/, "");
1772
+ if (target !== "lib/brain" && target !== "lib/brain-server") continue;
1773
+ for (const part of /\{([^}]*)\}/.exec(m[1])?.[1].split(",") ?? []) {
1774
+ const [fn, as] = part.trim().split(/\s+as\s+/);
1775
+ if (fn) named.set((as ?? fn).trim(), fn.trim());
1776
+ }
1777
+ const ns = new RegExp(String.raw`\*\s*as\s+(${ID})`).exec(m[1]);
1778
+ if (ns) spaces.add(ns[1]);
1779
+ }
1780
+ if (!named.size && !spaces.size) continue;
1781
+ const esc = (name) => name.replace(/\$/g, "\\$");
1782
+ const found = [];
1783
+ for (const [local, fn] of named) {
1784
+ for (const m of code.matchAll(new RegExp(String.raw`(?<![\w$.])${esc(local)}\s*\(`, "g"))) found.push({ fn, open: m.index + m[0].length - 1 });
1785
+ }
1786
+ for (const ns of spaces) {
1787
+ for (const m of code.matchAll(new RegExp(String.raw`(?<![\w$.])${esc(ns)}\s*\??\.\s*(${ID})\s*\(`, "g"))) found.push({ fn: m[1], open: m.index + m[0].length - 1 });
1788
+ }
1789
+ const constAt = stringConsts(code);
1790
+ // A property's value as kind names: a literal, or a constant of this
1791
+ // file that the use at `at` sees; `many` reads a list ("a,b", or an
1792
+ // array of either). Anything else the code decides at run time: no
1793
+ // names.
1794
+ const namesOf = (value, many, at) => {
1795
+ const one = (v) => {
1796
+ const m = new RegExp(`^${lit(1)}$`).exec(v.trim());
1797
+ if (m) return m[2];
1798
+ return new RegExp(`^${ID}$`).test(v.trim()) ? (constAt(v.trim(), at)?.value ?? null) : null;
1799
+ };
1800
+ if (many && /^\[[\s\S]*\]$/.test(value)) return value.slice(1, -1).split(",").map(one).filter((v) => v !== null).map((v) => v.trim()).filter(Boolean);
1801
+ const v = one(value);
1802
+ if (v === null) return [];
1803
+ return many ? v.split(",").map((s) => s.trim()).filter(Boolean) : [v];
1804
+ };
1805
+ for (const { fn: called, open } of found) {
1806
+ let fn = called;
1807
+ let label = called;
1808
+ let at = open + 1; // where `args` starts in the code
1809
+ let args = code.slice(at, closeOf(code, open));
1810
+ // brainRead("listRecords", { kind }, send): every read of
1811
+ // lib/brain.js runs through it, and it is exported for
1812
+ // lib/brain-server.js, so an app can call it too. It is read as the
1813
+ // read it names, its second argument as that read's.
1814
+ if (fn === "brainRead") {
1815
+ const m = new RegExp(String.raw`^\s*${lit(1)}\s*,\s*`).exec(args);
1816
+ if (!m || args[m[0].length] !== "{") continue;
1817
+ fn = m[2];
1818
+ label = `brainRead("${fn}")`;
1819
+ at += m[0].length;
1820
+ args = code.slice(at, closeOf(code, at) + 1);
1821
+ }
1822
+ const add = (p, write, many) => {
1823
+ for (const kind of namesOf(p.value, many, at + p.at)) calls.push({ f, line: lineOf(code, at + p.at), fn: label, kind, write, call: open });
1824
+ };
1825
+ for (const p of objectProps(args)) {
1826
+ if (p.depth === 1 && p.key === "kind" && (ONE_READ.has(fn) || ONE_WRITE.has(fn))) add(p, ONE_WRITE.has(fn), false);
1827
+ else if (p.depth === 1 && p.key === "kinds" && MANY_READ.has(fn)) add(p, false, true);
1828
+ // writeRecords: the kind of each entry of `writes`, wherever the
1829
+ // entries are made (an array, a map's callback); never an
1830
+ // entry's owner ({ kind: "member", id }).
1831
+ else if (fn === "writeRecords" && p.key === "kind" && p.path[0] === "writes" && !p.path.some((k) => k === "owner" || k === "fields")) add(p, true, false);
1832
+ }
1833
+ }
1834
+ }
1835
+
1836
+ const list = (xs) => (xs.length ? xs.map((k) => (k === "*" ? "every kind" : k)).join(", ") : "none");
1837
+ if (broken) fail("brain", "makersclaw.json", "is not JSON, so what this app declares in the company brain is unknown");
1838
+ else if (shape.length) {
1839
+ for (const s of shape.slice(0, 4)) fail("brain", "makersclaw.json", s);
1840
+ if (shape.length > 4) fail("brain", `${shape.length - 4} more`, "in makersclaw.json's brain block");
1841
+ } else {
1842
+ const reads = declared ? (brain.reads ?? []) : [];
1843
+ const writes = declared ? (brain.writes ?? []) : [];
1844
+ const defined = new Set(declared ? (brain.kinds ?? []).map((d) => d.kind) : []);
1845
+ const readAll = reads.includes("*") || writes.includes("*");
1846
+ const writeAll = writes.includes("*");
1847
+ const hits = [];
1848
+ for (const c of calls) {
1849
+ const said = `${c.fn} ${c.write ? "writes" : "reads"} "${c.kind}"`;
1850
+ // note is the brain's own kind: declared by name, with no def.
1851
+ const def = c.kind === "note" || defined.has(c.kind) ? "" : ', with its def in "kinds"';
1852
+ let why = null;
1853
+ if (!declared) why = `${said}, and makersclaw.json has no brain block: the door hands an app only the kinds it declares. Add "brain": { "kinds": [its def], "${c.write ? "writes" : "reads"}": ["${c.kind}"] } (README §5)`;
1854
+ else if (c.write && !writeAll && !writes.includes(c.kind))
1855
+ why = readAll || reads.includes(c.kind) ? `${said}, which the brain block only reads: add it to "writes"` : `${said}, which the brain block does not declare: add it to "writes"${def}`;
1856
+ else if (!c.write && !readAll && !reads.includes(c.kind) && !writes.includes(c.kind))
1857
+ why = defined.has(c.kind)
1858
+ ? `${said}: "kinds" defines it, but neither "reads" nor "writes" names it, and the door hands the app only what those name; add it to "reads"`
1859
+ : `${said}, which the brain block does not declare (reads: ${list(reads)}; writes: ${list(writes)}): add it to "reads"${def}`;
1860
+ if (why) hits.push({ ...c, why });
1861
+ }
1862
+ const order = new Map(appFiles.map((f, i) => [f, i]));
1863
+ hits.sort((a, b) => order.get(a.f) - order.get(b.f) || a.line - b.line);
1864
+ for (const h of hits) L.hit(`${h.f.path}:${h.line}`, `${h.fn} ${h.kind}`, h.why);
1865
+ if (L.count > 4) fail("brain", `${L.count - 4} more`, "lib/brain.js calls whose kind makersclaw.json's brain block does not declare");
1866
+ if (!L.count) {
1867
+ const n = new Set(calls.map((c) => `${c.f.path} ${c.call}`)).size;
1868
+ const names = n ? `${n} lib/brain.js call${n === 1 ? "" : "s"} name${n === 1 ? "s" : ""} only declared kinds` : "no lib/brain.js call names a kind";
1869
+ ok("brain", declared ? `reads ${list(reads)} · writes ${list(writes)} (makersclaw.json); ${names}` : `no brain block in makersclaw.json; ${names}`);
1870
+ }
625
1871
  }
626
1872
  }
627
1873
 
@@ -648,17 +1894,34 @@ function writable(dir) {
648
1894
  }
649
1895
 
650
1896
  /** Where the build goes: { dir, dist } — in place when the app folder is
651
- * ours to write, else a copy of the sources under a folder that is. */
652
- function buildSite() {
653
- if (writable(ROOT)) return { dir: ROOT, dist: ".next-check", copied: false };
1897
+ * ours to write, else a copy of the sources under a folder that is. A
1898
+ * browser app (`exported`) always takes the copy, with the default
1899
+ * distDir: under export a custom distDir is the export's folder, and
1900
+ * the build goes to `.next` all the same. */
1901
+ function buildSite(exported = false) {
1902
+ if (!exported && writable(ROOT)) return { dir: ROOT, dist: ".next-check", copied: false };
654
1903
  const name = basename(dirname(ROOT)) || "app"; // plays/<name>/app
655
1904
  const home = writable(homedir()) ? homedir() : tmpdir();
656
1905
  return { dir: join(home, ".claw-check", name), dist: ".next", copied: true };
657
1906
  }
658
1907
 
1908
+ /** The nearest folder holding both paths, by their real paths ("/" at
1909
+ * worst). A copy's build reads node_modules through a symlink back to the
1910
+ * app, and Turbopack reads nothing outside its root: the check hands it
1911
+ * this folder as the root (CLAW_TURBOPACK_ROOT, next.config.mjs). */
1912
+ function commonRoot(a, b) {
1913
+ const x = realpathSync(a).split(sep);
1914
+ const y = realpathSync(b).split(sep);
1915
+ let i = 0;
1916
+ while (i < x.length && i < y.length && x[i] === y[i]) i++;
1917
+ return x.slice(0, i).join(sep) || sep;
1918
+ }
1919
+
659
1920
  /** Copy the app's sources (never node_modules or a build) to `dir`, and
660
- * point its node_modules back at the app's. */
661
- function copySources(dir) {
1921
+ * point its node_modules back at the app's. `dotenv: false` leaves the
1922
+ * app's .env files out too (a browser app's build takes no .env: claw
1923
+ * builds one under an allowlist, and Next would load any it found). */
1924
+ function copySources(dir, { dotenv = true } = {}) {
662
1925
  for (const stale of readdirSync(dir, { withFileTypes: true }).filter((e) => e.name !== "node_modules" && e.name !== ".next")) {
663
1926
  rmSync(join(dir, stale.name), { recursive: true, force: true });
664
1927
  }
@@ -667,6 +1930,7 @@ function copySources(dir) {
667
1930
  dereference: false,
668
1931
  filter: (src) => {
669
1932
  const rel = relative(ROOT, src);
1933
+ if (!dotenv && /^\.env(?:\..*)?$/.test(rel)) return false;
670
1934
  return !notSource(rel) && !/^check(\/|$)/.test(rel);
671
1935
  },
672
1936
  });
@@ -674,13 +1938,16 @@ function copySources(dir) {
674
1938
  if (!existsSync(link)) symlinkSync(join(ROOT, "node_modules"), link, "dir");
675
1939
  }
676
1940
 
677
- function buildForCheck(site, env) {
1941
+ /** Build, unless the last build is newer than every source and `fresh`
1942
+ * (what else the reuse needs) holds. */
1943
+ function buildForCheck(site, env, { fresh = () => true, dotenv = true } = {}) {
678
1944
  const stamp = join(site.dir, site.dist, "BUILD_ID");
679
- if (existsSync(stamp) && statSync(stamp).mtimeMs >= newestSource()) return { ok: true, reused: true };
1945
+ if (existsSync(stamp) && statSync(stamp).mtimeMs >= newestSource() && fresh()) return { ok: true, reused: true };
1946
+ if (!existsSync(join(ROOT, "node_modules", ".bin", "next"))) return { ok: false, tail: "next is not installed (node_modules/.bin/next): run `makersclaw install`, then the check" };
680
1947
  const started = Date.now();
681
1948
  if (site.copied) {
682
1949
  mkdirSync(site.dir, { recursive: true });
683
- copySources(site.dir);
1950
+ copySources(site.dir, { dotenv });
684
1951
  }
685
1952
  const r = spawnSync(join(site.dir, "node_modules", ".bin", "next"), ["build"], { cwd: site.dir, env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
686
1953
  if (r.status !== 0) {
@@ -691,9 +1958,17 @@ function buildForCheck(site, env) {
691
1958
  }
692
1959
 
693
1960
  async function smoke() {
1961
+ // A browser app (makersclaw.json "mode": "browser", exactly as
1962
+ // next.config.mjs reads it) is exported, not started: its own smoke.
1963
+ if (MANIFEST.manifest?.mode === "browser") return browserSmoke();
694
1964
  const site = buildSite();
695
1965
  const base = { ...process.env, ...dotenv(join(ROOT, ".env")), NODE_ENV: "production", HOSTNAME: "127.0.0.1", CLAW_DIST_DIR: site.dist };
696
- delete base.CLAW_PRODUCT_TOKEN; // the check is not the proxy; the middleware would 401 it
1966
+ delete base.CLAW_TURBOPACK_ROOT;
1967
+ if (site.copied) {
1968
+ mkdirSync(site.dir, { recursive: true });
1969
+ base.CLAW_TURBOPACK_ROOT = commonRoot(site.dir, ROOT);
1970
+ }
1971
+ delete base.CLAW_PRODUCT_TOKEN; // the check is not the product proxy; proxy.js would 401 it
697
1972
  const built = buildForCheck(site, base);
698
1973
  if (!built.ok) {
699
1974
  fail("smoke", "build", `next build failed:\n${built.tail}`);
@@ -743,6 +2018,169 @@ async function smoke() {
743
2018
  }
744
2019
  }
745
2020
 
2021
+ // The browser smoke (claw's STATIC-SPEC §3). A build in the copy folder,
2022
+ // always, with the default distDir: the export in the copy's out/, the
2023
+ // build in the copy's .next, nothing in the tree. The build's env is the
2024
+ // check's own, .env left out: claw builds a browser app under an
2025
+ // allowlist and never sources .env, so a page that needs .env to
2026
+ // prerender would fail there, not here. Then the copy's out/ is served by
2027
+ // a file server of this check's own, on the router's rules (below), and
2028
+ // `/` must forward to a surface while every /s/<key> answers 200 with no
2029
+ // error-boundary text. No `next start` (an export has none) and no
2030
+ // /claw-alive (a browser app's auto-refresh asks the door).
2031
+ //
2032
+ // What it proves: every page prerenders. Not that a surface renders with
2033
+ // data: each browser surface sits in <Suspense> and reads
2034
+ // useSearchParams, so the export holds only its loading line, and its
2035
+ // hooks never run at build.
2036
+ async function browserSmoke() {
2037
+ const site = buildSite(true);
2038
+ const env = { ...process.env, NODE_ENV: "production" };
2039
+ delete env.CLAW_DIST_DIR; // the default distDir: a custom one is where an export goes
2040
+ mkdirSync(site.dir, { recursive: true });
2041
+ env.CLAW_TURBOPACK_ROOT = commonRoot(site.dir, ROOT);
2042
+ const wanted = env.CLAW_BASE_PATH || "";
2043
+ const out = join(site.dir, "out");
2044
+ // A build is reused only when it is newer than every source, its
2045
+ // export is there, and it was made for the base path asked for now.
2046
+ const builtFor = () => {
2047
+ try {
2048
+ return JSON.parse(readFileSync(join(site.dir, site.dist, "routes-manifest.json"), "utf8")).basePath ?? "";
2049
+ } catch {
2050
+ return null;
2051
+ }
2052
+ };
2053
+ const built = buildForCheck(site, env, { fresh: () => existsSync(join(out, "index.html")) && builtFor() === wanted, dotenv: false });
2054
+ if (!built.ok) {
2055
+ fail("smoke", "build", `next build failed:\n${built.tail}`);
2056
+ return;
2057
+ }
2058
+ if (!existsSync(join(out, "index.html"))) {
2059
+ fail("smoke", "build", `next build left no out/index.html in ${site.dir}: the export did not run (next.config.mjs is the template's; \`makersclaw update\` restores it)`);
2060
+ return;
2061
+ }
2062
+ const basePath = builtFor() ?? wanted;
2063
+ const size = exportSize(out);
2064
+ const buildNote = `${built.reused ? "build reused" : `built in ${built.seconds}s`} (copy at ${site.dir}) · export ${(size.bytes / 1e6).toFixed(1)} MB in ${size.files} files`;
2065
+ const server = serveExport(out, basePath);
2066
+ await new Promise((resolve, reject) => {
2067
+ server.once("error", reject);
2068
+ server.listen(0, "127.0.0.1", resolve);
2069
+ });
2070
+ const origin = `http://127.0.0.1:${server.address().port}${basePath}`;
2071
+ const started = Date.now();
2072
+ try {
2073
+ const results = [];
2074
+ const home = await fetch(`${origin}/`, { redirect: "manual" });
2075
+ const body = await home.text();
2076
+ const to = home.status >= 300 && home.status < 400 ? home.headers.get("location") : /<meta\b[^>]*\bhttp-equiv="refresh"[^>]*\bcontent="0;\s*url=([^"]*)"/i.exec(body)?.[1]?.replace(/&amp;/g, "&");
2077
+ const key = to ? /\/s\/([^/?#]+)$/.exec(to.replace(/[?#].*$/, ""))?.[1] : undefined;
2078
+ results.push(`/ → ${home.status}${to ? ` to ${to}` : ""}`);
2079
+ if (!(home.status === 200 || (home.status >= 300 && home.status < 400))) fail("smoke", "/", `answered ${home.status}`);
2080
+ else if (!to) fail("smoke", "/", "answered with no forward to a surface: app/page.jsx forwards to the home surface (`makersclaw update` restores it), and surfaces.js names one");
2081
+ else if (!to.startsWith(`${basePath}/s/`) || key === undefined || !surfaceKeys.includes(decodeURIComponent(key))) fail("smoke", "/", `forwards to ${to}, which is not ${basePath}/s/<a key of surfaces.js>`);
2082
+ for (const k of surfaceKeys) {
2083
+ const res = await fetch(`${origin}/s/${encodeURIComponent(k)}`, { redirect: "manual" });
2084
+ const page = await res.text();
2085
+ const broke = /Something broke on this surface|The page could not render|Application error: a (client|server)-side exception|id="__next_error__"/.test(page);
2086
+ results.push(`/s/${k} ${res.status}${broke ? " BROKE" : ""}`);
2087
+ if (res.status !== 200) fail("smoke", `/s/${k}`, `answered ${res.status}: the export holds no s/${k}.html`);
2088
+ else if (broke) fail("smoke", `/s/${k}`, "is the error boundary in the export: the page threw while it prerendered");
2089
+ }
2090
+ if (!failures.includes("smoke")) ok("smoke", `${buildNote} · ${results.join(" · ")} (${((Date.now() - started) / 1000).toFixed(1)}s)`);
2091
+ } finally {
2092
+ server.closeAllConnections(); // fetch keeps its sockets alive
2093
+ server.close();
2094
+ }
2095
+ }
2096
+
2097
+ /** The export's size: every file under out/. */
2098
+ function exportSize(dir) {
2099
+ let bytes = 0;
2100
+ let count = 0;
2101
+ for (const name of readdirSync(dir, { recursive: true })) {
2102
+ const s = statSync(join(dir, String(name)));
2103
+ if (s.isFile()) {
2104
+ bytes += s.size;
2105
+ count++;
2106
+ }
2107
+ }
2108
+ return { bytes, files: count };
2109
+ }
2110
+
2111
+ const TYPES = {
2112
+ ".html": "text/html; charset=utf-8",
2113
+ ".txt": "text/plain; charset=utf-8",
2114
+ ".js": "text/javascript; charset=utf-8",
2115
+ ".css": "text/css; charset=utf-8",
2116
+ ".json": "application/json",
2117
+ ".svg": "image/svg+xml",
2118
+ ".png": "image/png",
2119
+ ".ico": "image/x-icon",
2120
+ ".woff2": "font/woff2",
2121
+ };
2122
+
2123
+ /**
2124
+ * A file server over `out`, on the platform router's rules (claw's
2125
+ * STATIC-SPEC §4, "The router"), for this check alone: GET and HEAD; the
2126
+ * base path cut (anything outside it is 404); each segment decoded once,
2127
+ * and one that fails, is `.`/`..`, starts with a dot or holds a slash, a
2128
+ * backslash or a NUL is 404; then the file, else `.html`, else
2129
+ * `/index.html` (the empty path is index.html); else `404.html` with 404.
2130
+ * It serves a local build to a local fetch, so it reads each file whole
2131
+ * and holds none of the router's guards against a tree someone else can
2132
+ * write.
2133
+ */
2134
+ function serveExport(out, basePath) {
2135
+ const find = (url) => {
2136
+ let path = url.split(/[?#]/)[0];
2137
+ if (basePath) {
2138
+ if (path !== basePath && !path.startsWith(`${basePath}/`)) return null;
2139
+ path = path.slice(basePath.length);
2140
+ }
2141
+ const segments = [];
2142
+ for (const raw of path.split("/")) {
2143
+ if (!raw) continue;
2144
+ let seg;
2145
+ try {
2146
+ seg = decodeURIComponent(raw);
2147
+ } catch {
2148
+ return null;
2149
+ }
2150
+ if (seg.startsWith(".") || /[/\\\0]/.test(seg)) return null;
2151
+ segments.push(seg);
2152
+ }
2153
+ const rel = segments.join("/");
2154
+ for (const candidate of rel ? [rel, `${rel}.html`, `${rel}/index.html`] : ["index.html"]) {
2155
+ const file = join(out, candidate);
2156
+ try {
2157
+ if (statSync(file).isFile()) return file;
2158
+ } catch {}
2159
+ }
2160
+ return null;
2161
+ };
2162
+ return createHttpServer((req, res) => {
2163
+ if (req.method !== "GET" && req.method !== "HEAD") {
2164
+ res.writeHead(405, { allow: "GET, HEAD" }).end();
2165
+ return;
2166
+ }
2167
+ let file = find(req.url ?? "/");
2168
+ let status = 200;
2169
+ if (!file) {
2170
+ status = 404;
2171
+ file = existsSync(join(out, "404.html")) ? join(out, "404.html") : null;
2172
+ }
2173
+ if (!file) {
2174
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
2175
+ return;
2176
+ }
2177
+ const ext = /\.[^./]+$/.exec(file)?.[0] ?? "";
2178
+ const bytes = readFileSync(file);
2179
+ res.writeHead(status, { "content-type": TYPES[ext] ?? "application/octet-stream", "content-length": bytes.length, "x-content-type-options": "nosniff" });
2180
+ res.end(req.method === "HEAD" ? undefined : bytes);
2181
+ });
2182
+ }
2183
+
746
2184
  function dotenv(path) {
747
2185
  if (!existsSync(path)) return {};
748
2186
  const out = {};
@@ -776,9 +2214,9 @@ async function waitFor(probe, ms) {
776
2214
  return false;
777
2215
  }
778
2216
 
779
- if (!STATIC_ONLY) await smoke();
2217
+ if (!NO_BUILD) await smoke();
780
2218
 
781
- console.log(`check: ${files.length} files · ${surfaceKeys.length} surface${surfaceKeys.length === 1 ? "" : "s"}${STATIC_ONLY ? " · static only" : ""}`);
2219
+ console.log(`check: ${files.length} files · ${surfaceKeys.length} surface${surfaceKeys.length === 1 ? "" : "s"}${NO_BUILD ? " · no build" : ""}`);
782
2220
  for (const l of lines) console.log(l);
783
2221
  const failed = new Set(failures).size;
784
2222
  console.log(failed ? `${failed} rule${failed === 1 ? "" : "s"} failed` : "all clear");