@bongos/core 1.19.64 → 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.
@@ -8,6 +8,7 @@
8
8
  "origin": "Off the Boats (the first Cloud Bongos instance, amazonprimea.com)",
9
9
  "license": "AGPL-3.0",
10
10
  "maintainer": "Off the Boats maintainers",
11
+ "maintenance": { "status": "core-maintained" },
11
12
  "contributes": {
12
13
  "routes": ["provisioning"],
13
14
  "migrations": true
@@ -4,6 +4,7 @@
4
4
  "description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule. Also owns the scan-before-install surface (goal 1000055): the artifact-scan engine (quarantine/manifest/mirror) and the security_tool_scans record — POST Metic+, GET every rank.",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": { "routes": ["reports", "scans"], "migrations": true },
8
9
  "provides": ["security"],
9
10
  "consumes": []
@@ -4,6 +4,7 @@
4
4
  "description": "The session-records corpus + its data plane (POST /sessions upload, the Archon-gated cross-builder /sessions/search + /sessions/:id deep-dive), the BFG 'read-the-room' cross-builder scorer (ADR 0026 §6B), and the session-pulse / stale-claim heartbeat helpers. A core-domain module carved out of the GDS core in BV1.R82 (ADR 0093 §1); default-on because every instance records its builders' sessions for the BFG. Like the memory module, sessions registers no port — core never calls back into it: the ship-time digest write is an HTTP POST to the module's own /sessions route, and the only outward dependency is the economy `reward` kernel PORT it resolves (session token reward + sumCreditsForTasks) at upload time.",
5
5
  "coreVersion": "^1.10.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": { "routes": ["sessions"], "pollers": ["session-reward-reconcile"] },
8
9
  "provides": [],
9
10
  "consumes": []
@@ -4,6 +4,7 @@
4
4
  "description": "The public build-status dashboard served at status.<apex> (status.amazonprimea.com) — the live up/down banner, version-progress rollups, cost summary, recent ships, leaderboard, and the ecosystem fingerprint, all polling the read-only /api/gds/public/* surface every 60s. A WEB-SURFACE core-domain module carved out of the GDS core in BV1.R84 (ADR 0093 §1; 02-module-map §6 — a web surface is first-class). default-on: a Cloud Bongos instance ships its status page like it ships its hall. Registers no port and contributes no routes — it declares a webSurface (host prefix + static dir) the loader hands serve-internal, which serves the module's public/ dir on the matching host exactly as it served public-status/ before. The public read endpoints it renders stay SHARED core in routes/public.js (the hall + the off-box mirror read them too).",
5
5
  "coreVersion": "^1.11.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": {
8
9
  "webSurfaces": [
9
10
  { "host": "status.", "dir": "public" }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.64",
3
+ "version": "1.19.65",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.64",
9
+ "version": "1.19.65",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.64",
3
+ "version": "1.19.65",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -34,7 +34,10 @@
34
34
  // because a brand-new module is never on the ALLOWLIST until it is
35
35
  // accepted (§6); that omission must not read as "denied content." Also
36
36
  // requires the manifest to declare an AGPL-3.0 license (inbound = outbound,
37
- // ADR 0107 §4 — no CLA). On a pass, `--sign-off "Name <email>"` records a
37
+ // ADR 0107 §4 — no CLA) and a `maintenance` posture (ADR 0107 §7 / ADR
38
+ // 0166 — core-maintained, or maintained by a named maintainer; a module
39
+ // already deprecated/orphaned cannot be submitted INTO the catalog).
40
+ // On a pass, `--sign-off "Name <email>"` records a
38
41
  // lightweight DCO-style affirmation (own work, licensed AGPL-3.0 — never a
39
42
  // copyright-assignment CLA) to modules/<key>/.upstream-signoff.json.
40
43
  //
@@ -54,7 +57,8 @@ const path = require('node:path');
54
57
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
55
58
  const MODULES_DIR = path.join(REPO_ROOT, 'modules');
56
59
 
57
- const { validateManifest, assertValidManifest, KEY_RE } = require('../../src/module-loader/manifest-schema');
60
+ const { validateManifest, assertValidManifest, KEY_RE, SUNSET_STATUSES } = require('../../src/module-loader/manifest-schema');
61
+ const { parseVersion, cmp } = require('../../src/module-loader/semver');
58
62
  const loader = require('../../src/module-loader/loader');
59
63
  const { resolveModules, MODULE_REGISTRY, NEUTRAL_PATH, INSTANCE_PATH } = require('../../src/modules');
60
64
  // The public-mirror publish boundary (ADR 0098) — REUSED, never re-typed, for
@@ -115,6 +119,11 @@ function buildManifest(key, { core = coreVersion() } = {}) {
115
119
  origin: 'TODO: the instance/project this module was built in',
116
120
  license: 'AGPL-3.0',
117
121
  maintainer: 'TODO: who to credit/contact if this module is upstreamed',
122
+ // Upkeep posture (ADR 0107 §7 / ADR 0166). Scaffolded as `maintained`
123
+ // because a brand-new module's author IS its maintainer — the honest
124
+ // default. Change it to `core-maintained` only if the core team has agreed
125
+ // to own it, and to `deprecated`/`orphaned` (with a path) when that changes.
126
+ maintenance: { status: 'maintained' },
118
127
  contributes: { routes: [key] },
119
128
  };
120
129
  }
@@ -191,13 +200,51 @@ kernel seams (\`api.registerProvider\` / \`api.resolve\` / \`api.on\` / \`api.em
191
200
  `;
192
201
  }
193
202
 
203
+ // Has the core advanced past a deprecated module's declared removal version?
204
+ // `removeAfter: "1.12.0"` means "may be deleted once core is ABOVE 1.12.0".
205
+ function isPastRemoveAfter(maintenance, core) {
206
+ const after = maintenance && maintenance.removeAfter;
207
+ const a = parseVersion(after);
208
+ const c = parseVersion(core);
209
+ return !!(a && c && cmp(c, a) > 0);
210
+ }
211
+
212
+ // One line of "who do I chase about this?" for a row, from its maintenance block
213
+ // (ADR 0166). Shared by cmdUpgrade and upgrade.js's real-bump pre-flight so both
214
+ // surfaces say the same thing about the same module.
215
+ function formatMaintenanceHint(row = {}) {
216
+ const m = row.maintenance;
217
+ if (!m || !m.status) {
218
+ return 'no maintenance declared — nobody is on the hook for this module (ADR 0166: declare a `maintenance` block)';
219
+ }
220
+ const parts = [`maintenance: ${m.status}`];
221
+ if (m.status === 'maintained') parts.push(`maintainer ${row.maintainer || '(unnamed!)'}`);
222
+ if (m.status === 'core-maintained') parts.push('core-maintained — fixing this is the core\'s job');
223
+ if (m.successor) parts.push(`superseded by "${m.successor}"`);
224
+ if (m.removeAfter) parts.push(`removable after core ${m.removeAfter}`);
225
+ if (m.note) parts.push(m.note);
226
+ return parts.join('; ');
227
+ }
228
+
194
229
  // Decide compatibility from rows (pure — tests drive it with injected rows).
195
- // A row: { key, coreVersion, enabled, satisfied }. BLOCKING = an enabled module
196
- // that does not satisfy the core (fail-closed). A disabled mismatch is a warning.
230
+ // A row: { key, coreVersion, enabled, satisfied, maintenance?, maintainer? }.
231
+ //
232
+ // BLOCKING = an enabled module that does not satisfy the core (fail-closed).
233
+ // WARNINGS = a DISABLED mismatch. These are not cosmetic: an accepted catalog
234
+ // module ships `default: false` (ADR 0107 §6), so EVERY rotted catalog entry
235
+ // lands in this bucket. Dropping warnings on the floor is exactly how "the
236
+ // catalog fills with silently-broken entries" happens (ADR 0107 §7) — every
237
+ // caller must surface them, even though none of them block.
238
+ // DEPRECATIONS = modules whose own manifest says they are on the way out;
239
+ // EXPIRED = deprecated modules the core has already advanced past `removeAfter`
240
+ // (they are due for deletion, not just a label).
197
241
  function evaluateUpgrade({ rows = [], errors = [], core } = {}) {
198
242
  const blocking = rows.filter((r) => r.enabled && !r.satisfied);
199
243
  const warnings = rows.filter((r) => !r.enabled && !r.satisfied);
200
- return { ok: blocking.length === 0, blocking, warnings, rows, errors, core };
244
+ const deprecations = rows.filter((r) => r.maintenance && SUNSET_STATUSES.includes(r.maintenance.status));
245
+ const expired = deprecations.filter((r) => isPastRemoveAfter(r.maintenance, core));
246
+ const undeclared = rows.filter((r) => !r.maintenance || !r.maintenance.status);
247
+ return { ok: blocking.length === 0, blocking, warnings, deprecations, expired, undeclared, rows, errors, core };
201
248
  }
202
249
 
203
250
  // ---------------------------------------------------------------------------
@@ -261,12 +308,20 @@ function gatherUpgrade({ modulesDir = MODULES_DIR, core = coreVersion(), env = p
261
308
  try { enabled = resolveModules({ neutral, instance, env, envPrefix: prefix, registry }); }
262
309
  catch { /* a malformed config shouldn't crash the pre-check; treat all as disabled */ }
263
310
 
311
+ // Maintenance posture travels with the row (ADR 0166) so both the pre-check
312
+ // printout and the real bump can say WHO owns a module that no longer fits —
313
+ // an incompatibility with no owner named is the orphaning failure itself.
314
+ const upkeep = (manifest) => ({
315
+ maintenance: (manifest && manifest.maintenance) || null,
316
+ maintainer: (manifest && manifest.maintainer) || null,
317
+ });
318
+
264
319
  const rows = [];
265
320
  for (const { key, manifest } of loaded) {
266
- rows.push({ key, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true });
321
+ rows.push({ key, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true, ...upkeep(manifest) });
267
322
  }
268
323
  for (const { key, manifest, reason } of skippedFull) {
269
- rows.push({ key, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason });
324
+ rows.push({ key, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason, ...upkeep(manifest) });
270
325
  }
271
326
  rows.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
272
327
  return { rows, errors, core };
@@ -320,6 +375,29 @@ function checkModulePublishability(key, { modulesDir = MODULES_DIR } = {}) {
320
375
  'AGPL-3.0 (inbound = outbound, ADR 0107 §4)',
321
376
  });
322
377
  }
378
+
379
+ // ADR 0107 §7 / ADR 0166 — the maintenance gate. Acceptance into the shared
380
+ // catalog is a commitment to keep the module true as the core advances, so
381
+ // the module must SAY who is committing before it can be filed for review.
382
+ // The shape is already schema-validated above (a `maintained` module must
383
+ // name a maintainer; a sunset one must state its path); what this adds is
384
+ // that the block cannot simply be absent. Refusing here — at submit time —
385
+ // is what keeps the catalog from filling with entries nobody owns.
386
+ const maint = manifestRaw.maintenance;
387
+ if (!maint || !maint.status) {
388
+ findings.push({
389
+ kind: 'maintenance',
390
+ detail: 'module.json declares no `maintenance.status` — an upstreamed module must state its upkeep ' +
391
+ 'posture (ADR 0107 §7): "core-maintained" (the core team owns it) or "maintained" with a named ' +
392
+ '`maintainer`. A module nobody owns rots silently as the core advances.',
393
+ });
394
+ } else if (SUNSET_STATUSES.includes(maint.status)) {
395
+ findings.push({
396
+ kind: 'maintenance',
397
+ detail: `module.json declares maintenance.status "${maint.status}" — a module already on its way out ` +
398
+ 'cannot be submitted INTO the catalog. Upstream a maintained module, or its successor.',
399
+ });
400
+ }
323
401
  }
324
402
 
325
403
  // Classify every file AS IF it already lived at its canonical modules/<key>/
@@ -428,13 +506,28 @@ function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather =
428
506
  const state = r.enabled ? 'enabled' : 'disabled';
429
507
  let note = '';
430
508
  if (!r.satisfied) {
509
+ // A DISABLED mismatch is harmless to THIS boot, but it is not harmless to
510
+ // the catalog — it is a rotted entry with an owner who needs telling
511
+ // (ADR 0166). Name that owner right here rather than shrugging at it.
431
512
  note = ` — coreVersion ${r.coreVersion || '(none)'} not satisfied by core ${verdict.core}` +
432
- (r.enabled ? ' — will NOT mount' : ' (disabled, harmless)');
513
+ (r.enabled ? ' — will NOT mount' : ' — disabled, so it does not block this boot, but it is BROKEN if enabled') +
514
+ `\n ↳ ${formatMaintenanceHint(r)}`;
433
515
  }
434
516
  log(` ${mark} ${r.key.padEnd(18)} ${String(r.coreVersion || '').padEnd(10)} ${state}${note}`);
435
517
  }
