eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
16import { readFileSync } from "node:fs";
17import { spawnSync } from "node:child_process";
18
19const path = process.argv[2];
20if (!path) {
21 console.error("usage: node check-app.mjs path/to/app.js");
22 process.exit(2);
23}
24
25let src;
26try {
27 src = readFileSync(path, "utf8");
28} catch (e) {
29 console.error(`FAIL cannot read ${path}: ${e.message}`);
30 process.exit(1);
31}
32
33let failures = 0;
34const pass = (msg) => console.log("PASS " + msg);
35const 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.
44function 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
91const code = strip(src); // comments removed, strings kept (keys, literals)
92const 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.
98const check = spawnSync(process.execPath, ["--input-type=module", "--check"], { encoding: "utf8", input: src });
99if (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 -----------------------------------------
106const defineCalls = (bare.match(/\bdefineApp\s*\(/g) || []).length;
107if (defineCalls === 1) pass("exactly one defineApp( call");
108else fail(`expected exactly one defineApp( call, found ${defineCalls}`);
109
110// ---- 3. required fields -----------------------------------------------------
111for (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.)
125const placesM = code.match(/\bplaces\s*:\s*\[([^\]]*)\]/);
126const scopeM = code.match(/\bscope\s*:\s*["'`]([^"'`]*)["'`]/);
127if (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>.".
142const nameM = code.match(/\bname\s*:\s*["']([^"']+)["']/);
143const eventsAt = code.search(/\bevents\s*:\s*\{/);
144if (!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 -------------------------------------------------------
189const 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];
200for (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
238console.log(failures ? `\n${failures} check(s) failed` : "\nall checks passed");
239process.exit(failures ? 1 : 0);
240