#!/usr/bin/env node // // Static checks on RunicNPC, run on every pull request and again on the commit // being released. // // 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. It is the bridge's // checker (Rust-Plugins' scripts/checkPlugin.js) adapted to what RunicNPC can get // silently wrong. // // Both frameworks bind hooks, chat commands and `Call` targets **by name and // arity, through reflection**, with no compile-time check and no warning when a // name matches nothing. So: // // 1. A hook the plugin implements but does not list in `ExpectedHooks` is // invisible to `rnpc.status`, the instrument that answers "does this hook // fire on this framework". A name listed and never implemented reports // silent for ever, which reads exactly like a hook the framework dropped. // // 2. A hook that ANSWERS changes what the game does: it can cancel a death, // stop a turret targeting, or replace a corpse's loot. RunicNPC WILL answer // some hooks — that is how an NPC plugin works (PLAN.md §3) — but each one // must be a decision written down, so the rule is inverted: EVERY hook is // `void` unless it is listed in `ANSWERS_DELIBERATELY` below, with a reason. // A list of "dangerous" hook names would have to be maintained against a // catalogue in another repository, and the first one somebody forgot would // be the one that passed. There is nothing to forget this way. // // 3. A chat command with the wrong signature is never called, and nobody says // so: the admin types `/rnpc` and nothing happens. // // 4. An API method (`RunicNpc_*`) that is `public` without `[HookMethod]` is // unreachable: Oxide's `Call` finds a non-public method by name, and a public // one only when it carries that attribute. The spike found HumanNPC's // `RefreshNPC` and `RemoveNPC` in exactly this state (PLAN.md §1.2). // // 5. The facts declared twice must agree: // • `ApiVersion` in the plugin and `api` in plugin.toml. The release copies // plugin.toml into the manifest the installer and the bridge read, so a // disagreement ships a RunicNPC that answers one version and claims // another. // • the plugin's `// Requires:` lines and plugin.toml's `requires_plugins`. // The first stops the framework loading RunicNPC without Kits; the // second is what `doctor` reports. Kits must be in both (D217). // • exactly one `[Info("RunicNPC", "Runic Gateway", "…")]`, because the // release stamps its version into that one line. // // 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, 'plugin', 'RunicNPC.cs') const PLUGIN_TOML = path.join(ROOT, 'plugin.toml') /** * Hooks this plugin answers on purpose, and why. * * Empty in stage 0, which spawns nothing. From stage 2 an entry here is a * deliberate decision to let RunicNPC change what the game does — and, because * the plugin shares the server with everyone else's entities, every one of them * must answer for RunicNPC's OWN NPCs only and return null for anything else. * That half cannot be read statically; it is the reviewer's to check, which is * why the reason goes here where the reviewer will see it. */ const ANSWERS_DELIBERATELY = Object.create(null) /** Plugins RunicNPC must require, whatever else it requires (D217). */ const REQUIRED_PLUGINS = ['Kits'] /** Anything shaped like this is a game hook, by both frameworks' own convention. */ const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/ /** Every call of the public API carries this prefix (PLAN.md §4). */ const API_NAME = /^RunicNpc_\w+$/ /** * The parameter list both frameworks bind a chat command on: the caller, the command word, and * whatever followed it. Names are the author's; the types are not. */ const CHAT_SIGNATURE = /^BasePlayer\s+\w+,\s*string\s+\w+,\s*string\[\]\s+\w+$/ /** * Method declarations, as this file cares about them: any attributes directly above, the access * modifier, 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 against the real plugin rather than assumed. `\r?` because a Windows checkout has * CRLF endings, and an attribute line that failed to match there would hide a [HookMethod]. */ const METHOD = /((?:^[ \t]*\[[^\]\r\n]*\][ \t]*\r?\n)*)^[ \t]*(private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(/gm /** The attribute the release stamps a version into. */ const INFO = /^[ \t]*\[Info\("RunicNPC", "Runic Gateway", "[^"]*"\)\]/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({ attributes: m[1], access: m[2], returns: m[3].trim(), name: m[4] }) } return found } /** * Every `[ChatCommand("x")]` and the signature of the method under it. * * **A chat command binds by reflection, exactly like a hook**, and fails the same silent way: a * method with the wrong parameter list is never called and neither framework says a word. */ function readChatCommands(source) { const found = [] const attribute = /\[ChatCommand\("([^"]+)"\)\]\s*(?:\/\/[^\n]*\n\s*)*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(([^)]*)\)/g let match while ((match = attribute.exec(source)) !== null) { found.push({ command: match[1], returns: match[2].trim(), name: match[3], params: match[4].replace(/\s+/g, ' ').trim(), }) } return found } /** The plugin names in `// Requires: A, B` lines at the top of the file, in order. */ function readRequires(source) { const names = [] const line = /^\s*\/\/\s*Requires:\s*(.+)$/gm let m while ((m = line.exec(source)) !== null) { for (const name of m[1].split(',')) { if (name.trim()) names.push(name.trim()) } } return names } /** `requires_plugins = ["A", "B"]`, one line, quoted strings only — or null if it is not that. */ function readTomlRequires(toml) { const m = /^\s*requires_plugins\s*=\s*(\[.*\])\s*$/m.exec(toml) if (!m) return null try { const list = JSON.parse(m[1]) return Array.isArray(list) && list.every((x) => typeof x === 'string') ? list : null } catch { return null } } 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 `rnpc.status` can report on. for (const hook of hooks) { if (!expected.includes(hook.name)) { problems.push( `${hook.name} is implemented but missing from ExpectedHooks, so rnpc.status cannot report it` ) } } // ... and no phantom entries. for (const name of expected) { if (!hookNames.has(name)) { problems.push(`ExpectedHooks lists ${name}, but no method of that name is implemented`) } } // 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 hook that answers changes what the game ` + 'does. If RunicNPC must answer it, add it to ANSWERS_DELIBERATELY in scripts/checkPlugin.js ' + 'with the reason, and answer for RunicNPC\'s own NPCs only.' ) } // 3. Every chat command has the signature the frameworks bind, and one name binds once. const chat = readChatCommands(source) const seen = new Set() for (const cmd of chat) { if (cmd.returns !== 'void') { problems.push( `/${cmd.command} (${cmd.name}) returns ${cmd.returns}, not void — a chat command's return ` + 'value is not read, and a non-void signature is the shape that silently binds nothing.' ) } if (!CHAT_SIGNATURE.test(cmd.params)) { problems.push( `/${cmd.command} (${cmd.name}) takes (${cmd.params}), not (BasePlayer, string, string[]). ` + 'Both frameworks bind a chat command by reflection on that exact signature, so this one ' + 'would never be called and neither would log it.' ) } if (seen.has(cmd.command)) { problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`) } seen.add(cmd.command) } // 4. Every API method is one `Call` can reach. for (const method of methods.filter((x) => API_NAME.test(x.name))) { if (method.access === 'public' && !/\[HookMethod\(/.test(method.attributes)) { problems.push( `${method.name} is public without [HookMethod], so Oxide's Call cannot reach it — make it ` + 'private, as every RunicNpc_ call is (PLAN.md §4).' ) } } // 5a. The API version, declared twice. const inCode = /ApiVersion\s*=\s*(\d+)\s*;/.exec(source) const inToml = /^\s*api\s*=\s*(\d+)\s*$/m.exec(toml) if (!inCode) problems.push('could not read ApiVersion from the plugin source') if (!inToml) problems.push('could not read `api` from plugin.toml') if (inCode && inToml && inCode[1] !== inToml[1]) { problems.push( `the plugin answers API ${inCode[1]} and plugin.toml declares ${inToml[1]}. The release ` + 'copies plugin.toml into the manifest the installer and the bridge read, so this would ship ' + 'a RunicNPC that claims one version and answers another.' ) } // 5b. The required plugins, declared twice. const requires = readRequires(source) const tomlRequires = readTomlRequires(toml) if (!tomlRequires) { problems.push('could not read `requires_plugins` from plugin.toml as a one-line array of strings') } else { const a = [...new Set(requires)].sort().join(', ') const b = [...new Set(tomlRequires)].sort().join(', ') if (a !== b) { problems.push( `the plugin's // Requires: lines name [${a}] and plugin.toml's requires_plugins names [${b}]. ` + 'The first is what stops the framework loading RunicNPC; the second is what doctor reports.' ) } } for (const name of REQUIRED_PLUGINS) { if (!requires.includes(name)) { problems.push( `the plugin does not declare // Requires: ${name}. ${name} is required (D217), and without ` + 'the line the framework loads RunicNPC on a server that cannot equip a single NPC.' ) } } // 5c. The one line the release stamps. const infoCount = (source.match(INFO) || []).length if (infoCount !== 1) { problems.push( `expected exactly one [Info("RunicNPC", "Runic Gateway", "…")] attribute, found ${infoCount}. ` + 'The release stamps its version into that line; zero or two would ship a plugin whose ' + 'reported version is a lie.' ) } return problems } function main() { const source = fs.readFileSync(PLUGIN, 'utf8') const toml = fs.readFileSync(PLUGIN_TOML, 'utf8') const problems = check(source, toml) if (problems.length > 0) { console.error('RunicNPC failed its static checks:\n') for (const p of problems) console.error(` • ${p}`) console.error('') process.exit(1) } const expected = readExpectedHooks(source) const api = /ApiVersion\s*=\s*(\d+)\s*;/.exec(source)[1] const calls = readMethods(source).filter((x) => API_NAME.test(x.name)).length console.log( `plugin ok — API ${api}, ${calls} API call(s), ${expected.length} hook(s) declared, ` + `requires ${readRequires(source).join(', ')}` ) } module.exports = { check, readExpectedHooks, readMethods, readChatCommands, readRequires, readTomlRequires, HOOK_NAME, API_NAME, } if (require.main === module) main()