makersclaw 0.6.0 → 0.8.0

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