Files
website/server/scripts/engagementManifest.js
wtclaude 82a50e5e04
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 1m5s
PR Checks / client-build (pull_request) Successful in 2m40s
PR Checks / server-tests (pull_request) Successful in 12m22s
fix(engagement): the trigger manifest was stale, and its check was crying wolf
Two defects, and the second is why the first survived a whole phase.

The manifest is stale. `engagement-triggers.json` embeds `moduleApiVersion`
deliberately -- "a stale manifest needs to know which API's rules produced it"
-- and Phase 7 (website#189) bumped MODULE_API_VERSION to 1.10.0 without
regenerating it. The committed file has said 1.9.0 ever since. One line, and
regenerating is the whole fix.

The check could not be believed. Both the test and the `--check` CI gate
compared bytes, and this repo is developed on Windows under core.autocrlf=true,
so git checks the committed LF blob out as CRLF and the comparison then calls an
unchanged manifest stale. That failure fires on every Windows checkout, says "a
trigger declaration changed", and is "fixed" by regenerating a file whose
content was already correct.

So the one check that exists to be believed had been failing for a reason
everyone had learned to write off as environmental -- including me, twice: the
Phase 7 PR recorded it as a pre-existing CRLF failure, and the Phase 8 PR
repeated the claim. It was neither pre-existing nor CRLF. A check that cries
wolf is a check nobody reads, and the genuine staleness underneath it went
unnoticed for exactly that reason.

Line endings are now normalised on both sides, which is the convention
routeManifest.js and routeManifest.test.js already use one file along -- that
pair had clearly hit this and been fixed; the engagement pair never was. What is
being asserted is that the committed manifest describes the same declarations,
and a line ending is not a declaration. Policing the encoding is .gitattributes'
job, not this check's.

Verify

- The check is still LIVE, proved by breaking it deliberately: with the content
  changed the gate exits 1; with only the line endings changed it exits 0. That
  is the whole point of the fix, so it is not taken on trust.
- `npm test` under the TAP reporter: 1950 tests, 1877 pass, 0 fail. That is
  pristine `edge`'s 1950/1876 plus the one test this repairs.

A harness note, disclosed rather than buried

The default (spec) reporter intermittently reports a FILE-level failure with all
of that file's subtests passing, no assertion, and no diagnostic beyond 'test
failed'. It named a different unrelated file on each of four runs
(requireInternalKey, routeManifest, eventAuthorize, totp) and the TAP reporter
shows zero failures over the same suite. It appears to be a reporter artifact
under concurrency rather than a failing test, but it correlates with this branch
(4/4) against pristine edge (0/2) on the same machine state, which I could not
explain and am not claiming to have. Worth its own look; it does not indicate a
product defect and no assertion fails.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 02:43:53 -05:00

147 lines
6.3 KiB
JavaScript

#!/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
}
// **Line endings are normalised before the comparison**, exactly as
// `routeManifest.js` does one file along, and for a reason that is not
// cosmetic: this repo is developed on Windows under `core.autocrlf=true`, so
// git checks a committed LF blob out as CRLF and a byte comparison then calls
// an unchanged manifest stale. That failure is worse than useless — it fires on
// every Windows checkout, says "a trigger declaration changed", and is fixed by
// regenerating a file whose CONTENT was already correct, which teaches a
// developer to ignore the one check that exists to be believed.
//
// What is being asserted is that the committed manifest describes the same
// declarations, and a line ending is not a declaration. Policing the encoding
// is `.gitattributes`' job, not this check's.
const current = fs.existsSync(MANIFEST_PATH)
? fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
: ''
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 }