@bongos/core 1.19.835 → 1.19.836
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 +44 -29
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-release/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +2 -0
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/db-claims.js +4 -2
- package/modules/lifecycle/db-ship.js +64 -0
- package/modules/lifecycle/publish-reconciler.js +23 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api.js +5 -1
- package/scripts/gds/build-cli-package.js +1 -0
- package/scripts/gds/claim.js +17 -2
- package/scripts/gds/cli-lib.js +11 -0
- package/scripts/gds/plain-cards.js +348 -0
- package/scripts/gds/push-path-brief.js +2 -0
- package/scripts/gds/release.js +3 -0
- package/scripts/gds/ship-flow.js +4 -0
- package/scripts/gds/ship-grade-steps.js +5 -0
- package/scripts/gds/ship-land.js +14 -1
- package/scripts/gds/start.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/claim_gate_rebase.mjs +54 -3
- package/tests/plain_cards.mjs +221 -0
- package/tests/rebase_gate_unprovable.mjs +134 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// tests/plain_cards.mjs — every failure a builder can be parked behind has a
|
|
2
|
+
// plain-language card (task 1003989).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The CLIs explain a refusal in engineer prose and the lifecycle skills said
|
|
5
|
+
// "relay verbatim", so a non-technical builder met REBASE_REQUIRED or
|
|
6
|
+
// strand:pr_not_landed raw, at exactly the moment they were stuck. The registry in
|
|
7
|
+
// scripts/gds/plain-cards.js says the same thing in plain words. This file holds
|
|
8
|
+
// three lines: a new failure code cannot ship without a card, a card cannot drift
|
|
9
|
+
// back into jargon, and the deliberate restrictions stay marked as deliberate.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/plain_cards.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { CARDS, cardFor, plainCardLines, PLAIN_MARKER } from '../scripts/gds/plain-cards.js';
|
|
17
|
+
import * as shipLand from '../scripts/gds/ship-land.js';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
|
|
21
|
+
const { formatClaimFailure } = require('../scripts/gds/claim.js');
|
|
22
|
+
const { formatGradeOutcome } = require('../scripts/gds/ship-grade-steps.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function test(name, fn) {
|
|
27
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const read = (rel) => fs.readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
|
32
|
+
const missing = (codes) => [...new Set(codes)].filter((c) => !cardFor(c)).sort();
|
|
33
|
+
|
|
34
|
+
// ── completeness: the codes are read out of the code that raises them ──────────
|
|
35
|
+
|
|
36
|
+
test('every refusal claim.js renders has a card', () => {
|
|
37
|
+
const src = read('scripts/gds/claim.js');
|
|
38
|
+
const body = src.slice(src.indexOf('function claimFailureGuidance'), src.indexOf('function goalMembershipOfferPlan'));
|
|
39
|
+
const codes = [...body.matchAll(/code === '([A-Za-z_]+)'/g)].map((m) => m[1]);
|
|
40
|
+
assert.ok(codes.length >= 12, `expected the claim ladder, found ${codes.length}`);
|
|
41
|
+
assert.deepEqual(missing(codes), []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('every refusal the claim ladder can throw server-side has a card', () => {
|
|
45
|
+
const src = read('modules/lifecycle/db-claims.js');
|
|
46
|
+
const codes = [...src.matchAll(/\bcode: '([A-Za-z_]+)'/g), ...src.matchAll(/\.code = '([A-Za-z_]+)'/g)].map((m) => m[1]);
|
|
47
|
+
assert.ok(codes.includes('REBASE_REQUIRED'), 'the ladder was not found — has db-claims.js moved?');
|
|
48
|
+
assert.deepEqual(missing(codes), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('every reason a confirmed task can be flagged with has a card', () => {
|
|
52
|
+
// The reconciler composes `strand:${reason}` at runtime, so read both halves: the
|
|
53
|
+
// literal tokens, and the four values the `reason` ternary can take.
|
|
54
|
+
const src = read('modules/lifecycle/publish-reconciler.js');
|
|
55
|
+
const ternary = src.slice(src.indexOf('const reason = prClosedUnmerged'));
|
|
56
|
+
const reasons = [...ternary.slice(0, ternary.indexOf(';')).matchAll(/'([a-z_]+)'/g)].map((m) => `strand:${m[1]}`);
|
|
57
|
+
const literals = [...src.matchAll(/strand:([a-z_]{3,})/g)].map((m) => `strand:${m[1]}`);
|
|
58
|
+
assert.equal(reasons.length, 4, `the reason ternary moved: ${reasons.join(',')}`);
|
|
59
|
+
assert.ok(literals.length >= 2, 'the conflict strand literals were not found');
|
|
60
|
+
assert.deepEqual(missing([...reasons, ...literals]), []);
|
|
61
|
+
assert.ok(cardFor('strand:unresolvable_conflict:docs/a.md,docs/b.md'), 'a reason carrying file names still resolves');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('every reason an open pull request has not merged has a card', () => {
|
|
65
|
+
const src = read('modules/lifecycle/github-push.js');
|
|
66
|
+
const body = src.slice(src.indexOf('function mergeBlockReason'), src.indexOf('async function mergeIfGreen'));
|
|
67
|
+
const tokens = [...body.matchAll(/return [`']([a-z_]+)/g)].map((m) => m[1]);
|
|
68
|
+
assert.ok(tokens.includes('merge_conflict') && tokens.includes('failing_check_unreadable'), tokens.join(','));
|
|
69
|
+
assert.deepEqual(missing(tokens), []);
|
|
70
|
+
assert.ok(cardFor('failing_check:unit') && cardFor('non_required_check_failing:e2e'), 'named-check reasons resolve by prefix');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('every push diagnosis and land bail has a card', () => {
|
|
74
|
+
const ids = [...shipLand.PUSH_DIAGNOSES.map((d) => d.id), 'unknown',
|
|
75
|
+
shipLand.LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH, shipLand.LAND_BAIL_NO_TASK_BRANCH];
|
|
76
|
+
assert.deepEqual(missing(ids.map((id) => `ship:${id}`)), []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── the words ─────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// Beyond the scorer's list: the git/GitHub nouns these particular failures are
|
|
82
|
+
// made of, which is exactly what the card exists to keep out of the first line.
|
|
83
|
+
const GIT_NOUNS = ['branch', 'pull request', 'commit', 'merge conflict', 'CI', 'SHA', 'diff', 'PAT', 'origin'];
|
|
84
|
+
|
|
85
|
+
test('no card speaks in the vocabulary it exists to translate', () => {
|
|
86
|
+
const banned = [...JARGON_TERMS, ...GIT_NOUNS];
|
|
87
|
+
const hits = [];
|
|
88
|
+
for (const [code, card] of Object.entries(CARDS)) {
|
|
89
|
+
for (const field of ['happened', 'next', 'person']) {
|
|
90
|
+
for (const term of banned) {
|
|
91
|
+
const re = new RegExp(`(^|[^A-Za-z])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z]|$)`, /^[A-Z+-]+$/.test(term) ? '' : 'i');
|
|
92
|
+
if (re.test(card[field] || '')) hits.push(`${code}.${field}: "${term}"`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
assert.deepEqual(hits, []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('every card is complete, and short enough to be read', () => {
|
|
100
|
+
for (const [code, card] of Object.entries(CARDS)) {
|
|
101
|
+
assert.ok(card.happened && card.next, `${code} needs happened + next`);
|
|
102
|
+
assert.equal(typeof card.by_design, 'boolean', `${code}.by_design`);
|
|
103
|
+
assert.ok(card.person === null || typeof card.person === 'string', `${code}.person is null or who`);
|
|
104
|
+
for (const f of ['happened', 'next']) assert.ok(card[f].length <= 230, `${code}.${f} is ${card[f].length} chars`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the restrictions that must never be worked around are marked by_design', () => {
|
|
109
|
+
for (const code of ['REBASE_REQUIRED', 'INSUFFICIENT_RANK', 'rank_too_low_for_task', 'session_already_has_active_claim',
|
|
110
|
+
'main_checkout_claim', 'ship:protected_branch', 'ship:auth', 'box_scope', 'no_push_credential', 'grade_refused_authorization']) {
|
|
111
|
+
assert.equal(cardFor(code)?.by_design, true, code);
|
|
112
|
+
}
|
|
113
|
+
assert.match(cardFor('no_push_credential').next, /never ask/i);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── the rendering, and the seams that print it ────────────────────────────────
|
|
117
|
+
|
|
118
|
+
test('plainCardLines renders four labelled lines under the marker, and nothing for an unknown code', () => {
|
|
119
|
+
const lines = plainCardLines('ALREADY_CLAIMED');
|
|
120
|
+
assert.ok(lines[0].includes(PLAIN_MARKER));
|
|
121
|
+
assert.equal(lines.length, 5);
|
|
122
|
+
assert.deepEqual(plainCardLines('never_heard_of_it'), []);
|
|
123
|
+
assert.deepEqual(plainCardLines(undefined), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a card interpolates nothing — it is static text, so it adds no injection surface', () => {
|
|
127
|
+
for (const card of Object.values(CARDS)) assert.ok(!/\$\{|%s|<[a-z_]+>/.test(JSON.stringify(card)));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a refused claim prints the engineer text first and the card after it', () => {
|
|
131
|
+
const r = { status: 409, data: { error: { code: 'REBASE_REQUIRED', message: 'x', details: { rebaseTasks: [{ id: 7, reason: 'strand:pr_not_landed' }] } } } };
|
|
132
|
+
const { lines, exit } = formatClaimFailure(r, 42);
|
|
133
|
+
assert.equal(exit, 1);
|
|
134
|
+
assert.match(lines[0], /queue is gated/);
|
|
135
|
+
const at = lines.findIndex((l) => l.includes(PLAIN_MARKER));
|
|
136
|
+
assert.ok(at > 0, 'card follows the existing guidance');
|
|
137
|
+
assert.ok(lines.slice(at).join('\n').includes(CARDS.REBASE_REQUIRED.happened));
|
|
138
|
+
assert.ok(lines.join('\n').includes(CARDS['strand:pr_not_landed'].happened), 'the flagged reason is translated too');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an unmapped claim code still renders, just without a card', () => {
|
|
142
|
+
const { lines } = formatClaimFailure({ status: 500, data: { error: { code: 'SOMETHING_NEW', message: 'm' } } }, 1);
|
|
143
|
+
assert.ok(lines.length >= 1 && !lines.join('\n').includes(PLAIN_MARKER));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a grader outage and a failed grade each carry their own card', () => {
|
|
147
|
+
const outage = formatGradeOutcome({ passed: false, signals: { panel_outcome: 'unavailable' } }, 9);
|
|
148
|
+
assert.ok(outage.includes(CARDS.grade_unavailable.happened));
|
|
149
|
+
const fail = formatGradeOutcome({ passed: false, score: 4, threshold: 7, issues: [] }, 9);
|
|
150
|
+
assert.ok(fail.includes(CARDS.grade_failed.happened));
|
|
151
|
+
assert.ok(!formatGradeOutcome({ passed: true, score: 9, threshold: 7 }, 9).includes(PLAIN_MARKER), 'a pass needs no card');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a red pull request line is followed by its card', () => {
|
|
155
|
+
const lines = shipLand.redPrLines({ reason: 'failing_check_unreadable', checks_unreadable: true });
|
|
156
|
+
assert.ok(lines[0].includes('RED') && lines.join('\n').includes(CARDS.failing_check_unreadable.happened));
|
|
157
|
+
assert.deepEqual(shipLand.redPrLines({ reason: 'review_required' }), []);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a person at a terminal gets no card — it is written in the agent\'s voice', () => {
|
|
161
|
+
assert.deepEqual(plainCardLines('REBASE_REQUIRED', { BONGOS_SURFACE: 'terminal' }), []);
|
|
162
|
+
assert.equal(plainCardLines('REBASE_REQUIRED', {}).length, 5);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the sign-in printers, the release refusals and the claim guard each print their card', () => {
|
|
166
|
+
const wired = {
|
|
167
|
+
'scripts/gds/cli-lib.js': ['session_invalid', 'box_scope', 'no_session'],
|
|
168
|
+
'scripts/gds/release.js': ['no_active_claim', 'CLAIM_SESSION_MISMATCH'],
|
|
169
|
+
'scripts/gds/claim.js': ['main_checkout_claim'],
|
|
170
|
+
'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked'],
|
|
171
|
+
'scripts/gds/push-path-brief.js': ['no_push_credential'],
|
|
172
|
+
};
|
|
173
|
+
for (const [file, codes] of Object.entries(wired)) {
|
|
174
|
+
const src = read(file);
|
|
175
|
+
for (const code of codes) {
|
|
176
|
+
assert.ok(src.includes(`plainCardLines('${code}')`), `${file} should print the ${code} card`);
|
|
177
|
+
assert.ok(cardFor(code), `${code} has no card`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('every way start.js can refuse or fail prints a card', () => {
|
|
183
|
+
const src = read('scripts/gds/start.js');
|
|
184
|
+
const codes = [...src.matchAll(/\.error === '([A-Za-z_]+)'/g)].map((m) => m[1]);
|
|
185
|
+
assert.ok(codes.includes('stage_required'), 'start.js no longer names its refusal — has the branch moved?');
|
|
186
|
+
assert.deepEqual(missing(codes), []);
|
|
187
|
+
for (const code of [...new Set(codes)]) assert.ok(src.includes(`plainCardLines('${code}')`), `start.js never prints the ${code} card`);
|
|
188
|
+
// The generic non-ok exit is the last way out; it must not be the one mute path.
|
|
189
|
+
const generic = src.slice(src.indexOf('if (!r.ok) {'));
|
|
190
|
+
assert.ok(generic.slice(0, generic.indexOf('process.exit(1)')).includes("plainCardLines('board_unavailable')"));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('no card is an orphan — each is printed by name, or belongs to a family a seam prints by code', () => {
|
|
194
|
+
const scripts = fs.readdirSync(new URL('../scripts/gds/', import.meta.url)).filter((f) => f.endsWith('.js') && f !== 'plain-cards.js');
|
|
195
|
+
const printed = new Set();
|
|
196
|
+
for (const f of scripts) for (const m of read(`scripts/gds/${f}`).matchAll(/plainCardLines\('([^']+)'\)/g)) printed.add(m[1]);
|
|
197
|
+
// Families reach plainCardLines through a variable: the claim refusal code, the
|
|
198
|
+
// flagged strand reason, the merge reason on a red pull request, `ship:${d.id}`.
|
|
199
|
+
const claimSrc = read('scripts/gds/claim.js') + read('modules/lifecycle/db-claims.js') + read('modules/lifecycle/routes/claims.js') + read('modules/lifecycle/goal-authz.js');
|
|
200
|
+
const mergeSrc = read('modules/lifecycle/github-push.js');
|
|
201
|
+
const byFamily = (code) => code.startsWith('strand:') || code.startsWith('ship:')
|
|
202
|
+
|| claimSrc.includes(`'${code}'`) || mergeSrc.includes(`'${code}'`) || mergeSrc.includes(`\`${code}:`);
|
|
203
|
+
const orphans = Object.keys(CARDS).filter((code) => !printed.has(code) && !byFamily(code));
|
|
204
|
+
assert.deepEqual(orphans, []);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the four lifecycle skills tell the agent how to use the card', () => {
|
|
208
|
+
for (const skill of ['builder-claim', 'builder-ship', 'builder-start', 'builder-release']) {
|
|
209
|
+
const md = read(`.claude/skills/${skill}/SKILL.md`);
|
|
210
|
+
assert.ok(md.includes(PLAIN_MARKER), `${skill} never mentions the ${PLAIN_MARKER} card`);
|
|
211
|
+
assert.match(md, /interaction profile/, `${skill} does not tie the card to the builder's profile`);
|
|
212
|
+
}
|
|
213
|
+
assert.match(read('.claude/skills/builder-claim/SKILL.md'), /never look for a way round it/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('the public CLI package ships the card file its scripts require', () => {
|
|
217
|
+
assert.ok(read('scripts/gds/build-cli-package.js').includes("'scripts/gds/plain-cards.js'"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log(`\nplain cards (task 1003989) tests: ${passed} passed, ${failed} failed`);
|
|
221
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// tests/rebase_gate_unprovable.mjs — a strand the builder CANNOT clear must stop
|
|
2
|
+
// blocking their queue without stopping being reported (task 1003294).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. publish-reconciler proves a land by looking for a branch / published tip
|
|
5
|
+
// in the CORE repo. A task that legitimately delivers into the INSTANCE repo (task
|
|
6
|
+
// 1003253 — a real, complete, deployed commit on the instance host) can never be
|
|
7
|
+
// proven here, so the sweep writes needs_rebase_reason='strand:no_branch_no_tip'.
|
|
8
|
+
// ADR 0120 part 3 then refuses that builder EVERY new claim until they land or
|
|
9
|
+
// release it — and they can do neither, because there is nothing in this repository
|
|
10
|
+
// to land. The recorded remedy was a hand-run SQL UPDATE on the droplet.
|
|
11
|
+
//
|
|
12
|
+
// THE SHAPE OF THE FIX, and what these tests are really protecting: the gate and the
|
|
13
|
+
// SURFACING were one function. "Stop blocking" must not become "stop telling them",
|
|
14
|
+
// so the fix SPLITS them — gatingRebaseTasksForBuilder may release a strand past the
|
|
15
|
+
// grace, needsRebaseTasksForBuilder keeps answering in full for me.js / the hall /
|
|
16
|
+
// the CLI. Re-collapsing the two is the regression this file exists to catch.
|
|
17
|
+
//
|
|
18
|
+
// SQL SEMANTICS ARE NOT TESTED HERE — they need a real Postgres and live in
|
|
19
|
+
// tests/claim_gate_rebase.mjs (INTEGRATION set). What is testable DB-free is the
|
|
20
|
+
// wiring, the parameters, and the NULL-handling SHAPE of the predicate, and a
|
|
21
|
+
// fail-open in that shape is the dangerous mistake, so it is asserted directly.
|
|
22
|
+
//
|
|
23
|
+
// Run: node tests/rebase_gate_unprovable.mjs
|
|
24
|
+
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { makeRunner } from './helpers.mjs';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const { test, summary } = makeRunner();
|
|
34
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const read = (rel) => readFileSync(path.join(REPO_ROOT, rel), 'utf8');
|
|
36
|
+
|
|
37
|
+
const shipMod = require('../modules/lifecycle/db-ship.js');
|
|
38
|
+
const CLAIMS = read('modules/lifecycle/db-claims.js');
|
|
39
|
+
const SHIP = read('modules/lifecycle/db-ship.js');
|
|
40
|
+
const RECON = read('modules/lifecycle/publish-reconciler.js');
|
|
41
|
+
|
|
42
|
+
// A client that records what it was asked, so the query can be inspected without a DB.
|
|
43
|
+
const spyClient = () => {
|
|
44
|
+
const seen = [];
|
|
45
|
+
return { seen, query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await test('the CLAIM GATE asks the gating query, not the surfacing one', async () => {
|
|
49
|
+
assert.match(CLAIMS, /gatingRebaseTasksForBuilder\(builderId, client\)/,
|
|
50
|
+
'claimTask must consult the gating subset');
|
|
51
|
+
assert.doesNotMatch(CLAIMS, /needsRebaseTasksForBuilder\(/,
|
|
52
|
+
'the gate must NOT use the unfiltered surfacing query — that is the bug, restored');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await test('the SURFACING query still returns everything — no exclusion at all', () => {
|
|
56
|
+
const body = SHIP.slice(
|
|
57
|
+
SHIP.indexOf('async function needsRebaseTasksForBuilder'),
|
|
58
|
+
SHIP.indexOf('// The ship gate (task 1002483')
|
|
59
|
+
);
|
|
60
|
+
assert.ok(body.length > 0, 'needsRebaseTasksForBuilder must still exist');
|
|
61
|
+
assert.doesNotMatch(body, /interval '1 millisecond'/,
|
|
62
|
+
'the surfacing query must not inherit the gate grace — the builder still sees the strand');
|
|
63
|
+
assert.doesNotMatch(body, /no_branch_no_tip/,
|
|
64
|
+
'the surfacing query must not filter on the strand reason');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await test('the gating query is parameterised with the grace and the reason', async () => {
|
|
68
|
+
const c = spyClient();
|
|
69
|
+
await shipMod.gatingRebaseTasksForBuilder(4242, c);
|
|
70
|
+
assert.equal(c.seen.length, 1);
|
|
71
|
+
const { sql, params } = c.seen[0];
|
|
72
|
+
assert.equal(params[0], 4242, 'builder id');
|
|
73
|
+
assert.equal(params[1], shipMod.UNPROVABLE_GATE_GRACE_MS, 'the grace is passed, not inlined');
|
|
74
|
+
assert.equal(params[2], 'strand:no_branch_no_tip',
|
|
75
|
+
'the reason is passed in the SPELLING announceStrand stores (strand:<reason>)');
|
|
76
|
+
assert.match(sql, /needs_rebase = true AND status = 'confirmed' AND shipped_by = \$1/,
|
|
77
|
+
'it must still be the same set of owed rebases, only narrowed');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('an explicit grace overrides the default — the window is tunable', async () => {
|
|
81
|
+
const c = spyClient();
|
|
82
|
+
await shipMod.gatingRebaseTasksForBuilder(1, c, 5_000);
|
|
83
|
+
assert.equal(c.seen[0].params[1], 5_000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a negative or junk grace degrades to 0, never to a negative interval', async () => {
|
|
87
|
+
// A negative interval would make `needs_rebase_at < now() + X` true for FRESH
|
|
88
|
+
// strands — releasing the gate immediately, the exact inversion of the intent.
|
|
89
|
+
for (const bad of [-1, NaN, undefined, 'nonsense']) {
|
|
90
|
+
const c = spyClient();
|
|
91
|
+
await shipMod.gatingRebaseTasksForBuilder(1, c, bad);
|
|
92
|
+
assert.ok(c.seen[0].params[1] >= 0, `grace ${String(bad)} must not go negative`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('the predicate is FAIL-CLOSED on NULLs — an unknown keeps the gate', () => {
|
|
97
|
+
const body = SHIP.slice(SHIP.indexOf('async function gatingRebaseTasksForBuilder'));
|
|
98
|
+
const q = body.slice(0, body.indexOf('return rows;'));
|
|
99
|
+
// A bare `needs_rebase_reason = $3` yields NULL for a NULL reason, and
|
|
100
|
+
// NOT(NULL AND true AND true) is NULL, which drops the row from the WHERE —
|
|
101
|
+
// releasing the gate for a row we know nothing about. COALESCE removes the NULL.
|
|
102
|
+
assert.match(q, /COALESCE\(needs_rebase_reason, ''\) = \$3/,
|
|
103
|
+
'the reason comparison must not be able to produce NULL');
|
|
104
|
+
assert.match(q, /needs_rebase_at IS NOT NULL/,
|
|
105
|
+
'a NULL timestamp must not participate in the age comparison');
|
|
106
|
+
assert.match(q, /AND NOT \(/, 'the exclusion is expressed as NOT over non-NULL operands');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('the reconciler ESCALATES past the grace instead of going quiet', () => {
|
|
110
|
+
assert.match(RECON, /UNPROVABLE_GATE_GRACE_MS/,
|
|
111
|
+
'the sweep must use the SAME constant as the gate, not a second number that can drift');
|
|
112
|
+
assert.match(RECON, /require\('\.\/db-ship\.js'\)/, 'imported rather than restated');
|
|
113
|
+
assert.match(RECON, /UNPROVABLE task \$\{r\.task_id\}/,
|
|
114
|
+
'an ungated strand must still be named on every sweep');
|
|
115
|
+
assert.match(RECON, /still confirmed and still unlanded/,
|
|
116
|
+
'the log must say this is not a resolution');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('releasing the gate does NOT clear the flag — the row stays owed', () => {
|
|
120
|
+
// The fix must not touch needs_rebase/needs_rebase_reason. If a future change
|
|
121
|
+
// "tidied up" by clearing the flag past the grace, the strand would vanish from
|
|
122
|
+
// every surfacing and the task would silently sit confirmed forever.
|
|
123
|
+
const body = SHIP.slice(SHIP.indexOf('async function gatingRebaseTasksForBuilder'));
|
|
124
|
+
const q = body.slice(0, body.indexOf('return rows;'));
|
|
125
|
+
assert.doesNotMatch(q, /UPDATE|SET /, 'the gating query must be a pure read');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('the grace is bounded and sane by default', () => {
|
|
129
|
+
assert.ok(shipMod.UNPROVABLE_GATE_GRACE_MS > 0, 'a zero default would never gate at all');
|
|
130
|
+
assert.ok(shipMod.UNPROVABLE_GATE_GRACE_MS >= 60 * 60_000,
|
|
131
|
+
'too short and case (a) — the builder who simply never pushed — escapes the gate that exists for them');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
summary();
|