feat(bridge): protocol 2 — the read path, and the first CI this repo has had #2

Merged
whitlocktech merged 2 commits from feat/phase-3-read-path into main 2026-09-16 16:36:59 +00:00
5 changed files with 1396 additions and 26 deletions

View File

@@ -0,0 +1,61 @@
# Gate every pull request into `main`.
#
# This repository had no workflows at all — the same hole phase 2 found in
# Module-Rust, in the repo that ships the half running inside somebody's game
# server. It is the one component here that cannot be compiled by CI: the plugin
# is deployed as SOURCE and built by Oxide or Carbon against game assemblies that
# exist only on a Rust server, so a build job is not available at any price.
#
# What is available is a reader, and the mistakes worth reading for are the ones
# both frameworks make silent. Hooks bind by name and arity through reflection,
# with no compile-time check and no warning when a name matches nothing, so:
#
# • a hook that is not in `ExpectedHooks` is invisible to `rg.hooks`, which is
# the instrument this project relies on to answer "does this hook fire on
# this framework" (CARBON.md §6);
# • a hook that RETURNS something can cancel a death, swallow a player's
# gathered wood, or refuse a login (PROTOCOL.md §8.7);
# • a `ProtocolVersion` that disagrees with `overlay.toml` produces a bundle
# that will not compose, and the game link has no handshake to catch it.
#
# `scripts/checkPlugin.js` asks all three, dependency-free, and its own test
# suite breaks it seven ways — including the failure that would make every other
# case meaningless, a method parser that silently matches nothing.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Gitea only lists a context after it has reported once; the glob matches
# without the dropdown and keeps matching as jobs are added.
name: PR Checks
on:
pull_request:
branches: [main, edge]
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
jobs:
plugin-checks:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# No install step: the checks are dependency-free on purpose, which is also
# how a contributor runs them.
- name: Check the plugin's hooks, void rule and protocol declaration
run: node scripts/checkPlugin.js
# Named individually rather than `node --test scripts/`: directory mode is
# not portable across the Node versions this project runs on.
- name: Test the checker itself
run: node --test scripts/checkPlugin.test.js

View File

@@ -26,8 +26,8 @@
# the same change that alters the emitters, exactly as the sidecar bumps # the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant. # PROTOCOL_VERSION and the module bumps its own constant.
# #
# Current: 1 — the transport (docs/rust-link/PROTOCOL.md). # Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8).
protocol = 1 protocol = 2
# ── Oxide compatibility ────────────────────────────────────────────────────── # ── Oxide compatibility ──────────────────────────────────────────────────────
# #

File diff suppressed because it is too large Load Diff

176
scripts/checkPlugin.js Normal file
View File

@@ -0,0 +1,176 @@
#!/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] <type> <Name>(`) 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()

130
scripts/checkPlugin.test.js Normal file
View File

@@ -0,0 +1,130 @@
// A check is worth what it catches, so this breaks it seven ways.
//
// The case that matters most is the last one: `checkPlugin.js` finds hooks with a
// deliberately narrow regex, and a regex that silently matches NOTHING passes
// every check in this file and every check in CI while asserting nothing at all.
// So the real plugin source is read here too, and the parse is asserted against
// hooks that are known to be in it.
//
// node --test scripts/checkPlugin.test.js
//
// Named individually rather than `node --test scripts/`: directory mode is not
// portable across the Node versions this project runs on.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { check, readExpectedHooks, readMethods, HOOK_NAME } = require('./checkPlugin')
/** A minimal plugin that passes, as the baseline every case below deviates from. */
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
const body =
methods ??
` private void OnPlayerDeath(BasePlayer player, HitInfo info)
{
}`
return `namespace Oxide.Plugins
{
internal class RunicGateway : RustPlugin
{
private const int ProtocolVersion = ${version};
private static readonly string[] ExpectedHooks =
{
${expected.map((e) => `"${e}"`).join(', ')}
};
${body}
}
}`
}
const toml = (version = 2) => `protocol = ${version}\n`
test('a plugin that follows the rules passes', () => {
assert.deepEqual(check(source(), toml()), [])
})
test('a hook missing from ExpectedHooks is caught, because rg.hooks could not report it', () => {
const problems = check(source({ expected: [] }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /OnPlayerDeath is implemented but missing from ExpectedHooks/)
})
test('a hook that can answer is caught — the rule the read path depends on', () => {
const methods = ` private object OnPlayerDeath(BasePlayer player, HitInfo info)
{
return null;
}`
const problems = check(source({ methods }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /returns object, not void/)
})
test('returning null is not good enough — the signature is the rule', () => {
// `return null` today is one edit away from `return true` tomorrow, and the
// edit that breaks it looks harmless in a diff. A void method cannot be
// changed into a veto without changing its signature, which is visible.
const methods = ` private bool CanUserLogin(string name, string id, string ip)
{
return true;
}`
const problems = check(source({ expected: ['CanUserLogin'], methods }), toml())
assert.match(problems[0], /CanUserLogin returns bool, not void/)
})
test('a name listed but never implemented is caught, because it reports silent for ever', () => {
const problems = check(source({ expected: ['OnPlayerDeath', 'OnNewSave'] }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /ExpectedHooks lists OnNewSave, but no method/)
})
test('a protocol version that disagrees with overlay.toml is caught', () => {
const problems = check(source({ version: 3 }), toml(2))
assert.equal(problems.length, 1)
assert.match(problems[0], /speaks protocol 3 and overlay\.toml declares 2/)
})
test('a method that is not shaped like a hook is left alone', () => {
// `Cadence`, `Frame`, `Flatten` and friends are ours, return real types, and
// must not be dragged into the void rule.
const methods = ` private Dictionary<string, object> Frame(string kind, string type)
{
return null;
}
private static string Column(int index)
{
return null;
}`
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
assert.ok(!HOOK_NAME.test('Cadence'))
assert.ok(!HOOK_NAME.test('Frame'))
assert.ok(HOOK_NAME.test('OnPlayerDeath'))
assert.ok(HOOK_NAME.test('CanUserLogin'))
})
test('the parser actually reads the real plugin, rather than quietly matching nothing', () => {
const real = fs.readFileSync(
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
'utf8'
)
const methods = readMethods(real)
const names = new Set(methods.map((m) => m.name))
// A narrow regex that matches nothing passes every other test in this file.
assert.ok(methods.length > 20, `only found ${methods.length} methods in the real plugin`)
for (const hook of ['OnPlayerDeath', 'OnPlayerConnected', 'CanUserLogin', 'OnNewSave']) {
assert.ok(names.has(hook), `${hook} was not found by the method parser`)
}
const expected = readExpectedHooks(real)
assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`)
})