#!/usr/bin/env node // check-app.mjs — static validator for a single-file mini-app. // // node check-app.mjs path/to/app.js // // Lives at tools/check-app.mjs in the repo, and every instance also serves it // at GET /api/check-app.mjs so authors following the guide can curl it down // with no checkout. // // Verifies the mechanical rules from docs/building-apps.md (served at // /api/app-guide) so an LLM-authored app can be iterated against a clear // pass/fail list before anyone installs it. Checks are static and best-effort: // a PASS here doesn't prove the app is correct, but a FAIL is always a real // rule violation. Zero dependencies; exits non-zero on any failure. import { readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; const path = process.argv[2]; if (!path) { console.error("usage: node check-app.mjs path/to/app.js"); process.exit(2); } let src; try { src = readFileSync(path, "utf8"); } catch (e) { console.error(`FAIL cannot read ${path}: ${e.message}`); process.exit(1); } let failures = 0; const pass = (msg) => console.log("PASS " + msg); const fail = (msg) => { console.log("FAIL " + msg); failures++; }; // strip walks the source once, dropping comments and (optionally) string // contents, so token scans don't trip on prose like "never call setInterval". // Replaced spans keep their length/newlines so any offsets stay meaningful. // Best-effort: template-literal ${} nesting is not fully parsed. function strip(code, { strings = false } = {}) { let out = ""; let i = 0; const n = code.length; while (i < n) { const c = code[i]; const c2 = code[i + 1]; if (c === "/" && c2 === "/") { while (i < n && code[i] !== "\n") i++; continue; } if (c === "/" && c2 === "*") { i += 2; while (i < n && !(code[i] === "*" && code[i + 1] === "/")) { if (code[i] === "\n") out += "\n"; i++; } i += 2; continue; } if (c === '"' || c === "'" || c === "`") { const quote = c; out += c; i++; while (i < n && code[i] !== quote) { if (code[i] === "\\") { if (!strings) out += code[i] + (code[i + 1] || ""); i += 2; continue; } if (strings) { if (code[i] === "\n") out += "\n"; } else { out += code[i]; } i++; } out += quote; i++; continue; } out += c; i++; } return out; } const code = strip(src); // comments removed, strings kept (keys, literals) const bare = strip(src, { strings: true }); // comments AND string contents removed // ---- 1. parses ------------------------------------------------------------ // Piped with --input-type=module: a bare `node --check ` silently exits 0 // on files it detects as ES modules (Node 22), so it would miss real syntax // errors — the stdin form parses the source as a module for real. const check = spawnSync(process.execPath, ["--input-type=module", "--check"], { encoding: "utf8", input: src }); if (check.status === 0) { pass("parses (node --check)"); } else { fail("does not parse: " + (check.stderr || "").trim().split("\n")[0]); } // ---- 2. exactly one defineApp call ----------------------------------------- const defineCalls = (bare.match(/\bdefineApp\s*\(/g) || []).length; if (defineCalls === 1) pass("exactly one defineApp( call"); else fail(`expected exactly one defineApp( call, found ${defineCalls}`); // ---- 3. required fields ----------------------------------------------------- for (const field of ["name", "title", "glyph", "initialState", "events"]) { if (new RegExp(`\\b${field}\\s*:`).test(code)) pass(`required field "${field}" present`); else fail(`required field "${field}" missing`); } // ---- 4. places declares where the app lives ---------------------------------- // The ONE placement declaration (docs/space-apps.md): a literal array, any // NON-EMPTY subset of "you"/"chat"/"space" — personal-only (["you"]) is the // common shape; a space-only app (one shared copy for everyone, gif-style) // is just as legal. The legacy `scope: "personal"` is still accepted for // already-written apps — defineApp normalizes it into places: ["you"] — but // new apps declare places. (`scope: "shared"`, the old ambient panel, is // retired: such an app degrades to command-only, so it fails here rather // than passing as something it can no longer be.) const placesM = code.match(/\bplaces\s*:\s*\[([^\]]*)\]/); const scopeM = code.match(/\bscope\s*:\s*["'`]([^"'`]*)["'`]/); if (placesM) { const entries = [...placesM[1].matchAll(/["'`]([^"'`]*)["'`]/g)].map((m) => m[1]); const badPlaces = entries.filter((p) => p !== "you" && p !== "chat" && p !== "space"); if (!entries.length) fail('places must name at least one place, e.g. places: ["you"]'); else if (badPlaces.length) fail(`places entries must be "you", "chat", or "space" — got: ${badPlaces.join(", ")}`); else pass(`places is [${entries.map((p) => `"${p}"`).join(", ")}]`); } else if (scopeM && scopeM[1] === "personal") { pass('legacy scope: "personal" accepted (normalizes to places: ["you"] — new apps declare places)'); } else { fail('declare places: a literal non-empty subset of ["you", "chat", "space"], e.g. places: ["you"]'); } // ---- 5. event types prefixed with the app name ------------------------------- // Extract the app name, then walk the events object (brace-matched from // `events:`) collecting quoted keys at its top level; each must start ".". const nameM = code.match(/\bname\s*:\s*["']([^"']+)["']/); const eventsAt = code.search(/\bevents\s*:\s*\{/); if (!nameM) { fail("cannot find the app name (name: \"…\") to check event-type prefixes"); } else if (eventsAt === -1) { fail("cannot find the events map (events: { … })"); } else { const name = nameM[1]; const open = code.indexOf("{", eventsAt); let depth = 0; let end = open; for (let i = open; i < code.length; i++) { if (code[i] === "{" || code[i] === "(" || code[i] === "[") depth++; else if (code[i] === "}" || code[i] === ")" || code[i] === "]") { depth--; if (depth === 0) { end = i; break; } } } const block = code.slice(open, end + 1); // Quoted keys at the top level of the events object only (depth 1). const keys = []; let d = 0; for (let i = 0; i < block.length; i++) { const ch = block[i]; if (ch === "{" || ch === "(" || ch === "[") d++; else if (ch === "}" || ch === ")" || ch === "]") d--; else if (d === 1 && (ch === '"' || ch === "'")) { const close = block.indexOf(ch, i + 1); if (close === -1) break; const rest = block.slice(close + 1).match(/^\s*:/); if (rest) keys.push(block.slice(i + 1, close)); i = close; } } if (keys.length === 0) { fail("no event types found in the events map"); } else { const badKeys = keys.filter((k) => !k.startsWith(name + ".")); if (badKeys.length === 0) pass(`all ${keys.length} event type(s) prefixed "${name}."`); else fail(`event type(s) not prefixed "${name}.": ${badKeys.join(", ")}`); } } // ---- 6. forbidden APIs ------------------------------------------------------- const FORBIDDEN = [ [/\bfetch\s*\(/, "fetch( — no network calls; all I/O goes through ctx"], [/\bXMLHttpRequest\b/, "XMLHttpRequest — no network calls"], [/\bnew\s+WebSocket\b/, "new WebSocket — no network calls"], [/\blocalStorage\b/, "localStorage — no browser storage; state lives in the fold"], [/\bindexedDB\b/i, "indexedDB — no browser storage; state lives in the fold"], [/\bdocument\s*\.\s*cookie\b/, "document.cookie — no browser storage"], [/\bsetInterval\s*\(/, "setInterval( — use ctx.interval so the framework stops the timer"], [/\beval\s*\(/, "eval( — never"], [/\bimport\s*\(/, "dynamic import( — imports must be static, from ../apps.js or ../format.js"], ]; for (const [re, why] of FORBIDDEN) { if (re.test(bare)) fail("forbidden: " + why); else pass("no " + why.split(" — ")[0]); } // ---- 7. innerHTML only from plain string literals ---------------------------- // Scan the comment-stripped source line by line: `.innerHTML =` is allowed only // when the right-hand side is a single plain string literal (no ${} templates, // no concatenation, no variables). `+=` is never allowed. { let bad = 0; const lines = code.split("\n"); for (let ln = 0; ln < lines.length; ln++) { const m = lines[ln].match(/\.innerHTML\s*(\+?=)([^=].*)$/); if (!m) continue; const rhs = m[2].trim().replace(/;\s*$/, ""); const plain = m[1] === "=" && (/^"([^"\\]|\\.)*"$/.test(rhs) || /^'([^'\\]|\\.)*'$/.test(rhs) || (/^`[^`]*`$/.test(rhs) && !rhs.includes("${"))); if (!plain) { fail(`innerHTML assignment on line ${ln + 1} is not a plain string literal — build DOM nodes / use textContent`); bad++; } } if (!bad) pass("no innerHTML assignments of dynamic content"); } // ---- 8. imports only from ../apps.js and ../format.js ------------------------ { const ALLOWED = new Set(["../apps.js", "../format.js"]); const specs = []; for (const m of code.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) specs.push(m[1]); for (const m of code.matchAll(/^\s*import\s+["']([^"']+)["']/gm)) specs.push(m[1]); const badImports = specs.filter((s) => !ALLOWED.has(s)); if (badImports.length === 0) pass("imports only from ../apps.js / ../format.js"); else fail(`imports not allowed: ${badImports.join(", ")} (only ../apps.js and ../format.js)`); } console.log(failures ? `\n${failures} check(s) failed` : "\nall checks passed"); process.exit(failures ? 1 : 0);