123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
#!/usr/bin/env node
// Guard/hint coverage gate. Every verb dispatched in sw.js MUST be classified in
// tools/verbs-policy.json, and every 'mutating' verb MUST be wired through the ownership
// guard (its `verb: 'nbrowser_x'` requireOwned tag must appear in the source). A new verb
// that isn't classified — or a mutating verb that isn't gated — fails this check, so the
// safety doctrine can't silently decay as the surface grows. Run: node tools/check-guard-coverage.mjs
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const sw = readFileSync(join(root, 'extension/src/sw.js'), 'utf8');
const native = readFileSync(join(root, 'extension/src/native.js'), 'utf8');
const server = readFileSync(join(root, 'bridge/server.js'), 'utf8');
const policy = JSON.parse(readFileSync(join(root, 'tools/verbs-policy.json'), 'utf8'));
const dispatched = [...new Set([...sw.matchAll(/case 'nbrowser_([a-z_]+)'/g)].map((m) => m[1]))];
const src = sw + native;
const errors = [];
// Parse the VERB_META table: verb -> its (single-line) entry text, so we can assert HINT COVERAGE — every
// verb an AI can call must ship a non-empty hint + non-empty pitfalls, or the AI is left guessing.
const metaEntry = new Map([...server.matchAll(/^ {2}nbrowser_([a-z_]+): (\{.*\}),?\s*$/gm)].map((m) => [m[1], m[2]]));
for (const verb of dispatched) {
const cls = policy[verb];
if (!cls) { errors.push(`UNCLASSIFIED verb 'nbrowser_${verb}' — add it to tools/verbs-policy.json (readonly|meta|mutating|owned_implicit|global|alias)`); continue; }
if (cls === 'mutating' && !src.includes(`verb: 'nbrowser_${verb}'`)) {
errors.push(`UNGATED mutating verb 'nbrowser_${verb}' — its resolveTabId call must pass { requireOwned: true, verb: 'nbrowser_${verb}' }`);
}
if (cls === 'alias') continue; // aliases inherit the canonical verb's hint
const entry = metaEntry.get(verb);
if (!entry) { errors.push(`NO HINT: verb 'nbrowser_${verb}' has no VERB_META entry in bridge/server.js — add { hint, related, pitfalls } so the AI knows what it does / what next / the traps`); continue; }
if (/hint:\s*(''|"")/.test(entry) || !/hint:/.test(entry)) errors.push(`EMPTY HINT: 'nbrowser_${verb}' VERB_META has no hint`);
if (/pitfalls:\s*\[\s*\]/.test(entry)) errors.push(`EMPTY PITFALLS: 'nbrowser_${verb}' VERB_META has pitfalls:[] — add at least one trap the AI should avoid`);
}
for (const verb of Object.keys(policy)) {
if (!dispatched.includes(verb)) errors.push(`STALE policy entry 'nbrowser_${verb}' — not dispatched in sw.js`);
}
// MANIFEST PARITY. bridge.json's verbs[] is what ab (Bridge) reads to learn our surface: a verb we
// implement but never declare is INVISIBLE to every caller, and one we declare but never implement is a
// dead advertisement. Both drifted silently before (12 verbs, incl. nbrowser_events, were implemented
// and undeclared) because the manifest was hand-maintained. VERB_META is the source of truth; this
// asserts bridge.json mirrors it exactly, order included, so `verbs[]` can never be stale again.
const metaKeys = [...server.slice(server.indexOf('const VERB_META = {')).matchAll(/^ {2}(nbrowser_[a-z0-9_]+):\s*\{/gm)].map((m) => m[1]);
const manifest = JSON.parse(readFileSync(join(root, 'bridge/bridge.json'), 'utf8'));
const undeclared = metaKeys.filter((v) => !manifest.verbs.includes(v));
const undefinedVerbs = manifest.verbs.filter((v) => !metaKeys.includes(v));
if (undeclared.length) errors.push(`UNDECLARED in bridge/bridge.json verbs[] (implemented but ab cannot see them): ${undeclared.join(', ')}`);
if (undefinedVerbs.length) errors.push(`DEAD entries in bridge/bridge.json verbs[] (declared but no VERB_META): ${undefinedVerbs.join(', ')}`);
if (!undeclared.length && !undefinedVerbs.length && manifest.verbs.join() !== metaKeys.join()) {
errors.push('bridge/bridge.json verbs[] is out of ORDER vs VERB_META — regenerate it from the server order');
}
if (manifest.version !== readFileSync(join(root, 'bridge/BRIDGE_VERSION'), 'utf8').trim()) {
errors.push(`VERSION SKEW: bridge.json version ${manifest.version} != BRIDGE_VERSION — bump them in lockstep`);
}
// DESCRIBE PARITY. nbrowser_describe is the THIRD copy of the verb list (the d(...) calls) and it
// rotted with the other two: the same 12 verbs were implemented, undeclared AND undescribed, so even
// a caller who guessed the name got no schema. An undescribed verb is one no AI will ever discover.
const described = [...new Set([...server.matchAll(/\bd\(\s*'(nbrowser_[a-z0-9_]+)'/g)].map((m) => m[1]))];
const undescribed = metaKeys.filter((v) => !described.includes(v));
const ghostDescribed = described.filter((v) => !metaKeys.includes(v));
if (undescribed.length) errors.push(`UNDESCRIBED in nbrowser_describe (no AI can discover them): ${undescribed.join(', ')}`);
if (ghostDescribed.length) errors.push(`DESCRIBED but not implemented (no VERB_META): ${ghostDescribed.join(', ')}`);
if (errors.length) { console.error('guard-coverage FAILED:\n ' + errors.join('\n ')); process.exit(1); }
console.log(`guard-coverage OK: ${dispatched.length} verbs classified, ${Object.values(policy).filter((c) => c === 'mutating').length} mutating verbs gated, ${dispatched.filter((v) => policy[v] !== 'alias').length} verbs with non-empty hint + pitfalls, ${manifest.verbs.length} verbs declared to ab in bridge.json`);