#!/usr/bin/env node // Studio's actions, generated from the WAI operation catalogue, and the check that fails on // drift. // // node scripts/actions.mjs write src/actions.generated.js from the catalogue // node scripts/actions.mjs --check write nothing; exit 1 with one line a finding // [--ops FILE] the catalogue export to read (wai.ops/1) // // The catalogue is wai/catalogue/ops.json, which `wai ops export` writes from the table in // wai-rs (and CI holds to it). Until that table is on main, ops.pending.json beside this // script is a copy of the export from the pull request that brings it, and is read instead; // once wai/catalogue/ops.json exists, the copy must go, and the check says so. // // Every operation becomes an action: its id, its menu and item (the noun and the verb), its // palette entry (`noun verb`), its summary, where it is placed, its effect, and the names // the other doors give it (the command line, the MCP tool, the service's route), so the // palette can say where an operation runs that this Studio cannot run. src/bindings.js says, // by hand, which operations Studio runs and on which host. The check fails on: // // - an action with no operation: a binding, a core call or a server operation whose id the // catalogue does not hold; // - an operation with a browser placement (`client` or `both`: it runs on this side of a // service it names, as a page does) and no action: no binding; // - a mismatched name: src/actions.generated.js is not what the catalogue generates; a // binding run in the tab whose call is not the core's `noun_verb`; a core call or server // operation Studio binds to another host than the one that runs it; an `op: '…'`, // `.op('…'` or `data-op="…"` in the source that names no operation. // // Standard library only. import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; export const HERE = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const CATALOGUE = resolve(HERE, '../catalogue/ops.json'); export const PENDING = join(HERE, 'ops.pending.json'); export const GENERATED = join(HERE, 'src/actions.generated.js'); export const CORE = join(HERE, 'core/src/lib.rs'); export const SERVER = resolve(HERE, '../wai-rs/src/bin/wai_cli/dx/studio.rs'); export const FORMAT = 'wai.ops/1'; // The placements whose operations run on this side of a service, as a page does. export const BROWSER_PLACEMENTS = ['client', 'both']; const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); /** The catalogue to read, and why: `--ops`, else the export on main, else the pending copy. */ export function catalogueFile(given) { if (given) return { file: resolve(given), why: 'given' }; if (existsSync(CATALOGUE)) return { file: CATALOGUE, why: 'the catalogue' }; return { file: PENDING, why: 'the pending copy (the catalogue is not on main yet)' }; } export function readCatalogue(file) { const bytes = readFileSync(file); const doc = JSON.parse(bytes.toString('utf8')); if (doc.format !== FORMAT) throw new Error(`${file}: not a catalogue export (format ${JSON.stringify(doc.format)})`); return { doc, sha256: createHash('sha256').update(bytes).digest('hex') }; } /** One operation as Studio's action table holds it. */ export function action(o) { const [noun, verb] = o.id.split('.'); return { id: o.id, noun, verb, menu: cap(noun), item: cap(verb), palette: `${noun} ${verb}`, summary: o.summary, placement: o.placement, effect: o.effect, stability: o.stability, toolset: o.toolset, cli: o.doors?.cli ? o.cli : null, mcp: o.doors?.mcp ? o.names?.mcp ?? null : null, route: o.route ?? null, call: `${noun}_${verb}`, }; } /** The text of src/actions.generated.js for a catalogue. Internal operations are never published. */ export function generate(doc, sha256) { const ops = doc.operations.filter((o) => o.stability !== 'internal').map(action); const lines = [ '// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the', '// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run', '// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.', `export const CATALOGUE = ${JSON.stringify({ format: doc.format, toolchain: doc.toolchain, sha256 })};`, 'export const ACTIONS = [', ...ops.map((a) => ` ${JSON.stringify(a)},`), '];', '', ]; return lines.join('\n'); } /** The ids the core binds for WebAssembly: each `#[wasm_bindgen] pub fn noun_verb`, and its OPERATIONS list. */ export function coreCalls(src) { const calls = [...src.matchAll(/#\[wasm_bindgen\]\s*\npub fn ([a-z0-9]+_[a-z0-9]+)\s*\(/g)].map((m) => m[1]); const listed = [...(src.match(/pub const OPERATIONS: &\[&str\] = &\[([\s\S]*?)\];/)?.[1] ?? '').matchAll(/"([a-z0-9]+\.[a-z0-9]+)"/g)].map((m) => m[1]); return { calls, listed }; } /** The operations the local core runs through `wai dev` (`/_wai/api/op/`): its LOCAL_OPS table. */ export function serverOps(src) { const table = src.match(/const LOCAL_OPS: &\[[^\]]*\] = &\[([\s\S]*?)\n\];/)?.[1] ?? ''; return [...table.matchAll(/\(\s*"([a-z0-9]+\.[a-z0-9]+)"/g)].map((m) => m[1]); } /** Every `op: '…'` and `data-op="…"` in Studio's source, with where it stands. */ export function sourceOps(files) { const out = []; for (const [path, text] of files) { for (const m of text.matchAll(/(?:\bop:\s*|\.op\(\s*|data-op['"]?\s*[:=]\s*)(["'`])([a-z0-9]+\.[a-z0-9]+)\1/g)) out.push({ path, id: m[2] }); } return out; } /** * Every finding of the drift check, as lines. `bindings` is src/bindings.js's BINDINGS; * `generated` the committed text of src/actions.generated.js; `core` and `server` the * sources of the core and of `wai dev`'s Studio routes; `files` Studio's source as * [path, text]. */ export function findings({ doc, sha256, generated, bindings, core, server, files }) { const out = []; const ids = new Set(doc.operations.map((o) => o.id)); const byId = new Map(doc.operations.map((o) => [o.id, o])); if (generated !== generate(doc, sha256)) out.push('src/actions.generated.js is not what the catalogue generates: run `node scripts/actions.mjs`'); for (const [id, b] of Object.entries(bindings)) { if (!ids.has(id)) out.push(`the action ${id} has no operation in the catalogue`); if (b.panel == null && b.elsewhere == null) out.push(`the action ${id} names neither a panel nor where it runs`); } for (const o of doc.operations) { if (BROWSER_PLACEMENTS.includes(o.placement) && o.stability !== 'internal' && !(o.id in bindings)) { out.push(`the operation ${o.id} is placed ${o.placement} (it runs on this side of a service, as a page does) and has no action in src/bindings.js`); } } const { calls, listed } = coreCalls(core); const tab = Object.entries(bindings).filter(([, b]) => b.hosted === 'tab' || b.local === 'tab' || b.local === 'tab+local').map(([id]) => id); for (const id of tab) { const want = id.replace('.', '_'); if (!calls.includes(want)) out.push(`the action ${id} runs in the tab, and the core has no call ${want}`); if (!listed.includes(id)) out.push(`the action ${id} runs in the tab, and the core's OPERATIONS does not list it`); } for (const c of calls) { const [noun] = c.split('_'); const id = c.replace('_', '.'); if ([...ids].some((i) => i.startsWith(`${noun}.`)) && !ids.has(id)) out.push(`the core's call ${c} names ${id}, which the catalogue does not hold`); } for (const id of listed) { if (!ids.has(id)) out.push(`the core's OPERATIONS lists ${id}, which the catalogue does not hold`); else if (!tab.includes(id)) out.push(`the core runs ${id}, and no action runs it in the tab`); } const local = serverOps(server); for (const id of local) { if (!ids.has(id)) out.push(`wai dev's LOCAL_OPS runs ${id}, which the catalogue does not hold`); else if (!Object.entries(bindings).some(([b, x]) => b === id && (x.local === 'local' || x.local === 'tab+local'))) out.push(`wai dev's LOCAL_OPS runs ${id}, and no action runs it through the local core`); } for (const [id, b] of Object.entries(bindings)) { if ((b.local === 'local' || b.local === 'tab+local') && !local.includes(id)) out.push(`the action ${id} runs through the local core, and wai dev's LOCAL_OPS does not run it`); } for (const { path, id } of sourceOps(files)) { if (!ids.has(id)) out.push(`${path}: ${id} names no operation of the catalogue`); } for (const [id, b] of Object.entries(bindings)) { const o = byId.get(id); if (o && b.elsewhere === 'cli' && !o.doors?.cli) out.push(`the action ${id} says it runs from the command line, and the catalogue gives it no command line`); if (o && b.elsewhere === 'mcp' && !o.doors?.mcp) out.push(`the action ${id} says it runs through wai mcp, and the catalogue gives it no MCP tool`); } return out; } function walk(dir) { const out = []; for (const n of readdirSync(dir)) { const p = join(dir, n); if (statSync(p).isDirectory()) out.push(...walk(p)); else if (/\.(m?js|html)$/.test(n) && n !== 'actions.generated.js') out.push(p); } return out; } async function main() { const args = process.argv.slice(2); const at = args.indexOf('--ops'); const { file, why } = catalogueFile(at >= 0 ? args[at + 1] : null); const { doc, sha256 } = readCatalogue(file); if (!args.includes('--check')) { writeFileSync(GENERATED, generate(doc, sha256)); console.log(`wrote ${relative(process.cwd(), GENERATED)} from ${relative(process.cwd(), file)} (${why}): ${doc.operations.length} operations`); return 0; } const { BINDINGS } = await import(pathToFileURL(join(HERE, 'src/bindings.js')).href); const found = findings({ doc, sha256, generated: existsSync(GENERATED) ? readFileSync(GENERATED, 'utf8') : '', bindings: BINDINGS, core: readFileSync(CORE, 'utf8'), server: existsSync(SERVER) ? readFileSync(SERVER, 'utf8') : '', files: walk(join(HERE, 'src')).map((p) => [relative(HERE, p), readFileSync(p, 'utf8')]), }); if (at < 0 && file === CATALOGUE && existsSync(PENDING)) found.push('the catalogue is on main (wai/catalogue/ops.json): delete ops.pending.json, the copy read until it landed'); console.log(`read ${relative(process.cwd(), file)} (${why}): ${doc.operations.length} operations, ${Object.keys(BINDINGS).length} actions bound`); for (const f of found) console.log(`FAIL ${f}`); console.log(found.length ? `${found.length} finding(s)` : 'Studio\'s actions hold to the catalogue'); return found.length ? 1 : 0; } if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(await main());