436
518
  for (const e of data.errors) errlog(` ! ${e.dir}: ${(e.errors || []).join('; ')}`);
437
519
 
520
+ // Deprecation roll-up (ADR 0166) — separate from compatibility: a module can
521
+ // satisfy the core perfectly and still be on its way out.
522
+ for (const d of verdict.deprecations) {
523
+ const overdue = verdict.expired.includes(d);
524
+ errlog(` ${overdue ? '✗' : '!'} ${d.key}: ${d.maintenance.status}${overdue ? ` — PAST removeAfter ${d.maintenance.removeAfter} (core is ${verdict.core}); it is due for removal` : ''}`);
525
+ errlog(` ↳ ${formatMaintenanceHint(d)}`);
526
+ }
527
+ if (verdict.undeclared.length) {
528
+ errlog(` ! ${verdict.undeclared.length} module(s) declare no \`maintenance\` block — nobody is on the hook for them as the core advances (ADR 0166): ${verdict.undeclared.map((r) => r.key).join(', ')}`);
529
+ }
530
+
438
531
  if (!verdict.ok) {
439
532
  errlog(`\nREFUSING: ${verdict.blocking.length} enabled module(s) incompatible with core ${verdict.core}: ${verdict.blocking.map((b) => b.key).join(', ')}.`);
440
533
  errlog(`Widen each module's coreVersion to a range that includes ${verdict.core} (or hold the core upgrade) before proceeding.`);
@@ -459,7 +552,7 @@ function cmdCheck(args, { log = console.log, errlog = console.error, check = che
459
552
 
460
553
  log(`bongos module check — upstreaming pre-check for "${key}"`);
461
554
  if (result.ok) {
462
- log(' ✓ AGPL/publish pre-check passed — no denied content, license is AGPL-3.0.');
555
+ log(' ✓ AGPL/publish pre-check passed — no denied content, license is AGPL-3.0, upkeep is declared.');
463
556
  } else {
464
557
  for (const f of result.findings) errlog(` ✗ ${f.kind}: ${f.detail}`);
465
558
  }
@@ -582,6 +675,7 @@ async function main(argv) {
582
675
  module.exports = {
583
676
  main, cmdNew, cmdUpgrade, cmdCheck, cmdSubmit, printHelp,
584
677
  scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
678
+ formatMaintenanceHint, isPastRemoveAfter,
585
679
  checkModulePublishability, recordSignOff, readSignOff, DCO_STATEMENT,
586
680
  listModuleFiles, packageModuleForSubmission,
587
681
  routeTemplate, claudeMdTemplate, titleize, camel, coreVersion,
@@ -141,18 +141,59 @@ function vendorTarball({ instanceDir, fromTgz, targetVersion }, fsImpl = fs) {
141
141
 
142
142
  // Lightweight module-compat pre-check reusing module.js against the TARGET core version. Best-effort:
143
143
  // a vanilla consumer has no modules/ (trivially OK); any failure downgrades to a warning, never blocks
144
- // the MVP bump. Returns { ok, blocking:[], note }.
144
+ // the MVP bump. Returns { ok, blocking:[], warnings:[], deprecations:[], expired:[], note }.
145
+ //
146
+ // WARNINGS ARE NOT OPTIONAL OUTPUT (ADR 0166 / task 1001773). This function used to return only
147
+ // `blocking`, so a DISABLED module whose coreVersion the target no longer satisfies vanished from the
148
+ // bump entirely. That is precisely the catalog-rot case ADR 0107 §7 names: an accepted catalog module
149
+ // ships `default: false` (§6), so EVERY rotted catalog entry is disabled by construction and every one
150
+ // of them was invisible here. The bump still doesn't BLOCK on them — a disabled module breaks nothing
151
+ // at boot — but it must SAY so, with the maintainer named, or the incompatibility is discovered by
152
+ // whoever next tries to enable it.
145
153
  function preflightModules({ instanceDir, targetVersion }, deps = {}) {
154
+ const empty = { blocking: [], warnings: [], deprecations: [], expired: [] };
146
155
  const modulesDir = path.join(instanceDir, 'modules');
147
- if (!fs.existsSync(modulesDir)) return { ok: true, blocking: [], note: 'no modules/ — nothing to pre-check' };
156
+ if (!fs.existsSync(modulesDir)) return { ok: true, ...empty, note: 'no modules/ — nothing to pre-check' };
148
157
  try {
149
158
  const mod = deps.moduleLib || require('./module');
150
159
  const data = mod.gatherUpgrade({ modulesDir, core: targetVersion });
151
160
  const verdict = mod.evaluateUpgrade(data);
152
- return { ok: verdict.ok, blocking: verdict.blocking || [], note: verdict.ok ? 'all enabled modules satisfy the target' : 'incompatible enabled module(s)' };
161
+ const rotted = (verdict.warnings || []).length;
162
+ const note = !verdict.ok
163
+ ? 'incompatible enabled module(s)'
164
+ : rotted
165
+ ? `all enabled modules satisfy the target, but ${rotted} disabled module(s) do NOT`
166
+ : 'all enabled modules satisfy the target';
167
+ return {
168
+ ok: verdict.ok,
169
+ blocking: verdict.blocking || [],
170
+ warnings: verdict.warnings || [],
171
+ deprecations: verdict.deprecations || [],
172
+ expired: verdict.expired || [],
173
+ note,
174
+ };
153
175
  } catch (e) {
154
- return { ok: true, blocking: [], note: `module pre-check skipped (${e.message})` };
176
+ return { ok: true, ...empty, note: `module pre-check skipped (${e.message})` };
177
+ }
178
+ }
179
+
180
+ // Print the non-blocking half of the module pre-check: rotted-but-disabled modules and
181
+ // self-declared deprecations, each with its maintenance hint so the reader knows who to chase.
182
+ // Split out of runUpgrade so it is testable without driving a whole bump.
183
+ function reportModulePreflight(mods, targetVersion, { log = console.log, err = console.error, hint } = {}) {
184
+ const fmt = hint || ((row) => { try { return require('./module').formatMaintenanceHint(row); } catch { return ''; } });
185
+ const lines = [];
186
+ for (const w of mods.warnings || []) {
187
+ lines.push(` ! module "${w.key}" is DISABLED here, so it does not block this bump — but its coreVersion ${w.coreVersion || '(none)'} is not satisfied by core ${targetVersion}: enabling it would silently fail to mount.`);
188
+ lines.push(` ↳ ${fmt(w)}`);
189
+ }
190
+ for (const d of mods.deprecations || []) {
191
+ const overdue = (mods.expired || []).includes(d);
192
+ lines.push(` ! module "${d.key}" is ${d.maintenance.status}${overdue ? ` and PAST its removeAfter ${d.maintenance.removeAfter} (core ${targetVersion}) — it is due for removal` : ''}.`);
193
+ lines.push(` ↳ ${fmt(d)}`);
155
194
  }
195
+ for (const l of lines) err(l);
196
+ return lines;
156
197
  }
157
198
 
158
199
  function npmInstall(instanceDir, run = spawnSync) {
@@ -582,6 +623,10 @@ async function runUpgrade(opts, deps = {}) {
582
623
  }
583
624
  const mods = preflightModules({ instanceDir, targetVersion }, deps);
584
625
  log(` ✓ pre-flight: ${mods.note}`);
626
+ // Surface the non-blocking findings (rotted-but-disabled + deprecated modules) BEFORE the
627
+ // blocking check returns, so they are reported on a refused bump too — a refusal is exactly
628
+ // when someone is reading this output (ADR 0166).
629
+ (deps.reportModulePreflight || reportModulePreflight)(mods, targetVersion, { log, err });
585
630
  if (!mods.ok) return { ok: false, error: `module pre-check failed: ${mods.blocking.map((b) => b.key).join(', ')} incompatible with core ${targetVersion}` };
586
631
 
587
632
  // 1b. mandatory post-restart health check (audit H9 / task 1002222, finding F5). A bump that
@@ -822,7 +867,7 @@ async function main(argv = process.argv.slice(2)) {
822
867
  module.exports = {
823
868
  runUpgrade, parseArgs, main, rollback,
824
869
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
825
- vendorTarball, gitTreeClean, preflightModules, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
870
+ vendorTarball, gitTreeClean, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
826
871
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
827
872
  CORE_PKG, ARTIFACT,
828
873
  };
@@ -99,6 +99,10 @@ function listModules() {
99
99
  origin: manifest.origin || null,
100
100
  license: manifest.license || null,
101
101
  maintainer: manifest.maintainer || null,
102
+ // Upkeep posture (ADR 0107 §7 / ADR 0166) — the catalog's job is to show
103
+ // who keeps a module true as the core advances, so a deprecated or
104
+ // unowned entry reads as such instead of looking like any other module.
105
+ maintenance: manifest.maintenance || null,
102
106
  };
103
107
  }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
104
108
  }
package/src/module-api.js CHANGED
@@ -555,8 +555,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
555
555
  // landed since 1.19.62 with no explicit bump. run 31553743979. (task 1002620)
556
556
  // 1.19.64 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
557
557
  // landed since 1.19.63 with no explicit bump. run 31560059785. (task 1002620)
558
+ // 1.19.65 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
559
+ // landed since 1.19.64 with no explicit bump. run 31562193292. (task 1002620)
558
560
  // ---------------------------------------------------------------------------
559
- const CORE_VERSION = '1.19.64'; // CI auto-patch carrier (ADR 0161); see changelog above
561
+ const CORE_VERSION = '1.19.65'; // CI auto-patch carrier (ADR 0161); see changelog above
560
562
 
561
563
  // A namespaced logger so a module's log lines are attributable + consistent.
562
564
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -18,6 +18,15 @@
18
18
  // "origin": "…", // OPTIONAL. Origin instance/project this module was built in.
19
19
  // "license": "…", // OPTIONAL. e.g. "AGPL-3.0" — the license this module is offered under.
20
20
  // "maintainer": "…", // OPTIONAL. Who to credit/contact if this module is upstreamed.
21
+ // "maintenance": { // OPTIONAL. Upkeep posture (ADR 0107 §7 / ADR 0166) — who keeps
22
+ // "status": "maintained", // REQUIRED in-block: core-maintained | maintained | deprecated | orphaned.
23
+ // "since": "1.8.0", // OPTIONAL. Core version the status took effect.
24
+ // "removeAfter": "1.12.0", // OPTIONAL. Core version after which it may be deleted.
25
+ // "successor": "econ-v2", // OPTIONAL. Module key that replaces it.
26
+ // "note": "…" // OPTIONAL. The deprecation path in one sentence.
27
+ // }, // this module true as the core advances, and what happens if
28
+ // // nobody does. `maintained` needs a named `maintainer`;
29
+ // // `deprecated`/`orphaned` need a `successor` or a `note`.
21
30
  // "contributes": { // REQUIRED object (may be empty). What the module mounts:
22
31
  // "routes": ["box"], // route-factory keys (mounted behind kernel-composed auth)
23
32
  // "rooms": ["WorldRoom"], // Colyseus room classes (realtime seam)
@@ -40,7 +49,7 @@
40
49
  // validateManifest(obj) -> { valid, errors[], manifest } — pure, no I/O.
41
50
  // assertValidManifest(obj) throws on the first failure (loader convenience).
42
51
 
43
- const { isValidRange } = require('./semver');
52
+ const { isValidRange, parseVersion } = require('./semver');
44
53
 
45
54
  const KEY_RE = /^[a-z][a-z0-9-]*$/;
46
55
 
@@ -54,6 +63,23 @@ const ALLOWED_CONTRIBUTES = [...ARRAY_OF_STRING_KEYS, 'webSurfaces', 'migrations
54
63
  // Top-level attribution fields (ADR 0107 §2) — optional, portable strings.
55
64
  const ATTRIBUTION_KEYS = ['author', 'origin', 'license', 'maintainer'];
56
65
 
66
+ // The `maintenance` block (ADR 0107 §7, decided in ADR 0166). A module pinned to
67
+ // an old coreVersion rots as the core advances, so every module states — in the
68
+ // manifest, where the upgrade pre-check can read it — WHO keeps it true and what
69
+ // happens if nobody does. The four statuses are the whole policy:
70
+ // core-maintained the core team owns it; a core change that breaks it is the
71
+ // core's problem to fix before shipping.
72
+ // maintained a named `maintainer` owns it (required — an unnamed owner
73
+ // IS the orphaning failure this field exists to prevent).
74
+ // deprecated on the way out; needs a path (`successor` and/or `note`),
75
+ // optionally a `removeAfter` core version.
76
+ // orphaned nobody owns it. Still needs a `note`/`successor` so the
77
+ // next reader knows the situation, not just the label.
78
+ const MAINTENANCE_STATUSES = ['core-maintained', 'maintained', 'deprecated', 'orphaned'];
79
+ const MAINTENANCE_KEYS = ['status', 'since', 'removeAfter', 'successor', 'note'];
80
+ // Statuses that describe a module on its way out — these owe a deprecation path.
81
+ const SUNSET_STATUSES = ['deprecated', 'orphaned'];
82
+
57
83
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
58
84
  const isStr = (v) => typeof v === 'string' && v.length > 0;
59
85
  const isStrArray = (v) => Array.isArray(v) && v.every(isStr);
@@ -93,6 +119,44 @@ function validateManifest(obj) {
93
119
  if (k in obj && !isStr(obj[k])) E(`${k}: must be a non-empty string when present`);
94
120
  }
95
121
 
122
+ // --- optional maintenance block (ADR 0107 §7 / ADR 0166) ---
123
+ // Closed key set (like contributes) so a typo'd "removeafter" fails loud rather
124
+ // than silently declaring no removal date at all.
125
+ if ('maintenance' in obj) {
126
+ const m = obj.maintenance;
127
+ if (!isObj(m)) {
128
+ E('maintenance: must be an object (e.g. { "status": "maintained" })');
129
+ } else {
130
+ for (const k of Object.keys(m)) {
131
+ if (!MAINTENANCE_KEYS.includes(k)) E(`maintenance.${k}: unknown key (allowed: ${MAINTENANCE_KEYS.join(', ')})`);
132
+ }
133
+ if (!isStr(m.status)) E(`maintenance.status: required — one of ${MAINTENANCE_STATUSES.join(', ')}`);
134
+ else if (!MAINTENANCE_STATUSES.includes(m.status)) {
135
+ E(`maintenance.status: "${m.status}" is not one of ${MAINTENANCE_STATUSES.join(', ')}`);
136
+ }
137
+ for (const k of ['since', 'removeAfter']) {
138
+ if (k in m && !parseVersion(m[k])) E(`maintenance.${k}: must be an exact core version "X.Y.Z" when present`);
139
+ }
140
+ if ('successor' in m && (!isStr(m.successor) || !KEY_RE.test(m.successor))) {
141
+ E('successor: must be a module key (lowercase kebab-case) when present');
142
+ }
143
+ if ('note' in m && !isStr(m.note)) E('maintenance.note: must be a non-empty string when present');
144
+
145
+ // The two rules that make the block load-bearing rather than decorative:
146
+ // a "maintained" module must NAME its maintainer, and a module on its way
147
+ // out must state the path out. Everything else above is shape-checking.
148
+ if (m.status === 'maintained' && !isStr(obj.maintainer)) {
149
+ E('maintenance.status "maintained" requires a named top-level `maintainer` (ADR 0166) — use "orphaned" if nobody owns it');
150
+ }
151
+ if (SUNSET_STATUSES.includes(m.status) && !isStr(m.successor) && !isStr(m.note)) {
152
+ E(`maintenance.status "${m.status}" requires a deprecation path — a "successor" module key and/or a "note" (ADR 0166)`);
153
+ }
154
+ if ('removeAfter' in m && !SUNSET_STATUSES.includes(m.status)) {
155
+ E(`maintenance.removeAfter only applies to a ${SUNSET_STATUSES.join('/')} module (status is "${m.status}")`);
156
+ }
157
+ }
158
+ }
159
+
96
160
  // --- contributes (required object) ---
97
161
  if (!isObj(obj.contributes)) {
98
162
  E('contributes: required object (use {} if the module contributes nothing)');
@@ -152,4 +216,7 @@ function assertValidManifest(obj, label = 'module.json') {
152
216
  return obj;
153
217
  }
154
218
 
155
- module.exports = { validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS };
219
+ module.exports = {
220
+ validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
221
+ MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
222
+ };
@@ -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
+ });