#!/usr/bin/env node // // Static checks on the bridge plugin, run on every pull request. // // This plugin has no unit tests and cannot have any in the ordinary sense: it is // deployed as SOURCE and compiled by Oxide or Carbon against game assemblies that // exist only on a Rust server. There is no way to build it here, and the nearest // thing to a compiler this repository owns is a reader. // // So these checks ask the questions a compiler would not answer anyway. Both // frameworks bind hooks **by name and arity, through reflection**, with no // compile-time check and no warning when a name matches nothing — which makes // three mistakes silent, and each has a cost bigger than it looks: // // 1. A hook method the plugin declares but never lists in `ExpectedHooks`. // `rg.hooks` is how we answer "does this hook fire on this framework" — // the standing answer to Facepunch renaming one and to Carbon's catalogue // omitting thirteen names (CARBON.md §6). A hook missing from that list is // invisible to the one instrument built to see it. // // 2. A hook that ANSWERS. Four of the hooks in the read path are documented as // "returning a non-null value overrides default behavior" — a bridge that // returned something would cancel a death, swallow a player's gathered // wood, or refuse a login, on somebody's production server at 3am // (PROTOCOL.md §8.7). // // The rule is inverted on purpose: EVERY hook must be `void`, rather than // every *vetoable* hook. A list of vetoable hook names would have to be // maintained here, against a catalogue in another repository, and the first // hook somebody forgot to add to it would be the one that passed. There is // nothing to forget this way — a hook that must genuinely answer is added // to `ANSWERS_DELIBERATELY` below, with a reason, as a visible exception. // // 3. A protocol version that disagrees with `overlay.toml`. The game link has // no version handshake (PROTOCOL.md §2), so a half-bumped pair does not // refuse — it mis-parses. `overlay.toml` exists precisely so the installer // can refuse the pairing BEFORE an operator deploys it, and it is worth // exactly as much as its agreement with the code. // // Dependency-free by design, like every check script in this project: it runs on // a bare Node with no install step, which is also how a contributor runs it. // // node scripts/checkPlugin.js const fs = require('fs') const path = require('path') const ROOT = path.resolve(__dirname, '..') const PLUGIN = path.join(ROOT, 'overlay', 'oxide', 'plugins', 'RunicGateway.cs') const OVERLAY_TOML = path.join(ROOT, 'overlay.toml') /** * Hooks this plugin answers on purpose, and why. * * Empty, and it should stay empty for as long as the plugin is a read path. A * name here is a deliberate decision to let the bridge change what the game * does — reviewable because it is written down in one place rather than implied * by a return type somewhere in 1,500 lines. */ const ANSWERS_DELIBERATELY = Object.create(null) /** Anything shaped like this is a game hook, by both frameworks' own convention. */ const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/ /** * Method declarations, as this file cares about them: the return type and the * name. Deliberately narrow — it matches the plugin's own single style * (`private [static] (`) rather than trying to parse C#. A method * written some other way is not matched, which would let a hook through, so the * shape is asserted by the self-test rather than assumed. */ const METHOD = /^\s*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(/gm function readExpectedHooks(source) { const block = /ExpectedHooks\s*=\s*\{([\s\S]*?)\}\s*;/.exec(source) if (!block) return null return block[1] .split(',') .map((entry) => /"([^"]+)"/.exec(entry)) .filter(Boolean) .map((m) => m[1]) } function readMethods(source) { const found = [] let m METHOD.lastIndex = 0 while ((m = METHOD.exec(source)) !== null) { found.push({ returns: m[1].trim(), name: m[2] }) } return found } function check(source, toml) { const problems = [] const expected = readExpectedHooks(source) if (!expected) { return ['could not find the ExpectedHooks array in the plugin source'] } const methods = readMethods(source) const hooks = methods.filter((x) => HOOK_NAME.test(x.name)) const hookNames = new Set(hooks.map((x) => x.name)) // 1. Every hook the plugin implements is one `rg.hooks` can report on. for (const hook of hooks) { if (!expected.includes(hook.name)) { problems.push( `${hook.name} is implemented but missing from ExpectedHooks, so rg.hooks cannot report it` ) } } // 2. Every hook is void, unless answering is a decision somebody wrote down. for (const hook of hooks) { if (hook.returns === 'void') continue if (hook.name in ANSWERS_DELIBERATELY) continue problems.push( `${hook.name} returns ${hook.returns}, not void — a read-path hook must not be able to ` + 'veto what the game was going to do (PROTOCOL.md §8.7). If it must answer, add it to ' + 'ANSWERS_DELIBERATELY with a reason.' ) } // 3. No phantom entries: a name listed but never implemented reports "silent" // for ever, which reads exactly like a hook the framework does not fire. for (const name of expected) { if (!hookNames.has(name)) { problems.push(`ExpectedHooks lists ${name}, but no method of that name is implemented`) } } // 4. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) if (!inCode) problems.push('could not read ProtocolVersion from the plugin source') if (!inToml) problems.push('could not read `protocol` from overlay.toml') if (inCode && inToml && inCode[1] !== inToml[1]) { problems.push( `the plugin speaks protocol ${inCode[1]} and overlay.toml declares ${inToml[1]}. ` + 'The installer refuses to pair a sidecar and an overlay that disagree, so a bundle built ' + 'from this would not compose — and the game link itself has no version check to catch it.' ) } return problems } function main() { const source = fs.readFileSync(PLUGIN, 'utf8') const toml = fs.readFileSync(OVERLAY_TOML, 'utf8') const problems = check(source, toml) if (problems.length > 0) { console.error('The bridge plugin failed its static checks:\n') for (const p of problems) console.error(` • ${p}`) console.error('') process.exit(1) } const expected = readExpectedHooks(source) const version = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)[1] console.log( `plugin ok — protocol ${version}, ${expected.length} hooks declared, every one void and listed` ) } module.exports = { check, readExpectedHooks, readMethods, HOOK_NAME } if (require.main === module) main()