@bongos/core 1.19.63 → 1.19.65

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.
Files changed (36) hide show
  1. package/.bongos-core.json +46 -41
  2. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +4 -0
  3. package/docs/adr/0166-module-maintenance-and-deprecation-policy.md +91 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/modules-contract.md +23 -1
  6. package/modules/autonomy/module.json +1 -0
  7. package/modules/builder-settings/module.json +1 -0
  8. package/modules/dev-box/module.json +1 -0
  9. package/modules/discord/module.json +1 -0
  10. package/modules/economy/module.json +1 -0
  11. package/modules/governance/module.json +1 -0
  12. package/modules/grading/module.json +1 -0
  13. package/modules/hall-ui/module.json +1 -0
  14. package/modules/hall-ui/public/modules.js +17 -1
  15. package/modules/ideas/module.json +1 -0
  16. package/modules/lifecycle/db.js +35 -3
  17. package/modules/lifecycle/module.json +1 -0
  18. package/modules/lifecycle/routes/claims.js +14 -8
  19. package/modules/memory/module.json +1 -0
  20. package/modules/onboarding/module.json +1 -0
  21. package/modules/platform-identity/module.json +1 -0
  22. package/modules/provisioning/module.json +1 -0
  23. package/modules/security/module.json +1 -0
  24. package/modules/sessions/module.json +1 -0
  25. package/modules/status-ui/module.json +1 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/scripts/gds/module.js +103 -9
  29. package/scripts/gds/upgrade.js +50 -5
  30. package/src/bongos/routes/modules.js +4 -0
  31. package/src/module-api.js +5 -1
  32. package/src/module-loader/manifest-schema.js +69 -2
  33. package/tests/module_cli.mjs +126 -0
  34. package/tests/module_manifest.mjs +65 -0
  35. package/tests/one_active_claim_per_session.mjs +74 -0
  36. package/tests/upgrade.mjs +94 -0
