@bongos/core 1.21.167 → 1.21.169

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 (83) hide show
  1. package/.bongos-core.json +157 -72
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +308 -5
  8. package/docs/api-reference.md +7 -3
  9. package/docs/architecture.md +2 -0
  10. package/docs/copy-inventory.md +21 -20
  11. package/docs/copy-registry.json +34 -25
  12. package/docs/module-api-changelog.md +5 -1
  13. package/docs/modules-contract.md +2 -1
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +430 -430
  16. package/modules/autonomy/governor-docket.js +10 -5
  17. package/modules/builder-settings/builder-needs.js +29 -3
  18. package/modules/copy-desk/guarded-lines.js +89 -0
  19. package/modules/copy-desk/guarded-lines.json +14 -0
  20. package/modules/copy-desk/module.json +2 -2
  21. package/modules/copy-desk/page-status.js +77 -7
  22. package/modules/copy-desk/pages.js +4 -0
  23. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  24. package/modules/copy-desk/routes/copy-desk.js +8 -0
  25. package/modules/copy-desk/stuck-tweaks.js +79 -0
  26. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  27. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  28. package/modules/government/asks-db.js +38 -2
  29. package/modules/government/catalog.js +1 -1
  30. package/modules/government/docket.js +20 -2
  31. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  32. package/modules/government/routes/government.js +43 -1
  33. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  34. package/modules/hall-ui/public/approval-queue.js +1 -0
  35. package/modules/hall-ui/public/city-decide.js +642 -0
  36. package/modules/hall-ui/public/city-draw.js +14 -3
  37. package/modules/hall-ui/public/city-switchboard.js +141 -0
  38. package/modules/hall-ui/public/city.css +176 -10
  39. package/modules/hall-ui/public/city.html +8 -3
  40. package/modules/hall-ui/public/city.js +185 -38
  41. package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
  42. package/modules/hall-ui/public/tweak-editor.css +2 -0
  43. package/modules/hall-ui/public/tweak-editor.js +3 -1
  44. package/modules/lifecycle/db-claims.js +20 -5
  45. package/modules/lifecycle/rebase-gate.js +78 -0
  46. package/modules/lifecycle/routes/claims.js +10 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +28 -0
  50. package/scripts/gds/ask.js +68 -1
  51. package/scripts/gds/claim.js +6 -1
  52. package/scripts/gds/copy-apply.js +2 -0
  53. package/src/bongos/module-promotion.js +219 -0
  54. package/src/bongos/module-store.js +47 -9
  55. package/src/bongos/route-rank-check.js +7 -0
  56. package/src/bongos/routes/me.js +11 -0
  57. package/src/bongos/routes/modules.js +72 -10
  58. package/src/module-api.js +1 -1
  59. package/src/platform-server.js +5 -0
  60. package/tests/claim_gate_ci_unblock.mjs +4 -0
  61. package/tests/claim_gate_rebase.mjs +56 -3
  62. package/tests/copy_desk_page_editor.mjs +1 -1
  63. package/tests/copy_desk_page_submit.mjs +42 -0
  64. package/tests/copy_guarded_lines.mjs +54 -0
  65. package/tests/copy_stuck_page_tweaks.mjs +118 -0
  66. package/tests/government_ask_flags.mjs +80 -0
  67. package/tests/government_seed.mjs +1 -0
  68. package/tests/government_treasury.mjs +1 -0
  69. package/tests/governor_docket.mjs +1 -1
  70. package/tests/hall_approval_queue.mjs +15 -0
  71. package/tests/hall_city.mjs +5 -4
  72. package/tests/hall_city_living.mjs +118 -12
  73. package/tests/hall_city_rewording.mjs +51 -0
  74. package/tests/hall_city_switchboard.mjs +63 -0
  75. package/tests/hall_gov_real_site.mjs +2 -2
  76. package/tests/hall_street_deck.mjs +6 -4
  77. package/tests/hall_street_depth.mjs +1 -1
  78. package/tests/hall_street_desk.mjs +5 -3
  79. package/tests/hall_tweak_editor.mjs +26 -0
  80. package/tests/module_channel_install.mjs +112 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/module_upgrade_updates.mjs +2 -2
  83. package/tests/rebase_gate_set_aside.mjs +73 -0
