open-standards/wai/wai-studio/scripts/actions.mjs
Transaction Science f85a0a7273 WAI Studio on the web: one front end, its core in WebAssembly, served by wai dev at /_wai/
wai/wai-studio: the panels (Inspect, Verify and receipts, Encode/decode/package, Rooms,
Run, Quantum) and the command palette; actions generated from the operation catalogue
with a drift check; the core (wai-studio-core) binding the wai crate for the tab. wai dev
serves Studio at /_wai/ on its own origin, with the dev stack's routes for it under
/_wai/api/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:22:51 -04:00

213 lines
11 KiB
JavaScript

#!/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/<id>`): 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());