@bongos/core 1.19.1037 → 1.19.1039

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.
@@ -0,0 +1,217 @@
1
+ // tests/grade_model_provider.mjs — the ModelProvider port + the openai-compatible
2
+ // adapter (task 1002840, ADR 0089 §3).
3
+ //
4
+ // Pins: (1) every model id in use today still runs on the Claude spawn, with the
5
+ // identical argv; (2) the openai-compatible adapter against a LOCAL STUB server —
6
+ // request shape, envelope, cost, and every failure mapped to the error codes the
7
+ // worker runner already understands — with no real key and no network; (3) a key
8
+ // that is missing sends nothing, and a key never leaks into an error; (4) the
9
+ // config loader fails to vanilla; (5) the judge table reproduces the old if/else
10
+ // and refuses a same-family or non-Claude judge (ADR 0024 / the Claude floor).
11
+ //
12
+ // Run: node --test tests/grade_model_provider.mjs
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import http from 'node:http';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const subagent = require('../modules/grading/grader-subagent.js');
24
+ const mp = require('../modules/grading/model-provider.js');
25
+ const model = require('../modules/grading/grader-model.js');
26
+ const workers = require('../modules/grading/grader-workers');
27
+
28
+ // A local OpenAI-shaped server. `reply(req, body)` returns { status, json|text, delayMs }.
29
+ async function stubServer(reply) {
30
+ const seen = [];
31
+ const server = http.createServer((req, res) => {
32
+ let raw = '';
33
+ req.on('data', (d) => { raw += d; });
34
+ req.on('end', async () => {
35
+ const body = raw ? JSON.parse(raw) : null;
36
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body });
37
+ const r = reply(req, body) || {};
38
+ if (r.delayMs) await new Promise((ok) => setTimeout(ok, r.delayMs));
39
+ res.writeHead(r.status || 200, { 'content-type': 'application/json' });
40
+ res.end(r.text != null ? r.text : JSON.stringify(r.json));
41
+ });
42
+ });
43
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
44
+ const { port } = server.address();
45
+ return { seen, base: `http://127.0.0.1:${port}/v1`, close: () => new Promise((ok) => server.close(ok)) };
46
+ }
47
+
48
+ const KEY = 'test-key-not-a-real-secret-0123456789';
49
+ function provider(base, extra = {}, env = { STUB_LLM_KEY: KEY }) {
50
+ const v = mp.validateProviderDef('stub', { kind: 'openai-compatible', base_url: base, api_key_env: 'STUB_LLM_KEY', ...extra });
51
+ assert.ok(v.def, v.error);
52
+ return mp.createOpenAICompatibleProvider(v.def, { env });
53
+ }
54
+ const completion = (content, usage = { prompt_tokens: 1000, completion_tokens: 200 }) => ({ json: { choices: [{ message: { role: 'assistant', content } }], usage } });
55
+
56
+ test('parseModelRef: every id in use today is a Claude model; only a REGISTERED prefix routes elsewhere', () => {
57
+ for (const id of ['claude-sonnet-5', 'claude-opus-5', 'claude-fable-5', '', 'x:y']) {
58
+ assert.deepEqual(mp.parseModelRef(id), { provider: 'anthropic-cli', model: id });
59
+ }
60
+ mp.registerModelProvider({ name: 'test-ollama', kind: 'openai-compatible', run: async () => ({}) });
61
+ assert.deepEqual(mp.parseModelRef('test-ollama:qwen2.5-coder:32b'), { provider: 'test-ollama', model: 'qwen2.5-coder:32b' },
62
+ 'split at the FIRST colon only — a colon inside the model id survives');
63
+ assert.deepEqual(mp.parseModelRef('anthropic-cli:claude-sonnet-5'), { provider: 'anthropic-cli', model: 'anthropic-cli:claude-sonnet-5' },
64
+ 'the default is never a prefix');
65
+ assert.ok(mp.listModelProviders().includes('anthropic-cli'));
66
+ });
67
+
68
+ test('the Claude spawn is the default provider, with the identical argv', { skip: process.platform === 'win32' && 'the fake CLI is a shebang script' }, async () => {
69
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grade-provider-'));
70
+ const bin = path.join(dir, 'claude');
71
+ fs.writeFileSync(bin, '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ type: "result", result: JSON.stringify(process.argv.slice(2)), total_cost_usd: 0.5 })));\n');
72
+ fs.chmodSync(bin, 0o755);
73
+ try {
74
+ const r = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, model: 'claude-sonnet-5', cwd: dir } });
75
+ const argv = JSON.parse(subagent.extractAssistantResult(r.stdout));
76
+ const perm = typeof process.getuid === 'function' && process.getuid() === 0 ? 'acceptEdits' : 'bypassPermissions';
77
+ assert.deepEqual(argv, ['-p', '--print', '--model', 'claude-sonnet-5', '--output-format', 'json', '--tools', 'Read', '--disable-slash-commands', '--permission-mode', perm]);
78
+ assert.equal(r.cost_usd, 0.5);
79
+ const d = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, cwd: dir } });
80
+ assert.equal(JSON.parse(subagent.extractAssistantResult(d.stdout))[3], 'claude-sonnet-5', 'no model → the default grader model, as before');
81
+ } finally {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('openai-compatible: request shape, envelope and cost — against a local stub', async () => {
87
+ const s = await stubServer(() => completion('{"code_quality": 8}'));
88
+ try {
89
+ const p = provider(s.base, { price_per_mtok: { 'deepseek-x': { input: 0.25, output: 1 } } });
90
+ const r = await p.run({ prompt: 'PROMPT TEXT', opts: { model: 'deepseek-x', timeoutMs: 5000 } });
91
+ assert.equal(s.seen.length, 1);
92
+ assert.equal(s.seen[0].method, 'POST');
93
+ assert.equal(s.seen[0].url, '/v1/chat/completions');
94
+ assert.equal(s.seen[0].headers.authorization, `Bearer ${KEY}`);
95
+ assert.deepEqual(s.seen[0].body, { model: 'deepseek-x', messages: [{ role: 'user', content: 'PROMPT TEXT' }] });
96
+ // The same envelope contract the Claude spawn honours, so nothing downstream changes.
97
+ assert.equal(subagent.extractAssistantResult(r.stdout), '{"code_quality": 8}');
98
+ const cost = (1000 * 0.25 + 200 * 1) / 1e6;
99
+ assert.equal(subagent.extractCostUsd(r.stdout), cost);
100
+ assert.equal(r.cost_usd, cost);
101
+ assert.equal(r.exit_code, 0);
102
+ const unpriced = await p.run({ prompt: 'x', opts: { model: 'other-model' } });
103
+ assert.equal(unpriced.cost_usd, null, 'no declared price → no cost number, never a guess');
104
+ assert.equal(subagent.extractCostUsd(unpriced.stdout), null);
105
+ } finally { await s.close(); }
106
+ });
107
+
108
+ test('openai-compatible: failures map to the codes the worker runner already retries or reports', async () => {
109
+ const s = await stubServer((req, body) => {
110
+ if (body.model === 'http-500') return { status: 500, text: `upstream says ${KEY} is invalid` };
111
+ if (body.model === 'slow') return { delayMs: 300, ...completion('late') };
112
+ if (body.model === 'garbage') return { text: 'not json at all' };
113
+ return completion('ok');
114
+ });
115
+ try {
116
+ const p = provider(s.base);
117
+ await assert.rejects(p.run({ prompt: 'x', opts: { model: 'http-500' } }), (e) => {
118
+ assert.equal(e.code, 'SUBAGENT_NONZERO_EXIT');
119
+ assert.equal(e.exit_code, 500);
120
+ assert.ok(!e.stderr.includes(KEY) && e.stderr.includes('[redacted]'), 'the key is scrubbed from what a grade row stores');
121
+ return true;
122
+ });
123
+ await assert.rejects(p.run({ prompt: 'x', opts: { model: 'slow', timeoutMs: 50 } }), (e) => e.code === 'SUBAGENT_TIMEOUT');
124
+ const g = await p.run({ prompt: 'x', opts: { model: 'garbage' } });
125
+ assert.equal(subagent.extractAssistantResult(g.stdout), '', 'an unparseable 200 is an empty result → the worker parse_error path, retried once');
126
+ } finally { await s.close(); }
127
+ const dead = provider(s.base);
128
+ await assert.rejects(dead.run({ prompt: 'x', opts: { model: 'm' } }), (e) => e.code === 'SUBAGENT_SPAWN_FAILED', 'a closed port is a spawn failure');
129
+ });
130
+
131
+ test('a missing key sends NOTHING anywhere', async () => {
132
+ const s = await stubServer(() => completion('ok'));
133
+ try {
134
+ const p = provider(s.base, {}, {});
135
+ await assert.rejects(p.run({ prompt: 'the diff', opts: { model: 'm' } }), (e) => e.code === 'SUBAGENT_SPAWN_FAILED' && /set STUB_LLM_KEY/.test(e.message));
136
+ assert.equal(s.seen.length, 0);
137
+ } finally { await s.close(); }
138
+ });
139
+
140
+ test('an open-model judge runs through the SAME worker runner and verdict code', async () => {
141
+ const s = await stubServer(() => completion(JSON.stringify({ code_quality: 8, economy: 8, confidence: 0.8, issues: [], notes_md: 'fine' })));
142
+ try {
143
+ mp.registerModelProvider({ ...provider(s.base, { price_per_mtok: { 'q-coder': { input: 1, output: 1 } } }), name: 'test-stub' });
144
+ const r = await workers.runWorker(workers.quality, {
145
+ task: { id: 1, title: 't', description: 'd' }, valueSummary: 'v', diff: 'diff', changedFiles: [], diffStats: null,
146
+ model: 'test-stub:q-coder', runOpts: {}, runSubagentFn: subagent.runSubagent, extractAssistantResultFn: subagent.extractAssistantResult,
147
+ });
148
+ assert.equal(r.verdict, 'pass');
149
+ assert.equal(r.scores.code_quality, 8);
150
+ assert.equal(r.model, 'test-stub:q-coder', 'the record names the provider');
151
+ assert.equal(r.cost_usd, 1200 / 1e6);
152
+ assert.equal(s.seen[0].body.model, 'q-coder', 'the provider receives its own model id');
153
+ } finally { await s.close(); }
154
+ });
155
+
156
+ test('provider config: validated, and a bad instance file fails to vanilla', () => {
157
+ const bad = (name, def) => mp.validateProviderDef(name, def).error;
158
+ assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'http://api.example.com/v1', api_key_env: 'K_KEY' }), /https/);
159
+ assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'https://api.example.com/v1', api_key_env: 'sk-live-abc' }), /env var NAME/);
160
+ assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'https://api.example.com/v1' }), /needs api_key_env/);
161
+ assert.match(bad('anthropic-cli', { kind: 'openai-compatible', base_url: 'https://x.example/v1', api_key_env: 'K' }), /must match|not be/);
162
+ assert.match(bad('x-prov', { kind: 'grpc', base_url: 'https://x.example/v1', api_key_env: 'KK' }), /kind/);
163
+ assert.equal(bad('local-ollama', { kind: 'openai-compatible', base_url: 'http://localhost:11434/v1' }), undefined, 'keyless http is allowed to this machine only');
164
+
165
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grade-provider-cfg-'));
166
+ try {
167
+ const neutral = path.join(gradingDir(), 'grader-providers.neutral.json');
168
+ assert.deepEqual(mp.loadProviderConfig({ instancePath: path.join(dir, 'absent.json'), neutralPath: neutral }).defs, [],
169
+ 'the shipped neutral starter declares no provider — a vanilla instance is Claude-only');
170
+ const broken = path.join(dir, 'broken.json');
171
+ fs.writeFileSync(broken, '{ not json');
172
+ assert.equal(mp.loadProviderConfig({ instancePath: broken, neutralPath: neutral }).source, neutral);
173
+ const mixed = path.join(dir, 'mixed.json');
174
+ fs.writeFileSync(mixed, JSON.stringify({ providers: {
175
+ deepinfra: { kind: 'openai-compatible', base_url: 'https://api.deepinfra.com/v1/openai', api_key_env: 'DEEPINFRA_API_KEY' },
176
+ leaky: { kind: 'openai-compatible', base_url: 'http://api.example.com/v1', api_key_env: 'LEAKY_KEY' },
177
+ } }));
178
+ const cfg = mp.loadProviderConfig({ instancePath: mixed, neutralPath: neutral });
179
+ assert.deepEqual(cfg.defs.map((d) => d.name), ['deepinfra']);
180
+ assert.equal(cfg.refused.length, 1, 'one bad entry is refused by name; the rest load');
181
+ } finally {
182
+ fs.rmSync(dir, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ function gradingDir() {
187
+ return path.dirname(require.resolve('../modules/grading/model-provider.js'));
188
+ }
189
+
190
+ test('the judge table reproduces the old if/else, and refuses a same-family or non-Claude judge', () => {
191
+ const oldIfElse = (bm) => {
192
+ const s = bm.toLowerCase();
193
+ if (s.includes('fable') || s.includes('mythos')) return 'claude-sonnet-5';
194
+ if (s.includes('opus')) return 'claude-sonnet-5';
195
+ if (s.includes('sonnet')) return 'claude-opus-5';
196
+ if (s.includes('haiku')) return 'claude-opus-5';
197
+ return null;
198
+ };
199
+ const tokens = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
200
+ const ids = ['claude-fable-5', 'claude-opus-5-5', 'claude-sonnet-5', 'claude-haiku-4-5', 'Claude-OPUS-4-8'];
201
+ for (const a of tokens) for (const b of tokens) ids.push(`claude-${a}-${b}`);
202
+ for (const id of ids) assert.equal(model.selectGraderModel({ builderModel: id }), oldIfElse(id), id);
203
+ assert.deepEqual(model.JUDGE_TABLE, model.BUILTIN_JUDGE_TABLE, 'the rubric table is exactly the built-in fallback');
204
+
205
+ const logged = [];
206
+ const orig = console.error;
207
+ console.error = (...a) => { logged.push(a.join(' ')); };
208
+ try {
209
+ assert.equal(model.selectGraderModel({ builderModel: 'gpt-9' }), 'claude-sonnet-5', 'an unknown builder warns and grades on the default — never throws (task 1002659)');
210
+ } finally { console.error = orig; }
211
+ assert.match(logged.join('\n'), /unrecognized builder model/);
212
+
213
+ assert.match(model.judgeTableProblems([{ builder_families: ['opus'], judge_model: 'claude-opus-5' }]).join(), /family it would grade/);
214
+ assert.match(model.judgeTableProblems([{ builder_families: ['opus'], judge_model: 'deepinfra:deepseek-v3' }]).join(), /plain Claude model id/);
215
+ assert.match(model.judgeTableProblems([]).join(), /non-empty/);
216
+ assert.deepEqual(model.judgeTableProblems(model.BUILTIN_JUDGE_TABLE), []);
217
+ });
@@ -0,0 +1,198 @@
1
+ // tests/publish_integration_gate.mjs — npm publishing waits for the integration lane's
2
+ // verdict on the commit it releases (task 1004290).
3
+ //
4
+ // WHY. publish.yml fires on `unit`, which is DB-free by design. The proof that the
5
+ // PACKAGED core can migrate an empty database (tests/consumer_layout_boot.mjs, task
6
+ // 1002863) runs in integration.yml, and until this gate its verdict arrived after the
7
+ // publish and gated nothing. What is pinned here:
8
+ // 1. the verdicts, from the real list-workflow-runs payload shape
9
+ // 2. the wait: polls while running, stops at once on an unreadable API, times out loudly
10
+ // 3. the lane is WIRED: actions:read, the step sits before any install/pack/publish, and
11
+ // every later armed step carries the integration go — so a new step cannot slip past
12
+ //
13
+ // Network-free: fetch, sleep and the clock are injected.
14
+ //
15
+ // Run: node tests/publish_integration_gate.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const gate = require('../scripts/gds/publish-integration-gate.js');
25
+ const { decideIntegrationGate, waitForIntegration, reportLines, main, runsUrl } = gate;
26
+
27
+ let passed = 0;
28
+ let failed = 0;
29
+ async function test(name, fn) {
30
+ try { await fn(); passed++; console.log(` ok ${name}`); }
31
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
32
+ }
33
+
34
+ const SHA = '078e8b7b'.padEnd(40, '0');
35
+ // The fields GitHub's list-workflow-runs answers with that the gate reads (from a real
36
+ // integration run on main, 2026-09-26: run 36210...; trimmed to what matters).
37
+ const ghRun = (run_number, status, conclusion) => ({
38
+ id: 36210000000 + run_number, run_number, event: 'push', head_sha: SHA, status, conclusion,
39
+ created_at: `2026-09-26T03:${String(run_number % 60).padStart(2, '0')}:00Z`,
40
+ html_url: `https://github.com/example-org/example-repo/actions/runs/${36210000000 + run_number}`,
41
+ });
42
+ const okJson = (body) => ({ ok: true, status: 200, json: async () => body });
43
+
44
+ // ---- 1. the verdicts ----
45
+
46
+ await test('decideIntegrationGate: success → go, running or absent → wait, cancelled → superseded, anything else → red', async () => {
47
+ assert.equal(decideIntegrationGate([ghRun(1, 'completed', 'success')]).verdict, 'go');
48
+ assert.equal(decideIntegrationGate([]).verdict, 'wait', 'the run has not been created yet');
49
+ assert.equal(decideIntegrationGate(undefined).verdict, 'wait');
50
+ for (const status of ['queued', 'in_progress', 'waiting', 'pending']) {
51
+ assert.equal(decideIntegrationGate([ghRun(1, status, null)]).verdict, 'wait', status);
52
+ }
53
+ assert.equal(decideIntegrationGate([ghRun(1, 'completed', 'cancelled')]).verdict, 'superseded');
54
+ for (const c of ['failure', 'timed_out', 'startup_failure', 'action_required', 'skipped', 'neutral', 'stale', null]) {
55
+ assert.equal(decideIntegrationGate([ghRun(1, 'completed', c)]).verdict, 'red', `fail-closed on ${c}`);
56
+ }
57
+ });
58
+
59
+ await test('decideIntegrationGate: the NEWEST run on the sha decides, whatever order the API lists them in', async () => {
60
+ assert.equal(decideIntegrationGate([ghRun(5, 'completed', 'failure'), ghRun(7, 'completed', 'success')]).verdict, 'go');
61
+ assert.equal(decideIntegrationGate([ghRun(7, 'completed', 'failure'), ghRun(5, 'completed', 'success')]).verdict, 'red');
62
+ assert.equal(decideIntegrationGate([ghRun(8, 'in_progress', null), ghRun(7, 'completed', 'success')]).verdict, 'wait');
63
+ });
64
+
65
+ await test('only PUSH runs are asked for: the weekly scheduled run carries HEAD\'s sha but tests only the parked set', async () => {
66
+ const u = runsUrl('example-org/example-repo', SHA);
67
+ assert.match(u, /\/actions\/workflows\/integration\.yml\/runs\?/);
68
+ assert.match(u, new RegExp(`head_sha=${SHA}`));
69
+ assert.match(u, /event=push/);
70
+ assert.ok(fs.existsSync(new URL('../.github/workflows/integration.yml', import.meta.url)), 'the workflow the gate names exists');
71
+ });
72
+
73
+ // ---- 2. the wait ----
74
+
75
+ function clock() {
76
+ let t = 0;
77
+ return { now: () => t, sleep: async (ms) => { t += ms; } };
78
+ }
79
+
80
+ await test('waitForIntegration: polls while integration runs, then publishes on green', async () => {
81
+ const c = clock();
82
+ const replies = [okJson({ workflow_runs: [] }), okJson({ workflow_runs: [ghRun(3, 'in_progress', null)] }),
83
+ okJson({ workflow_runs: [ghRun(3, 'completed', 'success')] })];
84
+ const asked = [];
85
+ const r = await waitForIntegration({
86
+ sha: SHA, repo: 'example-org/example-repo', token: 't', timeoutMs: 600000, intervalMs: 20000,
87
+ fetchImpl: async (url, init) => { asked.push({ url, auth: init.headers.Authorization }); return replies.shift(); },
88
+ ...c,
89
+ });
90
+ assert.equal(r.verdict, 'go');
91
+ assert.equal(asked.length, 3);
92
+ assert.equal(asked[0].auth, 'Bearer t');
93
+ assert.equal(c.now(), 40000, 'slept between reads, not after the verdict');
94
+ });
95
+
96
+ await test('waitForIntegration: a 403/404 ends the wait at once (waiting cannot grant a permission)', async () => {
97
+ for (const status of [401, 403, 404]) {
98
+ const c = clock();
99
+ let calls = 0;
100
+ const r = await waitForIntegration({
101
+ sha: SHA, repo: 'o/r', token: 't', timeoutMs: 600000, intervalMs: 20000,
102
+ fetchImpl: async () => { calls++; return { ok: false, status, json: async () => ({}) }; }, ...c,
103
+ });
104
+ assert.equal(r.verdict, 'unreadable', String(status));
105
+ assert.equal(calls, 1);
106
+ }
107
+ });
108
+
109
+ await test('waitForIntegration: flaky reads are retried, and a lane with no verdict times out rather than publishing', async () => {
110
+ const c = clock();
111
+ let calls = 0;
112
+ const r = await waitForIntegration({
113
+ sha: SHA, repo: 'o/r', token: 't', timeoutMs: 60000, intervalMs: 20000,
114
+ fetchImpl: async () => {
115
+ calls++;
116
+ if (calls === 1) throw new Error('ECONNRESET');
117
+ if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
118
+ return okJson({ workflow_runs: [ghRun(3, 'in_progress', null)] });
119
+ },
120
+ ...c,
121
+ });
122
+ assert.equal(r.verdict, 'timeout');
123
+ assert.ok(calls >= 3, 'kept reading through the transient failures');
124
+ });
125
+
126
+ await test('reportLines: only a green lane says go; red, unreadable and timeout exit non-zero', async () => {
127
+ const base = { sha: SHA, run: ghRun(3, 'completed', 'failure'), why: 'HTTP 403', timeoutMin: 12 };
128
+ assert.deepEqual([reportLines('go', base).go, reportLines('go', base).code], [true, 0]);
129
+ assert.deepEqual([reportLines('superseded', base).go, reportLines('superseded', base).code], [false, 0]);
130
+ for (const v of ['red', 'unreadable', 'timeout']) {
131
+ const o = reportLines(v, base);
132
+ assert.equal(o.go, false, v);
133
+ assert.equal(o.code, 1, v);
134
+ assert.match(o.line, /^::error title=not published/, v);
135
+ assert.doesNotMatch(o.line.split('::')[1], /,/, `${v}: a comma in the title would split the annotation's properties`);
136
+ }
137
+ assert.match(reportLines('red', base).line, /task 1002863/, 'says what the red lane was proving');
138
+ assert.match(reportLines('unreadable', base).line, /actions: read/);
139
+ });
140
+
141
+ await test('main: writes go=… to GITHUB_OUTPUT and refuses bad arguments with exit 2', async () => {
142
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'integ-gate-'));
143
+ try {
144
+ const out = path.join(dir, 'out');
145
+ const log = console.log;
146
+ const err = console.error;
147
+ console.log = () => {};
148
+ console.error = () => {};
149
+ try {
150
+ assert.equal(await main(['--sha', 'nope', '--repo', 'o/r'], { GH_TOKEN: 't', GITHUB_OUTPUT: out }), 2);
151
+ assert.equal(await main(['--sha', SHA, '--repo', 'o/r'], { GITHUB_OUTPUT: out }), 2, 'no token');
152
+ const realFetch = globalThis.fetch;
153
+ globalThis.fetch = async () => okJson({ workflow_runs: [ghRun(3, 'completed', 'success')] });
154
+ try {
155
+ assert.equal(await main(['--sha', SHA, '--repo', 'o/r'], { GH_TOKEN: 't', GITHUB_OUTPUT: out }), 0);
156
+ } finally { globalThis.fetch = realFetch; }
157
+ } finally { console.log = log; console.error = err; }
158
+ assert.equal(fs.readFileSync(out, 'utf8'), 'go=true\n');
159
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
160
+ });
161
+
162
+ // ---- 3. the wiring ----
163
+
164
+ const YML = fs.readFileSync(new URL('../.github/workflows/publish.yml', import.meta.url), 'utf8');
165
+ // Split the job into steps at each ` - ` list item (the job's step indentation).
166
+ const steps = YML.split(/\n(?= - (?:name|uses):)/).slice(1);
167
+ const stepIndex = (re) => steps.findIndex((s) => re.test(s));
168
+
169
+ await test('publish.yml can read the integration runs, and asks about the commit it packages', async () => {
170
+ assert.match(YML, /^permissions:[\s\S]*?^\s+actions: read$/m, 'the job token needs actions:read to list runs');
171
+ assert.doesNotMatch(YML, /actions: write/, 'read only');
172
+ const g = steps[stepIndex(/id: integ\n/)];
173
+ assert.ok(g, 'the integration gate step exists');
174
+ assert.match(g, /node scripts\/gds\/publish-integration-gate\.js --sha "\$\(git rev-parse HEAD\)"/, 'HEAD — the checkout is ref: main, not the trigger sha');
175
+ assert.match(g, /GH_TOKEN: \$\{\{ github\.token \}\}/);
176
+ });
177
+
178
+ await test('publish.yml: the gate runs before anything is installed, bumped, packed, published, recorded or tagged', async () => {
179
+ const at = stepIndex(/id: integ\n/);
180
+ assert.ok(at > stepIndex(/id: relhead\n/), 'after the cascade terminator (a carrier HEAD needs no gate)');
181
+ for (const re of [/actions\/setup-node@/, /run: npm ci\b/, /id: ver\n/, /id: bump\n/, /id: pack\n/, /id: pub\n/, /git push origin HEAD:main/, /git tag "core-v/]) {
182
+ const i = stepIndex(re);
183
+ assert.ok(i > at, `${re} must come after the integration gate (at ${i}, gate at ${at})`);
184
+ }
185
+ });
186
+
187
+ await test('publish.yml: every armed step after the gate also requires its go — no step can slip past it', async () => {
188
+ const at = stepIndex(/id: integ\n/);
189
+ const later = steps.slice(at + 1).filter((s) => /steps\.armed\.outputs\.armed == 'true'/.test(s));
190
+ assert.ok(later.length >= 9, `expected the install..tag chain after the gate, found ${later.length}`);
191
+ for (const s of later) {
192
+ const name = (/- (?:name: (.*)|uses: (.*))/.exec(s) || [])[1] || s.slice(0, 60);
193
+ assert.match(s, /steps\.integ\.outputs\.go == 'true'/, `"${name}" runs without the integration gate`);
194
+ }
195
+ });
196
+
197
+ console.log(`\npublish integration gate (task 1004290) tests: ${passed} passed, ${failed} failed`);
198
+ process.exitCode = failed > 0 ? 1 : 0;
@@ -0,0 +1,168 @@
1
+ // tests/ship_land_ci_not_started.mjs — a land wait that says "CI never ran" when it did
2
+ // not, instead of "fix your code" (task 1004289).
3
+ //
4
+ // WHY. During the 2026-09-25 GitHub Actions budget outage (blocker 1000173) every job on
5
+ // every PR "failed" in about two seconds without running: conclusion FAILURE, no steps, no
6
+ // runner, and one annotation — "The job was not started because an Actions budget is
7
+ // preventing further use." Task 1003764's red-check reader saw an ordinary red `unit`
8
+ // check and told the builder on PR 1211 to fix it, commit and re-run, plus the "CI tests
9
+ // your branch MERGED with main" trap. None of that can help: nothing was tested, and the
10
+ // fix is the owner's, on the GitHub account. These drive the laptop poll with a fake gh
11
+ // carrying the REAL payloads from PR 1211 (run 36210373664, attempt 1).
12
+ //
13
+ // Run: node tests/ship_land_ci_not_started.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import {
18
+ jobNeverStarted, landPendingLines, localMergeDriver, notStartedLines, pollCiDeploy, redBlocksMerge, redPrLine, redPrLines,
19
+ } from '../scripts/gds/ship-land.js';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const { CARDS, cardFor } = require('../scripts/gds/plain-cards.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ async function test(name, fn) {
27
+ try { await fn(); passed++; console.log(` ok ${name}`); }
28
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
29
+ }
30
+ async function capture(fn) {
31
+ const lines = [];
32
+ const orig = console.log;
33
+ console.log = (...a) => { lines.push(a.join(' ')); };
34
+ try { return { result: await fn(), out: lines.join('\n') }; }
35
+ finally { console.log = orig; }
36
+ }
37
+
38
+ const SHA = 'b9b03b03687ce823c016a29b0141736a3595c547';
39
+ const RUN = '36210373664';
40
+ const JOB_UNIT = '108315429264';
41
+ const JOB_REPORT = '108315436139';
42
+ const jobUrl = (job) => `https://github.com/example-org/example-repo/actions/runs/${RUN}/job/${job}`;
43
+ const checkRun = (name, job, conclusion = 'FAILURE') => ({
44
+ __typename: 'CheckRun', name, conclusion, status: 'COMPLETED', workflowName: 'unit', detailsUrl: jobUrl(job),
45
+ startedAt: '2026-09-26T02:01:46Z', completedAt: '2026-09-26T02:01:48Z',
46
+ });
47
+ const ctx = (context, state) => ({ __typename: 'StatusContext', context, state, targetUrl: '' });
48
+ const openPr = (rollup) => ({ state: 'OPEN', mergeCommit: null, mergeStateStatus: 'BLOCKED', headRefOid: SHA, statusCheckRollup: rollup });
49
+ // PR 1211's rollup during the outage: both unit-workflow jobs "failed", the trust status green.
50
+ const OUTAGE_ROLLUP = [checkRun('unit', JOB_UNIT), checkRun('report', JOB_REPORT), ctx('gate-author-trust', 'SUCCESS')];
51
+
52
+ // The two annotations GitHub returned for job 108315429264, verbatim (paths trimmed).
53
+ const BUDGET_NOTES = [
54
+ { path: '.github', annotation_level: 'failure', title: '', message: 'The job was not started because an Actions budget is preventing further use.' },
55
+ { path: '.github', annotation_level: 'notice', title: '', message: '"The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026."' },
56
+ ];
57
+ const NOTICE_ONLY = [BUDGET_NOTES[1]];
58
+ const annotationsPath = (job) => `repos/{owner}/{repo}/check-runs/${job}/annotations`;
59
+
60
+ // ---- the detector ----
61
+
62
+ await test('jobNeverStarted: the budget annotation on the job reads as never started', async () => {
63
+ const asked = [];
64
+ const gh = (args) => { asked.push(args); return BUDGET_NOTES; };
65
+ assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), gh), true);
66
+ assert.deepEqual(asked, [['api', annotationsPath(JOB_UNIT)]], 'reads the check run named by the job id in its detailsUrl');
67
+ });
68
+
69
+ await test('jobNeverStarted: the payment-failed wording is the same shape and is caught too', async () => {
70
+ const gh = () => [{ annotation_level: 'failure', message: 'The job was not started because recent account payments have failed or your spending limit needs to be increased.' }];
71
+ assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), gh), true);
72
+ });
73
+
74
+ await test('jobNeverStarted is FAIL-OPEN: a real failure, an unreadable read, or no job id is "no"', async () => {
75
+ assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), () => NOTICE_ONLY), false, 'a job that ran and failed');
76
+ assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), () => null), false, 'gh could not read the annotations');
77
+ assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), () => ({ message: 'Not Found' })), false, 'an error object, not a list');
78
+ let calls = 0;
79
+ const gh = () => { calls++; return BUDGET_NOTES; };
80
+ assert.equal(jobNeverStarted({ __typename: 'CheckRun', name: 'x', detailsUrl: 'https://example.com/checks' }, gh), false);
81
+ assert.equal(jobNeverStarted(ctx('unit-report', 'FAILURE'), gh), false, 'a commit status is not a job');
82
+ assert.equal(calls, 0, 'nothing to look up, so nothing is asked');
83
+ });
84
+
85
+ // ---- the merge-driver answer and the lines it prints ----
86
+
87
+ await test('localMergeDriver: every red check never started → ci_not_started, not a failing check', async () => {
88
+ const md = localMergeDriver(openPr(OUTAGE_ROLLUP), () => 'must not be asked', () => true);
89
+ assert.deepEqual(md, { reason: 'ci_not_started:unit', not_started: true, failing_tests: null, run_id: RUN });
90
+ });
91
+
92
+ await test('localMergeDriver: one real failure beside a never-started job is still a failing check', async () => {
93
+ const rollup = [checkRun('unit', JOB_UNIT), checkRun('lint', '999', 'FAILURE')];
94
+ const md = localMergeDriver(openPr(rollup), () => null, (c) => c.name === 'unit');
95
+ assert.equal(md.reason, 'failing_check:unit');
96
+ assert.equal(md.not_started, undefined);
97
+ });
98
+
99
+ await test('localMergeDriver: without a never-started answer (the default) nothing changes', async () => {
100
+ assert.deepEqual(localMergeDriver(openPr(OUTAGE_ROLLUP), () => null), { reason: 'failing_check:unit', failing_tests: null });
101
+ });
102
+
103
+ await test('the red line says CI never ran and the work needs nothing; no fix-your-code, no merge trap', async () => {
104
+ const md = { reason: 'ci_not_started:unit', not_started: true, failing_tests: null, run_id: RUN };
105
+ const out = redPrLines(md, { taskId: 42, via: 'local' }).join('\n');
106
+ assert.match(redPrLine(md), /CI NEVER RAN/);
107
+ assert.match(out, /the `unit` job/);
108
+ assert.match(out, /nothing in it to fix/);
109
+ assert.match(out, /repo OWNER's to fix/);
110
+ assert.match(out, new RegExp(`gh run rerun ${RUN} --failed`), 'names the re-run for the exact run');
111
+ assert.match(out, /do not re-push or edit the work/);
112
+ assert.doesNotMatch(out, /fix it, commit/, 'the advice that cannot help is gone');
113
+ assert.doesNotMatch(out, /MERGED with the current main/, 'and so is the merge trap');
114
+ assert.ok(out.includes(CARDS.ci_not_started.happened), 'the plain card rides along');
115
+ assert.ok(!out.includes(CARDS.failing_check.happened), 'not the "a test failed" card');
116
+ assert.equal(cardFor('ci_not_started:unit'), CARDS.ci_not_started, 'the named reason resolves to its card');
117
+ assert.equal(redBlocksMerge(md), false, 'it is not the failing-check kind of red');
118
+ });
119
+
120
+ await test('notStartedLines without a run id points at the PR button instead of inventing a command', async () => {
121
+ const out = notStartedLines({ reason: 'ci_not_started:unit', not_started: true, run_id: null }).join('\n');
122
+ assert.match(out, /"Re-run failed jobs" on the PR/);
123
+ assert.doesNotMatch(out, /gh run rerun/);
124
+ });
125
+
126
+ await test('the closing banner for a never-started CI is neither "NOT a failure" nor "fix the check"', async () => {
127
+ const out = landPendingLines(42, { notStarted: true }).join('\n');
128
+ assert.match(out, /CI never started/);
129
+ assert.match(out, /Nothing in the work needs to change/);
130
+ assert.match(out, /node scripts\/gds\/ship\.js 42/);
131
+ assert.doesNotMatch(out, /NOT a failure/);
132
+ assert.doesNotMatch(out, /Do what the lines above say/);
133
+ });
134
+
135
+ // ---- the laptop poll, end to end ----
136
+
137
+ await test('pollCiDeploy (laptop): an Actions-budget outage is named as CI never running, once, with no code advice', async () => {
138
+ const seen = { prView: 0, annotations: 0, status: 0 };
139
+ const gh = (args) => {
140
+ if (args[0] === 'pr' && args[1] === 'view') { seen.prView++; return openPr(OUTAGE_ROLLUP); }
141
+ if (args[0] === 'api' && /check-runs\/\d+\/annotations$/.test(args[1])) { seen.annotations++; return BUDGET_NOTES; }
142
+ if (args[0] === 'api') { seen.status++; return { statuses: [] }; }
143
+ throw new Error(`unexpected gh call: ${args.join(' ')}`);
144
+ };
145
+ const { result, out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
146
+ assert.equal(result.pending, true, 'the land is still pending — the checks can yet run');
147
+ assert.equal(seen.prView, 30, 'polled the whole window');
148
+ assert.equal(seen.annotations, 2, 'each job\'s annotations are read once, not once per poll');
149
+ assert.equal(out.split('CI NEVER RAN on this PR').length - 1, 1, 'named once');
150
+ assert.match(out, /CI never started on the PR/, 'the closing banner says the same');
151
+ assert.doesNotMatch(out, /fix it, commit/);
152
+ assert.doesNotMatch(out, /MERGED with the current main/);
153
+ assert.doesNotMatch(out, /This is NOT a failure/);
154
+ assert.ok(!out.includes(CARDS.land_pending.happened), 'nor the "nothing to do" card');
155
+ });
156
+
157
+ await test('pollCiDeploy: when the annotations cannot be read, it falls back to the ordinary red wording', async () => {
158
+ const gh = (args) => {
159
+ if (args[0] === 'pr' && args[1] === 'view') return openPr(OUTAGE_ROLLUP);
160
+ return null; // no Checks read on this gh, or the API is down
161
+ };
162
+ const { out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
163
+ assert.match(out, /the PR is RED — the `unit` check failed/);
164
+ assert.doesNotMatch(out, /CI NEVER RAN/);
165
+ });
166
+
167
+ console.log(`\nship-land CI never started (task 1004289) tests: ${passed} passed, ${failed} failed`);
168
+ process.exitCode = failed > 0 ? 1 : 0;