@@ -0,0 +1,290 @@
1
+ // tests/module_promotion.mjs — the promotion gate: tester to general, and what
2
+ // earns it (task 1003804, ADR 0344 D3; owner decisions 2026-10-23). The rule
3
+ // (decidePromotion) is driven directly; the sweep, the author's standing, the
4
+ // person's release and both routes run against an in-memory stand-in for the
5
+ // four tables they read and the one they write.
6
+ //
7
+ // Run: node tests/module_promotion.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+
19
+ const DAY = 24 * 60 * 60 * 1000;
20
+ const NOW = new Date('2026-10-23T12:00:00Z');
21
+ const ago = (days) => new Date(NOW.getTime() - days * DAY).toISOString();
22
+
23
+ // ---- an in-memory store ---------------------------------------------------
24
+ let state;
25
+ function fresh() {
26
+ state = {
27
+ modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' } },
28
+ versions: [{ id: 7, module_key: 'weather', version: '1.3.0', tarball_sha256: 'x', artifact_path: 'a', published_at: ago(3) }],
29
+ channels: [{ id: 1, version_id: 7, channel: 'tester', reason: 'new_version', set_by: 9, set_at: ago(3) }],
30
+ installs: [],
31
+ errors: [],
32
+ signals: [
33
+ { id: 1, version_id: 7, part: 'security', outcome: 'passed', score: null, measured_at: ago(3) },
34
+ { id: 2, version_id: 7, part: 'tests', outcome: 'scored', score: 100, measured_at: ago(3) },
35
+ ],
36
+ };
37
+ return state;
38
+ }
39
+ fresh();
40
+
41
+ const newestRow = (vid) => state.channels.filter((c) => String(c.version_id) === String(vid))
42
+ .sort((a, b) => (b.set_at.localeCompare(a.set_at)) || (b.id - a.id))[0];
43
+
44
+ const query = async (sql, params = []) => {
45
+ if (/CROSS JOIN LATERAL/.test(sql)) {
46
+ const [vid, limit] = params;
47
+ const ids = [...new Set(state.channels.map((c) => c.version_id))]
48
+ .filter((id) => vid == null || String(id) === String(vid)).sort((a, b) => a - b);
49
+ const rows = [];
50
+ for (const id of ids) {
51
+ const cur = newestRow(id);
52
+ if (cur.channel !== 'tester') continue;
53
+ const v = state.versions.find((x) => x.id === id);
54
+ const mine = (l) => l.filter((r) => r.version_id === id);
55
+ rows.push({
56
+ version_id: id, module_key: v.module_key, version: v.version, current_reason: cur.reason,
57
+ on_tester_since: mine(state.channels).filter((c) => c.channel === 'tester').map((c) => c.set_at).sort()[0],
58
+ clean_installs: mine(state.installs).filter((r) => r.ok).length,
59
+ failed_installs: mine(state.installs).filter((r) => !r.ok).length,
60
+ runtime_errors: mine(state.errors).reduce((n, e) => n + e.error_count, 0),
61
+ });
62
+ }
63
+ return { rows: rows.slice(0, limit) };
64
+ }
65
+ if (/FROM module_assessment_signals/.test(sql)) {
66
+ const out = new Map();
67
+ for (const s of [...state.signals].sort((a, b) => b.measured_at.localeCompare(a.measured_at) || b.id - a.id)) {
68
+ if (!params[0].includes(s.version_id)) continue;
69
+ const k = `${s.version_id}:${s.part}`;
70
+ if (!out.has(k)) out.set(k, s);
71
+ }
72
+ return { rows: [...out.values()] };
73
+ }
74
+ if (/^\s*INSERT INTO store_module_version_channels/.test(sql)) {
75
+ const [vid, channel, reason, setBy, fromReason] = params;
76
+ const cur = newestRow(vid);
77
+ if (!cur || cur.reason !== fromReason) return { rows: [], rowCount: 0 };
78
+ const id = Math.max(...state.channels.map((c) => c.id)) + 1;
79
+ // A later set_at than anything already there, so this row is the newest.
80
+ state.channels.push({ id, version_id: vid, channel, reason, set_by: setBy, set_at: new Date(NOW.getTime() + id).toISOString() });
81
+ return { rows: [], rowCount: 1 };
82
+ }
83
+ if (/SELECT channel, reason, set_by, set_at FROM store_module_version_channels/.test(sql)) {
84
+ const cur = newestRow(params[0]); return { rows: cur ? [cur] : [] };
85
+ }
86
+ if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
87
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
88
+ }
89
+ if (/FROM store_module_versions WHERE module_key = \$1 AND version = \$2/.test(sql)) {
90
+ return { rows: state.versions.filter((v) => v.module_key === params[0] && v.version === params[1]) };
91
+ }
92
+ return { rows: [] };
93
+ };
94
+ const pool = { connect: async () => ({ query, release() {} }), query };
95
+ const poolPath = require.resolve('../src/bongos/pool.js');
96
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
97
+
98
+ const {
99
+ PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start,
100
+ } = require('../src/bongos/module-promotion.js');
101
+ const promo = { PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start };
102
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
103
+
104
+ const clean = (n) => { for (let i = 0; i < n; i++) state.installs.push({ version_id: 7, holder_ref: `p${i}`, ok: true }); };
105
+ const current = () => newestRow(7);
106
+
107
+ // ---- the rule -------------------------------------------------------------
108
+ const ev = (over = {}) => ({
109
+ onTesterSince: ago(3), held: false, cleanInstalls: 0, failedInstalls: 0, runtimeErrors: 0,
110
+ security: { outcome: 'passed' }, tests: { outcome: 'scored', score: 100 }, ...over,
111
+ });
112
+
113
+ test('the owner numbers: 7 days, early from day 2 with 5 clean installs', () => {
114
+ assert.equal(promo.PROMOTE_AFTER_DAYS, 7);
115
+ assert.equal(promo.EARLY_AFTER_DAYS, 2);
116
+ assert.equal(promo.EARLY_CLEAN_INSTALLS, 5);
117
+ });
118
+
119
+ test('seven days on tester promotes, even with no tester at all', () => {
120
+ const d = promo.decidePromotion(ev({ onTesterSince: ago(7) }), NOW);
121
+ assert.equal(d.action, 'promote');
122
+ assert.equal(d.reason, 'promoted_after_7_days');
123
+ assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(6.9) }), NOW).action, 'wait');
124
+ });
125
+
126
+ test('early: day 2 and 5 clean installs; not before day 2, not with 4', () => {
127
+ assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(2), cleanInstalls: 5 }), NOW).reason, 'promoted_early_clean_installs');
128
+ assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(1.9), cleanInstalls: 50 }), NOW).action, 'wait');
129
+ assert.equal(promo.decidePromotion(ev({ onTesterSince: ago(5), cleanInstalls: 4 }), NOW).action, 'wait');
130
+ });
131
+
132
+ test('any failed tester install or runtime error holds, whatever the age', () => {
133
+ for (const over of [{ failedInstalls: 1 }, { runtimeErrors: 1 }, { failedInstalls: 1, onTesterSince: ago(30), cleanInstalls: 99 }]) {
134
+ const d = promo.decidePromotion(ev(over), NOW);
135
+ assert.equal(d.action, 'hold');
136
+ assert.equal(d.reason, 'held_tester_errors');
137
+ }
138
+ });
139
+
140
+ test('a held version never moves on its own, even at day 30', () => {
141
+ const d = promo.decidePromotion(ev({ held: true, onTesterSince: ago(30) }), NOW);
142
+ assert.equal(d.action, 'wait');
143
+ assert.equal(d.reason, 'held_until_released');
144
+ });
145
+
146
+ test('security and tests (ADR 0343): moves only when both pass; never held for them', () => {
147
+ const old = { onTesterSince: ago(10) };
148
+ assert.equal(promo.decidePromotion(ev({ ...old, security: { outcome: 'failed' } }), NOW).reason, 'checks_not_passed');
149
+ assert.equal(promo.decidePromotion(ev({ ...old, security: null }), NOW).reason, 'checks_not_passed');
150
+ assert.equal(promo.decidePromotion(ev({ ...old, tests: { outcome: 'scored', score: 80 } }), NOW).reason, 'checks_not_passed');
151
+ assert.equal(promo.decidePromotion(ev({ ...old, tests: { outcome: 'pending' } }), NOW).reason, 'checks_not_passed');
152
+ assert.equal(promo.decidePromotion(ev({ ...old, tests: { outcome: 'no_data' } }), NOW).action, 'promote', 'no tests is not a fail');
153
+ });
154
+
155
+ // ---- the sweep ------------------------------------------------------------
156
+ test('the sweep promotes early on clean installs, with a new system row', async () => {
157
+ fresh(); clean(5);
158
+ const out = await promo.sweepPromotions({ db: pool, now: NOW });
159
+ assert.deepEqual(out.promoted.map((p) => p.reason), ['promoted_early_clean_installs']);
160
+ assert.equal(current().channel, 'general');
161
+ assert.equal(current().set_by, null, 'the system moved it');
162
+ assert.equal(state.channels.length, 2, 'a move is a new row; the tester row stays');
163
+ const again = await promo.sweepPromotions({ db: pool, now: NOW });
164
+ assert.equal(again.promoted.length + again.held.length, 0, 'a general version is not swept again');
165
+ });
166
+
167
+ test('the sweep holds on an error, once, and the hold outlives day 7', async () => {
168
+ fresh(); clean(9);
169
+ state.errors.push({ version_id: 7, error_count: 2 });
170
+ const out = await promo.sweepPromotions({ db: pool, now: NOW });
171
+ assert.equal(out.held.length, 1);
172
+ assert.equal(current().channel, 'tester');
173
+ assert.equal(current().reason, 'held_tester_errors');
174
+ assert.equal(current().set_by, null);
175
+ const later = await promo.sweepPromotions({ db: pool, now: new Date(NOW.getTime() + 30 * DAY) });
176
+ assert.equal(later.held.length + later.promoted.length, 0, 'no second hold row, no promotion');
177
+ assert.equal(state.channels.length, 2);
178
+ });
179
+
180
+ test('the sweep moves an untested version at day 7 and leaves a young one', async () => {
181
+ fresh();
182
+ assert.equal((await promo.sweepPromotions({ db: pool, now: NOW })).promoted.length, 0);
183
+ const out = await promo.sweepPromotions({ db: pool, now: new Date(NOW.getTime() + 4 * DAY) });
184
+ assert.deepEqual(out.promoted.map((p) => p.reason), ['promoted_after_7_days']);
185
+ });
186
+
187
+ // ---- the author's standing ------------------------------------------------
188
+ test('an author reads the rule and where their version stands', async () => {
189
+ fresh(); clean(2);
190
+ const s = await promo.readStanding(7, { db: pool, now: NOW });
191
+ assert.equal(s.channel, 'tester');
192
+ assert.equal(s.moved_by, '9');
193
+ assert.match(s.rule, /7 days/);
194
+ assert.match(s.rule, /2 days once 5 or more testers/);
195
+ assert.equal(s.next.action, 'wait');
196
+ assert.equal(s.next.clean_installs, 2);
197
+ assert.equal(s.next.days_on_tester, 3);
198
+ assert.equal(s.next.general_on, new Date(new Date(ago(3)).getTime() + 7 * DAY).toISOString());
199
+ });
200
+
201
+ // ---- the person's release -------------------------------------------------
202
+ test('only a held version can be released, and the person is recorded', async () => {
203
+ fresh();
204
+ const early = await promo.releaseHeld(7, 3, { db: pool });
205
+ assert.equal(early.ok, false);
206
+ assert.equal(early.code, 'not_held');
207
+ state.installs.push({ version_id: 7, holder_ref: 'p', ok: false });
208
+ await promo.sweepPromotions({ db: pool, now: NOW });
209
+ const r = await promo.releaseHeld(7, 3, { db: pool });
210
+ assert.equal(r.ok, true);
211
+ assert.equal(current().channel, 'general');
212
+ assert.equal(current().reason, 'released_by_person');
213
+ assert.equal(current().set_by, 3);
214
+ assert.equal((await promo.releaseHeld(7, 3, { db: pool })).code, 'not_held', 'a second release is refused');
215
+ });
216
+
217
+ // ---- the routes -----------------------------------------------------------
218
+ function handlerFor(router, method, p) {
219
+ const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
220
+ assert.ok(layer, `no ${method.toUpperCase()} ${p} on the router`);
221
+ const stack = layer.route.stack;
222
+ return { handle: stack[stack.length - 1].handle, depth: stack.length };
223
+ }
224
+ async function invoke(handle, { key = 'weather', version = '1.3.0', body = {} } = {}) {
225
+ const res = {
226
+ statusCode: 200, body: undefined,
227
+ json(b) { this.body = b; return this; },
228
+ status(c) { this.statusCode = c; return this; },
229
+ set() { return this; },
230
+ fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
231
+ };
232
+ let nextErr = null;
233
+ await handle({ params: { key, version }, body, builder: { id: 3 } }, res, (err) => { nextErr = err; });
234
+ if (nextErr) throw nextErr;
235
+ return res;
236
+ }
237
+ const router = buildModulesRouter();
238
+ const release = handlerFor(router, 'post', '/store/modules/:key/versions/:version/release');
239
+ const channel = handlerFor(router, 'get', '/store/modules/:key/versions/:version/channel');
240
+
241
+ test('release is gated on module.delist (owner, no new permission) and pinned', () => {
242
+ assert.ok(release.depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
243
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
244
+ assert.match(src, /router\.post\('\/store\/modules\/:key\/versions\/:version\/release', auth\.requireBuilder, auth\.requirePermission\('module\.delist'\)/);
245
+ const pins = fs.readFileSync(path.join(ROOT, 'src/bongos/route-rank-check.js'), 'utf8');
246
+ assert.match(pins, /'POST \/store\/modules\/:key\/versions\/:version\/release': 'perm:module\.delist'/);
247
+ const { byKey } = require('../modules/government/catalog.js');
248
+ assert.match(byKey('module.delist').guards, /POST \/store\/modules\/:key\/versions\/:version\/release/);
249
+ });
250
+
251
+ test('release needs a reason, refuses a version not held, and releases a held one', async () => {
252
+ fresh();
253
+ for (const body of [{}, { reason: ' ' }]) assert.equal((await invoke(release.handle, { body })).body.error, 'reason_required');
254
+ assert.equal((await invoke(release.handle, { body: { reason: 'r', channel: 'general' } })).statusCode, 400, 'unknown fields are refused');
255
+ assert.equal((await invoke(release.handle, { version: '9.9.9', body: { reason: 'r' } })).statusCode, 404);
256
+ const notHeld = await invoke(release.handle, { body: { reason: 'r' } });
257
+ assert.equal(notHeld.statusCode, 409);
258
+ assert.equal(notHeld.body.error, 'not_held');
259
+ state.errors.push({ version_id: 7, error_count: 1 });
260
+ await promo.sweepPromotions({ db: pool, now: NOW });
261
+ const ok = await invoke(release.handle, { body: { reason: 'the error was the tester\'s own config' } });
262
+ assert.equal(ok.statusCode, 200);
263
+ assert.equal(ok.body.channel, 'general');
264
+ });
265
+
266
+ test('the channel read: any signed-in builder, the standing, 404 for no version', async () => {
267
+ fresh();
268
+ assert.equal(channel.depth, 2, 'requireBuilder only — the author must be able to read it');
269
+ const res = await invoke(channel.handle);
270
+ assert.equal(res.statusCode, 200);
271
+ assert.equal(res.body.channel, 'tester');
272
+ assert.equal(res.body.module_key, 'weather');
273
+ assert.ok(res.body.next);
274
+ assert.equal((await invoke(channel.handle, { version: '2.0.0' })).statusCode, 404);
275
+ state.channels = [];
276
+ const none = await invoke(channel.handle);
277
+ assert.equal(none.statusCode, 404);
278
+ assert.equal(none.body.error, 'version_has_no_channel', 'a version with no channel row says so');
279
+ fresh();
280
+ assert.equal((await invoke(channel.handle, { key: 'Bad_Key' })).statusCode, 400);
281
+ });
282
+
283
+ test('the sweep is started by the server after listen, never by a test, and can be switched off', () => {
284
+ const src = fs.readFileSync(path.join(ROOT, 'src/platform-server.js'), 'utf8');
285
+ assert.match(src, /require\('\.\/bongos\/module-promotion'\); startPromotionSweep\(\)/);
286
+ const start = src.indexOf('function startBackgroundServices');
287
+ const body = src.slice(start, src.indexOf('}', start));
288
+ assert.doesNotMatch(body, /module-promotion/);
289
+ assert.equal(promo.start({ env: { MODULE_PROMOTION_DISABLED: '1' } }), false);
290
+ });
@@ -24,7 +24,7 @@ const pool = {
24
24
  return {
25
25
  rows: state.versions
26
26
  .filter((v) => params[0].includes(v.module_key) && state.modules[v.module_key])
27
- .map((v) => ({ ...v, status: state.modules[v.module_key].status })),
27
+ .map((v) => ({ channel: 'general', ...v, status: state.modules[v.module_key].status })),
28
28
  };
29
29
  }
30
30
  return { rows: [] };
@@ -236,7 +236,7 @@ test('the route is open to any signed-in builder: requireBuilder and no permissi
236
236
  test('the route answers the newest version per key, in semver order', async () => {
237
237
  const r = await ask('weather');
238
238
  assert.equal(r.statusCode, 200, JSON.stringify(r.body));
239
- assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed' });
239
+ assert.deepEqual(r.body.latest.weather, { version: '1.10.0', core_version: '^1.2.0', status: 'listed', channel: 'general' });
240
240
  assert.equal(r.headers['Cache-Control'], 'no-store');
241
241
  });
242
242
 
@@ -0,0 +1,73 @@
1
+ // tests/rebase_gate_set_aside.mjs — one stuck land must not block every claim
2
+ // (task 1004788). DB-free half: the shared predicate (rebase-gate.js), the 409
3
+ // hint's wording, and that all three claim surfaces call the ONE predicate. The
4
+ // real-DB half (claimTask, claimTasksBatch, validateClaimBatch end to end) is
5
+ // cases 8-10 of tests/claim_gate_rebase.mjs.
6
+ //
7
+ // Run: node tests/rebase_gate_set_aside.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { makeRunner } from './helpers.mjs';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const gate = require('../modules/lifecycle/rebase-gate.js');
19
+ const { rebaseRequiredHint } = require('../modules/lifecycle/routes/claims.js');
20
+ const { test, summary } = makeRunner();
21
+
22
+ const row = (id, reason) => ({ id, title: `t${id}`, needs_rebase_reason: reason, needs_rebase_at: null });
23
+ const GUARD = 'strand:branch_modifies_executed_code:.gitattributes,scripts/gds/git-merge-regen.js';
24
+
25
+ await test('a strand parked on a server-side guard is set aside: it never gates', () => {
26
+ const s = gate.splitRebaseGate([row(1, GUARD)], { limit: 1 });
27
+ assert.equal(s.gates, false);
28
+ assert.deepEqual(s.gating, []);
29
+ assert.equal(s.waiting.length, 1);
30
+ assert.match(s.waiting[0].waits_on, /server-side guard/);
31
+ });
32
+
33
+ await test('an explicit waiting:<what> hold is set aside with what it waits on', () => {
34
+ assert.equal(gate.waitsOn('waiting:the owner'), 'the owner');
35
+ assert.equal(gate.waitsOn('strand:unresolvable_conflict:docs/x.json'), null);
36
+ assert.equal(gate.waitsOn('strand:pr_not_landed'), null);
37
+ assert.equal(gate.waitsOn(null), null, 'an unknown reason is the builder\'s, never silently waived');
38
+ });
39
+
40
+ await test('fixable strands gate only at the limit (default 3), not on the first', () => {
41
+ delete process.env.REBASE_GATE_LIMIT;
42
+ assert.equal(gate.REBASE_GATE_LIMIT, 3);
43
+ const one = [row(1, 'strand:pr_not_landed')];
44
+ const three = [...one, row(2, 'strand:pr_not_landed'), row(3, 'strand:unresolvable_conflict:a')];
45
+ assert.equal(gate.splitRebaseGate(one).gates, false);
46
+ assert.equal(gate.splitRebaseGate(three.slice(0, 2)).gates, false);
47
+ const s = gate.splitRebaseGate([...three, row(9, GUARD)]);
48
+ assert.equal(s.gates, true);
49
+ assert.deepEqual(s.gating.map((t) => t.id), [1, 2, 3], 'the waiting one never counts toward the limit');
50
+ const tasks = gate.rebaseRefusalTasks(s);
51
+ assert.deepEqual(tasks.map((t) => [t.id, !!t.waiting]), [[1, false], [2, false], [3, false], [9, true]]);
52
+ });
53
+
54
+ await test('the refusal says which tasks gate and lists the rest as waiting, not on you', () => {
55
+ const s = gate.splitRebaseGate([row(1, 'strand:pr_not_landed'), row(2, 'strand:pr_not_landed'), row(3, 'strand:pr_not_landed'), row(9, GUARD)]);
56
+ const h = rebaseRequiredHint(gate.rebaseRefusalTasks(s));
57
+ assert.match(h, /3 confirmed task\(s\) of yours are waiting to land and need you \(task 1, task 2, task 3\)/);
58
+ assert.match(h, /Waiting, not on you .*task 9 \(a server-side guard/);
59
+ assert.match(h, /\/strand-fix N/);
60
+ });
61
+
62
+ await test('all three claim surfaces call the ONE predicate', () => {
63
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-claims.js'), 'utf8');
64
+ const calls = src.match(/splitRebaseGate\(await gatingRebaseTasksForBuilder\(builderId, (client|pool)\)\)/g) || [];
65
+ assert.equal(calls.length, 3, 'claimTask, claimTasksBatch and validateClaimBatch');
66
+ for (const fn of ['async function claimTask(', 'async function claimTasksBatch(', 'async function validateClaimBatch(']) {
67
+ const start = src.indexOf(fn);
68
+ const next = src.indexOf('\nasync function ', start + 10);
69
+ assert.ok(src.slice(start, next < 0 ? undefined : next).includes('splitRebaseGate('), `${fn} uses the shared predicate`);
70
+ }
71
+ });
72
+
73
+ summary();