makersclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +68 -0
  2. package/bin/makersclaw.mjs +10 -0
  3. package/package.json +34 -0
  4. package/registry/blocks/board/README.md +130 -0
  5. package/registry/blocks/board/block.json +10 -0
  6. package/registry/blocks/board/board.jsx +435 -0
  7. package/registry/blocks/board/demo.jsx +50 -0
  8. package/registry/blocks/board/index.js +4 -0
  9. package/registry/blocks/job-panel/README.md +68 -0
  10. package/registry/blocks/job-panel/block.json +10 -0
  11. package/registry/blocks/job-panel/demo.jsx +75 -0
  12. package/registry/blocks/job-panel/index.js +3 -0
  13. package/registry/blocks/job-panel/job-panel.jsx +110 -0
  14. package/src/cli.mjs +301 -0
  15. package/src/fsutil.mjs +76 -0
  16. package/src/install.mjs +457 -0
  17. package/src/registry.mjs +191 -0
  18. package/templates/next/files/README.md +626 -0
  19. package/templates/next/files/app/auto-refresh.jsx +26 -0
  20. package/templates/next/files/app/error-relay.jsx +53 -0
  21. package/templates/next/files/app/error.jsx +44 -0
  22. package/templates/next/files/app/global-error.jsx +36 -0
  23. package/templates/next/files/app/globals.css +178 -0
  24. package/templates/next/files/app/layout.jsx +51 -0
  25. package/templates/next/files/app/not-found.jsx +23 -0
  26. package/templates/next/files/app/page.jsx +22 -0
  27. package/templates/next/files/app/s/[key]/page.jsx +47 -0
  28. package/templates/next/files/app/shell.jsx +64 -0
  29. package/templates/next/files/check/run.mjs +683 -0
  30. package/templates/next/files/components/parts/fixture.jsx +60 -0
  31. package/templates/next/files/components/parts/index.js +5 -0
  32. package/templates/next/files/components/parts/record-switcher.jsx +49 -0
  33. package/templates/next/files/components/ui/button.jsx +81 -0
  34. package/templates/next/files/components/ui/cx.js +4 -0
  35. package/templates/next/files/components/ui/depiction.jsx +66 -0
  36. package/templates/next/files/components/ui/field.jsx +126 -0
  37. package/templates/next/files/components/ui/icon.jsx +87 -0
  38. package/templates/next/files/components/ui/index.js +12 -0
  39. package/templates/next/files/components/ui/menu.jsx +123 -0
  40. package/templates/next/files/components/ui/notice.jsx +86 -0
  41. package/templates/next/files/components/ui/sheet.jsx +89 -0
  42. package/templates/next/files/components/ui/tabs.jsx +81 -0
  43. package/templates/next/files/lib/actions.js +187 -0
  44. package/templates/next/files/lib/claw.js +219 -0
  45. package/templates/next/files/lib/data.js +171 -0
  46. package/templates/next/files/lib/db.js +72 -0
  47. package/templates/next/files/lib/hooks.js +218 -0
  48. package/templates/next/files/lib/platform.js +59 -0
  49. package/templates/next/files/lib/sample.js +12 -0
  50. package/templates/next/files/middleware.js +42 -0
  51. package/templates/next/files/next.config.mjs +41 -0
  52. package/templates/next/files/package.json +19 -0
  53. package/templates/next/files/postcss.config.mjs +3 -0
  54. package/templates/next/files/schema.sql +22 -0
  55. package/templates/next/files/surfaces/README.md +32 -0
  56. package/templates/next/files/surfaces.js +53 -0
  57. package/templates/next/template.json +118 -0
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env node
2
+ // `npm run check` — does this app hold? One command, one line per rule,
3
+ // exit 1 on the first thing that does not. No dependency: node's own fs,
4
+ // crypto, fetch and child_process.
5
+ //
6
+ // Static rules read the source; the smoke test builds the app and loads
7
+ // every surface. Every static rule exists because the failure it names
8
+ // was seen in a real build: a hex in a surface (the app stopped following
9
+ // the workspace theme), a hand-rolled <select> that could not be used, an
10
+ // example INSERTed into a shared table, a manifest entry whose file was
11
+ // never written, an icon name that rendered nothing, a tweet card painted
12
+ // in the workspace's tokens (unrecognisable as a tweet — the one thing
13
+ // the app was for), a kit file "improved" per app.
14
+ //
15
+ // npm run check static rules, then smoke
16
+ // npm run check -- --static static rules only
17
+ //
18
+ // The smoke test builds for itself, and only when a source file is newer
19
+ // than its last build. Where: in the sandbox the app folder is
20
+ // `ubuntu:claw 2755` — the terminal user can read it, not write in it —
21
+ // so the sources are copied to a folder of its own (`~/.claw-check/<app>`,
22
+ // node_modules symlinked back) and built there. On a writable checkout
23
+ // (local dev) it builds in place, into `.next-check` (CLAW_DIST_DIR,
24
+ // next.config.mjs). Never into `.next`: that is the dev server's, and a
25
+ // second build there dies with EACCES on .next/trace. The founder's live
26
+ // preview keeps running, untouched, while the check runs beside it.
27
+ //
28
+ // Output is meant to be read by the platform agent in one glance: keep
29
+ // it under twenty lines, name file:line, say what to do instead.
30
+
31
+ import { accessSync, constants, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync } from "node:fs";
32
+ import { createHash } from "node:crypto";
33
+ import { spawn, spawnSync } from "node:child_process";
34
+ import { createServer } from "node:net";
35
+ import { basename, dirname, join, relative } from "node:path";
36
+ import { homedir, tmpdir } from "node:os";
37
+ import { fileURLToPath } from "node:url";
38
+
39
+ // The app dir: this file's parent, unless the platform runs a canonical
40
+ // copy of it from elsewhere against an app (CLAW_APP_DIR).
41
+ const ROOT = process.env.CLAW_APP_DIR || join(fileURLToPath(import.meta.url), "..", "..");
42
+ const STATIC_ONLY = process.argv.includes("--static");
43
+
44
+ // The kit, by exact file: what the template ships under components/ui and
45
+ // components/parts. Anything else in those folders is the agent's, and the
46
+ // `kit` rule refuses it (a shared piece goes in components/, a picture in
47
+ // components/depictions/). The list is the template's; `makersclaw
48
+ // update` lays both folders again.
49
+ // Exact names AND extensions: Next resolves .js before .jsx, so a
50
+ // components/ui/depiction.js beside depiction.jsx would win unseen.
51
+ const KIT = /^components\/(?:ui\/(?:cx|index)\.js|ui\/(?:button|depiction|field|icon|menu|notice|sheet|tabs)\.jsx|parts\/index\.js|parts\/(?:fixture|record-switcher)\.jsx)$/;
52
+
53
+ // The pins: sha256 of every fixed file, written by `makersclaw init` and
54
+ // `makersclaw add` (check/pins.json). Read first, because a BLOCK is
55
+ // fixed only while it is pinned: `makersclaw add <name>` lays a block
56
+ // under components/blocks/<name>/ and pins each of its files, so the
57
+ // rules below skip it the way they skip the kit (a block owns native
58
+ // elements — a canvas draws its links in <svg>). A file there that no
59
+ // pin names was not installed; the `kit` rule refuses it.
60
+ const PINS = (() => {
61
+ try {
62
+ return JSON.parse(readFileSync(join(ROOT, "check", "pins.json"), "utf8"));
63
+ } catch {
64
+ return null;
65
+ }
66
+ })();
67
+ const BLOCK = /^components\/blocks\/[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*\.(?:jsx|js)$/;
68
+ const pinnedBlock = (path) => BLOCK.test(path) && PINS !== null && Object.prototype.hasOwnProperty.call(PINS, path);
69
+ /** A depiction: a picture of something that exists elsewhere (README §3c). */
70
+ const DEPICTION = /^components\/depictions\//;
71
+ /** Where a depiction may be framed: a surface, or the agent's own
72
+ * component (the client component that holds the draft is where a
73
+ * preview can follow typing). Never the shell, the kit or lib/. */
74
+ const FRAME_HOST = /^(?:surfaces\/[^/]+\.jsx|components\/[^/]+\.jsx)$/;
75
+
76
+ // Files the rules skip: the kit (it owns the native elements and the
77
+ // fallback colours), the fixed template files, machine folders. Depiction
78
+ // files are NOT skipped — the `depictions` rule reads them.
79
+ const SKIP = [
80
+ /^node_modules\//,
81
+ /^\.next(-check)?\//,
82
+ /^check\//,
83
+ KIT,
84
+ /^app\/globals\.css$/,
85
+ /^lib\/(db|data|actions|claw|platform|hooks|sample)\.js$/,
86
+ /^app\/(layout|page|error|global-error|not-found|error-relay|auto-refresh)\.jsx$/,
87
+ /^app\/s\/\[key\]\/page\.jsx$/,
88
+ /^middleware\.js$/,
89
+ /^next\.config\.mjs$/,
90
+ /^postcss\.config\.mjs$/,
91
+ /^public\//,
92
+ /\.(md|sql|json|lock)$/,
93
+ ];
94
+
95
+ // Only the build's own folders are skipped: a dot-named module
96
+ // (components/.pictures.js) is a module Next bundles all the same.
97
+ function walk(dir, out = []) {
98
+ for (const name of readdirSync(dir)) {
99
+ if (name === "node_modules" || name === ".git" || name === ".next" || name === ".next-check") continue;
100
+ const full = join(dir, name);
101
+ if (statSync(full).isDirectory()) walk(full, out);
102
+ else out.push(full);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ const files = walk(ROOT)
108
+ .map((f) => ({ path: relative(ROOT, f), text: null }))
109
+ .filter((f) => /\.(jsx?|mjs|cjs|tsx?|mts|cts|css|svg)$/.test(f.path) && !SKIP.some((re) => re.test(f.path)) && !pinnedBlock(f.path))
110
+ .map((f) => ({ ...f, text: readFileSync(join(ROOT, f.path), "utf8") }))
111
+ .map((f) => ({ ...f, code: stripComments(f.text) }));
112
+ const depictionFiles = files.filter((f) => DEPICTION.test(f.path));
113
+ const appFiles = files.filter((f) => !DEPICTION.test(f.path));
114
+ // A folder file another depiction imports with ./ is a helper (glyphs, a
115
+ // mode table): the inside rules apply to it, the header and dead rules do
116
+ // not, and it is not a picture in its own right.
117
+ const helpers = new Set();
118
+ for (const f of depictionFiles) {
119
+ for (const m of f.code.matchAll(/^\s*import\b[^"']*\bfrom\s*["']\.\/([^"']+?)(?:\.jsx?)?["']/gm)) helpers.add(m[1]);
120
+ }
121
+ const pictures = depictionFiles.length - helpers.size;
122
+
123
+ const failures = [];
124
+ const lines = [];
125
+ function ok(rule, detail) {
126
+ lines.push(`ok ${rule.padEnd(10)} ${detail}`);
127
+ }
128
+ function fail(rule, where, detail) {
129
+ failures.push(rule);
130
+ lines.push(`FAIL ${rule.padEnd(10)} ${where} — ${detail}`);
131
+ }
132
+ function lineOf(text, index) {
133
+ return text.slice(0, index).split("\n").length;
134
+ }
135
+ /** Every match of `re` in every file (or in `over`), with its line. Reads
136
+ * the code, never the prose beside it (f.code: comments blanked). */
137
+ function scan(re, onHit, over = appFiles) {
138
+ for (const f of over) {
139
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
140
+ let m;
141
+ while ((m = g.exec(f.code))) onHit(f, m, lineOf(f.code, m.index));
142
+ }
143
+ }
144
+ /** A rule's FAIL lines, capped and deduped, so the report stays short. */
145
+ function limiter(rule, cap = 4) {
146
+ const seen = new Set();
147
+ let hits = 0;
148
+ return {
149
+ hit(where, what, detail) {
150
+ const key = `${where.replace(/:\d+$/, "")} ${what}`;
151
+ if (seen.has(key)) return;
152
+ seen.add(key);
153
+ if (hits++ < cap) fail(rule, where, detail);
154
+ },
155
+ get count() {
156
+ return hits;
157
+ },
158
+ };
159
+ }
160
+
161
+ const HUES = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black";
162
+ const TOKEN_SLOTS = "background|foreground|card|popover|primary|secondary|muted|accent|destructive|success|warning|info|border|input|ring";
163
+ const COLOUR_PREFIXES = "bg|text|border(?:-[xytrbles])?|ring(?:-offset)?|inset-ring|inset-shadow|text-shadow|from|to|via|fill|stroke|outline|decoration|accent|caret|divide(?:-[xy])?|placeholder|shadow";
164
+
165
+ /** Source without its comments, newlines kept so line numbers hold: a
166
+ * rule reads code, not the prose beside it (a header that says
167
+ * "window." or names <a> is not browser access or a link). */
168
+ function stripComments(text) {
169
+ const blank = (m) => m.replace(/[^\n]/g, " ");
170
+ return text
171
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, blank)
172
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
173
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length)));
174
+ }
175
+ /** JSX attributes up to the tag's `>`: strings and balanced braces may hold a `>` or `=>`. */
176
+ const ATTRS = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'{])*`;
177
+
178
+ // 1. Colour lives in the workspace theme, nowhere in the app — except a
179
+ // depiction, which wears the colours of the place it depicts (rule 7).
180
+ {
181
+ const L = limiter("colours");
182
+ const RULES = [
183
+ [/(?:#|\\x23|\\u0023|%23)(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})(?![\w-])/, "a hex colour"],
184
+ [/\b(?:rgba?|hsla?|hwb|lab|lch|oklch|oklab|color|color-mix|light-dark|device-cmyk)\(/i, "a colour function"],
185
+ [new RegExp(`\\b(?:${COLOUR_PREFIXES})-(?:${HUES})(?:-\\d{2,3})?\\b`), "a raw Tailwind hue"],
186
+ [new RegExp(`\\b(?:${COLOUR_PREFIXES})-\\[(?!(?:length|size|position|image|url|--|var\\()|\\d)[a-zA-Z#][^\\]]*\\]`), "an arbitrary colour"],
187
+ // A value through a colour property, or a custom property set here and
188
+ // read back (text-(--x)), bypasses the theme whatever it spells.
189
+ [/\[(?:--[\w-]+|[a-z-]*(?:color|background|shadow|decoration|outline|fill|stroke|filter|caret|accent)|border(?:-(?:top|right|bottom|left|inline|block|[xysetlrb]))?(?:-(?:start|end))?)\s*:[^\]]*\]/, "an arbitrary property (CSS the theme cannot reach)"],
190
+ [/<style\b/, "a <style> tag (CSS the theme cannot reach)"],
191
+ [/dangerouslySetInnerHTML|\[data-depiction|\[&_\[data-depiction/, "markup or selectors that reach past the theme"],
192
+ ];
193
+ for (const [re, what] of RULES) {
194
+ scan(re, (f, m, line) => L.hit(`${f.path}:${line}`, what, `${what} (${m[0]}); use a token: bg-card, text-muted-foreground, border-border, text-primary… (a picture of a tweet or a post goes in components/depictions/, README §3c)`));
195
+ }
196
+ // A filter recolours whatever the theme painted.
197
+ scan(/(?<=[\s"'`:])(?:backdrop-)?(?:hue-rotate|invert|sepia|saturate|grayscale)(?:-(?:\d+|\[[^\]]+\]|\([^)]+\)))?(?=[\s"'`])/, (f, m, line) => L.hit(`${f.path}:${line}`, "filter", `a filter (${m[0]}) recolours the theme; use tokens for every state`));
198
+ // The only stylesheet is app/globals.css (pinned). Another one can pin
199
+ // the workspace's slots (--primary: …) with no hex in sight.
200
+ for (const f of appFiles) if (/\.css$/.test(f.path)) L.hit(f.path, "sheet", "the only stylesheet is app/globals.css (fixed); design goes in the shell and the surfaces, with tokens");
201
+ if (!L.count) ok("colours", `no colour outside the workspace theme${pictures ? ` (${pictures} depiction${pictures === 1 ? "" : "s"} excepted)` : ""}`);
202
+ }
203
+
204
+ // 2. Controls come from the kit; the browser supplies their behaviour.
205
+ // A depiction may draw its own <svg> glyphs (the X mark looks like
206
+ // nothing in the sprite) but still has no app controls.
207
+ {
208
+ const L = limiter("controls");
209
+ const RULES = [
210
+ [/<select\b/, "Select from components/ui, or RecordSwitcher from components/parts", files],
211
+ [/<dialog\b/, "Sheet or Confirm from components/ui", files],
212
+ [/<details\b/, "Menu or Sheet from components/ui", files],
213
+ [/role=["']tab(?:list)?["']/, "Tabs from components/ui", files],
214
+ [/role=["']menu(?:item)?["']/, "Menu from components/ui", files],
215
+ [/role=["']dialog["']/, "Sheet from components/ui", files],
216
+ [/\bpopover=/, "Menu from components/ui", files],
217
+ [/window\.confirm\(/, "Confirm from components/ui", files],
218
+ [/<svg\b/, "Icon from components/ui (add a glyph there if it is missing)", appFiles],
219
+ ];
220
+ for (const [re, use, over] of RULES) {
221
+ scan(re, (f, m, line) => L.hit(`${f.path}:${line}`, use, `${m[0]} by hand; use ${use}`), over);
222
+ }
223
+ if (!L.count) ok("controls", "every control is a kit part");
224
+ }
225
+
226
+ // 3. The example is a fixture in code — never a row in the shared database.
227
+ {
228
+ const L = limiter("examples");
229
+ scan(/\bINSERT\s+INTO\b/i, (f, m, line) => L.hit(`${f.path}:${line}`, "insert", "SQL INSERT outside lib/actions.js; insertRow() is the write door, and a fixture is never inserted"), files);
230
+ for (const f of files) {
231
+ const decl = /\b(?:const|let)\s+((?:[A-Z][A-Z0-9_]*_)?(?:SAMPLE|EXAMPLE|FIXTURE|DEMO)S?(?:_[A-Z0-9_]+)?)\s*=\s*[\[{]/.exec(f.code);
232
+ if (!decl) continue;
233
+ // A data-only module (no JSX) may hold the rows; the file that renders
234
+ // them is the one that must run them through useFixture(). A depiction
235
+ // never holds one: its rows arrive as props from the surface.
236
+ const renders = /<\/|\/>/.test(f.code);
237
+ const uses = /\buseFixture\(|\bmarkSample\(/.test(f.code);
238
+ const server = /from\s*["']\.\.?\/lib\/data["']/.test(f.code);
239
+ if (renders && !uses) L.hit(`${f.path}:${lineOf(f.code, decl.index)}`, "fixture", `${decl[1]} is rendered without useFixture() — the example shows only while the table is empty, and is never saved${DEPICTION.test(f.path) ? " (a depiction takes its rows as props; the surface holds the fixture)" : server ? " (a server surface cannot call it: pass the rows down and let the client component run useFixture)" : ""}`);
240
+ }
241
+ if (!L.count) ok("examples", "fixtures stay in code (useFixture), nothing INSERTs one");
242
+ }
243
+
244
+ // 4. The manifest names surfaces that exist, once each, under keys that are free.
245
+ const surfaceKeys = [];
246
+ {
247
+ // Comments hold the worked example; only code counts.
248
+ const manifest = readFileSync(join(ROOT, "surfaces.js"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
249
+ const problems = [];
250
+ const imports = new Map();
251
+ for (const m of manifest.matchAll(/import\s+(\w+)\s+from\s+["'](\.\/surfaces\/[^"']+)["']/g)) {
252
+ const target = m[2].replace(/^\.\//, "");
253
+ const candidates = [target, `${target}.jsx`, `${target}.js`, `${target}/index.jsx`];
254
+ if (!candidates.some((c) => existsSync(join(ROOT, c)))) problems.push(`${m[1]} imports ${m[2]}, which is not there`);
255
+ imports.set(m[1], target);
256
+ }
257
+ for (const m of manifest.matchAll(/key:\s*["']([^"']+)["']/g)) surfaceKeys.push(m[1]);
258
+ const seen = new Set();
259
+ for (const k of surfaceKeys) {
260
+ if (seen.has(k)) problems.push(`key "${k}" appears twice`);
261
+ seen.add(k);
262
+ if (["agent", "runs", "activity", "settings"].includes(k)) problems.push(`key "${k}" is reserved (the OS draws that tab)`);
263
+ }
264
+ for (const m of manifest.matchAll(/component:\s*(\w+)/g)) {
265
+ if (!imports.has(m[1])) problems.push(`component ${m[1]} is not imported`);
266
+ }
267
+ if (surfaceKeys.length === 0) problems.push("no surfaces listed — the manifest is empty");
268
+ if (problems.length) for (const p of problems.slice(0, 4)) fail("manifest", "surfaces.js", p);
269
+ else ok("manifest", `${surfaceKeys.length} surface${surfaceKeys.length === 1 ? "" : "s"}: ${surfaceKeys.join(", ")}`);
270
+ }
271
+
272
+ // 5. Every icon named exists in the sprite (a depiction's own glyphs are
273
+ // not sprite names).
274
+ {
275
+ const sprite = readFileSync(join(ROOT, "components/ui/icon.jsx"), "utf8");
276
+ const names = new Set([...sprite.matchAll(/^\s+"?([a-z][a-z0-9-]*)"?:\s*"/gm)].map((m) => m[1]));
277
+ const missing = new Map();
278
+ const check = (f, name, line) => {
279
+ if (!names.has(name) && !missing.has(name)) missing.set(name, `${f.path}:${line}`);
280
+ };
281
+ scan(/<Icon\b[^>]*\bname=["']([a-z][a-z0-9-]*)["']/, (f, m, line) => check(f, m[1], line));
282
+ scan(/\bicon(?:=|:\s*)["']([a-z][a-z0-9-]*)["']/, (f, m, line) => check(f, m[1], line));
283
+ if (missing.size) {
284
+ const where = [...missing].map(([n, at]) => `"${n}" (${at})`).join(", ");
285
+ fail("icons", "sprite", `not in components/ui/icon.jsx: ${where}. Add the glyph there, or pick one of: ${[...names].join(" ")}`);
286
+ } else ok("icons", "every icon name exists");
287
+ }
288
+
289
+ // 6. The kit is fixed. Its folders hold the template's files and nothing
290
+ // else; the template's other fixed files are pinned by hash
291
+ // (check/pins.json, written by makersclaw init) so a colour slipped
292
+ // into app/globals.css is caught even though rule 1 skips that file.
293
+ // Blocks are fixed the same way: components/blocks/<name>/ holds what
294
+ // `makersclaw add <name>` laid and pinned, and nothing else.
295
+ {
296
+ const L = limiter("kit");
297
+ for (const dir of ["components/ui", "components/parts"]) {
298
+ const full = join(ROOT, dir);
299
+ if (!existsSync(full)) continue;
300
+ for (const f of walk(full)) {
301
+ const rel = relative(ROOT, f);
302
+ if (!KIT.test(rel)) L.hit(rel, "extra", "the kit is fixed; a shared piece goes in components/, a picture in components/depictions/");
303
+ }
304
+ }
305
+ if (existsSync(join(ROOT, "components", "blocks"))) {
306
+ for (const f of walk(join(ROOT, "components", "blocks"))) {
307
+ const rel = relative(ROOT, f);
308
+ if (!pinnedBlock(rel)) L.hit(rel, "extra", "blocks come from `makersclaw add <name>` and are fixed; your own piece goes in components/");
309
+ }
310
+ }
311
+ // components/ui.js beside components/ui/ wins module resolution: every
312
+ // `../components/ui` import would get a fake kit. Whatever the
313
+ // extension — a file so named has no other purpose.
314
+ if (existsSync(join(ROOT, "components"))) {
315
+ for (const e of readdirSync(join(ROOT, "components"), { withFileTypes: true })) {
316
+ 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`);
317
+ }
318
+ }
319
+ // check/ is this script and its pins; a module hidden there is bundled
320
+ // by Next and read by no rule.
321
+ if (existsSync(join(ROOT, "check"))) {
322
+ for (const e of readdirSync(join(ROOT, "check"))) {
323
+ if (e !== "run.mjs" && e !== "pins.json") L.hit(`check/${e}`, "extra", "check/ holds run.mjs and pins.json only; a module goes in components/ or lib/");
324
+ }
325
+ }
326
+ for (const f of files) {
327
+ if (/\.(tsx?|mts|cts|cjs)$/.test(f.path)) L.hit(f.path, "ext", "the template is JSX and ships no TypeScript; write .jsx / .js");
328
+ }
329
+ let pinned = 0;
330
+ const blocks = new Set();
331
+ for (const [path, sha] of Object.entries(PINS ?? {})) {
332
+ const block = BLOCK.test(path) ? path.split("/")[2] : null;
333
+ if (block) blocks.add(block);
334
+ const full = join(ROOT, path);
335
+ if (!existsSync(full)) {
336
+ L.hit(path, "missing", block ? `a file of the ${block} block is missing; \`makersclaw add ${block}\` restores it` : "a template file is missing; `makersclaw update` restores it");
337
+ continue;
338
+ }
339
+ const have = createHash("sha256").update(readFileSync(full)).digest("hex");
340
+ if (have !== sha) {
341
+ L.hit(
342
+ path,
343
+ "changed",
344
+ block
345
+ ? `differs from the ${block} block — blocks are fixed: compose it through its props, or write your own in components/; \`makersclaw add ${block}\` restores it`
346
+ : "differs from the template — fixed files are not yours to edit (design goes in app/shell.jsx and the surfaces); `makersclaw update` restores it",
347
+ );
348
+ }
349
+ pinned++;
350
+ }
351
+ const blockNote = blocks.size ? ` (blocks: ${[...blocks].sort().join(", ")})` : "";
352
+ if (!L.count) ok("kit", pinned ? `${pinned} fixed files match the template${blockNote}` : "kit folders hold the template's files (no pins)");
353
+ }
354
+
355
+ // 7. Depictions: a picture of something that exists elsewhere wears THAT
356
+ // place's colours, face and widths — and nothing of the workspace. It
357
+ // is a file in components/depictions/, inert, framed by the kit's
358
+ // <Depiction> on a surface or in the component that holds the draft.
359
+ // Both directions are checked: the workspace must not leak in, the
360
+ // picture must not leak out.
361
+ {
362
+ const L = limiter("depictions");
363
+ const rendered = new Map(); // slug → files framing it
364
+ const claimed = new Set(); // slugs a file imported wrongly — that line already points at them
365
+ const ok7 = (msg) => (L.count ? null : ok("depictions", msg));
366
+ const slugOf = (path) => basename(path).replace(/\.jsx?$/, "");
367
+ const depDir = join(ROOT, "components", "depictions");
368
+
369
+ // (a) inside the folder — read raw, so a .json or a subfolder no rule
370
+ // would open is seen too.
371
+ if (existsSync(depDir)) {
372
+ for (const e of readdirSync(depDir, { withFileTypes: true })) {
373
+ if (e.isDirectory()) L.hit(`components/depictions/${e.name}/`, "ext", "no folders here: one .jsx per picture, a helper beside it (./glyphs.jsx)");
374
+ else if (!/\.jsx$/.test(e.name)) L.hit(`components/depictions/${e.name}`, "ext", /\.js$/.test(e.name) ? "rename to .jsx: a depiction is a component" : "only .jsx here: colour goes on the element (style, bg-[#…]); a stylesheet or a data file reaches past the rules");
375
+ else if (!/^[a-z0-9-]+\.jsx$/.test(e.name)) L.hit(`components/depictions/${e.name}`, "name", `rename it ${e.name.replace(/\.jsx$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}.jsx: a depiction file is a lowercase-kebab slug, the same word as of="…" on its frame`);
376
+ }
377
+ }
378
+ for (const f of depictionFiles) {
379
+ const raw = f.text;
380
+ const code = f.code;
381
+ const at = (m) => `${f.path}:${lineOf(code, m.index)}`;
382
+ const helper = helpers.has(slugOf(f.path));
383
+ if (!/^[a-z0-9-]+\.jsx$/.test(basename(f.path))) continue; // reported above
384
+ if (!helper && !(/Exact:/.test(raw.slice(0, 8000)) && /Approximate:/.test(raw.slice(0, 8000)))) {
385
+ L.hit(f.path, "header", "open with a header comment: the target, the date traced, its column widths in px, its modes, then a line starting `Exact:` (colours, sizes, radii, the fold) and one starting `Approximate:` (a proprietary face, the emoji set) — that spelling and case");
386
+ }
387
+ for (const m of code.matchAll(/^\s*import\b[^"']*\bfrom\s*["']([^"']+)["']|\bimport\(\s*["']([^"']+)|\brequire\(\s*["']([^"']+)|^\s*export\b[^"']*\bfrom\s*["']([^"']+)["']/gm)) {
388
+ const spec = m[1] ?? m[2] ?? m[3] ?? m[4];
389
+ // A sibling is one path segment that IS a .jsx in the folder: an
390
+ // extensionless ./palette would resolve to palette.json unseen.
391
+ const sibling = /^\.\/[a-z0-9-]+(?:\.jsx)?$/.test(spec) && existsSync(join(depDir, `${spec.slice(2).replace(/\.jsx$/, "")}.jsx`));
392
+ if (!(spec === "react" || spec === "../ui/cx" || sibling)) L.hit(at(m), "import", `imports ${spec} — a depiction imports react, ../ui/cx and a sibling .jsx that exists (./glyphs) only; data comes in as props, the app's verbs stay in the chrome`);
393
+ }
394
+ let m;
395
+ if ((m = /^\s*export\s+(?!default\b)/m.exec(code))) L.hit(at(m), "export", "one default export, a component; a colour, a font or a class string never leaves the folder");
396
+ if ((m = /^[A-Z]\w*\.\w+\s*=|\bglobalThis\b|\bmodule\.exports\b/m.exec(code))) L.hit(at(m), "export", "nothing leaves the folder but the component: no property on it, no global");
397
+ if ((m = new RegExp(`\\b(?:${COLOUR_PREFIXES})-(?:${TOKEN_SLOTS})(?:-foreground)?(?:\\/\\d+)?\\b`).exec(code))) L.hit(at(m), "token", `the workspace's colour inside a depiction (${m[0]}); paint it in the target's: bg-[#ffffff], style={{ color: "#536471" }}`);
398
+ if ((m = new RegExp(`\\b(?:${COLOUR_PREFIXES})-(?:${HUES})(?:-\\d{2,3})?\\b`).exec(code))) L.hit(at(m), "hue", `${m[0]}: Tailwind's palette is not the target's; write the traced value: bg-[#ffffff], style={{ color: "#536471" }}`);
399
+ // Every form Tailwind turns into a live var(): inline var(--x),
400
+ // bg-(--x), bg-(color:--x), theme(--x), [prop:var(--x)].
401
+ if ((m = /var\(\s*--|\((?:[a-z-]+:)?--[a-z][\w-]+\)/.exec(code))) L.hit(at(m), "var", `a workspace variable inside a depiction (${m[0].trim()}); paint the target's own value`);
402
+ if ((m = /\bfont-(?:sans|mono|display)\b/.exec(code))) L.hit(at(m), "face", `the workspace's face (${m[0]}); write the target's stack in style={{ fontFamily }}`);
403
+ if ((m = /\bdark:(?=[a-z!\[-])|prefers-color-scheme|data-theme/.exec(code))) L.hit(at(m), "dark", `${m[0]} keys on the workspace's <html data-theme>; a depiction keys on its own mode prop`);
404
+ // Path data (d="M3 6h-2…") looks like utilities; blank it first.
405
+ const noPaths = code.replace(/\b(?:d|points)=(["'])[^"']*\1/g, (a) => a.replace(/[^\n]/g, " "));
406
+ if ((m = /\b-?(?:[mp][xytrbl]?|gap(?:-[xy])?|space-[xy]|w|h|size|min-w|max-w|min-h|max-h|inset(?:-[xy])?|top|right|bottom|left|leading|indent)-(?!0\b)\d+(?:\.\d+)?(?!\/)\b|\bmax-w-(?:xs|sm|md|lg|\d?xl|prose)\b|\btext-(?:2xs|xs|sm|base|lg|\d?xl)\b|\brounded(?:-[trbl]{1,2})?-(?:sm|md|lg|\d?xl)\b|\[[\d.]+r?em\]/.exec(noPaths))) L.hit(at(m), "rem", `${m[0]} is scaled by the workspace (rem × density × radius); inside a depiction write px: text-[15px] p-[12px] rounded-[16px]`);
407
+ if ((m = /<style\b|<link\b|dangerouslySetInnerHTML|\[(?:html|body|:root)\b|:has\(/.exec(code))) L.hit(at(m), "leak", "a depiction paints itself, never the page; put the colour on the element");
408
+ if ((m = /<(?:form|input|textarea|select|a|iframe|script|dialog|details)\b|contentEditable|role=["'](?:button|textbox|switch|checkbox|listbox|option|tab|menu)|\btabIndex=/.exec(code))) L.hit(at(m), "inert", `${m[0]}: a depiction is a picture — a link there is a <span> that looks like one; the founder's controls sit beside the frame`);
409
+ const handlers = [...code.matchAll(/\bon[A-Z]\w*=\{/g)];
410
+ if (handlers.length > 1) L.hit(at(handlers[1]), "handler", "one toggle at most (the target's own \"…more\"); more than one handler is a control, and controls sit beside the frame");
411
+ if ((m = /\bon[A-Z]\w*=\{(?!\s*\(\s*\w*\s*\)\s*=>)/.exec(code))) L.hit(at(m), "handler", "one toggle at most, written in place: onClick={() => setOpen(true)}");
412
+ // The default export's parameter list, whether it is written in place
413
+ // or `export default XPost;` names a function declared above.
414
+ const SIG = String.raw`(?:function\s*\w*\s*\(\s*(?:\{([^}]*)\}|(\w+))?\s*\)|(?:\(\s*(?:\{([^}]*)\}|(\w+))?\s*\)|(\w+))\s*=>)`;
415
+ let main = new RegExp(String.raw`export\s+default\s+${SIG}`).exec(code);
416
+ const byName = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;/.exec(code);
417
+ if (!main && byName) main = new RegExp(String.raw`(?:function\s+${byName[1]}\s*\(\s*(?:\{([^}]*)\}|(\w+))?\s*\)|(?:const|let|var)\s+${byName[1]}\s*=\s*(?:\(\s*(?:\{([^}]*)\}|(\w+))?\s*\)|(\w+))\s*=>)`).exec(code);
418
+ const destructured = main?.[1] ?? main?.[3] ?? "";
419
+ const bag = main?.[2] ?? main?.[4] ?? main?.[5];
420
+ if (/\b(?:on[A-Z]\w*|className|style|children|render)\b/.test(destructured)) L.hit(at(main), "props", "props are data (text, name, avatar, mode) — never a verb, a class, a style or children");
421
+ if (/\.\.\./.test(destructured)) L.hit(at(main), "props", "name every prop ({ text, name, mode }); a rest bag is how a class or a style would get in");
422
+ if (bag && (m = new RegExp(`\\b${bag}\\.(?:on[A-Z]\\w*|className|style|children|render)\\b`).exec(code))) L.hit(at(m), "props", "props are data (text, name, avatar, mode) — never a verb, a class, a style or children");
423
+ // A helper inside the file may take children (it gets JSX from this
424
+ // file); the default export never does, and never hands its props on
425
+ // whole — a spread is how children or a class would reach a helper.
426
+ if ((m = /\{\s*\.\.\.[\w.]+\s*\}/.exec(code))) L.hit(at(m), "props", "no spread: pass a depiction's data by name (text={text}), never {...props}");
427
+ if ((m = /\buse(?!State\b|Memo\b|Id\b)[A-Z]\w*\(/.exec(code))) L.hit(at(m), "hook", "a depiction is a pure function of its props (useState for one toggle, useMemo, useId at most)");
428
+ if (/\buse(?:State|Memo|Id)\(/.test(code) && !/^\s*["']use client["']/.test(raw)) L.hit(f.path, "client", 'a hook needs "use client" on the first line');
429
+ 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");
430
+ }
431
+
432
+ // (b) where it is framed. The folder has one door: a static default
433
+ // import under a Capitalised name. Any other string that names the
434
+ // folder (import(), require(), a re-export, a split path) is a way
435
+ // round the frame, wherever it sits.
436
+ const DOOR = /(?<!^\s*import\s+[\w$]+\s+from\s*)["'`][^"'`\n]*\bdepictions\b[^"'`\n]*["'`]/m;
437
+ scan(DOOR, (f, m, line) => {
438
+ const slug = /depictions\/([a-z0-9-]+)/.exec(m[0])?.[1];
439
+ if (slug) claimed.add(slug);
440
+ 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`);
441
+ });
442
+ scan(/\bimport\s*\(|\brequire\s*\(/, (f, m, line) => L.hit(`${f.path}:${line}`, "dynamic", `${m[0].trim()} — the app is one static bundle; write import X from "…" at the top of the file`));
443
+ const IMPORT = /^\s*import\s+([^;]*?)\s+from\s*["']([^"']*depictions\/([^"']+?))(?:\.jsx?)?["']/gm;
444
+ const FRAME = new RegExp(String.raw`<Depiction\b(${ATTRS})>\s*<([A-Z]\w*)\b${ATTRS}\/>\s*<\/Depiction>`, "g");
445
+ for (const f of appFiles) {
446
+ const code = f.code;
447
+ const importsHere = [...code.matchAll(IMPORT)];
448
+ const opened = [...code.matchAll(/<Depiction\b/g)];
449
+ if (/data-depiction=|(?:function|const)\s+Depiction\b|\bas\s+Depiction\b/.test(code)) L.hit(f.path, "frame", "the frame is the kit's (components/ui/depiction.jsx)");
450
+ if (!importsHere.length && !opened.length) continue;
451
+ if (!FRAME_HOST.test(f.path)) {
452
+ L.hit(f.path, "place", "a depiction is framed on a surface or in the component that holds the draft (components/<X>.jsx), never in the shell, the kit or lib/");
453
+ continue;
454
+ }
455
+ if (!/^\s*import\s*\{[^}]*\bDepiction\b[^}]*\}\s*from\s*["'](?:\.\.\/components\/ui|\.\/ui)(?:\/depiction)?["']/m.test(code)) L.hit(f.path, "wrap", 'wrap it: import { Depiction } from "../components/ui" (a surface) or "./ui" (a component)');
456
+ // Code with its strings blanked too (same length, so indices hold),
457
+ // and the names this file binds to JSX (const look = <b/>).
458
+ const bare = code.replace(/(["'`])(?:(?!\1)[^\\\n]|\\.)*\1/g, (str) => str.replace(/[^\n]/g, " "));
459
+ const bound = [...bare.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*\(?\s*</g)].map((m) => m[1]);
460
+ const names = new Map(); // component name → slug
461
+ let named = false;
462
+ for (const m of importsHere) {
463
+ const what = (m[1] ?? "").trim();
464
+ const slug = m[3];
465
+ if (!/^[A-Z]\w*$/.test(what)) {
466
+ named = true;
467
+ claimed.add(slug);
468
+ L.hit(`${f.path}:${lineOf(code, m.index)}`, "named", `import the picture under a Capitalised name and nothing else: import ${slug.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase())} from "…/depictions/${slug}"`);
469
+ continue;
470
+ }
471
+ if (!existsSync(join(ROOT, "components", "depictions", `${slug}.jsx`))) L.hit(`${f.path}:${lineOf(code, m.index)}`, "missing", `components/depictions/${slug}.jsx is not there`);
472
+ names.set(what, slug);
473
+ }
474
+ if (named) continue;
475
+ const wrapped = [...code.matchAll(FRAME)];
476
+ if (opened.length !== wrapped.length) {
477
+ const bad = opened.find((o) => !wrapped.some((w) => w.index === o.index)) ?? opened[0];
478
+ L.hit(`${f.path}:${lineOf(code, bad.index)}`, "one", "inside <Depiction> goes exactly one depiction, self-closing; the chrome around it is the app");
479
+ }
480
+ const framed = new Map();
481
+ for (const fr of wrapped) {
482
+ const [, attrs, name] = fr;
483
+ const where = `${f.path}:${lineOf(code, fr.index)}`;
484
+ const slug = names.get(name);
485
+ if (!slug) {
486
+ L.hit(where, "one", `<${name}> inside <Depiction> is not a depiction; the frame holds one imported picture`);
487
+ continue;
488
+ }
489
+ const of = /\bof=(?:["']([a-z0-9-]+)["']|\{["']([a-z0-9-]+)["']\})/.exec(attrs);
490
+ const ofSlug = of?.[1] ?? of?.[2];
491
+ if (!of) L.hit(where, "of", `of must be a plain string naming the file: of="${slug}"`);
492
+ else if (ofSlug !== slug) L.hit(where, "of", `of="${slug}" names the file it frames`);
493
+ if (!/\bscheme=["'](?:light|dark)["']/.test(attrs) || !/\bwidth=\{\d+\}/.test(attrs)) L.hit(where, "frame-props", 'the frame needs scheme="light|dark" (the TARGET\'s) and width={<px from the header>}');
494
+ // Data goes through the frame; an element, an object, an array, a
495
+ // function or a spread does not, whatever the prop is called.
496
+ const tag = fr[0].slice(fr[0].indexOf(`<${name}`));
497
+ if (/\{\s*\.\.\.|=\{\s*[<{\[(`]|=>|=\{\s*(?:async\s+)?function\b/.test(tag) || bound.some((b) => new RegExp(`=\\{\\s*${b}\\b`).test(tag))) L.hit(where, "one", "the picture takes data props only (text={draft.text}, name=\"…\"); no element, object, array, function or spread goes through the frame");
498
+ framed.set(name, (framed.get(name) ?? 0) + 1);
499
+ rendered.set(slug, [...(rendered.get(slug) ?? []), f.path]);
500
+ }
501
+ // Every reference to the name but the import must be a framed tag: an
502
+ // alias (const P = XPost), a prop or a bare render is a leak. A string
503
+ // may name it (a caption); only code counts. An escaped identifier
504
+ // (\u0058Post) is the same binding spelled past the regex: refused.
505
+ let esc;
506
+ if ((esc = /\\u(?:\{[0-9a-fA-F]+\}|[0-9a-fA-F]{4})/.exec(bare))) L.hit(`${f.path}:${lineOf(code, esc.index)}`, "escape", "an escaped identifier; write the name as it is imported");
507
+ for (const [name] of names) {
508
+ const imp = importsHere.find((m) => (m[1] ?? "").trim() === name);
509
+ const inside = (i) => (imp && i >= imp.index && i < imp.index + imp[0].length) || wrapped.some((fr) => fr[2] === name && i >= fr.index && i < fr.index + fr[0].length);
510
+ const stray = [...bare.matchAll(new RegExp(`\\b${name}\\b`, "g"))].filter((m) => !inside(m.index));
511
+ if (!stray.length) {
512
+ if (!framed.get(name)) L.hit(f.path, `outside-${name}`, `${name} is imported and never framed`);
513
+ continue;
514
+ }
515
+ const where = `${f.path}:${lineOf(code, stray[0].index)}`;
516
+ if (bare[stray[0].index - 1] === "<") L.hit(where, `outside-${name}`, `<${name}> rendered outside its frame — it inherits the workspace; wrap it in <Depiction of= scheme= width=>`);
517
+ else L.hit(where, `alias-${name}`, `${name} used as a value here (aliased, called or passed along) — a depiction is written as <${name} …/> directly inside <Depiction> and nowhere else`);
518
+ }
519
+ }
520
+
521
+ // (c) nothing in the folder is dead
522
+ for (const f of depictionFiles) {
523
+ const slug = slugOf(f.path);
524
+ if (!rendered.has(slug) && !helpers.has(slug) && !claimed.has(slug)) L.hit(f.path, "dead", "dead depiction — frame it on a surface or in the component that holds the draft, or delete it");
525
+ }
526
+
527
+ const hosts = new Set([...rendered.values()].flat()).size;
528
+ if (!depictionFiles.length) ok7("none");
529
+ else ok7(`${pictures} picture${pictures === 1 ? "" : "s"} of elsewhere, framed in ${hosts} file${hosts === 1 ? "" : "s"}: ${[...rendered.keys()].join(", ")}`);
530
+ }
531
+
532
+ // 6. Smoke: the app, built and started, answers every surface.
533
+
534
+ /** Newest mtime among the app's own sources (what a build reads). */
535
+ function newestSource() {
536
+ let newest = 0;
537
+ for (const f of walk(ROOT)) {
538
+ const rel = relative(ROOT, f);
539
+ if (/^(node_modules|\.next|\.next-check|check)\//.test(rel)) continue;
540
+ newest = Math.max(newest, statSync(f).mtimeMs);
541
+ }
542
+ return newest;
543
+ }
544
+
545
+ function writable(dir) {
546
+ try {
547
+ accessSync(dir, constants.W_OK);
548
+ return true;
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
553
+
554
+ /** Where the build goes: { dir, dist } — in place when the app folder is
555
+ * ours to write, else a copy of the sources under a folder that is. */
556
+ function buildSite() {
557
+ if (writable(ROOT)) return { dir: ROOT, dist: ".next-check", copied: false };
558
+ const name = basename(dirname(ROOT)) || "app"; // plays/<name>/app
559
+ const home = writable(homedir()) ? homedir() : tmpdir();
560
+ return { dir: join(home, ".claw-check", name), dist: ".next", copied: true };
561
+ }
562
+
563
+ /** Copy the app's sources (never node_modules or a build) to `dir`, and
564
+ * point its node_modules back at the app's. */
565
+ function copySources(dir) {
566
+ for (const stale of readdirSync(dir, { withFileTypes: true }).filter((e) => e.name !== "node_modules" && e.name !== ".next")) {
567
+ rmSync(join(dir, stale.name), { recursive: true, force: true });
568
+ }
569
+ cpSync(ROOT, dir, {
570
+ recursive: true,
571
+ dereference: false,
572
+ filter: (src) => {
573
+ const rel = relative(ROOT, src);
574
+ return !/^(node_modules|\.next|\.next-check|check)(\/|$)/.test(rel);
575
+ },
576
+ });
577
+ const link = join(dir, "node_modules");
578
+ if (!existsSync(link)) symlinkSync(join(ROOT, "node_modules"), link, "dir");
579
+ }
580
+
581
+ function buildForCheck(site, env) {
582
+ const stamp = join(site.dir, site.dist, "BUILD_ID");
583
+ if (existsSync(stamp) && statSync(stamp).mtimeMs >= newestSource()) return { ok: true, reused: true };
584
+ const started = Date.now();
585
+ if (site.copied) {
586
+ mkdirSync(site.dir, { recursive: true });
587
+ copySources(site.dir);
588
+ }
589
+ const r = spawnSync(join(site.dir, "node_modules", ".bin", "next"), ["build"], { cwd: site.dir, env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
590
+ if (r.status !== 0) {
591
+ const tail = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.trim().split("\n").filter(Boolean).slice(-8).join("\n");
592
+ return { ok: false, tail };
593
+ }
594
+ return { ok: true, reused: false, seconds: ((Date.now() - started) / 1000).toFixed(0) };
595
+ }
596
+
597
+ async function smoke() {
598
+ const site = buildSite();
599
+ const base = { ...process.env, ...dotenv(join(ROOT, ".env")), NODE_ENV: "production", HOSTNAME: "127.0.0.1", CLAW_DIST_DIR: site.dist };
600
+ delete base.CLAW_PRODUCT_TOKEN; // the check is not the proxy; the middleware would 401 it
601
+ const built = buildForCheck(site, base);
602
+ if (!built.ok) {
603
+ fail("smoke", "build", `next build failed:\n${built.tail}`);
604
+ return;
605
+ }
606
+ const buildNote = built.reused ? "build reused" : `built in ${built.seconds}s${site.copied ? ` (copy at ${site.dir})` : ""}`;
607
+ const manifest = join(site.dir, site.dist, "required-server-files.json");
608
+ const basePath = existsSync(manifest) ? (JSON.parse(readFileSync(manifest, "utf8"))?.config?.basePath ?? "") : "";
609
+ const env = { ...base, CLAW_BASE_PATH: basePath };
610
+ const port = await freePort();
611
+ env.PORT = String(port);
612
+ const child = spawn(join(site.dir, "node_modules", ".bin", "next"), ["start"], { cwd: site.dir, env, stdio: ["ignore", "pipe", "pipe"] });
613
+ let log = "";
614
+ child.stdout.on("data", (d) => (log += d));
615
+ child.stderr.on("data", (d) => (log += d));
616
+ const origin = `http://127.0.0.1:${port}${basePath}`;
617
+ const started = Date.now();
618
+ try {
619
+ const up = await waitFor(() => fetch(`${origin}/`, { redirect: "manual" }).then((r) => r.status < 500), 60_000);
620
+ if (!up) {
621
+ fail("smoke", origin, `did not start in 60s. Last log lines:\n${log.trim().split("\n").slice(-6).join("\n")}`);
622
+ return;
623
+ }
624
+ const results = [];
625
+ const home = await fetch(`${origin}/`, { redirect: "manual" });
626
+ results.push(`/ → ${home.status}`);
627
+ if (!(home.status === 200 || (home.status >= 300 && home.status < 400))) fail("smoke", "/", `answered ${home.status}`);
628
+ for (const key of surfaceKeys) {
629
+ const res = await fetch(`${origin}/s/${encodeURIComponent(key)}`);
630
+ const body = await res.text();
631
+ const broke = /Something broke on this surface|The page could not render|Application error: a (client|server)-side exception/.test(body);
632
+ results.push(`/s/${key} ${res.status}${broke ? " BROKE" : ""}`);
633
+ if (res.status !== 200) fail("smoke", `/s/${key}`, `answered ${res.status}`);
634
+ else if (broke) fail("smoke", `/s/${key}`, `rendered the error boundary. Last log lines:\n${log.trim().split("\n").slice(-6).join("\n")}`);
635
+ }
636
+ const errors = log.split("\n").filter((l) => /\b(Error|TypeError|ReferenceError)\b/.test(l) && !/ECONNREFUSED|DATABASE_URL/.test(l));
637
+ if (errors.length) fail("smoke", "server log", errors.slice(0, 3).join(" | "));
638
+ if (!failures.includes("smoke")) ok("smoke", `${buildNote} · ${results.join(" · ")} (${((Date.now() - started) / 1000).toFixed(1)}s)`);
639
+ } finally {
640
+ child.kill("SIGTERM");
641
+ }
642
+ }
643
+
644
+ function dotenv(path) {
645
+ if (!existsSync(path)) return {};
646
+ const out = {};
647
+ for (const line of readFileSync(path, "utf8").split("\n")) {
648
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
649
+ if (!m) continue;
650
+ out[m[1]] = m[2].replace(/^(['"])(.*)\1$/, "$2");
651
+ }
652
+ return out;
653
+ }
654
+
655
+ function freePort() {
656
+ return new Promise((resolve, reject) => {
657
+ const s = createServer();
658
+ s.listen(0, "127.0.0.1", () => {
659
+ const { port } = s.address();
660
+ s.close(() => resolve(port));
661
+ });
662
+ s.on("error", reject);
663
+ });
664
+ }
665
+
666
+ async function waitFor(probe, ms) {
667
+ const until = Date.now() + ms;
668
+ while (Date.now() < until) {
669
+ try {
670
+ if (await probe()) return true;
671
+ } catch {}
672
+ await new Promise((r) => setTimeout(r, 500));
673
+ }
674
+ return false;
675
+ }
676
+
677
+ if (!STATIC_ONLY) await smoke();
678
+
679
+ console.log(`check: ${files.length} files · ${surfaceKeys.length} surface${surfaceKeys.length === 1 ? "" : "s"}${STATIC_ONLY ? " · static only" : ""}`);
680
+ for (const l of lines) console.log(l);
681
+ const failed = new Set(failures).size;
682
+ console.log(failed ? `${failed} rule${failed === 1 ? "" : "s"} failed` : "all clear");
683
+ process.exit(failed ? 1 : 0);