@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.
- package/.bongos-core.json +46 -41
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +4 -0
- package/docs/adr/0166-module-maintenance-and-deprecation-policy.md +91 -0
- package/docs/adr/README.md +1 -0
- package/docs/modules-contract.md +23 -1
- package/modules/autonomy/module.json +1 -0
- package/modules/builder-settings/module.json +1 -0
- package/modules/dev-box/module.json +1 -0
- package/modules/discord/module.json +1 -0
- package/modules/economy/module.json +1 -0
- package/modules/governance/module.json +1 -0
- package/modules/grading/module.json +1 -0
- package/modules/hall-ui/module.json +1 -0
- package/modules/hall-ui/public/modules.js +17 -1
- package/modules/ideas/module.json +1 -0
- package/modules/lifecycle/db.js +35 -3
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/claims.js +14 -8
- package/modules/memory/module.json +1 -0
- package/modules/onboarding/module.json +1 -0
- package/modules/platform-identity/module.json +1 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/security/module.json +1 -0
- package/modules/sessions/module.json +1 -0
- package/modules/status-ui/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/module.js +103 -9
- package/scripts/gds/upgrade.js +50 -5
- package/src/bongos/routes/modules.js +4 -0
- package/src/module-api.js +5 -1
- package/src/module-loader/manifest-schema.js +69 -2
- package/tests/module_cli.mjs +126 -0
- package/tests/module_manifest.mjs +65 -0
- package/tests/one_active_claim_per_session.mjs +74 -0
- package/tests/upgrade.mjs +94 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
|
|
5
5
|
"coreVersion": "^1.8.0",
|
|
6
6
|
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
7
8
|
"contributes": { "routes": ["inbox", "blockers"] },
|
|
8
9
|
"provides": ["ideas.capture"],
|
|
9
10
|
"consumes": []
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -335,6 +335,30 @@ function singleRungPromotionError(oldRank, newRank) {
|
|
|
335
335
|
};
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
// foldWorktreeName — task 1002522. The ONE normalization of an incoming
|
|
339
|
+
// worktree_name, applied at every ingest point so the app guard
|
|
340
|
+
// (shouldRefuseSessionClaim) and its DB backstop (core_204's index, keyed on
|
|
341
|
+
// (creator_session_id, COALESCE(worktree_name, ''))) can never disagree about
|
|
342
|
+
// which claims share a working tree.
|
|
343
|
+
//
|
|
344
|
+
// The index folds NULL to '' — so to Postgres an EMPTY-string name IS the
|
|
345
|
+
// worktree-less group. The guard compared raw values, where '' !== null: it
|
|
346
|
+
// waved through a ''-named claim beside a worktree-less one, and the INSERT
|
|
347
|
+
// then failed the backstop with a 23505 the caller never earned. Whitespace-only
|
|
348
|
+
// is the same case wearing a coat (`.claude/worktrees/<blank>` is the repo root,
|
|
349
|
+
// not a tree), so it folds too, and surrounding whitespace is trimmed rather
|
|
350
|
+
// than stored — an untrimmed name would key the index differently from the
|
|
351
|
+
// trimmed one the guard compared.
|
|
352
|
+
//
|
|
353
|
+
// Fold ONCE, at ingest: claimTask/claimTasksBatch normalize before the INSERT,
|
|
354
|
+
// so every stored name is already trimmed-or-NULL and the guard's comparison of
|
|
355
|
+
// stored rows is exact. Pure + exported for DB-free tests.
|
|
356
|
+
function foldWorktreeName(name) {
|
|
357
|
+
if (name == null) return null;
|
|
358
|
+
const trimmed = String(name).trim();
|
|
359
|
+
return trimmed === '' ? null : trimmed;
|
|
360
|
+
}
|
|
361
|
+
|
|
338
362
|
// disambiguateWorktreeName — V3.R51 (#270). Two simultaneously-ACTIVE claims
|
|
339
363
|
// must never share a worktree_name: `.claude/worktrees/<name>` is a filesystem
|
|
340
364
|
// path, and two live builders pointing at the same one would stomp each other.
|
|
@@ -2762,7 +2786,10 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
2762
2786
|
// which is what the overnight builder relies on. We hold FOR UPDATE on the
|
|
2763
2787
|
// task row already; reading the active names inside the same txn is good
|
|
2764
2788
|
// enough for the disambiguation to be stable for this insert.
|
|
2765
|
-
|
|
2789
|
+
// task 1002522: fold ''/whitespace → NULL before anything keys on the name.
|
|
2790
|
+
// core_204's index folds them together anyway (COALESCE(worktree_name, ''));
|
|
2791
|
+
// normalizing here means the stored row matches what the guard compared.
|
|
2792
|
+
let finalWorktreeName = foldWorktreeName(worktreeName);
|
|
2766
2793
|
if (finalWorktreeName) {
|
|
2767
2794
|
const { rows: activeNameRows } = await client.query(
|
|
2768
2795
|
`SELECT worktree_name FROM claims
|
|
@@ -3121,8 +3148,12 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
3121
3148
|
);
|
|
3122
3149
|
reservedMigrationNumber = bumpRows[0].allocated;
|
|
3123
3150
|
}
|
|
3124
|
-
|
|
3125
|
-
|
|
3151
|
+
// task 1002522: fold the prefix the same way a single claim's name is
|
|
3152
|
+
// folded, so a blank/whitespace prefix yields worktree-less rows rather
|
|
3153
|
+
// than names like " -1234" that the index would key as distinct trees.
|
|
3154
|
+
const foldedPrefix = foldWorktreeName(worktreePrefix);
|
|
3155
|
+
const worktreeName = foldedPrefix
|
|
3156
|
+
? `${foldedPrefix}-${t.id}`
|
|
3126
3157
|
: null;
|
|
3127
3158
|
const rowCreatorSessionId = i === 0 ? (creatorSessionId ?? null) : null;
|
|
3128
3159
|
try {
|
|
@@ -5546,6 +5577,7 @@ module.exports = {
|
|
|
5546
5577
|
countShippedTasksForBuilder,
|
|
5547
5578
|
singleRungPromotionError,
|
|
5548
5579
|
disambiguateWorktreeName,
|
|
5580
|
+
foldWorktreeName,
|
|
5549
5581
|
updateTaskParallelSafe,
|
|
5550
5582
|
updateTaskSecuritySensitive,
|
|
5551
5583
|
updateTaskAutomationTag,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"description": "The build state machine — tasks · claims · versions · done-when criteria · goals · dependencies · the claim→ship→grade→credit lifecycle · override requests · the ship/confirm/promote orchestration · github-push + merge-lease + conflict-resolve + the publish reconciler. The LAST and most-coupled core domain carved out of the GDS core in BV1.R86 (ADR 0093 §1,§3, 02-module-map §4 — kept whole, never split). default-on; every instance runs the lifecycle. Registers the `lifecycle` kernel PORT (createTask · VOTABLE_TASK_STATUSES · classifyTaskKind · tallyPeerVotes + the work-tracking reads) that core read routes + the ideas/discord/autonomy modules resolve. As a transaction-participant it RESOLVES the `reward` + `onboarding` ports inside its claim/ship/confirm txns — but via resolveOptional (graceful, like sessions→reward), so consumes is empty: a vanilla instance without those modules degrades the credit/onboarding writes to best-effort no-ops, it does not fail.",
|
|
5
5
|
"coreVersion": "^1.12.0",
|
|
6
6
|
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
7
8
|
"contributes": {
|
|
8
9
|
"routes": ["tasks", "claims", "versions", "done-when", "goals", "dependencies", "gate-approvals", "analytics", "lifecycle"],
|
|
9
10
|
"pollers": ["publish-reconciler"]
|
|
@@ -80,13 +80,17 @@ function shouldRefuseSessionClaim({ activeClaimsForSession, creatorSessionId, wo
|
|
|
80
80
|
return { refuse: false, inFlightTaskId: null, inFlightTitle: null, reason: null };
|
|
81
81
|
}
|
|
82
82
|
const claims = Array.isArray(activeClaimsForSession) ? activeClaimsForSession : [];
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
// task 1002522: fold through db.foldWorktreeName — the SAME normalization the
|
|
84
|
+
// INSERT applies — so this decision keys trees exactly the way core_204's
|
|
85
|
+
// backstop index does (it folds NULL and '' into one group via
|
|
86
|
+
// COALESCE(worktree_name, '')). Comparing raw values let a ''-named claim slip
|
|
87
|
+
// past the guard and then trip the index, refusing a claim on a task that was
|
|
88
|
+
// never taken.
|
|
89
|
+
const incomingTree = db.foldWorktreeName(worktreeName);
|
|
90
|
+
// Two claims share a WORKING TREE iff their folded worktree_names match — which
|
|
91
|
+
// includes both being worktree-less (a plain clone, whose single branch
|
|
92
|
+
// genuinely commingles).
|
|
93
|
+
const sameWorkingTree = (name) => db.foldWorktreeName(name) === incomingTree;
|
|
90
94
|
// Defence in depth: only count claims that belong to THIS session, are genuinely
|
|
91
95
|
// session-bound (a NULL-session row must never trip the block), AND would land in
|
|
92
96
|
// the SAME working tree as the incoming claim.
|
|
@@ -122,7 +126,9 @@ async function gateOneActiveClaimPerSession(req, res, next) {
|
|
|
122
126
|
// The incoming claim's working tree — the hazard boundary the guard keys on. A
|
|
123
127
|
// second claim in a DIFFERENT worktree is safe (different branch); only a second
|
|
124
128
|
// claim into the SAME tree (or off one worktree-less clone) commingles.
|
|
125
|
-
|
|
129
|
+
// Folded (task 1002522) so the guard, the refusal message, and the row the
|
|
130
|
+
// INSERT will actually write all speak about the same tree.
|
|
131
|
+
const worktreeName = db.foldWorktreeName(req.body?.worktree_name);
|
|
126
132
|
let activeClaimsForSession;
|
|
127
133
|
try {
|
|
128
134
|
activeClaimsForSession = await db.getActiveClaimsForSession(creatorSessionId);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"description": "Per-builder session memory, the lexical recall/search retrieval layer (ADR 0060), and durable learnings. A core-domain module carved out of the GDS core in BV1.R72 (ADR 0091); default-on because every instance needs the build system's memory.",
|
|
5
5
|
"coreVersion": "^1.5.0",
|
|
6
6
|
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
7
8
|
"contributes": {
|
|
8
9
|
"routes": ["memory", "search", "learnings"]
|
|
9
10
|
},
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
|
|
5
5
|
"coreVersion": "^1.9.0",
|
|
6
6
|
"default": true,
|
|
7
|
+
"maintenance": { "status": "core-maintained" },
|
|
7
8
|
"contributes": { "routes": ["onboarding"] },
|
|
8
9
|
"provides": ["onboarding"],
|
|
9
10
|
"consumes": []
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"origin": "Cloud Bongos platform (cloudbongos.com)",
|
|
9
9
|
"license": "AGPL-3.0",
|
|
10
10
|
"maintainer": "Cloud Bongos maintainers",
|
|
11
|
+
"maintenance": { "status": "core-maintained" },
|
|
11
12
|
"contributes": {
|
|
12
13
|
"routes": ["sso", "projects", "my-projects", "scouting"],
|
|
13
14
|
"migrations": true,
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/module.js
CHANGED
|
@@ -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)
|
|
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 }.
|
|
196
|
-
//
|
|
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
|
-
|
|
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' : '
|
|
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,
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -553,8 +553,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
553
553
|
// landed since 1.19.61 with no explicit bump. run 31552833331. (task 1002620)
|
|
554
554
|
// 1.19.63 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
555
555
|
// landed since 1.19.62 with no explicit bump. run 31553743979. (task 1002620)
|
|
556
|
+
// 1.19.64 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
556
560
|
// ---------------------------------------------------------------------------
|
|
557
|
-
const CORE_VERSION = '1.19.
|
|
561
|
+
const CORE_VERSION = '1.19.65'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
558
562
|
|
|
559
563
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
560
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 = {
|
|
219
|
+
module.exports = {
|
|
220
|
+
validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
|
|
221
|
+
MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
|
|
222
|
+
};
|