@bongos/core 1.19.865 → 1.19.867
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 +42 -22
- package/docs/copy-inventory.md +94 -89
- package/docs/copy-registry.json +154 -108
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +2 -2
- package/modules/hall-ui/public/hall-render.js +50 -4
- package/modules/hall-ui/public/people.css +9 -0
- package/modules/hall-ui/public/roster.js +11 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/lifecycle/routes/tasks.js +33 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/scripts/gds/ship-merge-gate.js +187 -0
- package/scripts/gds/ship-merge.js +22 -0
- package/src/module-api.js +1 -1
- package/tests/acclaim_feed_evidence_db.mjs +186 -0
- package/tests/hall_builders_page_pass.mjs +324 -0
- package/tests/joinability_invite_only.mjs +11 -1
- package/tests/ship_merge_gate.mjs +172 -0
|
@@ -2204,5 +2204,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2204
2204
|
landed since 1.19.863 with no explicit bump. run 35294233113. (task 1002620)
|
|
2205
2205
|
1.19.865 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2206
2206
|
landed since 1.19.864 with no explicit bump. run 35295856053. (task 1002620)
|
|
2207
|
+
1.19.866 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2208
|
+
landed since 1.19.865 with no explicit bump. run 35355019469. (task 1002620)
|
|
2209
|
+
1.19.867 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2210
|
+
landed since 1.19.866 with no explicit bump. run 35380895321. (task 1002620)
|
|
2207
2211
|
---------------------------------------------------------------------------
|
|
2208
2212
|
```
|
|
@@ -790,7 +790,7 @@
|
|
|
790
790
|
const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
|
|
791
791
|
return `
|
|
792
792
|
<tr data-acclaim-row="${tid}">
|
|
793
|
-
<td>${title}</td>
|
|
793
|
+
<td>${title}${acclaimEvidenceHtml(t)}</td>
|
|
794
794
|
<td>${by}</td>
|
|
795
795
|
<td class="acclaim__net ${netCls}" data-acclaim-net="${tid}">${netText}</td>
|
|
796
796
|
<td>${controls}</td>
|
|
@@ -1424,7 +1424,7 @@
|
|
|
1424
1424
|
// reads what this page holds NOW rather than what it held at boot. Everything
|
|
1425
1425
|
// else is stable for the life of the page. This call sits AFTER every
|
|
1426
1426
|
// declaration it passes — moving it earlier is a temporal-dead-zone crash.
|
|
1427
|
-
const { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
|
|
1427
|
+
const { SETUP_CONFIG_KEY, acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey } = window.OTBHallRender({
|
|
1428
1428
|
API, RANK_ORDER, api, brandWorld, curLabel, emptyRow, fmtDate, mins, normalizeRank, pct, postJSON, rankLabel, rankWeight, signClasses, toast, usd,
|
|
1429
1429
|
get instanceRepo() { return instanceRepo; },
|
|
1430
1430
|
get lastClaimableCount() { return lastClaimableCount; },
|
|
@@ -332,6 +332,46 @@
|
|
|
332
332
|
</div>`;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// task 1002835: a karma vote on a peer's ship was a vote on a title alone.
|
|
336
|
+
// The two facts the ship already recorded — what the grader scored it and
|
|
337
|
+
// what it cost — turn it into an informed one. ABSENCE IS NOT ZERO: an
|
|
338
|
+
// ungraded ship says so, and so does an unlogged cost, because "0.0" and
|
|
339
|
+
// "$0.00" would read as measurements that were never taken.
|
|
340
|
+
function acclaimEvidenceHtml(t) {
|
|
341
|
+
const score = t.grade_score == null ? null : Number(t.grade_score);
|
|
342
|
+
const grade = score == null || !Number.isFinite(score)
|
|
343
|
+
? '<span class="ledger__verdict--none">not graded</span>'
|
|
344
|
+
: `<span class="${t.grade_passed ? 'ledger__verdict--ok' : 'ledger__verdict--danger'}">${score.toFixed(1)} / 10</span>`;
|
|
345
|
+
// Three states, not two. The route OMITS cost_usd for a builder who may not
|
|
346
|
+
// vote, and that is not the same fact as a logged cost of nothing: a reader
|
|
347
|
+
// who is shown no figure is told nothing, never "no cost logged".
|
|
348
|
+
const parts = [grade];
|
|
349
|
+
if (Object.prototype.hasOwnProperty.call(t, 'cost_usd')) {
|
|
350
|
+
const usd = t.cost_usd == null ? null : Number(t.cost_usd);
|
|
351
|
+
parts.push(usd == null || !Number.isFinite(usd)
|
|
352
|
+
? '<span class="ledger__verdict--none">no cost logged</span>'
|
|
353
|
+
: `<span class="acclaim__cost">$${usd.toFixed(2)}</span>`);
|
|
354
|
+
}
|
|
355
|
+
return `<span class="acclaim__evidence">${parts.join(' · ')}</span>`;
|
|
356
|
+
}
|
|
357
|
+
// The RETURNING builder's door (task 1002851). `open` and `invite_only`
|
|
358
|
+
// already render a real .landing__btn sign-in inside the primary door, so a
|
|
359
|
+
// second button there would be a third scattered path rather than a clearer
|
|
360
|
+
// one — those modes keep the prose note alone. Only the `apply` door, whose
|
|
361
|
+
// primary control is the request FORM, left signing in as a footnote link.
|
|
362
|
+
// It gets a real button: the request button's geometry so it reads as a
|
|
363
|
+
// control, a quieter fill so the request path still leads for the strangers
|
|
364
|
+
// who are most of this page's traffic.
|
|
365
|
+
function returningDoorHtml(mode) {
|
|
366
|
+
if (mode !== 'apply') return '';
|
|
367
|
+
return `
|
|
368
|
+
<div class="landing__returning" id="landing-returning">
|
|
369
|
+
<h3 class="landing__request-h">Already a member?</h3>
|
|
370
|
+
<p class="landing__request-lede">You've been approved before — sign in and pick up where you left off.</p>
|
|
371
|
+
<p class="landing__cta"><a class="landing__btn landing__btn--quiet" href="${API}/auth/web/start">Sign in with GitHub</a></p>
|
|
372
|
+
</div>`;
|
|
373
|
+
}
|
|
374
|
+
|
|
335
375
|
function renderLanding() {
|
|
336
376
|
// task 1723: collapse the shell nav to the signed-out minimum (Home + a
|
|
337
377
|
// Sign-in button). Cosmetic — the server gates every page regardless.
|
|
@@ -343,14 +383,20 @@
|
|
|
343
383
|
const landing = $('#landing-root');
|
|
344
384
|
if (landing) {
|
|
345
385
|
landing.hidden = false;
|
|
386
|
+
const mode = joinabilityMode();
|
|
387
|
+
// On the apply door the sign-in is a real button above, so the note keeps
|
|
388
|
+
// only the privacy sentence — one sign-in path per landing, never two.
|
|
389
|
+
const signInNote = mode === 'apply'
|
|
390
|
+
? ''
|
|
391
|
+
: `Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>. `;
|
|
346
392
|
landing.innerHTML = `
|
|
347
393
|
<div class="scroll landing">
|
|
348
394
|
<h2 class="scroll__h">Welcome</h2>
|
|
349
|
-
${landingDoorHtml(
|
|
395
|
+
${landingDoorHtml(mode)}
|
|
396
|
+
${returningDoorHtml(mode)}
|
|
350
397
|
|
|
351
398
|
<p class="landing__signin-note">
|
|
352
|
-
|
|
353
|
-
We store only your GitHub login, avatar, and the work you ship.
|
|
399
|
+
${signInNote}We store only your GitHub login, avatar, and the work you ship.
|
|
354
400
|
</p>
|
|
355
401
|
</div>
|
|
356
402
|
`;
|
|
@@ -1305,6 +1351,6 @@
|
|
|
1305
1351
|
return idx === -1 ? null : { place: idx + 1, total: ranked.length };
|
|
1306
1352
|
}
|
|
1307
1353
|
|
|
1308
|
-
return { SETUP_CONFIG_KEY, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
|
|
1354
|
+
return { SETUP_CONFIG_KEY, acclaimEvidenceHtml, analyticsState, lbState, mountHallWidgets, onboardingBox, renderCurveBody, renderGreeting, renderLanding, renderNav, renderOnboarding, renderProfile, renderRecentShips, renderWelcomeCore, shipState, shipsPager, welcomeDismissalKey };
|
|
1309
1355
|
};
|
|
1310
1356
|
})();
|
|
@@ -24,5 +24,14 @@
|
|
|
24
24
|
.dir-who__t { display: flex; flex-direction: column; gap: 1px; min-width: 132px; }
|
|
25
25
|
.dir-name { font-weight: 500; color: var(--ink); overflow-wrap: anywhere; }
|
|
26
26
|
.dir-login { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-faint); }
|
|
27
|
+
/* The roster's place cell carries two facts: the place, and under it the
|
|
28
|
+
builder's number in the same mono/faint voice the login uses (task
|
|
29
|
+
1002837). Block display so the id never wraps up beside the place. */
|
|
30
|
+
.ledger__place .ledger__id {
|
|
31
|
+
display: block;
|
|
32
|
+
font-family: var(--font-mono);
|
|
33
|
+
font-size: 11.5px;
|
|
34
|
+
color: var(--ink-faint);
|
|
35
|
+
}
|
|
27
36
|
.dir-crafts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
|
28
37
|
.dir-none { color: var(--ink-faint); }
|
|
@@ -148,9 +148,19 @@
|
|
|
148
148
|
return fmtInt(Math.round((Number(b.tokens_used) || 0) / credits)) + ' tokens per ' + one;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// task 1002837: a builder's place answers "who is ahead", their NUMBER
|
|
152
|
+
// answers "which builder is this" — the id every task row, claim and ship
|
|
153
|
+
// note already cites. It sits under the place in the same mono/faint voice
|
|
154
|
+
// the login uses, and only when the payload carries one, so an older
|
|
155
|
+
// /builders response degrades to the place alone.
|
|
156
|
+
function idHtml(b) {
|
|
157
|
+
if (b.id == null || b.id === '') return '';
|
|
158
|
+
return '<span class="ledger__id">#' + esc(String(b.id)) + '</span>';
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
function rowHtml(b) {
|
|
152
162
|
return '<tr>'
|
|
153
|
-
+ '<td class="ledger__num">' + esc(String(b.__place || '—')) + '</td>'
|
|
163
|
+
+ '<td class="ledger__num ledger__place">' + esc(String(b.__place || '—')) + idHtml(b) + '</td>'
|
|
154
164
|
+ '<td>' + whoHtml(b) + achRowHtml(b.achievements) + '</td>'
|
|
155
165
|
+ '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
|
|
156
166
|
+ '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
|
|
@@ -1536,6 +1536,16 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
1536
1536
|
.ledger__verdict--ok { color: var(--ok); font-weight: 600; }
|
|
1537
1537
|
.ledger__verdict--danger { color: var(--danger); font-weight: 600; }
|
|
1538
1538
|
.ledger__verdict--none { color: var(--ink-soft); }
|
|
1539
|
+
/* The acclaim feed's evidence line (task 1002835): the grade and the cost the
|
|
1540
|
+
ship recorded, under the work it describes. Secondary to the title — this is
|
|
1541
|
+
what a voter checks, not what they read first. */
|
|
1542
|
+
.acclaim__evidence {
|
|
1543
|
+
display: block;
|
|
1544
|
+
margin-top: 3px;
|
|
1545
|
+
font-size: 12px;
|
|
1546
|
+
color: var(--ink-faint);
|
|
1547
|
+
}
|
|
1548
|
+
.acclaim__cost { font-variant-numeric: tabular-nums; }
|
|
1539
1549
|
.ledger th.ledger__sortable { cursor: pointer; user-select: none; }
|
|
1540
1550
|
.ledger th.ledger__sortable:hover { color: var(--ink); }
|
|
1541
1551
|
.ledger th.ledger__sortable[aria-sort="ascending"]::after { content: " ↑"; }
|
|
@@ -2492,6 +2502,24 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2492
2502
|
.landing__btn--request { align-self: flex-start; }
|
|
2493
2503
|
.landing__request-status { font-size: 13px; margin: 0; color: var(--ok); }
|
|
2494
2504
|
.landing__request-status[data-kind="err"] { color: var(--danger); }
|
|
2505
|
+
/* The returning builder's door, rendered only on the `apply` landing (task
|
|
2506
|
+
1002851). Same panel as the request block and the same button geometry, so
|
|
2507
|
+
signing in reads as a real control rather than a footnote — but a quiet
|
|
2508
|
+
fill, because the request path is what most of this page's traffic needs. */
|
|
2509
|
+
.landing__returning {
|
|
2510
|
+
border: 0;
|
|
2511
|
+
border-radius: var(--radius);
|
|
2512
|
+
background: var(--surface-2);
|
|
2513
|
+
padding: 18px 20px;
|
|
2514
|
+
margin: 16px 0;
|
|
2515
|
+
}
|
|
2516
|
+
.landing__returning .landing__cta { margin: 0; }
|
|
2517
|
+
.landing__btn--quiet {
|
|
2518
|
+
background: transparent;
|
|
2519
|
+
color: var(--ink);
|
|
2520
|
+
box-shadow: inset 0 0 0 1px var(--border-strong);
|
|
2521
|
+
}
|
|
2522
|
+
.landing__btn--quiet:hover { background: var(--surface-2); color: var(--ink); }
|
|
2495
2523
|
.landing__signin-note { font-size: 13px; color: var(--ink-soft); }
|
|
2496
2524
|
|
|
2497
2525
|
/* ---------- goals ---------- */
|
|
@@ -29,6 +29,10 @@ const api = require('../../../src/module-api');
|
|
|
29
29
|
// depRefusalRow: the ONE shape a dependency refusal carries, shared with the claim
|
|
30
30
|
// path so promote and claim describe a blocked task identically (task 1003553).
|
|
31
31
|
const { depRefusalRow } = require('../dead-deps.js');
|
|
32
|
+
// gradeQualityWhere: the ONE definition of a task_grades row that is an actual
|
|
33
|
+
// quality verdict rather than an outage or an errored worker (task 1002652).
|
|
34
|
+
// The acclaim feed reads it so a grader outage never reaches a voter as 0.0.
|
|
35
|
+
const { gradeQualityWhere } = require('../db-shared');
|
|
32
36
|
const auth = api;
|
|
33
37
|
const db = require('../db');
|
|
34
38
|
const hierarchy = require('../hierarchy');
|
|
@@ -771,9 +775,28 @@ module.exports = function buildTasksRouter() {
|
|
|
771
775
|
t.shipped_at, t.shipped_by,
|
|
772
776
|
b.github_login, b.display_name,
|
|
773
777
|
COALESCE(pv.net, 0)::int AS net_votes,
|
|
774
|
-
mine.vote AS my_vote
|
|
778
|
+
mine.vote AS my_vote,
|
|
779
|
+
-- task 1002835: a karma vote is uninformed without the two facts
|
|
780
|
+
-- the ship already recorded. BOTH stay NULL when absent — an
|
|
781
|
+
-- ungraded ship and a zero-scoring one are different claims, and
|
|
782
|
+
-- so are an unlogged cost and a free one, so neither is
|
|
783
|
+
-- COALESCEd to a number the client would print as a measurement.
|
|
784
|
+
--
|
|
785
|
+
-- BOTH JOINS ARE INDEXED LOOKUPS, not scans: task_grades.task_id
|
|
786
|
+
-- is declared UNIQUE (migrations/016_pms_task_grades.sql:14, which
|
|
787
|
+
-- gives it its own index) and cost_log.task_id carries
|
|
788
|
+
-- idx_cost_log_task (migrations/003_pms.sql:152). Neither needs a
|
|
789
|
+
-- migration here, and neither degrades as cost_log grows.
|
|
790
|
+
CASE WHEN ${gradeQualityWhere('g')} THEN g.score END AS grade_score,
|
|
791
|
+
CASE WHEN ${gradeQualityWhere('g')} THEN g.passed END AS grade_passed,
|
|
792
|
+
cost.total_usd AS cost_usd
|
|
775
793
|
FROM tasks t
|
|
776
794
|
LEFT JOIN builders b ON b.id = t.shipped_by
|
|
795
|
+
LEFT JOIN task_grades g ON g.task_id = t.id
|
|
796
|
+
LEFT JOIN LATERAL (
|
|
797
|
+
SELECT SUM(amount_usd) AS total_usd
|
|
798
|
+
FROM cost_log WHERE task_id = t.id
|
|
799
|
+
) cost ON true
|
|
777
800
|
LEFT JOIN LATERAL (
|
|
778
801
|
SELECT SUM(vote) AS net
|
|
779
802
|
FROM peer_votes WHERE target_task_id = t.id
|
|
@@ -788,7 +811,15 @@ module.exports = function buildTasksRouter() {
|
|
|
788
811
|
LIMIT $2`,
|
|
789
812
|
[req.builder.id, limit]
|
|
790
813
|
);
|
|
791
|
-
|
|
814
|
+
const canVote = ['metic', 'archon'].includes(req.builder.rank);
|
|
815
|
+
// The cost is INTERNAL SPEND, and it is on this feed for one reason: to
|
|
816
|
+
// inform a vote. So it reaches only the ranks that can cast one. The grade
|
|
817
|
+
// is a quality verdict rather than spend and stays readable by any builder,
|
|
818
|
+
// as it already is on the grades table. Note the field is OMITTED, not
|
|
819
|
+
// nulled: a null here means "nothing was logged", and a reader who is not
|
|
820
|
+
// shown a figure must not be told a figure is missing.
|
|
821
|
+
const tasks = canVote ? rows : rows.map(({ cost_usd, ...rest }) => rest);
|
|
822
|
+
res.json({ tasks, can_vote: canVote });
|
|
792
823
|
} catch (err) {
|
|
793
824
|
// Log the detail server-side; don't return err.message — a raw Postgres
|
|
794
825
|
// error can leak schema internals (table/column/constraint names).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.867",
|
|
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.867",
|
|
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.867",
|
|
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",
|
|
@@ -71,6 +71,11 @@ const SUPPORT_MODULES = new Set(['helpers']);
|
|
|
71
71
|
|
|
72
72
|
// Need a live DB / server / network — run in the integration-smoke gate instead.
|
|
73
73
|
const INTEGRATION = new Set([
|
|
74
|
+
// task 1004004: the acclaim feed proves a claim about SQL semantics — that a
|
|
75
|
+
// grader outage nulls rather than scoring 0.0, and that a summed cost over no
|
|
76
|
+
// rows is NULL rather than a free ship. Only a transaction engine settles
|
|
77
|
+
// those, so this runs here and the DB-free sibling covers the renderer.
|
|
78
|
+
'acclaim_feed_evidence_db',
|
|
74
79
|
'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
|
|
75
80
|
'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
|
|
76
81
|
// consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// scripts/gds/ship-merge-gate.js — the fitness gate on the MERGE RESULT, run
|
|
2
|
+
// between the local merge and the push that makes it main (task 1004032).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. Branch protection requires the `unit` check of a PULL
|
|
5
|
+
// REQUEST. The local-merge land path is not a pull request: ship-merge.js
|
|
6
|
+
// merges the task branch into main inside the main checkout and pushes the
|
|
7
|
+
// result straight to origin/main, so no required check is consulted at any
|
|
8
|
+
// point. Until this file, the only things standing between a builder's branch
|
|
9
|
+
// and a red main were the grader (which reads a diff, not the gates) and
|
|
10
|
+
// runSmoke (an HTTP liveness probe against the deployed instance). Neither runs
|
|
11
|
+
// fitness.js, and ship-check.js — which does — is a command the builder may
|
|
12
|
+
// choose to run, not a step of the ship.
|
|
13
|
+
//
|
|
14
|
+
// THE INCIDENT IT CLOSES (2026-09-17). Commit 89ac9620 (task 1000862) added two
|
|
15
|
+
// legacy-acronym literals to .github/workflows/integration.yml. Measured after
|
|
16
|
+
// the fact, on the commits themselves:
|
|
17
|
+
//
|
|
18
|
+
// 89ac9620 the BRANCH TIP legacy_gds_literals 1137, committed baseline 1135
|
|
19
|
+
// -> fitness.js exits non-zero. Already red.
|
|
20
|
+
// 272b664d the MERGE the same 1137 against the same 1135, since the
|
|
21
|
+
// merge changed nothing about those two literals.
|
|
22
|
+
// 41ce52d4 the main before fitness.js passes.
|
|
23
|
+
//
|
|
24
|
+
// So the guess in the original bug report — that a merge-ref-evaluated ratchet
|
|
25
|
+
// let a clean branch produce a red main — is WRONG, and the truth is worse: the
|
|
26
|
+
// branch was red on its own, and the ship pushed it to main regardless. This
|
|
27
|
+
// was never the subtle "green branch, red merge" case; nothing measured
|
|
28
|
+
// anything. main went red, and three tasks (1003456, 1004014, 1004021) sat at
|
|
29
|
+
// 'confirmed' with nothing able to land until the baseline was raised by hand
|
|
30
|
+
// to unblock the queue.
|
|
31
|
+
//
|
|
32
|
+
// WHAT IT DOES NOT DO. It does not punish a builder for damage that is already
|
|
33
|
+
// on main. A gate that refused every ship while main is red would have turned
|
|
34
|
+
// that same incident into a total deadlock instead of a bad afternoon — so when
|
|
35
|
+
// the merge result fails, the pre-merge base is measured too, and a failure
|
|
36
|
+
// already present there is reported and waved through. The rule is "do not make
|
|
37
|
+
// main worse", not "main must be perfect before you may ship".
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const fs = require('node:fs');
|
|
42
|
+
const os = require('node:os');
|
|
43
|
+
const path = require('node:path');
|
|
44
|
+
const { execFileSync } = require('node:child_process');
|
|
45
|
+
|
|
46
|
+
// mergeGateVerdict — PURE. The whole decision, over two measurements:
|
|
47
|
+
//
|
|
48
|
+
// mergeClean did fitness pass on the merge result (the tree about to be main)
|
|
49
|
+
// baseClean did it pass on the pre-merge base — true, false, or null for
|
|
50
|
+
// "could not be measured"
|
|
51
|
+
//
|
|
52
|
+
// The null case blocks on purpose. A failing merge result IS a red main
|
|
53
|
+
// whatever the base was doing; an unmeasurable base means the gate cannot show
|
|
54
|
+
// the failure was inherited, and the honest report is "this push makes main
|
|
55
|
+
// red and I cannot prove it was already red". The builder's own next step is
|
|
56
|
+
// the same either way — run fitness.js and read the failing lines.
|
|
57
|
+
function mergeGateVerdict({ mergeClean, baseClean }) {
|
|
58
|
+
if (mergeClean) return { block: false, reason: 'clean' };
|
|
59
|
+
if (baseClean === false) return { block: false, reason: 'pre-existing' };
|
|
60
|
+
if (baseClean === null || baseClean === undefined) return { block: true, reason: 'unmeasured-base' };
|
|
61
|
+
return { block: true, reason: 'regression' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// gateMessage — PURE. The one line printed for each verdict, in the voice of
|
|
65
|
+
// the sibling bails in ship-merge.js.
|
|
66
|
+
function gateMessage(reason) {
|
|
67
|
+
switch (reason) {
|
|
68
|
+
case 'clean':
|
|
69
|
+
return ' fitness on the merge result: clean';
|
|
70
|
+
case 'pre-existing':
|
|
71
|
+
return ' fitness on the merge result: FAILS, but the pre-merge main fails too — inherited, not caused by this branch; proceeding';
|
|
72
|
+
case 'regression':
|
|
73
|
+
return ' auto-merge: fitness FAILS on the merge result and PASSES on the pre-merge main — this branch would turn main red; task stays at confirmed';
|
|
74
|
+
case 'unmeasured-base':
|
|
75
|
+
return ' auto-merge: fitness FAILS on the merge result and the pre-merge main could not be measured; task stays at confirmed';
|
|
76
|
+
default:
|
|
77
|
+
return ` auto-merge: fitness gate returned an unknown verdict (${reason})`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// fitnessOutcome — one fitness.js run over the tree at `treeRoot`, as a
|
|
82
|
+
// TRI-STATE: 'clean', 'failing', or 'unmeasurable'.
|
|
83
|
+
//
|
|
84
|
+
// The three are kept apart on purpose. An earlier cut returned a boolean, which
|
|
85
|
+
// made a crashed run and a genuine finding the same answer — and since a
|
|
86
|
+
// FAILING base waves the ship through, a probe that merely broke would have
|
|
87
|
+
// certified any regression as inherited. A control that degrades to permissive
|
|
88
|
+
// under infra flakiness is not a control. 'unmeasurable' blocks instead.
|
|
89
|
+
//
|
|
90
|
+
// IT MUST BE THAT TREE'S OWN fitness.js, not this file's sibling copy.
|
|
91
|
+
// fitness.js resolves the repo it measures from its own __dirname and ignores
|
|
92
|
+
// cwd, so invoking the shipping worktree's copy with `cwd` pointed elsewhere
|
|
93
|
+
// measures the shipping worktree twice and reports "clean" for whatever it was
|
|
94
|
+
// asked about. That is not a hypothetical: the first cut of this gate did
|
|
95
|
+
// exactly that, and replaying the 2026-09-17 incident through it returned a
|
|
96
|
+
// confident pass on a tree that fails fitness. Running each tree's own gate is
|
|
97
|
+
// also the honest semantics — a base commit is measured by the rules that were
|
|
98
|
+
// committed with it, which is what CI would have done on that commit.
|
|
99
|
+
//
|
|
100
|
+
// Output is inherited so the failing lines land in the ship log where the
|
|
101
|
+
// builder reads them. A non-zero exit is a FINDING; anything else that goes
|
|
102
|
+
// wrong (no fitness.js in that tree, a spawn that never ran, a checker that
|
|
103
|
+
// crashed) is 'unmeasurable'.
|
|
104
|
+
function fitnessOutcome(treeRoot) {
|
|
105
|
+
const script = path.join(treeRoot, 'scripts', 'gds', 'fitness.js');
|
|
106
|
+
if (!fs.existsSync(script)) return 'unmeasurable';
|
|
107
|
+
try {
|
|
108
|
+
execFileSync(process.execPath, [script], { cwd: treeRoot, stdio: 'inherit', shell: false });
|
|
109
|
+
return 'clean';
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// A real gate verdict is a clean non-zero exit. A spawn that never produced
|
|
112
|
+
// an exit code (ENOENT on the interpreter, a signal, a permissions refusal)
|
|
113
|
+
// is the infra case, and must not be read as "this tree fails".
|
|
114
|
+
return typeof err?.status === 'number' ? 'failing' : 'unmeasurable';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// runFitnessAt — the boolean face of fitnessOutcome, kept for the merge-result
|
|
119
|
+
// side where "not clean" is all the caller needs: a merge result that cannot be
|
|
120
|
+
// measured is one this gate will not certify either way.
|
|
121
|
+
function runFitnessAt(treeRoot) {
|
|
122
|
+
return fitnessOutcome(treeRoot) === 'clean';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// probeBaseClean — measure the PRE-MERGE base in a throwaway detached worktree,
|
|
126
|
+
// so the merge already sitting in the main checkout does not have to be unwound
|
|
127
|
+
// to ask the question. Returns true (base clean), false (base genuinely fails
|
|
128
|
+
// its own gate) or null (could not be measured at all).
|
|
129
|
+
//
|
|
130
|
+
// No `npm ci` and no node_modules junction: fitness.js reads the tree and
|
|
131
|
+
// shells out to git, and runs green in a bare worktree — verified on this repo.
|
|
132
|
+
// If that ever stops being true the probe reports 'unmeasurable' rather than
|
|
133
|
+
// inventing a verdict, and mergeGateVerdict blocks.
|
|
134
|
+
function probeBaseClean(repoRoot, baseSha) {
|
|
135
|
+
if (!baseSha) return null;
|
|
136
|
+
let dir = null;
|
|
137
|
+
try {
|
|
138
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-merge-gate-'));
|
|
139
|
+
execFileSync('git', ['worktree', 'add', '--detach', '--quiet', dir, baseSha], {
|
|
140
|
+
cwd: repoRoot, stdio: 'ignore', shell: false,
|
|
141
|
+
});
|
|
142
|
+
} catch (_) {
|
|
143
|
+
if (dir) removeProbe(repoRoot, dir);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const outcome = fitnessOutcome(dir);
|
|
148
|
+
if (outcome === 'clean') return true;
|
|
149
|
+
if (outcome === 'failing') return false;
|
|
150
|
+
return null;
|
|
151
|
+
} finally {
|
|
152
|
+
removeProbe(repoRoot, dir);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// removeProbe — best-effort teardown. A husk left in the OS temp dir is
|
|
157
|
+
// untidy; a ship that dies because its cleanup threw is worse.
|
|
158
|
+
function removeProbe(repoRoot, dir) {
|
|
159
|
+
try {
|
|
160
|
+
execFileSync('git', ['worktree', 'remove', '--force', dir], { cwd: repoRoot, stdio: 'ignore', shell: false });
|
|
161
|
+
} catch (_) { /* fall through to the filesystem */ }
|
|
162
|
+
try {
|
|
163
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
} catch (_) { /* nothing left to do */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// gateMergeResult — the I/O half: measure, decide, print. `baseSha` is the main
|
|
168
|
+
// tip as it stood BEFORE the branch was merged in (in ship-merge.js that is
|
|
169
|
+
// origin/main, since the ff-only step immediately above it guarantees the two
|
|
170
|
+
// are the same commit).
|
|
171
|
+
function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
|
|
172
|
+
log(' fitness against post-merge state...');
|
|
173
|
+
const mergeClean = runFitnessAt(repoRoot);
|
|
174
|
+
const baseClean = mergeClean ? true : probeBaseClean(repoRoot, baseSha);
|
|
175
|
+
const verdict = mergeGateVerdict({ mergeClean, baseClean });
|
|
176
|
+
log(gateMessage(verdict.reason));
|
|
177
|
+
return verdict;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
fitnessOutcome,
|
|
182
|
+
gateMergeResult,
|
|
183
|
+
gateMessage,
|
|
184
|
+
mergeGateVerdict,
|
|
185
|
+
probeBaseClean,
|
|
186
|
+
runFitnessAt,
|
|
187
|
+
};
|
|
@@ -26,6 +26,7 @@ const {
|
|
|
26
26
|
landed,
|
|
27
27
|
} = require('./ship-land.js');
|
|
28
28
|
const { runSmoke } = require('./ship-grade.js');
|
|
29
|
+
const { gateMergeResult } = require('./ship-merge-gate.js');
|
|
29
30
|
const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
|
|
30
31
|
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
31
32
|
const { REPO_ROOT } = require('./ship-state.js');
|
|
@@ -247,6 +248,12 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
247
248
|
return landBailed('the local main is ahead of origin/main', '/merge-mode');
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
// The tree the merge result will be compared against (task 1004032). The
|
|
252
|
+
// ff-only step above has just proved local main IS origin/main, so this is
|
|
253
|
+
// main as the rest of the world currently sees it — read before the branch
|
|
254
|
+
// is merged in, because afterwards the commit is no longer reachable by name.
|
|
255
|
+
const baseSha = gitOk('git rev-parse HEAD', { cwd: REPO_ROOT });
|
|
256
|
+
|
|
250
257
|
// #681: build the merge argv and run it WITHOUT a shell. valueSummary is
|
|
251
258
|
// DB-sourced (untrusted) — passing it as a literal argv element means
|
|
252
259
|
// `$(...)`/backticks in it can never be interpreted as a command.
|
|
@@ -261,6 +268,21 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
261
268
|
return landBailed('post-merge smoke tests failed', '/merge-mode');
|
|
262
269
|
}
|
|
263
270
|
|
|
271
|
+
// task 1004032: the merge result is about to BECOME main by a direct push,
|
|
272
|
+
// which no required status check gates. Run the same fitness gate CI would
|
|
273
|
+
// have run on a PR, and refuse the push when this branch is what turns main
|
|
274
|
+
// red. A failure already present on the pre-merge base is reported and
|
|
275
|
+
// waved through — see scripts/gds/ship-merge-gate.js for why.
|
|
276
|
+
const fitnessVerdict = gateMergeResult({ repoRoot: REPO_ROOT, baseSha });
|
|
277
|
+
if (fitnessVerdict.block) {
|
|
278
|
+
return landBailed(
|
|
279
|
+
fitnessVerdict.reason === 'regression'
|
|
280
|
+
? 'fitness fails on the merge result and passes on the pre-merge main — this branch would turn main red'
|
|
281
|
+
: 'fitness fails on the merge result and the pre-merge main could not be measured',
|
|
282
|
+
'node scripts/gds/fitness.js',
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
264
286
|
console.log(' pushing merged main to origin...');
|
|
265
287
|
// SEC #863: route through push-main.js, which carries the
|
|
266
288
|
// OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.867'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|