@bongos/core 1.19.648 → 1.19.650
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +71 -66
- package/.claude/skills/backlog-review/SKILL.md +1 -5
- package/.claude/skills/blocker-review/SKILL.md +2 -1
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/.claude/skills/bug-triage/SKILL.md +1 -5
- package/.claude/skills/builder-backup/SKILL.md +2 -1
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +2 -1
- package/.claude/skills/builder-connect/SKILL.md +1 -1
- package/.claude/skills/builder-cost/SKILL.md +2 -1
- package/.claude/skills/builder-end/SKILL.md +1 -1
- package/.claude/skills/builder-exit/SKILL.md +1 -1
- package/.claude/skills/builder-key/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +2 -1
- package/.claude/skills/builder-redteam/SKILL.md +2 -1
- package/.claude/skills/builder-release/SKILL.md +2 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +2 -1
- package/.claude/skills/builder-ship/SKILL.md +2 -1
- package/.claude/skills/builder-stage/SKILL.md +1 -1
- package/.claude/skills/builder-start/SKILL.md +2 -1
- package/.claude/skills/builder-sync/SKILL.md +2 -1
- package/.claude/skills/close-box/SKILL.md +2 -1
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/design-sync/SKILL.md +2 -1
- package/.claude/skills/feedback/SKILL.md +2 -1
- package/.claude/skills/figma-design-sync/SKILL.md +2 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/goal-review/SKILL.md +1 -1
- package/.claude/skills/grade-audit/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/grader-health/SKILL.md +2 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -5
- package/.claude/skills/merge-mode/SKILL.md +2 -1
- package/.claude/skills/new-project/SKILL.md +1 -1
- package/.claude/skills/otb-character-review/SKILL.md +1 -1
- package/.claude/skills/otb-design-review/SKILL.md +2 -1
- package/.claude/skills/otb-feedback-capture/SKILL.md +2 -1
- package/.claude/skills/otb-figma-sync/SKILL.md +2 -1
- package/.claude/skills/otb-tile-generate/SKILL.md +2 -1
- package/.claude/skills/planning-session/SKILL.md +2 -1
- package/.claude/skills/priority-session/SKILL.md +2 -1
- package/.claude/skills/read-session-export/SKILL.md +2 -1
- package/.claude/skills/recall/SKILL.md +2 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +2 -1
- package/.claude/skills/ship-check/SKILL.md +1 -1
- package/.claude/skills/status/SKILL.md +2 -1
- package/.claude/skills/strand-fix/SKILL.md +1 -1
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/docs/adr/0276-the-skill-listing-budget-cannot-hold-every-trigger.md +121 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-ratchets.js +24 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/loader.js +47 -2
- package/src/module-seams.js +8 -4
- package/tests/module_loader.mjs +68 -0
- package/tests/platform_boot.mjs +53 -0
|
@@ -20,6 +20,10 @@ const path = require('node:path');
|
|
|
20
20
|
const { validateManifest } = require('./manifest-schema');
|
|
21
21
|
const { satisfies } = require('./semver');
|
|
22
22
|
const { resolveCoreRoot, resolveInstanceRoot } = require('../instance-config');
|
|
23
|
+
// Required DIRECTLY, never through src/module-api.js: the doorway pulls in auth
|
|
24
|
+
// and would re-form the eval-time cycle coreVersion() exists to avoid.
|
|
25
|
+
// src/module-seams.js is pure (it requires nothing), so this is cycle-safe.
|
|
26
|
+
const { verifyPortsSatisfied } = require('../module-seams');
|
|
23
27
|
|
|
24
28
|
// Collapse roots that resolve to the SAME absolute path, preserving order.
|
|
25
29
|
// Normalizes via path.resolve so a cwd that arrives with a trailing '.'/'//'
|
|
@@ -129,6 +133,39 @@ function discoveredRegistry(opts) {
|
|
|
129
133
|
return out;
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
// The ENABLED slice of the discovered set, in discovery order. The one place the
|
|
137
|
+
// `isEnabled` gate is spelled out, so verification and mounting can never disagree
|
|
138
|
+
// about which modules are in play.
|
|
139
|
+
function enabledModules({ isEnabled } = {}, opts) {
|
|
140
|
+
return discovered(opts).loaded.filter(({ key }) => typeof isEnabled !== 'function' || isEnabled(key));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// BOOT-TIME seam verification (BV1.R41), run over the ENABLED manifests before a
|
|
144
|
+
// single module router is mounted. THROWS on an unsatisfiable set — a module that
|
|
145
|
+
// consumes a port no enabled module provides, or a port claimed by two of them.
|
|
146
|
+
//
|
|
147
|
+
// Why it throws rather than warns: without it the failure surfaces as resolve()'s
|
|
148
|
+
// runtime throw the first time the seam is used — an arbitrarily long time after
|
|
149
|
+
// boot, on a request path, in a process that reported healthy. ADR 0150's "fail
|
|
150
|
+
// loud, never boot half-wired" is the same argument: refuse at boot, name the
|
|
151
|
+
// module and the port, and let the operator fix the module set.
|
|
152
|
+
//
|
|
153
|
+
// Only the ENABLED set is checked, deliberately. A disabled module's `consumes` is
|
|
154
|
+
// not a wiring error — it is a module that isn't there.
|
|
155
|
+
function verifyEnabledPorts(enabled) {
|
|
156
|
+
const { ok, errors } = verifyPortsSatisfied(enabled.map(({ key, manifest }) => ({
|
|
157
|
+
key,
|
|
158
|
+
provides: manifest.provides || [],
|
|
159
|
+
consumes: manifest.consumes || [],
|
|
160
|
+
})));
|
|
161
|
+
if (ok) return;
|
|
162
|
+
throw new Error([
|
|
163
|
+
'[module-loader] unsatisfiable module wiring — refusing to boot half-wired:',
|
|
164
|
+
...errors.map((e) => ` - ${e}`),
|
|
165
|
+
'Fix the enabled module set (config/modules.json, or a <PREFIX>_MODULE_<KEY> env override) so every consumed port has exactly one provider.',
|
|
166
|
+
].join('\n'));
|
|
167
|
+
}
|
|
168
|
+
|
|
132
169
|
// Mount each discovered + ENABLED module's route factories onto `router`. A
|
|
133
170
|
// module declares contributes.routes:["box"] → factory at modules/<key>/routes/box.js
|
|
134
171
|
// exporting a function returning an express Router. Auth model: audit is composed
|
|
@@ -136,10 +173,18 @@ function discoveredRegistry(opts) {
|
|
|
136
173
|
// its required rank via api.requireBuilder / api.requireRank (public read routes
|
|
137
174
|
// need no gate). The BV1.R44 fitness check enforces that every write route is
|
|
138
175
|
// rank-gated. Returns the count mounted. No-op when nothing is discovered/enabled.
|
|
176
|
+
//
|
|
177
|
+
// Verifies the seam wiring FIRST (verifyEnabledPorts, above). This is the boot hook
|
|
178
|
+
// the check belongs on because it is the one hook every server that mounts modules
|
|
179
|
+
// runs — the live API, platform-server, and the test harness alike — so the
|
|
180
|
+
// verification cannot be skipped by adding an entrypoint, the way a separately
|
|
181
|
+
// called check could be. It runs before the first `require` of a module's route
|
|
182
|
+
// factory, so a half-wired instance never gets as far as loading module code.
|
|
139
183
|
function mountModuleRoutes(router, { isEnabled } = {}, opts) {
|
|
184
|
+
const enabled = enabledModules({ isEnabled }, opts);
|
|
185
|
+
verifyEnabledPorts(enabled);
|
|
140
186
|
let mounted = 0;
|
|
141
|
-
for (const {
|
|
142
|
-
if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
|
|
187
|
+
for (const { dir, manifest } of enabled) {
|
|
143
188
|
const routeKeys = (manifest.contributes && manifest.contributes.routes) || [];
|
|
144
189
|
for (const rk of routeKeys) {
|
|
145
190
|
const factory = require(path.join(dir, 'routes', `${rk}.js`));
|
package/src/module-seams.js
CHANGED
|
@@ -75,10 +75,14 @@ function listPorts() {
|
|
|
75
75
|
|
|
76
76
|
// BOOT-TIME (manifest-level) verification, distinct from resolve()'s RUNTIME
|
|
77
77
|
// throw: given the ENABLED modules' manifests, confirm every consumed port is
|
|
78
|
-
// provided by some enabled module, and no port has two providers. The loader
|
|
79
|
-
// (BV1.R41) runs this before mounting
|
|
80
|
-
//
|
|
81
|
-
//
|
|
78
|
+
// provided by some enabled module, and no port has two providers. The loader's
|
|
79
|
+
// mountModuleRoutes (BV1.R41) runs this via verifyEnabledPorts before mounting any
|
|
80
|
+
// module router, and THROWS on a non-ok result, so a required capability with no
|
|
81
|
+
// provider is caught at boot with a clear message — not as a surprise throw the
|
|
82
|
+
// first time the seam is used. This function itself only REPORTS: it is pure (no
|
|
83
|
+
// registry mutation, no throw), so it is safe to run anytime, and the loader owns
|
|
84
|
+
// the decision to refuse the boot. Wired + regression-pinned by task 1003820,
|
|
85
|
+
// which found it defined, exported, tested, and called by nothing.
|
|
82
86
|
// manifests: [{ key, provides: [...], consumes: [...] }] → { ok, errors: [...] }.
|
|
83
87
|
function verifyPortsSatisfied(manifests = []) {
|
|
84
88
|
const provided = new Map(); // port -> [providerKey,...]
|
package/tests/module_loader.mjs
CHANGED
|
@@ -155,6 +155,71 @@ test('mountModuleRoutes: mounts an enabled module router, skips a disabled one',
|
|
|
155
155
|
loader._reset();
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// task 1003820 — the mount hook must VERIFY the seam wiring before it mounts.
|
|
160
|
+
//
|
|
161
|
+
// verifyPortsSatisfied was defined, exported through the doorway, and unit-tested
|
|
162
|
+
// in isolation, but no production code called it: a module consuming a port no
|
|
163
|
+
// enabled module provided booted clean and threw later, on a request path, out of
|
|
164
|
+
// resolve(). These pin the CALL, not the function — see also the spawn-boot half
|
|
165
|
+
// in tests/platform_boot.mjs, which is what actually turns red if the call site is
|
|
166
|
+
// deleted (learning 1000154).
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
test('task 1003820: mountModuleRoutes refuses an enabled module consuming an unprovided port', () => {
|
|
170
|
+
loader._reset();
|
|
171
|
+
const factory = "module.exports = () => function moduleRoute(req, res, next) { next(); };";
|
|
172
|
+
const root = fixtureRoot({
|
|
173
|
+
consumer: {
|
|
174
|
+
manifest: validManifest('consumer', { consumes: ['reward'], contributes: { routes: ['main'] } }),
|
|
175
|
+
files: { 'routes/main.js': factory },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const used = [];
|
|
179
|
+
const fakeRouter = { use: (fn) => used.push(fn) };
|
|
180
|
+
assert.throws(
|
|
181
|
+
() => loader.mountModuleRoutes(fakeRouter, { isEnabled: () => true }, { roots: [root], core: '1.0.0' }),
|
|
182
|
+
(e) => /unsatisfiable module wiring/.test(e.message)
|
|
183
|
+
&& /"consumer" consumes port "reward"/.test(e.message),
|
|
184
|
+
'the refusal names the module and the port it cannot satisfy'
|
|
185
|
+
);
|
|
186
|
+
assert.equal(used.length, 0, 'it refuses BEFORE requiring any module route factory');
|
|
187
|
+
loader._reset();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('task 1003820: a DISABLED module’s unsatisfied consumes is not a wiring error', () => {
|
|
191
|
+
loader._reset();
|
|
192
|
+
const factory = "module.exports = () => function moduleRoute(req, res, next) { next(); };";
|
|
193
|
+
const root = fixtureRoot({
|
|
194
|
+
consumer: { manifest: validManifest('consumer', { consumes: ['reward'] }) },
|
|
195
|
+
plain: {
|
|
196
|
+
manifest: validManifest('plain', { contributes: { routes: ['main'] } }),
|
|
197
|
+
files: { 'routes/main.js': factory },
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const used = [];
|
|
201
|
+
const fakeRouter = { use: (fn) => used.push(fn) };
|
|
202
|
+
// Only the ENABLED set is verified: a module that is off is a module that is
|
|
203
|
+
// not there, so its consumes names nothing that has to be provided.
|
|
204
|
+
const mounted = loader.mountModuleRoutes(fakeRouter, { isEnabled: (k) => k === 'plain' }, { roots: [root], core: '1.0.0' });
|
|
205
|
+
assert.equal(mounted, 1);
|
|
206
|
+
loader._reset();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('task 1003820: mountModuleRoutes refuses two enabled providers of one port', () => {
|
|
210
|
+
loader._reset();
|
|
211
|
+
const root = fixtureRoot({
|
|
212
|
+
a: { manifest: validManifest('a', { provides: ['reward'] }) },
|
|
213
|
+
b: { manifest: validManifest('b', { provides: ['reward'] }) },
|
|
214
|
+
});
|
|
215
|
+
assert.throws(
|
|
216
|
+
() => loader.mountModuleRoutes({ use: () => {} }, { isEnabled: () => true }, { roots: [root], core: '1.0.0' }),
|
|
217
|
+
(e) => /provided by more than one enabled module/.test(e.message),
|
|
218
|
+
'the single-provider invariant is enforced at boot, not at first resolve()'
|
|
219
|
+
);
|
|
220
|
+
loader._reset();
|
|
221
|
+
});
|
|
222
|
+
|
|
158
223
|
test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49/R72)', () => {
|
|
159
224
|
// dev-box graduated first (modules/dev-box/, BV1.R43), art-pipeline next
|
|
160
225
|
// (BV1.R48 / task 1417), discord next (BV1.R47 / task 1416), game next
|
|
@@ -352,6 +417,9 @@ test('the demo proof module mounts + serves through the loader (BV1.R52 / task 1
|
|
|
352
417
|
|
|
353
418
|
const used = [];
|
|
354
419
|
const fakeRouter = { use: (fn) => used.push(fn) };
|
|
420
|
+
// Since task 1003820 this also proves the SHIPPED enabled set is satisfiable:
|
|
421
|
+
// mountModuleRoutes verifies the seam wiring first and throws on a half-wired
|
|
422
|
+
// set, so a manifest change that strands a consumed port reds this test.
|
|
355
423
|
const mounted = loader.mountModuleRoutes(fakeRouter, { isEnabled: isModuleEnabled });
|
|
356
424
|
assert.ok(mounted >= 1, 'at least the demo router mounted');
|
|
357
425
|
|
package/tests/platform_boot.mjs
CHANGED
|
@@ -284,3 +284,56 @@ test('task 1002419: a real `node src/platform-server.js` boot announces the reco
|
|
|
284
284
|
child.kill('SIGKILL');
|
|
285
285
|
}
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// task 1003820 — a half-wired module set must REFUSE the boot.
|
|
290
|
+
//
|
|
291
|
+
// The mutation-proof half of the wiring guard. tests/module_loader.mjs calls
|
|
292
|
+
// mountModuleRoutes directly, so it stays green if the verifyEnabledPorts call
|
|
293
|
+
// site is deleted; this spawns the real entrypoint the way docker-entrypoint.sh
|
|
294
|
+
// does and asserts the process dies with the wiring error, so removing the call
|
|
295
|
+
// turns it red (learning 1000154 — the same shape as task 1002419 above).
|
|
296
|
+
//
|
|
297
|
+
// The unsatisfiable set is built from REAL modules via a documented env knob, not
|
|
298
|
+
// a fixture: economy provides the `reward` port and ideas consumes it, both
|
|
299
|
+
// default:true, so turning economy off strands ideas. DB-free — the refusal
|
|
300
|
+
// happens while the router is being built, long before anything opens a socket.
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
test('task 1003820: platform-server refuses to boot when an enabled module consumes an unprovided port', async () => {
|
|
304
|
+
const port = await freePort();
|
|
305
|
+
const child = spawn(process.execPath, [path.join(REPO_ROOT, 'src', 'platform-server.js')], {
|
|
306
|
+
cwd: REPO_ROOT,
|
|
307
|
+
env: {
|
|
308
|
+
...process.env,
|
|
309
|
+
PORT: String(port),
|
|
310
|
+
HOST: '127.0.0.1',
|
|
311
|
+
// economy provides "reward"; ideas consumes it and stays on.
|
|
312
|
+
CLOUDBONGOS_MODULE_ECONOMY: '0',
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
let out = '';
|
|
317
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
318
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
319
|
+
|
|
320
|
+
// SIGKILL in a finally: if the guard regresses, the child boots CLEANLY and never
|
|
321
|
+
// exits, so without this the suite would hang on a live server instead of failing.
|
|
322
|
+
try {
|
|
323
|
+
const code = await new Promise((resolve, reject) => {
|
|
324
|
+
const timer = setTimeout(
|
|
325
|
+
() => reject(new Error(`platform-server stayed up; it must refuse a half-wired boot. Log:${out}`)),
|
|
326
|
+
20_000
|
|
327
|
+
);
|
|
328
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
329
|
+
child.on('exit', (c) => { clearTimeout(timer); resolve(c); });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
assert.notEqual(code, 0, `the boot must FAIL, not come up half-wired (exit ${code}). Log:${out}`);
|
|
333
|
+
assert.match(out, /unsatisfiable module wiring/, 'the refusal says what is wrong');
|
|
334
|
+
assert.match(out, /"ideas" consumes port "reward"/, 'it names the module and the port');
|
|
335
|
+
assert.doesNotMatch(out, /listening/, 'it never reaches the listen announcement');
|
|
336
|
+
} finally {
|
|
337
|
+
child.kill('SIGKILL');
|
|
338
|
+
}
|
|
339
|
+
});
|