Files
runicnpc-rust/scripts/checkPlugin.test.js
wtclaude 249f2e513f
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s
feat: stage 0, an empty RunicNPC that releases through CI
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
2026-09-29 21:01:49 -05:00

304 lines
11 KiB
JavaScript

// A check is worth what it catches, so this breaks it every way it claims to catch.
//
// The cases that matter most are the last ones: `checkPlugin.js` finds hooks and
// API calls with deliberately narrow regexes, and a regex that silently matches
// NOTHING passes every other check in this file and every check in CI while
// asserting nothing at all. So the real plugin source is read too, and the parse
// is asserted against names 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,
readRequires,
readTomlRequires,
HOOK_NAME,
API_NAME,
} = require('./checkPlugin')
const INFO_LINE = ' [Info("RunicNPC", "Runic Gateway", "0.0.0")]'
/** A minimal plugin that passes, as the baseline every case below deviates from. */
function source({
expected = ['OnServerInitialized'],
methods,
api = 1,
requires = '// Requires: Kits',
info = INFO_LINE,
} = {}) {
const body =
methods ??
` private void OnServerInitialized()
{
}
private int RunicNpc_ApiVersion() => ApiVersion;`
return `${requires}
namespace Oxide.Plugins
{
${info}
internal class RunicNPC : RustPlugin
{
private const int ApiVersion = ${api};
private static readonly string[] ExpectedHooks =
{
${expected.map((e) => `"${e}"`).join(', ')}
};
${body}
}
}`
}
const toml = ({ api = 1, requires = '["Kits"]' } = {}) =>
`api = ${api}\nmin_oxide_version = "2.0.7585"\nrequires_plugins = ${requires}\n`
test('a plugin that follows the rules passes', () => {
assert.deepEqual(check(source(), toml()), [])
})
// ── Hooks ──────────────────────────────────────────────────────────────────
test('a hook missing from ExpectedHooks is caught, because rnpc.status could not report it', () => {
const problems = check(source({ expected: [] }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /OnServerInitialized is implemented but missing from ExpectedHooks/)
})
test('a name listed but never implemented is caught, because it reports silent for ever', () => {
const problems = check(source({ expected: ['OnServerInitialized', 'OnEntityDeath'] }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /ExpectedHooks lists OnEntityDeath, but no method/)
})
test('a hook that answers is caught unless it is written down', () => {
const methods = ` private object OnNpcTarget(BaseEntity npc, BaseEntity target)
{
return null;
}`
const problems = check(source({ expected: ['OnNpcTarget'], methods }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /OnNpcTarget returns object, not void/)
assert.match(problems[0], /ANSWERS_DELIBERATELY/)
})
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 turned
// into a veto without changing its signature, which is visible.
const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret)
{
return true;
}`
const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml())
assert.match(problems[0], /CanBeTargeted returns bool, not void/)
})
test('a method that is not shaped like a hook is left alone', () => {
const methods = ` private Dictionary<string, object> Describe(BasePlayer npc)
{
return null;
}
private static string Column(int index)
{
return null;
}`
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
assert.ok(!HOOK_NAME.test('Describe'))
assert.ok(HOOK_NAME.test('OnServerInitialized'))
assert.ok(HOOK_NAME.test('CanBeTargeted'))
})
// ── The API ────────────────────────────────────────────────────────────────
test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => {
// The state the spike found HumanNPC's RefreshNPC and RemoveNPC in (PLAN.md §1.2).
const methods = ` private void OnServerInitialized()
{
}
public int RunicNpc_ApiVersion() => ApiVersion;`
const problems = check(source({ methods }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /RunicNpc_ApiVersion is public without \[HookMethod\]/)
})
test('a public API call WITH [HookMethod] is reachable and passes', () => {
const methods = ` private void OnServerInitialized()
{
}
[HookMethod("RunicNpc_ApiVersion")]
public int RunicNpc_ApiVersion() => ApiVersion;`
assert.deepEqual(check(source({ methods }), toml()), [])
})
test('[HookMethod] is still seen in a CRLF checkout', () => {
// A Windows clone has CRLF endings; an attribute regex that only knew `\n`
// stopped seeing [HookMethod] there and failed a reachable call.
const methods = ` private void OnServerInitialized()
{
}
[HookMethod("RunicNpc_ApiVersion")]
public int RunicNpc_ApiVersion() => ApiVersion;`
assert.deepEqual(check(source({ methods }).replace(/\n/g, '\r\n'), toml()), [])
})
test('an API version that disagrees with plugin.toml is caught', () => {
const problems = check(source({ api: 2 }), toml({ api: 1 }))
assert.equal(problems.length, 1)
assert.match(problems[0], /answers API 2 and plugin\.toml declares 1/)
})
test('a missing api key in plugin.toml is caught', () => {
const problems = check(source(), 'requires_plugins = ["Kits"]\n')
assert.equal(problems.length, 1)
assert.match(problems[0], /could not read `api` from plugin\.toml/)
})
// ── Required plugins (D217) ────────────────────────────────────────────────
test('a plugin without // Requires: Kits is caught, even when the toml agrees', () => {
const problems = check(source({ requires: '' }), toml({ requires: '[]' }))
assert.equal(problems.length, 1)
assert.match(problems[0], /does not declare \/\/ Requires: Kits/)
})
test('// Requires: and requires_plugins that disagree are caught', () => {
const problems = check(
source({ requires: '// Requires: Kits, ZoneManager' }),
toml({ requires: '["Kits"]' })
)
assert.equal(problems.length, 1)
assert.match(problems[0], /name \[Kits, ZoneManager\] and plugin\.toml's requires_plugins names \[Kits\]/)
})
test('a requires_plugins that is not a one-line array of strings is caught', () => {
const problems = check(source(), toml({ requires: 'Kits' }))
assert.equal(problems.length, 1)
assert.match(problems[0], /could not read `requires_plugins`/)
assert.equal(readTomlRequires('requires_plugins = [1]'), null)
})
test('several // Requires: lines and comma lists are all read', () => {
assert.deepEqual(readRequires('// Requires: Kits\n// Requires: ZoneManager, Economics\n'), [
'Kits',
'ZoneManager',
'Economics',
])
})
// ── The stamped line ───────────────────────────────────────────────────────
test('a missing [Info] line is caught, because the release has nothing to stamp', () => {
const problems = check(source({ info: ' [Info("RunicNpc", "Runic Gateway", "0.0.0")]' }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /exactly one \[Info\("RunicNPC", "Runic Gateway", "…"\)\] attribute, found 0/)
})
test('two [Info] lines are caught', () => {
const problems = check(source({ info: `${INFO_LINE}\n${INFO_LINE}` }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /found 2/)
})
// ── Chat commands ──────────────────────────────────────────────────────────
//
// None exist until stage 3 (`/rnpc`), so these run on fixtures only. Stage 3
// adds the real-plugin assertion below, as the bridge's checker has.
const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args'
function withChat(name, signature, returns = 'void') {
return source({
expected: [],
methods: ` [ChatCommand("${name}")]
private ${returns} CmdThing(${signature})
{
}`,
})
}
test('a correctly shaped chat command passes', () => {
assert.deepEqual(check(withChat('rnpc', GOOD_SIGNATURE), toml()), [])
})
test('a chat command taking the wrong player type is caught', () => {
const problems = check(withChat('rnpc', 'IPlayer player, string command, string[] args'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/)
})
test('a chat command that returns something is caught', () => {
const problems = check(withChat('rnpc', GOOD_SIGNATURE, 'object'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /returns object, not void/)
})
test('two chat commands answering to one name is caught', () => {
const both = source({
expected: [],
methods: ` [ChatCommand("rnpc")]
private void CmdOne(${GOOD_SIGNATURE})
{
}
[ChatCommand("rnpc")]
private void CmdTwo(${GOOD_SIGNATURE})
{
}`,
})
const problems = check(both, toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /declared twice/)
})
// ── The real plugin ────────────────────────────────────────────────────────
const ROOT = path.resolve(__dirname, '..')
const real = fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8')
const realToml = fs.readFileSync(path.join(ROOT, 'plugin.toml'), 'utf8')
test('the parser actually reads the real plugin, rather than quietly matching nothing', () => {
const methods = readMethods(real)
const names = new Set(methods.map((m) => m.name))
// Raise these floors as the plugin grows; they are what stops a regex that
// matches nothing from passing every case above.
assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`)
assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser')
assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser')
const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name)
assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call')
assert.ok(readExpectedHooks(real).length >= 1)
assert.deepEqual(readRequires(real), ['Kits'])
})
test('the real plugin and plugin.toml pass', () => {
assert.deepEqual(check(real, realToml), [])
})