@@ -418,3 +418,129 @@ test('`bongos module submit` is documented in help / dispatches to module.js sub
418
418
  assert.ok(lines.some((l) => /module submit/.test(l)));
419
419
  assert.equal(await mod.main(['node', 'module.js', 'submit']), 2, 'no key => usage error');
420
420
  });
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Maintenance & deprecation policy (ADR 0107 §7 / ADR 0166, task 1001773)
424
+ // ---------------------------------------------------------------------------
425
+
426
+ test('evaluateUpgrade: sorts self-declared deprecations, and flags the ones the core has passed removeAfter', () => {
427
+ const v = mod.evaluateUpgrade({
428
+ core: '1.13.0',
429
+ rows: [
430
+ { key: 'healthy', coreVersion: '^1.0.0', enabled: true, satisfied: true, maintenance: { status: 'core-maintained' } },
431
+ { key: 'sunset', coreVersion: '^1.0.0', enabled: false, satisfied: true, maintenance: { status: 'deprecated', successor: 'newer', removeAfter: '1.12.0' } },
432
+ { key: 'sunset-later', coreVersion: '^1.0.0', enabled: false, satisfied: true, maintenance: { status: 'deprecated', note: 'going', removeAfter: '1.20.0' } },
433
+ { key: 'nobody', coreVersion: '^1.0.0', enabled: false, satisfied: true, maintenance: { status: 'orphaned', note: 'unowned' } },
434
+ ],
435
+ });
436
+ assert.equal(v.ok, true, 'a deprecation is not a compatibility failure — it must not block a bump');
437
+ assert.deepEqual(v.deprecations.map((d) => d.key), ['sunset', 'sunset-later', 'nobody']);
438
+ assert.deepEqual(v.expired.map((d) => d.key), ['sunset'], 'core 1.13.0 is past removeAfter 1.12.0 but not 1.20.0');
439
+ assert.deepEqual(v.undeclared, []);
440
+ });
441
+
442
+ test('evaluateUpgrade: a module with no maintenance block is reported as undeclared (nobody is on the hook)', () => {
443
+ const v = mod.evaluateUpgrade({
444
+ core: '1.0.0',
445
+ rows: [
446
+ { key: 'owned', coreVersion: '^1.0.0', enabled: true, satisfied: true, maintenance: { status: 'core-maintained' } },
447
+ { key: 'orphan-risk', coreVersion: '^1.0.0', enabled: false, satisfied: true },
448
+ ],
449
+ });
450
+ assert.deepEqual(v.undeclared.map((r) => r.key), ['orphan-risk']);
451
+ assert.equal(v.ok, true, 'an undeclared posture is a reporting gap, not a compatibility failure');
452
+ });
453
+
454
+ test('formatMaintenanceHint: names the owner, or says plainly that there is none', () => {
455
+ assert.match(mod.formatMaintenanceHint({ key: 'x' }), /no maintenance declared/);
456
+ assert.match(
457
+ mod.formatMaintenanceHint({ key: 'x', maintainer: 'Ada <ada@example.com>', maintenance: { status: 'maintained' } }),
458
+ /maintainer Ada <ada@example\.com>/,
459
+ );
460
+ assert.match(mod.formatMaintenanceHint({ key: 'x', maintenance: { status: 'maintained' } }), /unnamed!/);
461
+ const sunset = mod.formatMaintenanceHint({ key: 'x', maintenance: { status: 'deprecated', successor: 'y', removeAfter: '1.12.0', note: 'use y' } });
462
+ assert.match(sunset, /superseded by "y"/);
463
+ assert.match(sunset, /removable after core 1\.12\.0/);
464
+ });
465
+
466
+ test('gatherUpgrade: carries each module\'s maintenance posture onto its row', () => {
467
+ loader._reset();
468
+ const root = tmpModulesRoot();
469
+ mod.scaffoldModule({ key: 'econ', modulesDir: root, core: '1.0.0' });
470
+ const data = mod.gatherUpgrade({ modulesDir: root, core: '1.0.0', env: {}, neutral: {}, instance: {}, prefix: 'CLOUDBONGOS' });
471
+ const row = data.rows.find((r) => r.key === 'econ');
472
+ assert.equal(row.maintenance.status, 'maintained', 'the scaffolder stamps an honest default');
473
+ assert.ok(row.maintainer, 'the maintainer travels with the row so the hint can name them');
474
+ loader._reset();
475
+ });
476
+
477
+ test('cmdUpgrade: a DISABLED incompatible module is reported with its owner, not silently dropped', () => {
478
+ // The catalog-rot case: an accepted module ships default:false (ADR 0107 §6), so
479
+ // every rotted catalog entry is disabled by construction. Exit stays 0 — it breaks
480
+ // nothing at boot — but it must be visible and attributed.
481
+ const lines = [];
482
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
483
+ const code = mod.cmdUpgrade([], {
484
+ ...sink,
485
+ gather: () => ({
486
+ core: '2.0.0',
487
+ errors: [],
488
+ rows: [{
489
+ key: 'stale-catalog-entry', coreVersion: '^1.0.0', enabled: false, satisfied: false,
490
+ maintainer: 'Ada <ada@example.com>', maintenance: { status: 'maintained' },
491
+ }],
492
+ }),
493
+ });
494
+ assert.equal(code, 0, 'a disabled mismatch must not block');
495
+ const out = lines.join('\n');
496
+ assert.match(out, /stale-catalog-entry/);
497
+ assert.match(out, /BROKEN if enabled/);
498
+ assert.match(out, /maintainer Ada <ada@example\.com>/, 'the owner is named so someone can be told');
499
+ });
500
+
501
+ test('cmdUpgrade: reports an expired deprecation and modules with no declared upkeep', () => {
502
+ const lines = [];
503
+ const sink = { log: (s) => lines.push(String(s)), errlog: (s) => lines.push(String(s)) };
504
+ const code = mod.cmdUpgrade([], {
505
+ ...sink,
506
+ gather: () => ({
507
+ core: '1.13.0',
508
+ errors: [],
509
+ rows: [
510
+ { key: 'sunset', coreVersion: '^1.0.0', enabled: false, satisfied: true, maintenance: { status: 'deprecated', successor: 'newer', removeAfter: '1.12.0' } },
511
+ { key: 'unowned', coreVersion: '^1.0.0', enabled: false, satisfied: true },
512
+ ],
513
+ }),
514
+ });
515
+ assert.equal(code, 0);
516
+ const out = lines.join('\n');
517
+ assert.match(out, /sunset: deprecated/);
518
+ assert.match(out, /PAST removeAfter 1\.12\.0/);
519
+ assert.match(out, /declare no `maintenance` block/);
520
+ assert.match(out, /unowned/);
521
+ });
522
+
523
+ test('checkModulePublishability: refuses a module that declares no maintenance posture', () => {
524
+ const root = tmpModulesRoot();
525
+ const { dir } = mod.scaffoldModule({ key: 'unowned', modulesDir: root, core: '1.0.0' });
526
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'));
527
+ delete manifest.maintenance;
528
+ fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(manifest, null, 2));
529
+ const r = mod.checkModulePublishability('unowned', { modulesDir: root });
530
+ assert.equal(r.ok, false, 'acceptance is a commitment to upkeep — it must be stated');
531
+ assert.ok(r.findings.some((f) => f.kind === 'maintenance' && /core-maintained/.test(f.detail)), JSON.stringify(r.findings));
532
+ });
533
+
534
+ test('checkModulePublishability: refuses a module that is ALREADY deprecated or orphaned', () => {
535
+ const root = tmpModulesRoot();
536
+ for (const status of ['deprecated', 'orphaned']) {
537
+ const key = `sunset-${status}`;
538
+ const { dir } = mod.scaffoldModule({ key, modulesDir: root, core: '1.0.0' });
539
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'));
540
+ manifest.maintenance = { status, note: 'on the way out' };
541
+ fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(manifest, null, 2));
542
+ const r = mod.checkModulePublishability(key, { modulesDir: root });
543
+ assert.equal(r.ok, false, `${status} must not be submittable INTO the catalog`);
544
+ assert.ok(r.findings.some((f) => f.kind === 'maintenance' && /way out/.test(f.detail)));
545
+ }
546
+ });
@@ -211,3 +211,68 @@ test('dependencies: a non-object, or a non-string range, is rejected', () => {
211
211
  assert.ok(r.errors.some((e) => e.startsWith('dependencies')), `error should mention dependencies: ${r.errors}`);
212
212
  }
213
213
  });
