#!/usr/bin/env node /** * Engagement trigger manifest — the machine-readable freeze of core's event * contract (ENGAGEMENT.md §4.3, property 4). * * Why this exists: a trigger declaration is what a template interpolates and what * a rule is written against. Renaming a variable, changing its type, or widening * a ceiling breaks stored templates and stored rules — and does it silently, at * send time, in an email someone already received. `routes.manifest.json` freezes * the URL surface for exactly this reason and this is its twin: a generated * artifact committed to the repo, whose DIFF is the review signal. Changing a * declaration without regenerating is a red build; changing one deliberately puts * the change in front of a reviewer instead of letting it pass as a comment edit. * * **Core's only.** A module ships its own `engagement-triggers.json` in its * bundle, for the same reason it ships a prebuilt swagger fragment: core never * has its sources to analyse (MODULE_API.md §6.1a). So this loads * `config/coreTriggers.js` through the real `registerCore()` — the declarations * as VALIDATED, not as authored — which means a shape error is a failure here * rather than a surprise at boot. * * The `resolve` half of an audience cannot be frozen (it is a function over a * module's own store), so audiences are deliberately absent: what a manifest can * usefully freeze is the payload contract, and freezing half a declaration would * suggest the other half was checked. * * Usage: * npm run engagement:manifest # write server/engagement-triggers.json * npm run engagement:manifest -- --check # exit 1 if the committed file is stale */ // registries.js -> config/coreStreams + utils/discordAnnounce, which reach // utils/db and build a mariadb pool at require time. Point it at a closed port // (the same trick routeManifest.js and the test suite use) so generating a // manifest never opens a connection or hangs on a missing database. process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1' process.env.DB_PORT = process.env.DB_PORT || '59999' const fs = require('fs') const path = require('path') const registries = require('../src/modules/registries') const db = require('../src/utils/db') const { MODULE_API_VERSION } = require('../src/modules/version') const SERVER_ROOT = path.join(__dirname, '..') const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json') const MANIFEST_COMMENT = 'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' + 'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' + 'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' + 'templates and rules, so the diff here is the review signal. A module ships its own copy ' + 'in its bundle; this file never contains one.' function build() { // Through registerCore(), not by reading the array: what a reviewer needs // frozen is what the registry ACCEPTED — defaults filled in, audience resolved // against the ceiling, variables normalised — because that is what the editor // will read and the emit path will check against. registries.registerCore() const triggers = registries .allTriggers() .filter((t) => t.owner === 'core') // Sorted by id rather than left in registration order, like the route // manifest: reordering a declaration in the source is not a contract change // and must not produce a diff that looks like one. .sort((a, b) => a.id.localeCompare(b.id)) .map((t) => ({ id: t.id, owner: t.owner, label: t.label, description: t.description, kind: t.kind, subjectKey: t.subjectKey, audience: t.audience, ceiling: t.ceiling, version: t.version, // Variables keep their DECLARED order. Here it is contract: it is the // order the template editor lists them in, and an author reading the // manifest should see what the editor will show. variables: t.variables.map((v) => ({ name: v.name, type: v.type, required: v.required, example: v.example, description: v.description, })), })) return { _comment: MANIFEST_COMMENT, // The contract version these declarations are shaped by. A reader looking at // a stale manifest needs to know which API's rules produced it. moduleApiVersion: MODULE_API_VERSION, triggers, } } function main() { const check = process.argv.includes('--check') const next = `${JSON.stringify(build(), null, 2)}\n` if (!check) { fs.writeFileSync(MANIFEST_PATH, next) process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`) return } const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : '' if (current === next) { process.stdout.write('engagement-triggers.json is current\n') return } process.stderr.write( 'engagement-triggers.json is stale.\n' + 'A trigger declaration changed without the manifest being regenerated.\n' + 'Run `npm run engagement:manifest` in website/server and commit the result —\n' + 'the diff is what a reviewer reads to see the contract change.\n', ) process.exitCode = 1 } if (require.main === module) { main() // The mariadb pool never connects here, but it keeps the loop alive even // pointed at a dead port — the same exit routeManifest.js takes. db.close().finally(() => process.exit(process.exitCode || 0)) } module.exports = { build }