| 1 | #!/usr/bin/env node |
| 2 | // check-app.mjs — static validator for a single-file mini-app. |
| 3 | // |
| 4 | // node check-app.mjs path/to/app.js |
| 5 | // |
| 6 | // Lives at tools/check-app.mjs in the repo, and every instance also serves it |
| 7 | // at GET /api/check-app.mjs so authors following the guide can curl it down |
| 8 | // with no checkout. |
| 9 | // |
| 10 | // Verifies the mechanical rules from docs/building-apps.md (served at |
| 11 | // /api/app-guide) so an LLM-authored app can be iterated against a clear |
| 12 | // pass/fail list before anyone installs it. Checks are static and best-effort: |
| 13 | // a PASS here doesn't prove the app is correct, but a FAIL is always a real |
| 14 | // rule violation. Zero dependencies; exits non-zero on any failure. |
| 15 | |
| 16 | import { readFileSync } from "node:fs"; |
| 17 | import { spawnSync } from "node:child_process"; |
| 18 | |
| 19 | const path = process.argv[2]; |
| 20 | if (!path) { |
| 21 | console.error("usage: node check-app.mjs path/to/app.js"); |
| 22 | process.exit(2); |
| 23 | } |
| 24 | |
| 25 | let src; |
| 26 | try { |
| 27 | src = readFileSync(path, "utf8"); |
| 28 | } catch (e) { |
| 29 | console.error(`FAIL cannot read ${path}: ${e.message}`); |
| 30 | process.exit(1); |
| 31 | } |
| 32 | |
| 33 | let failures = 0; |
| 34 | const pass = (msg) => console.log("PASS " + msg); |
| 35 | const fail = (msg) => { |
| 36 | console.log("FAIL " + msg); |
| 37 | failures++; |
| 38 | }; |
| 39 | |
| 40 | // strip walks the source once, dropping comments and (optionally) string |
| 41 | // contents, so token scans don't trip on prose like "never call setInterval". |
| 42 | // Replaced spans keep their length/newlines so any offsets stay meaningful. |
| 43 | // Best-effort: template-literal ${} nesting is not fully parsed. |
| 44 | function strip(code, { strings = false } = {}) { |
| 45 | let out = ""; |
| 46 | let i = 0; |
| 47 | const n = code.length; |
| 48 | while (i < n) { |
| 49 | const c = code[i]; |
| 50 | const c2 = code[i + 1]; |
| 51 | if (c === "/" && c2 === "/") { |
| 52 | while (i < n && code[i] !== "\n") i++; |
| 53 | continue; |
| 54 | } |
| 55 | if (c === "/" && c2 === "*") { |
| 56 | i += 2; |
| 57 | while (i < n && !(code[i] === "*" && code[i + 1] === "/")) { |
| 58 | if (code[i] === "\n") out += "\n"; |
| 59 | i++; |
| 60 | } |
| 61 | i += 2; |
| 62 | continue; |
| 63 | } |
| 64 | if (c === '"' || c === "'" || c === "`") { |
| 65 | const quote = c; |
| 66 | out += c; |
| 67 | i++; |
| 68 | while (i < n && code[i] !== quote) { |
| 69 | if (code[i] === "\\") { |
| 70 | if (!strings) out += code[i] + (code[i + 1] || ""); |
| 71 | i += 2; |
| 72 | continue; |
| 73 | } |
| 74 | if (strings) { |
| 75 | if (code[i] === "\n") out += "\n"; |
| 76 | } else { |
| 77 | out += code[i]; |
| 78 | } |
| 79 | i++; |
| 80 | } |
| 81 | out += quote; |
| 82 | i++; |
| 83 | continue; |
| 84 | } |
| 85 | out += c; |
| 86 | i++; |
| 87 | } |
| 88 | return out; |
| 89 | } |
| 90 | |
| 91 | const code = strip(src); // comments removed, strings kept (keys, literals) |
| 92 | const bare = strip(src, { strings: true }); // comments AND string contents removed |
| 93 | |
| 94 | // ---- 1. parses ------------------------------------------------------------ |
| 95 | // Piped with --input-type=module: a bare `node --check <file>` silently exits 0 |
| 96 | // on files it detects as ES modules (Node 22), so it would miss real syntax |
| 97 | // errors — the stdin form parses the source as a module for real. |
| 98 | const check = spawnSync(process.execPath, ["--input-type=module", "--check"], { encoding: "utf8", input: src }); |
| 99 | if (check.status === 0) { |
| 100 | pass("parses (node --check)"); |
| 101 | } else { |
| 102 | fail("does not parse: " + (check.stderr || "").trim().split("\n")[0]); |
| 103 | } |
| 104 | |
| 105 | // ---- 2. exactly one defineApp call ----------------------------------------- |
| 106 | const defineCalls = (bare.match(/\bdefineApp\s*\(/g) || []).length; |
| 107 | if (defineCalls === 1) pass("exactly one defineApp( call"); |
| 108 | else fail(`expected exactly one defineApp( call, found ${defineCalls}`); |
| 109 | |
| 110 | // ---- 3. required fields ----------------------------------------------------- |
| 111 | for (const field of ["name", "title", "glyph", "initialState", "events"]) { |
| 112 | if (new RegExp(`\\b${field}\\s*:`).test(code)) pass(`required field "${field}" present`); |
| 113 | else fail(`required field "${field}" missing`); |
| 114 | } |
| 115 | |
| 116 | // ---- 4. places declares where the app lives ---------------------------------- |
| 117 | // The ONE placement declaration (docs/space-apps.md): a literal array, any |
| 118 | // NON-EMPTY subset of "you"/"chat"/"space" — personal-only (["you"]) is the |
| 119 | // common shape; a space-only app (one shared copy for everyone, gif-style) |
| 120 | // is just as legal. The legacy `scope: "personal"` is still accepted for |
| 121 | // already-written apps — defineApp normalizes it into places: ["you"] — but |
| 122 | // new apps declare places. (`scope: "shared"`, the old ambient panel, is |
| 123 | // retired: such an app degrades to command-only, so it fails here rather |
| 124 | // than passing as something it can no longer be.) |
| 125 | const placesM = code.match(/\bplaces\s*:\s*\[([^\]]*)\]/); |
| 126 | const scopeM = code.match(/\bscope\s*:\s*["'`]([^"'`]*)["'`]/); |
| 127 | if (placesM) { |
| 128 | const entries = [...placesM[1].matchAll(/["'`]([^"'`]*)["'`]/g)].map((m) => m[1]); |
| 129 | const badPlaces = entries.filter((p) => p !== "you" && p !== "chat" && p !== "space"); |
| 130 | if (!entries.length) fail('places must name at least one place, e.g. places: ["you"]'); |
| 131 | else if (badPlaces.length) fail(`places entries must be "you", "chat", or "space" — got: ${badPlaces.join(", ")}`); |
| 132 | else pass(`places is [${entries.map((p) => `"${p}"`).join(", ")}]`); |
| 133 | } else if (scopeM && scopeM[1] === "personal") { |
| 134 | pass('legacy scope: "personal" accepted (normalizes to places: ["you"] — new apps declare places)'); |
| 135 | } else { |
| 136 | fail('declare places: a literal non-empty subset of ["you", "chat", "space"], e.g. places: ["you"]'); |
| 137 | } |
| 138 | |
| 139 | // ---- 5. event types prefixed with the app name ------------------------------- |
| 140 | // Extract the app name, then walk the events object (brace-matched from |
| 141 | // `events:`) collecting quoted keys at its top level; each must start "<name>.". |
| 142 | const nameM = code.match(/\bname\s*:\s*["']([^"']+)["']/); |
| 143 | const eventsAt = code.search(/\bevents\s*:\s*\{/); |
| 144 | if (!nameM) { |
| 145 | fail("cannot find the app name (name: \"…\") to check event-type prefixes"); |
| 146 | } else if (eventsAt === -1) { |
| 147 | fail("cannot find the events map (events: { … })"); |
| 148 | } else { |
| 149 | const name = nameM[1]; |
| 150 | const open = code.indexOf("{", eventsAt); |
| 151 | let depth = 0; |
| 152 | let end = open; |
| 153 | for (let i = open; i < code.length; i++) { |
| 154 | if (code[i] === "{" || code[i] === "(" || code[i] === "[") depth++; |
| 155 | else if (code[i] === "}" || code[i] === ")" || code[i] === "]") { |
| 156 | depth--; |
| 157 | if (depth === 0) { |
| 158 | end = i; |
| 159 | break; |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | const block = code.slice(open, end + 1); |
| 164 | // Quoted keys at the top level of the events object only (depth 1). |
| 165 | const keys = []; |
| 166 | let d = 0; |
| 167 | for (let i = 0; i < block.length; i++) { |
| 168 | const ch = block[i]; |
| 169 | if (ch === "{" || ch === "(" || ch === "[") d++; |
| 170 | else if (ch === "}" || ch === ")" || ch === "]") d--; |
| 171 | else if (d === 1 && (ch === '"' || ch === "'")) { |
| 172 | const close = block.indexOf(ch, i + 1); |
| 173 | if (close === -1) break; |
| 174 | const rest = block.slice(close + 1).match(/^\s*:/); |
| 175 | if (rest) keys.push(block.slice(i + 1, close)); |
| 176 | i = close; |
| 177 | } |
| 178 | } |
| 179 | if (keys.length === 0) { |
| 180 | fail("no event types found in the events map"); |
| 181 | } else { |
| 182 | const badKeys = keys.filter((k) => !k.startsWith(name + ".")); |
| 183 | if (badKeys.length === 0) pass(`all ${keys.length} event type(s) prefixed "${name}."`); |
| 184 | else fail(`event type(s) not prefixed "${name}.": ${badKeys.join(", ")}`); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // ---- 6. forbidden APIs ------------------------------------------------------- |
| 189 | const FORBIDDEN = [ |
| 190 | [/\bfetch\s*\(/, "fetch( — no network calls; all I/O goes through ctx"], |
| 191 | [/\bXMLHttpRequest\b/, "XMLHttpRequest — no network calls"], |
| 192 | [/\bnew\s+WebSocket\b/, "new WebSocket — no network calls"], |
| 193 | [/\blocalStorage\b/, "localStorage — no browser storage; state lives in the fold"], |
| 194 | [/\bindexedDB\b/i, "indexedDB — no browser storage; state lives in the fold"], |
| 195 | [/\bdocument\s*\.\s*cookie\b/, "document.cookie — no browser storage"], |
| 196 | [/\bsetInterval\s*\(/, "setInterval( — use ctx.interval so the framework stops the timer"], |
| 197 | [/\beval\s*\(/, "eval( — never"], |
| 198 | [/\bimport\s*\(/, "dynamic import( — imports must be static, from ../apps.js or ../format.js"], |
| 199 | ]; |
| 200 | for (const [re, why] of FORBIDDEN) { |
| 201 | if (re.test(bare)) fail("forbidden: " + why); |
| 202 | else pass("no " + why.split(" — ")[0]); |
| 203 | } |
| 204 | |
| 205 | // ---- 7. innerHTML only from plain string literals ---------------------------- |
| 206 | // Scan the comment-stripped source line by line: `.innerHTML =` is allowed only |
| 207 | // when the right-hand side is a single plain string literal (no ${} templates, |
| 208 | // no concatenation, no variables). `+=` is never allowed. |
| 209 | { |
| 210 | let bad = 0; |
| 211 | const lines = code.split("\n"); |
| 212 | for (let ln = 0; ln < lines.length; ln++) { |
| 213 | const m = lines[ln].match(/\.innerHTML\s*(\+?=)([^=].*)$/); |
| 214 | if (!m) continue; |
| 215 | const rhs = m[2].trim().replace(/;\s*$/, ""); |
| 216 | const plain = |
| 217 | m[1] === "=" && |
| 218 | (/^"([^"\\]|\\.)*"$/.test(rhs) || /^'([^'\\]|\\.)*'$/.test(rhs) || (/^`[^`]*`$/.test(rhs) && !rhs.includes("${"))); |
| 219 | if (!plain) { |
| 220 | fail(`innerHTML assignment on line ${ln + 1} is not a plain string literal — build DOM nodes / use textContent`); |
| 221 | bad++; |
| 222 | } |
| 223 | } |
| 224 | if (!bad) pass("no innerHTML assignments of dynamic content"); |
| 225 | } |
| 226 | |
| 227 | // ---- 8. imports only from ../apps.js and ../format.js ------------------------ |
| 228 | { |
| 229 | const ALLOWED = new Set(["../apps.js", "../format.js"]); |
| 230 | const specs = []; |
| 231 | for (const m of code.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) specs.push(m[1]); |
| 232 | for (const m of code.matchAll(/^\s*import\s+["']([^"']+)["']/gm)) specs.push(m[1]); |
| 233 | const badImports = specs.filter((s) => !ALLOWED.has(s)); |
| 234 | if (badImports.length === 0) pass("imports only from ../apps.js / ../format.js"); |
| 235 | else fail(`imports not allowed: ${badImports.join(", ")} (only ../apps.js and ../format.js)`); |
| 236 | } |
| 237 | |
| 238 | console.log(failures ? `\n${failures} check(s) failed` : "\nall checks passed"); |
| 239 | process.exit(failures ? 1 : 0); |
| 240 | |