214
+
215
+ // --- maintenance & deprecation policy (ADR 0107 §7 / ADR 0166) ---
216
+
217
+ test('maintenance: omitted is fine (optional — pre-policy manifests still validate)', () => {
218
+ assert.equal(validateManifest(baseManifest()).valid, true);
219
+ });
220
+
221
+ test('maintenance: each of the four statuses validates when its own obligations are met', () => {
222
+ const cases = [
223
+ { maintenance: { status: 'core-maintained' } },
224
+ { maintainer: 'A. Builder', maintenance: { status: 'maintained' } },
225
+ { maintenance: { status: 'deprecated', successor: 'econ-v2', removeAfter: '1.12.0' } },
226
+ { maintenance: { status: 'orphaned', note: 'Author unreachable since 2026-07; enable at your own risk.' } },
227
+ ];
228
+ for (const extra of cases) {
229
+ const r = validateManifest({ ...baseManifest(), ...extra });
230
+ assert.equal(r.valid, true, `${extra.maintenance.status}: ${r.errors.join('; ')}`);
231
+ }
232
+ });
233
+
234
+ test('maintenance: "maintained" without a named top-level maintainer is rejected', () => {
235
+ const r = validateManifest({ ...baseManifest(), maintenance: { status: 'maintained' } });
236
+ assert.equal(r.valid, false, 'an unnamed maintainer IS the orphaning failure the policy prevents');
237
+ assert.ok(r.errors.some((e) => /maintained.*maintainer/.test(e)), r.errors.join('; '));
238
+ });
239
+
240
+ test('maintenance: a sunset status with no deprecation path (no successor, no note) is rejected', () => {
241
+ for (const status of ['deprecated', 'orphaned']) {
242
+ const r = validateManifest({ ...baseManifest(), maintenance: { status } });
243
+ assert.equal(r.valid, false, `${status} must state a path out`);
244
+ assert.ok(r.errors.some((e) => /deprecation path/.test(e)), r.errors.join('; '));
245
+ }
246
+ });
247
+
248
+ test('maintenance: an unknown status, an unknown key, or a bad version is rejected', () => {
249
+ const bad = [
250
+ { status: 'someday' },
251
+ { status: 'core-maintained', removeafter: '1.2.0' },
252
+ { status: 'core-maintained', since: 'v1.2' },
253
+ { status: 'deprecated', successor: 'Econ_V2' },
254
+ 'core-maintained',
255
+ ];
256
+ for (const m of bad) {
257
+ const r = validateManifest({ ...baseManifest(), maintenance: m });
258
+ assert.equal(r.valid, false, `should reject ${JSON.stringify(m)}`);
259
+ }
260
+ });
261
+
262
+ test('maintenance: removeAfter only applies to a module on its way out', () => {
263
+ const r = validateManifest({ ...baseManifest(), maintenance: { status: 'core-maintained', removeAfter: '1.12.0' } });
264
+ assert.equal(r.valid, false);
265
+ assert.ok(r.errors.some((e) => /removeAfter only applies/.test(e)), r.errors.join('; '));
266
+ });
267
+
268
+ test('every bundled module declares a maintenance posture (the catalog has no unowned entries)', async () => {
269
+ const fsm = (await import('node:fs')).default;
270
+ const modulesDir = path.join(ROOT, 'modules');
271
+ const undeclared = fsm.readdirSync(modulesDir)
272
+ .filter((k) => fsm.existsSync(path.join(modulesDir, k, 'module.json')))
273
+ .filter((k) => {
274
+ const m = JSON.parse(fsm.readFileSync(path.join(modulesDir, k, 'module.json'), 'utf8'));
275
+ return !m.maintenance || !m.maintenance.status;
276
+ });
277
+ assert.deepEqual(undeclared, [], `these modules declare no maintenance.status: ${undeclared.join(', ')}`);
278
+ });
@@ -183,6 +183,80 @@ t('names the in-flight task in the MATCHING worktree, not the first row', () =>
183
183
  assert.equal(d.inFlightTitle, 'Two');
184
184
  });
