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