All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s
The repository RunicNPC is built in (docs/runicnpc/PLAN.md §9, stage 0): - plugin/RunicNPC.cs: `// Requires: Kits` (D217), `[Info]` with the 0.0.0 placeholder the release stamps, `RunicNpc_ApiVersion()` (API 1), and `rnpc.status`, which reports the version and which hooks have fired. It spawns nothing. - plugin.toml: the API version, the framework floors it was loaded on (Oxide 2.0.7726, Carbon 2.0.259) and requires_plugins = ["Kits"]. - scripts/checkPlugin.js, adapted from Rust-Plugins': every hook listed and void unless written down; chat-command signatures; every RunicNpc_ call reachable by Call (the HumanNPC trap, PLAN.md §1.2); ApiVersion, `// Requires:` and [Info] agreeing with plugin.toml. 23 self-tests, including the real plugin and a CRLF checkout. - PR Checks on PRs into main and edge; the release workflow on main, with Rust-Plugins' release engine unchanged and an adapter that ships runicnpc-<ver>.tar.gz (runicnpc/RunicNPC.cs + manifest.json) and SHA256SUMS. No bundle dispatch until stage 4. - tools/: the rig panel scripts, with the panel and server ids moved into a git-ignored tools/rigs.json. `con.js` became `console.js`: CON is a reserved device name on Windows, and git there cannot open the file. - README, CONTRIBUTING (edge-based flow, AI disclosure, borrow-not-copy), SECURITY, the code of conduct, issue and PR templates. `feat:` so the cutover to main cuts the first release, 0.1.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
#!/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] <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 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()
|