185
185
 
186
+ // =========================================================================
187
+ // task 1002522 — the guard and core_204's backstop index must agree EXACTLY on
188
+ // which claims share a tree. The index keys (creator_session_id,
189
+ // COALESCE(worktree_name, '')), so to Postgres '' IS the worktree-less group;
190
+ // the guard compared raw values, where '' !== null. db.foldWorktreeName is the
191
+ // single normalization both sides now use (and claimTask applies before the
192
+ // INSERT, so stored rows are already trimmed-or-NULL).
193
+ // =========================================================================
194
+ const { foldWorktreeName } = require('../modules/lifecycle/db.js');
195
+
196
+ t('foldWorktreeName folds null / empty / whitespace to the same NULL key', () => {
197
+ assert.equal(foldWorktreeName(null), null);
198
+ assert.equal(foldWorktreeName(undefined), null);
199
+ assert.equal(foldWorktreeName(''), null);
200
+ assert.equal(foldWorktreeName(' '), null);
201
+ assert.equal(foldWorktreeName('\t\n'), null);
202
+ });
203
+
204
+ t('foldWorktreeName trims but never renames a real worktree', () => {
205
+ assert.equal(foldWorktreeName('wt-a'), 'wt-a');
206
+ assert.equal(foldWorktreeName(' wt-a '), 'wt-a');
207
+ // Distinct trees stay distinct — the fold must not collapse parallel work.
208
+ assert.notEqual(foldWorktreeName('wt-a'), foldWorktreeName('wt-a-2'));
209
+ });
210
+
211
+ t("empty-string worktree is the SAME tree as worktree-less → refused (matches the index)", () => {
212
+ // Before the fold this returned allow, and the INSERT then tripped
213
+ // uniq_active_claim_per_session_worktree — a 23505 on a task nobody had taken.
214
+ const d = shouldRefuseSessionClaim({
215
+ creatorSessionId: 'sess-6',
216
+ worktreeName: '',
217
+ activeClaimsForSession: [
218
+ { claim_id: 50, task_id: 70, creator_session_id: 'sess-6', worktree_name: null, title: 'shared clone' },
219
+ ],
220
+ });
221
+ assert.equal(d.refuse, true);
222
+ assert.equal(d.inFlightTaskId, 70);
223
+ });
224
+
225
+ t('the mirror case: worktree-less incoming vs a stored empty-string name → refused', () => {
226
+ const d = shouldRefuseSessionClaim({
227
+ creatorSessionId: 'sess-6',
228
+ worktreeName: null,
229
+ activeClaimsForSession: [
230
+ { claim_id: 51, task_id: 71, creator_session_id: 'sess-6', worktree_name: ' ', title: 'blank name' },
231
+ ],
232
+ });
233
+ assert.equal(d.refuse, true);
234
+ assert.equal(d.inFlightTaskId, 71);
235
+ });
236
+
237
+ t('whitespace around a real name does not fake a second tree', () => {
238
+ const d = shouldRefuseSessionClaim({
239
+ creatorSessionId: 'sess-6',
240
+ worktreeName: ' wt-a ',
241
+ activeClaimsForSession: [
242
+ { claim_id: 52, task_id: 72, creator_session_id: 'sess-6', worktree_name: 'wt-a', title: 'A' },
243
+ ],
244
+ });
245
+ assert.equal(d.refuse, true);
246
+ assert.equal(d.inFlightTaskId, 72);
247
+ });
248
+
249
+ t('the fold does NOT re-break parallel worktrees (the bug this family fixed)', () => {
250
+ const d = shouldRefuseSessionClaim({
251
+ creatorSessionId: 'sess-6',
252
+ worktreeName: 'wt-b',
253
+ activeClaimsForSession: [
254
+ { claim_id: 53, task_id: 73, creator_session_id: 'sess-6', worktree_name: 'wt-a', title: 'A' },
255
+ ],
256
+ });
257
+ assert.equal(d.refuse, false);
258
+ });
259
+
186
260
  // =========================================================================
