#!/usr/bin/env node // // The engagement freeze: every trigger, stream and audience this module // declares, and every rule it seeds, as one committed file whose DIFF is the // review signal (MODULE_API.md §2.4: "a module ships a prebuilt // `engagement-triggers.json` in its bundle"). // // **Why it exists when core never reads it.** A trigger declaration is what an // operator's templates interpolate and their rules are written against. // Renaming a variable, changing its type or widening a ceiling breaks stored // templates and rules — silently, at send time, in a mail somebody already // got. Committing the declarations as data turns that edit into a visible diff // in the PR that makes it, the same job `routes.manifest.json` does for URLs. // // **It is generated from the registrations, not from the source files**, by // running `register()` against a recording api — so what is frozen is what core // would be handed, including anything `index.js` does on the way. // // **It records `coreApi`, not core's `MODULE_API_VERSION`.** Core's own manifest // embeds the API version, and every API bump then makes it stale with no change // to a single declaration — which is how it once sat stale for a whole phase. // The range this module declares moves only when this module decides it should. // // Usage (from server/): // node scripts/engagementManifest.js write ../engagement-triggers.json // node scripts/engagementManifest.js --check exit 1 if the committed file is stale const fs = require('fs') const path = require('path') const { fakeCtx, fakeApi } = require('../test/_fakes') const MODULE_ROOT = path.resolve(__dirname, '..', '..') const MANIFEST = path.join(MODULE_ROOT, 'engagement-triggers.json') const COMMENT = 'Generated freeze of module-rust\'s engagement contract (docs/modules/rust/PLAN.md §25). ' + 'Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type ' + 'or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.' function build() { require('../core')._reset() const api = fakeApi() require('../index')(fakeCtx(), api) const { triggers, streams, audiences, engagementSeeds } = api.record const manifest = require('../../module.json') const byId = (a, b) => a.id.localeCompare(b.id) return { _comment: COMMENT, coreApi: manifest.coreApi, // Sorted by id: reordering a declaration in the source is not a contract // change and must not produce a diff that looks like one. Variables keep // their DECLARED order, which is the order the template editor shows. triggers: [...triggers].sort(byId).map((t) => ({ id: t.id, label: t.label, description: t.description, kind: t.kind, subjectKey: t.subjectKey, audience: t.audience, ceiling: t.ceiling, version: t.version, variables: t.variables.map((v) => ({ name: v.name, type: v.type, required: v.required, example: v.example, description: v.description, })), })), streams: [...streams].sort(byId).map((s) => ({ id: s.id, label: s.label, personal: s.personal, requiresLinkedAccount: s.requiresLinkedAccount, })), // `resolve` is a function over this module's store and cannot be frozen. // What is frozen is the part an operator's saved rule depends on. audiences: [...audiences].sort(byId).map((a) => ({ id: a.id, label: a.label, params: a.params, ceiling: a.ceiling, })), ruleGroups: engagementSeeds.ruleGroups.map((g) => ({ key: g.key, rules: g.rules.map((r) => ({ trigger_id: r.trigger_id, audience: r.audience, channels: r.channels, template_keys: r.template_keys, conditions: r.conditions === undefined ? null : r.conditions, cooldown_seconds: r.cooldown_seconds, delay_seconds: r.delay_seconds || 0, cancel_on: r.cancel_on || [], })), })), templates: engagementSeeds.templates.map((t) => ({ key: t.key, channel: t.channel, triggerId: t.triggerId, seedVersion: t.seedVersion, })), } } function main() { const check = process.argv.includes('--check') const next = `${JSON.stringify(build(), null, 2)}\n` if (!check) { fs.writeFileSync(MANIFEST, next) process.stdout.write(`wrote ${path.relative(MODULE_ROOT, MANIFEST)}\n`) return } // Line endings normalised, as `swaggerFragment.js` does: a Windows checkout // under `core.autocrlf=true` turns the committed LF blob into CRLF, and a byte // comparison would then call an unchanged file stale on every such machine — // a check that cries wolf is a check nobody reads. const lf = (s) => s.replace(/\r\n/g, '\n') let committed try { committed = fs.readFileSync(MANIFEST, 'utf8') } catch { process.stderr.write('engagement-triggers.json is missing — run `npm run engagement:manifest`\n') process.exit(1) } if (lf(committed) !== lf(next)) { process.stderr.write( 'engagement-triggers.json is stale: a trigger, stream, audience or seeded rule changed.\n' + 'Run `npm run engagement:manifest` in server/ and commit the result — and read the diff,\n' + 'because a changed variable or ceiling is a change to every rule an operator has saved.\n', ) process.exit(1) } process.stdout.write('engagement-triggers.json is current\n') } if (require.main === module) main() module.exports = { build }