187
261
  // task 1002589 (idea 1000638) — the DB backstop's ERROR SURFACE. A claims
188
262
  // INSERT can trip TWO partial unique indexes; claimTask used to map every pg
package/tests/upgrade.mjs CHANGED
@@ -935,6 +935,100 @@ t('healDocAssets: a generator absent from this core is recorded, not spawned', (
935
935
  assert.equal(spawned.length, 1, 'the absent script is never spawned');
936
936
  });
937
937
 
938
+ // --- module maintenance & deprecation surfacing (ADR 0107 §7 / ADR 0166, task 1001773) ---
939
+ //
940
+ // The bug this pins: preflightModules used to return ONLY `blocking`, so a DISABLED module
941
+ // whose coreVersion the target core no longer satisfies vanished from a real bump entirely.
942
+ // An accepted catalog module ships default:false (ADR 0107 §6) — so that was every rotted
943
+ // catalog entry, silently.
944
+
945
+ // A modules/ dir on disk that preflightModules will really discover, driven through a stub
946
+ // moduleLib so the verdict shape (not the loader) is what's under test.
947
+ function moduleLibStub(verdict) {
948
+ return { gatherUpgrade: () => ({ rows: verdict.rows || [], errors: [], core: verdict.core }), evaluateUpgrade: () => verdict };
949
+ }
950
+
951
+ t('preflightModules: a DISABLED incompatible module is returned as a warning, not dropped', () => {
952
+ const dir = scratchConsumer();
953
+ mkdirSync(join(dir, 'modules'), { recursive: true });
954
+ const warned = { key: 'stale-catalog-entry', coreVersion: '^1.0.0', enabled: false, satisfied: false, maintainer: 'Ada <ada@example.com>', maintenance: { status: 'maintained' } };
955
+ const res = u.preflightModules({ instanceDir: dir, targetVersion: '2.0.0' }, {
956
+ moduleLib: moduleLibStub({ ok: true, blocking: [], warnings: [warned], deprecations: [], expired: [], core: '2.0.0' }),
957
+ });
958
+ assert.equal(res.ok, true, 'a disabled mismatch must never block the bump');
959
+ assert.deepEqual(res.warnings.map((w) => w.key), ['stale-catalog-entry']);
960
+ assert.match(res.note, /1 disabled module\(s\) do NOT/, 'the headline note admits the rot instead of claiming all-clear');
961
+ });
962
+
963
+ t('preflightModules: with everything healthy the note stays the plain all-clear', () => {
964
+ const dir = scratchConsumer();
965
+ mkdirSync(join(dir, 'modules'), { recursive: true });
966
+ const res = u.preflightModules({ instanceDir: dir, targetVersion: '2.0.0' }, {
967
+ moduleLib: moduleLibStub({ ok: true, blocking: [], warnings: [], deprecations: [], expired: [], core: '2.0.0' }),
968
+ });
969
+ assert.equal(res.note, 'all enabled modules satisfy the target');
970
+ assert.deepEqual(res.warnings, []);
971
+ });
972
+
973
+ t('preflightModules: no modules/ dir returns every collection empty (a vanilla consumer)', () => {
974
+ const dir = scratchConsumer();
975
+ const res = u.preflightModules({ instanceDir: dir, targetVersion: '2.0.0' });
976
+ assert.equal(res.ok, true);
977
+ for (const k of ['blocking', 'warnings', 'deprecations', 'expired']) assert.deepEqual(res[k], [], k);
978
+ });
979
+
980
+ t('reportModulePreflight: prints the rotted-but-disabled module WITH its maintainer named', () => {
981
+ const out = [];
982
+ const mods = {
983
+ warnings: [{ key: 'stale-catalog-entry', coreVersion: '^1.0.0', maintainer: 'Ada <ada@example.com>', maintenance: { status: 'maintained' } }],
984
+ deprecations: [], expired: [],
985
+ };
986
+ u.reportModulePreflight(mods, '2.0.0', { log: (s) => out.push(s), err: (s) => out.push(s) });
987
+ const text = out.join('\n');
988
+ assert.match(text, /stale-catalog-entry/);
989
+ assert.match(text, /silently fail to mount/);
990
+ assert.match(text, /maintainer Ada <ada@example\.com>/);
991
+ });
992
+
993
+ t('reportModulePreflight: calls out a deprecation the core has already passed removeAfter', () => {
994
+ const out = [];
995
+ const dep = { key: 'sunset', maintenance: { status: 'deprecated', successor: 'newer', removeAfter: '1.12.0' } };
996
+ u.reportModulePreflight({ warnings: [], deprecations: [dep], expired: [dep] }, '1.13.0', { log: (s) => out.push(s), err: (s) => out.push(s) });
997
+ const text = out.join('\n');
998
+ assert.match(text, /module "sunset" is deprecated/);
999
+ assert.match(text, /PAST its removeAfter 1\.12\.0/);
1000
+ assert.match(text, /due for removal/);
1001
+ });
1002
+
1003
+ t('reportModulePreflight: nothing to say prints nothing', () => {
1004
+ const out = [];
1005
+ const lines = u.reportModulePreflight({ warnings: [], deprecations: [], expired: [] }, '2.0.0', { log: (s) => out.push(s), err: (s) => out.push(s) });
1006
+ assert.deepEqual(lines, []);
1007
+ assert.deepEqual(out, []);
1008
+ });
1009
+
1010
+ t('runUpgrade: a REFUSED bump still reports the non-blocking module findings', async () => {
1011
+ // A refusal is exactly when someone is reading this output, so the warnings must
1012
+ // already have been emitted by the time the blocking check returns.
1013
+ const dir = scratchConsumer();
1014
+ mkdirSync(join(dir, 'modules'), { recursive: true });
1015
+ const out = [];
1016
+ const res = await u.runUpgrade({ to: '2.0.0', instance: dir, force: true, dryRun: true }, {
1017
+ log: (s) => out.push(String(s)),
1018
+ err: (s) => out.push(String(s)),
1019
+ moduleLib: moduleLibStub({
1020
+ ok: false,
1021
+ blocking: [{ key: 'enabled-breaker', coreVersion: '^1.0.0', enabled: true, satisfied: false }],
1022
+ warnings: [{ key: 'stale-catalog-entry', coreVersion: '^1.0.0', maintenance: { status: 'orphaned', note: 'author unreachable' } }],
1023
+ deprecations: [], expired: [], core: '2.0.0',
1024
+ }),
1025
+ });
1026
+ assert.equal(res.ok, false, 'the enabled mismatch still refuses');
1027
+ const text = out.join('\n');
1028
+ assert.match(text, /stale-catalog-entry/, 'the disabled rot is reported even on a refused bump');
1029
+ assert.match(text, /author unreachable/);
1030
+ });
1031
+
938
1032
  await Promise.all(pending);
939
1033
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
940
1034
  process.exit(failed ? 1 : 0);