@bongos/core 1.19.782 → 1.19.784
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 +65 -25
- package/docs/adr/0291-the-accessibility-dimension-scores-the-transcript-not-the-diff.md +35 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +17 -14
- package/docs/copy-registry.json +46 -19
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/sessions.html +3 -1
- package/modules/hall-ui/public/sessions.js +33 -1
- package/modules/lifecycle/github-push.js +70 -15
- package/modules/sessions/accessibility.js +373 -0
- package/modules/sessions/db.js +11 -0
- package/modules/sessions/routes/sessions.js +35 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/artifact-audit.js +309 -0
- package/scripts/gds/session-eval.js +54 -4
- package/scripts/gds/start.js +26 -3
- package/src/module-api.js +1 -1
- package/tests/artifact_audit.mjs +210 -0
- package/tests/auto_merge_diagnosis.mjs +313 -0
- package/tests/hall_roster_world.mjs +6 -2
- package/tests/hall_sessions_ledger.mjs +94 -0
- package/tests/session_accessibility.mjs +243 -0
- package/tests/session_eval.mjs +52 -1
- package/tests/start_inbox_count.mjs +112 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// tests/artifact_audit.mjs — the check that asks whether a task's claimed work exists
|
|
2
|
+
// (task 1003200). Hermetic: pure fixtures, no DB, no API, no git.
|
|
3
|
+
//
|
|
4
|
+
// THE FIXTURES ARE THE THREE REAL CASES. On 2026-08-23 three tasks were found, one at a time
|
|
5
|
+
// and all by accident, to be finished-on-paper and absent from the remote. The task that asked
|
|
6
|
+
// for this check said it is only worth having if it would have caught all three, so they are
|
|
7
|
+
// the fixtures, in their 2026-08-23 shape:
|
|
8
|
+
//
|
|
9
|
+
// 1003158 "BUILT, tested and committed, 33 tests green" — branch absent from origin
|
|
10
|
+
// 1002459 confirmed, verified and CREDITED — no branch anywhere
|
|
11
|
+
// 1002792 completed — the validator it claimed to change was untouched
|
|
12
|
+
//
|
|
13
|
+
// (All three have since been recovered and are evidenced on main today, which is exactly why
|
|
14
|
+
// the fixtures are frozen here rather than read live: a regression test whose subject can be
|
|
15
|
+
// fixed underneath it stops testing anything.)
|
|
16
|
+
//
|
|
17
|
+
// The fourth fixture is the one that makes this check trustworthy rather than merely loud:
|
|
18
|
+
// 1003212's fix IS on main and is cited in a code comment, but the commit that carried it
|
|
19
|
+
// never named the task. A message-only index calls that stranded. It must not.
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const {
|
|
26
|
+
auditArtifacts, declaresArtifact, taskIdsIn,
|
|
27
|
+
AUDITED_STATUSES, ASSURANCE_GATE_DATE, fetchAuditedTasks,
|
|
28
|
+
fileBlocker, blockerSourceRef,
|
|
29
|
+
} = require('../scripts/gds/artifact-audit.js');
|
|
30
|
+
|
|
31
|
+
let passed = 0, failed = 0;
|
|
32
|
+
const t = async (n, f) => {
|
|
33
|
+
try { await f(); passed++; console.log(` PASS ${n}`); }
|
|
34
|
+
catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); }
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const NONE = { messageIds: new Set(), contentIds: new Set() };
|
|
38
|
+
|
|
39
|
+
// The three, in the shape they had on the day they were found.
|
|
40
|
+
const SEEDS = [
|
|
41
|
+
{ id: '1003158', status: 'shipped', shipped_at: '2026-08-25T00:00:00Z', title: 'auto-upgrade loud', touches: ['scripts/gds/deploy-divergence.js'] },
|
|
42
|
+
{ id: '1002459', status: 'confirmed', shipped_at: '2026-09-04T00:00:00Z', title: 'blocker detail hydration', touches: ['modules/ideas/blockers.js'] },
|
|
43
|
+
{ id: '1002792', status: 'completed', shipped_at: '2026-08-27T00:00:00Z', title: 'PATCH /tasks/:id title', touches: ['modules/lifecycle/routes/tasks.js'] },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------- the three real cases
|
|
47
|
+
|
|
48
|
+
await t('catches all THREE of the 2026-08-23 cases — the bar this check was asked to clear', () => {
|
|
49
|
+
const { stranded } = auditArtifacts(SEEDS, NONE);
|
|
50
|
+
const ids = stranded.map((s) => s.id).sort();
|
|
51
|
+
assert.deepEqual(ids, ['1002459', '1002792', '1003158'],
|
|
52
|
+
'every one of the three finished-on-paper tasks must surface');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await t('catches them across all three audited statuses, not just confirmed', () => {
|
|
56
|
+
// The reconciler only ever sweeps `confirmed`. `shipped` is the dangerous one: credit is
|
|
57
|
+
// booked and nothing will look again.
|
|
58
|
+
const seen = new Set(auditArtifacts(SEEDS, NONE).stranded.map((s) => s.status));
|
|
59
|
+
assert.deepEqual([...seen].sort(), ['completed', 'confirmed', 'shipped']);
|
|
60
|
+
assert.ok(AUDITED_STATUSES.includes('shipped'), 'shipped must be audited');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ------------------------------------------------------- the false-positive that matters
|
|
64
|
+
|
|
65
|
+
await t('a task landed under ANOTHER branch is proven by file content, not called stranded', () => {
|
|
66
|
+
// task 1003212: the fix is on main, cited in a comment; no commit message names the task.
|
|
67
|
+
const t1 = { id: '1003212', status: 'shipped', shipped_at: '2026-08-23T00:00:00Z', touches: ['.claude/scheduled-tasks/core-update-subscription/subscribe.js'] };
|
|
68
|
+
const only = auditArtifacts([t1], { messageIds: new Set(), contentIds: new Set(['1003212']) });
|
|
69
|
+
assert.equal(only.stranded.length, 0, 'content evidence must clear it');
|
|
70
|
+
assert.equal(only.evidenced[0].how, 'file-content', 'and the report must say HOW it was proven');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await t('commit-message evidence is reported as such', () => {
|
|
74
|
+
const r = auditArtifacts([SEEDS[0]], { messageIds: new Set(['1003158']), contentIds: new Set() });
|
|
75
|
+
assert.equal(r.stranded.length, 0);
|
|
76
|
+
assert.equal(r.evidenced[0].how, 'commit-message');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// --------------------------------------------------------------- the three-way distinction
|
|
80
|
+
|
|
81
|
+
await t('a ship that declares NO artifact is codeless, never stranded', () => {
|
|
82
|
+
const gov = { id: '1003726', status: 'shipped', shipped_at: '2026-09-09T00:00:00Z', title: 'Release the stale July claim', touches: [] };
|
|
83
|
+
const r = auditArtifacts([gov], NONE);
|
|
84
|
+
assert.equal(r.stranded.length, 0, 'a goal edit / claim release has no code artifact to find');
|
|
85
|
+
assert.equal(r.codeless.length, 1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('touches[] of empty strings is not a declaration', () => {
|
|
89
|
+
assert.equal(declaresArtifact({ touches: ['', ' '] }), false);
|
|
90
|
+
assert.equal(declaresArtifact({ touches: [] }), false);
|
|
91
|
+
assert.equal(declaresArtifact({}), false);
|
|
92
|
+
assert.equal(declaresArtifact({ touches: ['src/a.js'] }), true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t('a PRE-gate ship is unprovable by design and is never an alarm', () => {
|
|
96
|
+
// Before 2026-08-19 a DB-only ship recorded no declaration, so silence means nothing.
|
|
97
|
+
// 1469 older ships would otherwise drown the three that matter.
|
|
98
|
+
const old = { id: '1000001', status: 'shipped', shipped_at: '2026-07-01T00:00:00Z', touches: ['src/a.js'] };
|
|
99
|
+
const r = auditArtifacts([old], NONE);
|
|
100
|
+
assert.equal(r.stranded.length, 0);
|
|
101
|
+
assert.equal(r.preGate.length, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('the gate boundary is inclusive of the gate day itself', () => {
|
|
105
|
+
const onGate = { id: '1003133', status: 'shipped', shipped_at: `${ASSURANCE_GATE_DATE}T00:00:00Z`, touches: ['src/a.js'] };
|
|
106
|
+
assert.equal(auditArtifacts([onGate], NONE).stranded.length, 1, 'the gate day is audited');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await t('a ship with no date at all is treated as pre-gate, not as a strand', () => {
|
|
110
|
+
const undated = { id: '1000002', status: 'shipped', shipped_at: null, touches: ['src/a.js'] };
|
|
111
|
+
const r = auditArtifacts([undated], NONE);
|
|
112
|
+
assert.equal(r.stranded.length, 0);
|
|
113
|
+
assert.equal(r.preGate.length, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('statuses outside the audited set are ignored entirely', () => {
|
|
117
|
+
const ready = { id: '1000003', status: 'ready', shipped_at: '2026-09-01T00:00:00Z', touches: ['src/a.js'] };
|
|
118
|
+
const r = auditArtifacts([ready], NONE);
|
|
119
|
+
assert.equal(r.stranded.length + r.codeless.length + r.preGate.length + r.evidenced.length, 0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------------------- the id parser
|
|
123
|
+
|
|
124
|
+
await t('task ids are recognised in every form the repo writes them', () => {
|
|
125
|
+
const ids = taskIdsIn('fix(x): thing (task 1003200)\n\nsee task-1002459 and task #1003158');
|
|
126
|
+
assert.deepEqual([...ids].sort(), ['1002459', '1003158', '1003200']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await t('a PR or issue number is NOT read as a task id', () => {
|
|
130
|
+
// `PR #847` and `#123` must not evidence anything — ids are 6-7 digits.
|
|
131
|
+
assert.equal(taskIdsIn('Merge pull request #847 from acme/task-1003942').has('847'), false);
|
|
132
|
+
assert.deepEqual([...taskIdsIn('Merge pull request #847 from acme/task-1003942')], ['1003942']);
|
|
133
|
+
assert.equal(taskIdsIn('fixes #1234').size, 0, 'four digits is not a task id');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await t('an empty evidence set never throws and never proves anything', () => {
|
|
137
|
+
assert.equal(auditArtifacts(SEEDS, { messageIds: undefined, contentIds: undefined }).stranded.length, 3);
|
|
138
|
+
assert.deepEqual(auditArtifacts(null, NONE).stranded, []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ------------------------------------------------------------------------ the paging read
|
|
142
|
+
|
|
143
|
+
await t('the audited read PAGES, and asks for no more than the route allows', async () => {
|
|
144
|
+
// The corpus is past one page; asking for more than the cap is a 400 (task 1003752).
|
|
145
|
+
const calls = [];
|
|
146
|
+
const many = (n, from) => Array.from({ length: n }, (_, i) => ({ id: from + i, status: 'shipped' }));
|
|
147
|
+
const call = async (method, path) => {
|
|
148
|
+
calls.push(path);
|
|
149
|
+
const u = new URL(path, 'http://x');
|
|
150
|
+
const limit = Number(u.searchParams.get('limit'));
|
|
151
|
+
const offset = Number(u.searchParams.get('offset') || 0);
|
|
152
|
+
assert.ok(limit <= 1000, `page asked for limit=${limit}`);
|
|
153
|
+
assert.notEqual(u.searchParams.get('offset'), '0', 'offset=0 is a 400 — omit it');
|
|
154
|
+
if (u.searchParams.get('status') !== 'shipped') return { ok: true, status: 200, data: { tasks: [] } };
|
|
155
|
+
return { ok: true, status: 200, data: { tasks: many(Math.max(0, Math.min(1000, 2500 - offset)), offset + 1) } };
|
|
156
|
+
};
|
|
157
|
+
const rows = await fetchAuditedTasks({ call });
|
|
158
|
+
assert.equal(rows.length, 2500, 'every page is read');
|
|
159
|
+
assert.ok(calls.length >= 3);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await t('a failed page THROWS rather than returning a short corpus', async () => {
|
|
163
|
+
const call = async () => ({ ok: false, status: 500, data: {} });
|
|
164
|
+
await assert.rejects(() => fetchAuditedTasks({ call }), /500/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ------------------------------------------------------------------- the human surface
|
|
168
|
+
|
|
169
|
+
await t('a finding is filed to the blocker surface, not only printed', async () => {
|
|
170
|
+
const sent = [];
|
|
171
|
+
const call = async (method, path, body) => { sent.push({ method, path, body }); return { ok: true, status: 201, data: { blocker: { id: 42 } } }; };
|
|
172
|
+
const r = await fileBlocker([{ id: '1003188', status: 'shipped', shipped_at: '2026-08-23', title: 'Go-live', touches: ['package.json'] }], { call });
|
|
173
|
+
assert.equal(r.filed, true);
|
|
174
|
+
assert.equal(sent[0].path, '/api/gds/blockers');
|
|
175
|
+
assert.equal(sent[0].body.source, 'artifact-audit');
|
|
176
|
+
assert.match(sent[0].body.body_md, /task 1003188/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await t('the same finding set does not file a second blocker', async () => {
|
|
180
|
+
// POST /blockers dedupes on (source, source_ref) and answers 200 {skipped}. A check that
|
|
181
|
+
// files a fresh blocker every run is a check somebody mutes within a week.
|
|
182
|
+
const call = async () => ({ ok: true, status: 200, data: { skipped: true, reason: 'duplicate_source_ref' } });
|
|
183
|
+
const r = await fileBlocker([{ id: '1', status: 'shipped', shipped_at: '2026-09-01', title: 'x', touches: ['a.js'] }], { call });
|
|
184
|
+
assert.equal(r.filed, false);
|
|
185
|
+
assert.match(r.reason, /already filed/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await t('source_ref keys on the finding SET, so a NEW strand still gets through', () => {
|
|
189
|
+
const a = [{ id: '1003188' }, { id: '1003230' }];
|
|
190
|
+
const b = [{ id: '1003188' }, { id: '1003230' }, { id: '1003999' }];
|
|
191
|
+
assert.equal(blockerSourceRef(a), blockerSourceRef([...a].reverse()), 'order must not matter');
|
|
192
|
+
assert.notEqual(blockerSourceRef(a), blockerSourceRef(b), 'a new finding must file a new blocker');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await t('nothing to report files nothing at all', async () => {
|
|
196
|
+
let called = false;
|
|
197
|
+
const r = await fileBlocker([], { call: async () => { called = true; return { ok: true, status: 201, data: {} }; } });
|
|
198
|
+
assert.equal(r.filed, false);
|
|
199
|
+
assert.equal(called, false, 'a clean board must not touch the API');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await t('a failed POST is reported, never swallowed as success', async () => {
|
|
203
|
+
const r = await fileBlocker([{ id: '1', status: 'shipped', shipped_at: '2026-09-01', title: 'x', touches: ['a.js'] }],
|
|
204
|
+
{ call: async () => ({ ok: false, status: 503, data: {} }) });
|
|
205
|
+
assert.equal(r.filed, false);
|
|
206
|
+
assert.match(r.reason, /503/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
console.log(`\nartifact_audit: ${passed} passed, ${failed} failed`);
|
|
210
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// tests/auto_merge_diagnosis.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003511 — an auto-merge arming failure must leave a TRACE, not just a false.
|
|
4
|
+
//
|
|
5
|
+
// task 1001431 already gave enableAutoMergeDetailed a { ok, reason } and surfaced
|
|
6
|
+
// that reason in the publish-branch 201. But the reason travelled in exactly one
|
|
7
|
+
// direction. Nothing was logged, so:
|
|
8
|
+
// - an operator asking "why did seven PRs in a row need a manual merge?" had
|
|
9
|
+
// nothing to read; the cause existed only in response bodies already thrown away
|
|
10
|
+
// - recheckPullRequest (the owner-approval re-arm) called the BOOLEAN façade and
|
|
11
|
+
// dropped the reason on the floor, and its bare `catch (_)` swallowed the throw
|
|
12
|
+
// as well — no log AND no response field, the quietest of the four shapes
|
|
13
|
+
//
|
|
14
|
+
// A cause the server computes and then discards is the same outage as one it never
|
|
15
|
+
// computed. These assertions pin that every failure shape is both REPORTED and
|
|
16
|
+
// LOGGED, and that the success path stays quiet.
|
|
17
|
+
//
|
|
18
|
+
// DB-free: the GitHub call is deps.fetchImpl and the log sink is deps.logImpl, the
|
|
19
|
+
// file's existing injection idiom. Nothing here touches a network or a database.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/auto_merge_diagnosis.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const gp = require('../modules/lifecycle/github-push.js');
|
|
28
|
+
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let failed = 0;
|
|
31
|
+
async function t(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A sink that records what the module logged.
|
|
37
|
+
function spy() {
|
|
38
|
+
const lines = [];
|
|
39
|
+
return { lines, warn: (msg, fields) => lines.push({ msg, ...fields }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const okFetch = async () => ({ ok: true, json: async () => ({ data: {} }) });
|
|
43
|
+
const graphqlErr = (message) => async () => ({
|
|
44
|
+
ok: true, json: async () => ({ errors: [{ message }] }),
|
|
45
|
+
});
|
|
46
|
+
const httpErr = (status) => async () => ({ ok: false, status, json: async () => ({}) });
|
|
47
|
+
const throwFetch = async () => { throw new Error('socket hang up'); };
|
|
48
|
+
|
|
49
|
+
// --- the success path stays quiet ------------------------------------------
|
|
50
|
+
|
|
51
|
+
await t('arming successfully logs NOTHING (the log is for failures only)', async () => {
|
|
52
|
+
const log = spy();
|
|
53
|
+
const r = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { token: 'TT', fetchImpl: okFetch, logImpl: log });
|
|
54
|
+
assert.equal(r.ok, true);
|
|
55
|
+
assert.equal(r.reason, null);
|
|
56
|
+
assert.equal(log.lines.length, 0, 'a green arming must not log');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- all four failure shapes are reported AND logged -----------------------
|
|
60
|
+
|
|
61
|
+
await t('repo forbids auto-merge: GitHub\'s own message is reported and logged', async () => {
|
|
62
|
+
const log = spy();
|
|
63
|
+
const msg = 'Auto-merge is not allowed for this repository';
|
|
64
|
+
const r = await gp.enableAutoMergeDetailed(
|
|
65
|
+
{ nodeId: 'N1' }, { token: 'TT', fetchImpl: graphqlErr(msg), logImpl: log });
|
|
66
|
+
assert.equal(r.ok, false);
|
|
67
|
+
assert.equal(r.reason, msg, 'the actionable message must survive to the caller');
|
|
68
|
+
assert.equal(log.lines.length, 1, 'exactly one log line per failure');
|
|
69
|
+
assert.equal(log.lines[0].reason, msg);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await t('HTTP failure: the status is carried, not discarded', async () => {
|
|
73
|
+
const log = spy();
|
|
74
|
+
const r = await gp.enableAutoMergeDetailed(
|
|
75
|
+
{ nodeId: 'N1' }, { token: 'TT', fetchImpl: httpErr(502), logImpl: log });
|
|
76
|
+
assert.equal(r.ok, false);
|
|
77
|
+
assert.match(r.reason, /502/, 'the HTTP status is the whole diagnosis here');
|
|
78
|
+
assert.equal(log.lines.length, 1);
|
|
79
|
+
assert.match(log.lines[0].reason, /502/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t('a thrown request is reported and logged, not swallowed', async () => {
|
|
83
|
+
const log = spy();
|
|
84
|
+
const r = await gp.enableAutoMergeDetailed(
|
|
85
|
+
{ nodeId: 'N1' }, { token: 'TT', fetchImpl: throwFetch, logImpl: log });
|
|
86
|
+
assert.equal(r.ok, false);
|
|
87
|
+
assert.match(r.reason, /socket hang up/);
|
|
88
|
+
assert.equal(log.lines.length, 1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await t('no credential is a distinct, logged reason — not the same false as the rest', async () => {
|
|
92
|
+
const log = spy();
|
|
93
|
+
const r = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { fetchImpl: okFetch, logImpl: log });
|
|
94
|
+
assert.equal(r.ok, false);
|
|
95
|
+
assert.match(r.reason, /token/i);
|
|
96
|
+
assert.equal(log.lines.length, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await t('the four failure shapes are mutually DISTINGUISHABLE (the whole point)', async () => {
|
|
100
|
+
const log = spy();
|
|
101
|
+
const reasons = [];
|
|
102
|
+
for (const deps of [
|
|
103
|
+
{ token: 'TT', fetchImpl: graphqlErr('Auto-merge is not allowed for this repository') },
|
|
104
|
+
{ token: 'TT', fetchImpl: httpErr(502) },
|
|
105
|
+
{ token: 'TT', fetchImpl: throwFetch },
|
|
106
|
+
{ fetchImpl: okFetch },
|
|
107
|
+
]) {
|
|
108
|
+
const r = await gp.enableAutoMergeDetailed({ nodeId: 'N1' }, { ...deps, logImpl: log });
|
|
109
|
+
reasons.push(r.reason);
|
|
110
|
+
}
|
|
111
|
+
assert.equal(new Set(reasons).size, 4, 'all four causes must read differently');
|
|
112
|
+
assert.equal(log.lines.length, 4, 'and each must have left its own log line');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- no credential may reach the log ---------------------------------------
|
|
116
|
+
|
|
117
|
+
await t('the token never appears in the reason or the log line', async () => {
|
|
118
|
+
const log = spy();
|
|
119
|
+
const TOKEN = 'ghs_LIVECREDENTIALDONOTLOG0123456789';
|
|
120
|
+
const leaky = async () => { throw new Error(`request failed with header token ${TOKEN}`); };
|
|
121
|
+
const r = await gp.enableAutoMergeDetailed(
|
|
122
|
+
{ nodeId: 'N1' }, { token: TOKEN, fetchImpl: leaky, logImpl: log });
|
|
123
|
+
assert.equal(r.ok, false);
|
|
124
|
+
assert.ok(!String(r.reason).includes(TOKEN), 'the reason must be credential-scrubbed');
|
|
125
|
+
assert.ok(!JSON.stringify(log.lines).includes(TOKEN), 'and so must the log line');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// --- the re-arm path: the quietest shape ------------------------------------
|
|
129
|
+
|
|
130
|
+
await t('recheckPullRequest reports WHY the re-arm failed instead of a bare false', async () => {
|
|
131
|
+
const log = spy();
|
|
132
|
+
const msg = 'Auto-merge is not allowed for this repository';
|
|
133
|
+
let graphqlCalls = 0;
|
|
134
|
+
const fetchImpl = async (url, opts) => {
|
|
135
|
+
if (String(url).includes('/graphql')) {
|
|
136
|
+
graphqlCalls++;
|
|
137
|
+
return { ok: true, json: async () => ({ errors: [{ message: msg }] }) };
|
|
138
|
+
}
|
|
139
|
+
// the close / reopen PATCHes
|
|
140
|
+
assert.equal(opts.method, 'PATCH');
|
|
141
|
+
return { ok: true, json: async () => ({ node_id: 'N9' }) };
|
|
142
|
+
};
|
|
143
|
+
const r = await gp.recheckPullRequest({ number: 847 }, {
|
|
144
|
+
token: 'TT', fetchImpl, logImpl: log, repoInfo: { owner: 'o', repo: 'r' },
|
|
145
|
+
reopenDelayMs: 0, sleepImpl: async () => {},
|
|
146
|
+
});
|
|
147
|
+
assert.equal(graphqlCalls, 1, 'the re-arm was actually attempted');
|
|
148
|
+
assert.equal(r.reopened, true, 'the recheck itself still succeeded');
|
|
149
|
+
assert.equal(r.auto_merge, false);
|
|
150
|
+
assert.equal(r.auto_merge_reason, msg, 'the reason must no longer be dropped on the floor');
|
|
151
|
+
assert.equal(log.lines.length, 1, 'and it must be logged');
|
|
152
|
+
assert.match(log.lines[0].context, /847/, 'the log line names the PR it is about');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('a successful re-arm carries NO reason field and logs nothing', async () => {
|
|
156
|
+
const log = spy();
|
|
157
|
+
const fetchImpl = async (url) => (String(url).includes('/graphql')
|
|
158
|
+
? { ok: true, json: async () => ({ data: {} }) }
|
|
159
|
+
: { ok: true, json: async () => ({ node_id: 'N9' }) });
|
|
160
|
+
const r = await gp.recheckPullRequest({ number: 848 }, {
|
|
161
|
+
token: 'TT', fetchImpl, logImpl: log, repoInfo: { owner: 'o', repo: 'r' },
|
|
162
|
+
reopenDelayMs: 0, sleepImpl: async () => {},
|
|
163
|
+
});
|
|
164
|
+
assert.equal(r.auto_merge, true);
|
|
165
|
+
assert.ok(!('auto_merge_reason' in r), 'no reason field on the happy path');
|
|
166
|
+
assert.equal(log.lines.length, 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// --- the seam itself is never load-bearing ---------------------------------
|
|
170
|
+
|
|
171
|
+
await t('a logger that throws cannot break arming', async () => {
|
|
172
|
+
const boom = { warn: () => { throw new Error('log sink is down'); } };
|
|
173
|
+
const r = await gp.enableAutoMergeDetailed(
|
|
174
|
+
{ nodeId: 'N1' }, { token: 'TT', fetchImpl: httpErr(500), logImpl: boom });
|
|
175
|
+
assert.equal(r.ok, false, 'the arming result still comes back');
|
|
176
|
+
assert.match(r.reason, /500/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await t('the boolean façade still answers a bare true/false for its existing callers', async () => {
|
|
180
|
+
const log = spy();
|
|
181
|
+
assert.equal(await gp.enableAutoMerge({ nodeId: 'N1' }, { token: 'TT', fetchImpl: okFetch, logImpl: log }), true);
|
|
182
|
+
assert.equal(await gp.enableAutoMerge({ nodeId: 'N1' }, { token: 'TT', fetchImpl: httpErr(403), logImpl: log }), false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// --- the re-arm's no-node-id branch (grade finding: uncovered) --------------
|
|
186
|
+
|
|
187
|
+
await t('a reopen that returns no node id is itself a logged, reported failure', async () => {
|
|
188
|
+
const log = spy();
|
|
189
|
+
// GitHub answered the reopen PATCH, but with no node_id — there is nothing to arm.
|
|
190
|
+
// Before this task that fell into `autoMerge = false` with no log and no field.
|
|
191
|
+
const fetchImpl = async (url) => {
|
|
192
|
+
assert.ok(!String(url).includes('/graphql'), 'nothing to arm — no mutation should be sent');
|
|
193
|
+
return { ok: true, json: async () => ({}) };
|
|
194
|
+
};
|
|
195
|
+
const r = await gp.recheckPullRequest({ number: 849 }, {
|
|
196
|
+
token: 'TT', fetchImpl, logImpl: log, repoInfo: { owner: 'o', repo: 'r' },
|
|
197
|
+
reopenDelayMs: 0, sleepImpl: async () => {},
|
|
198
|
+
});
|
|
199
|
+
assert.equal(r.reopened, true);
|
|
200
|
+
assert.equal(r.auto_merge, false);
|
|
201
|
+
assert.match(r.auto_merge_reason, /no node id/i, 'the cause must be named, not implied');
|
|
202
|
+
assert.equal(r.node_id, null);
|
|
203
|
+
assert.equal(log.lines.length, 1, 'and it must be logged like every other shape');
|
|
204
|
+
assert.match(log.lines[0].context, /849/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await t('a throw inside the re-arm is reported and logged, not swallowed by catch (_)', async () => {
|
|
208
|
+
const log = spy();
|
|
209
|
+
const fetchImpl = async (url) => {
|
|
210
|
+
if (String(url).includes('/graphql')) throw new Error('connection reset');
|
|
211
|
+
return { ok: true, json: async () => ({ node_id: 'N9' }) };
|
|
212
|
+
};
|
|
213
|
+
const r = await gp.recheckPullRequest({ number: 850 }, {
|
|
214
|
+
token: 'TT', fetchImpl, logImpl: log, repoInfo: { owner: 'o', repo: 'r' },
|
|
215
|
+
reopenDelayMs: 0, sleepImpl: async () => {},
|
|
216
|
+
});
|
|
217
|
+
assert.equal(r.auto_merge, false);
|
|
218
|
+
assert.match(r.auto_merge_reason, /connection reset/);
|
|
219
|
+
assert.ok(log.lines.length >= 1, 'the swallowed throw now leaves a trace');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// --- the SECOND ambiguity: deploy_status 'unconfigured' means two things ----
|
|
223
|
+
//
|
|
224
|
+
// The task named getPublishStatus as a surface: deploy_status 'unconfigured' covers
|
|
225
|
+
// both "this repo has no deploy-prod.yml" (404) and "the token cannot read the Actions
|
|
226
|
+
// API" (403). deploy_status and deploy_probe.state must NOT change — landConfirmed()
|
|
227
|
+
// and deployProbeBlind() branch on them, and folding both permanent shapes into one
|
|
228
|
+
// state is deliberate (tasks 1002472 / 1002737) so a merged PR can still flip. The fix
|
|
229
|
+
// is a machine-readable `reason` naming WHICH shape it is, since the two need
|
|
230
|
+
// different operator actions: 404 is nothing to fix, 403 is grant Actions:read.
|
|
231
|
+
|
|
232
|
+
const mergedPr = {
|
|
233
|
+
number: 1, state: 'closed', merged_at: '2026-09-01T00:00:00Z',
|
|
234
|
+
merge_commit_sha: 'abc', html_url: 'u', title: 't', body: 'b',
|
|
235
|
+
};
|
|
236
|
+
const statusFetch = (runsRes) => async (url) => (String(url).includes('/actions/')
|
|
237
|
+
? runsRes
|
|
238
|
+
: { ok: true, json: async () => [mergedPr] });
|
|
239
|
+
|
|
240
|
+
await t('404 and 403 share deploy_status but are told apart by probe.reason', async () => {
|
|
241
|
+
const noWorkflow = await gp.getPublishStatus({ branch: 'b' }, {
|
|
242
|
+
token: 'TT', repoInfo: { owner: 'o', repo: 'r' },
|
|
243
|
+
fetchImpl: statusFetch({ ok: false, status: 404, json: async () => ({}) }),
|
|
244
|
+
});
|
|
245
|
+
const forbidden = await gp.getPublishStatus({ branch: 'b' }, {
|
|
246
|
+
token: 'TT', repoInfo: { owner: 'o', repo: 'r' },
|
|
247
|
+
fetchImpl: statusFetch({ ok: false, status: 403, json: async () => ({}) }),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// unchanged on purpose — the reconciler's contract
|
|
251
|
+
assert.equal(noWorkflow.deploy_status, 'unconfigured');
|
|
252
|
+
assert.equal(forbidden.deploy_status, 'unconfigured');
|
|
253
|
+
assert.equal(noWorkflow.deploy_probe.state, 'unconfigured');
|
|
254
|
+
assert.equal(forbidden.deploy_probe.state, 'unconfigured');
|
|
255
|
+
|
|
256
|
+
// the new distinction
|
|
257
|
+
assert.equal(noWorkflow.deploy_probe.reason, 'no_deploy_workflow');
|
|
258
|
+
assert.equal(forbidden.deploy_probe.reason, 'actions_read_forbidden');
|
|
259
|
+
assert.notEqual(noWorkflow.deploy_probe.reason, forbidden.deploy_probe.reason,
|
|
260
|
+
'the two causes need different operator actions, so they must read differently');
|
|
261
|
+
assert.equal(noWorkflow.deploy_probe.http, 404);
|
|
262
|
+
assert.equal(forbidden.deploy_probe.http, 403);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await t('every probe disposition carries a reason, so the field is never absent', async () => {
|
|
266
|
+
const seen = [];
|
|
267
|
+
// success (a deploy run for the merge commit)
|
|
268
|
+
seen.push((await gp.getPublishStatus({ branch: 'b' }, {
|
|
269
|
+
token: 'TT', repoInfo: { owner: 'o', repo: 'r' },
|
|
270
|
+
fetchImpl: statusFetch({ ok: true, status: 200, json: async () => ({
|
|
271
|
+
workflow_runs: [{ status: 'completed', conclusion: 'success', head_sha: 'abc' }] }) }),
|
|
272
|
+
})).deploy_probe);
|
|
273
|
+
// pending (no matching run yet)
|
|
274
|
+
seen.push((await gp.getPublishStatus({ branch: 'b' }, {
|
|
275
|
+
token: 'TT', repoInfo: { owner: 'o', repo: 'r' },
|
|
276
|
+
fetchImpl: statusFetch({ ok: true, status: 200, json: async () => ({ workflow_runs: [] }) }),
|
|
277
|
+
})).deploy_probe);
|
|
278
|
+
// unavailable (a transient read failure)
|
|
279
|
+
seen.push((await gp.getPublishStatus({ branch: 'b' }, {
|
|
280
|
+
token: 'TT', repoInfo: { owner: 'o', repo: 'r' },
|
|
281
|
+
fetchImpl: statusFetch({ ok: false, status: 500, json: async () => ({}) }),
|
|
282
|
+
})).deploy_probe);
|
|
283
|
+
|
|
284
|
+
for (const p of seen) {
|
|
285
|
+
assert.ok(p.reason, `every disposition names its cause (state=${p.state})`);
|
|
286
|
+
assert.match(p.reason, /^[a-z0-9_]+$/, 'machine-readable, not prose');
|
|
287
|
+
}
|
|
288
|
+
assert.equal(seen[0].state, 'success');
|
|
289
|
+
assert.equal(seen[1].state, 'pending');
|
|
290
|
+
assert.equal(seen[2].state, 'unavailable');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// --- the scrub is centralized, not delegated to call sites ------------------
|
|
294
|
+
|
|
295
|
+
await t('the log sink scrubs even when the caller handed it something raw', async () => {
|
|
296
|
+
const log = spy();
|
|
297
|
+
const TOKEN = 'ghp_RAWCREDENTIALSTRAIGHTFROMACALLSITE01';
|
|
298
|
+
// Simulates a FUTURE call site that forgot to scrub before reporting.
|
|
299
|
+
const leaky = async () => ({ ok: true, json: async () => ({ errors: [{ message: `bad credential ${TOKEN}` }] }) });
|
|
300
|
+
const r = await gp.enableAutoMergeDetailed(
|
|
301
|
+
{ nodeId: 'N1' }, { token: 'TT', fetchImpl: leaky, logImpl: log });
|
|
302
|
+
assert.equal(r.ok, false);
|
|
303
|
+
assert.ok(!JSON.stringify(log.lines).includes(TOKEN), 'the log must not depend on the call site remembering');
|
|
304
|
+
assert.ok(!String(r.reason).includes(TOKEN), 'nor must the reason handed back');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await t('the log seam is NOT exported — it has no consumer outside this module', async () => {
|
|
308
|
+
assert.equal(gp.logAutoMergeFailure, undefined,
|
|
309
|
+
'an internal helper must not widen the public surface');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
console.log(`\nauto_merge_diagnosis: ${passed} passed, ${failed} failed`);
|
|
313
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -270,8 +270,12 @@ test('sessions: every id on a row is a door, the earned figure is shown, the tot
|
|
|
270
270
|
assert.match(html, /<section class="ov-summary" id="stats-scroll"/, 'the totals are the top-of-page strip');
|
|
271
271
|
assert.match(html, /<div class="stat-row" id="stats-body"><\/div>/);
|
|
272
272
|
const ths = (html.match(/<th[ >]/g) || []).length;
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
// task 1002516: a tenth column, "Plain speech" — the transcript accessibility
|
|
274
|
+
// dimension the criterion asks to surface on the dashboard (ADR 0291). The
|
|
275
|
+
// per-cell contract (a not-scored session is a dash, never a zero) lives in
|
|
276
|
+
// tests/hall_sessions_ledger.mjs; this pin is only the column count.
|
|
277
|
+
assert.equal(ths, 10, 'ten columns: session, builder, tasks, earned, turns, tokens, tool errors, duration, plain speech, recorded');
|
|
278
|
+
assert.match(src, /colspan="10"/, 'the empty row spans them all');
|
|
275
279
|
const fam = html.indexOf('/builders/oversight.css');
|
|
276
280
|
assert.ok(fam > 0 && html.indexOf('/builders/sessions.css') > fam, 'sessions.html links the family sheet before its own');
|
|
277
281
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// tests/hall_sessions_ledger.mjs — the Session Ledger's accessibility column
|
|
2
|
+
// (task 1002516, clause 4 of criterion `accessibility-fluency-profile`;
|
|
3
|
+
// ADR 0291). The dashboard is where the criterion says the dimension has to
|
|
4
|
+
// surface, so these pin the two ways a ledger column silently lies: a header
|
|
5
|
+
// count that drifts from the cells beneath it, and a "not scored" rendering as
|
|
6
|
+
// a zero — which on this metric would read as the worst possible session when
|
|
7
|
+
// the truth is that the builder's own profile waived every signal.
|
|
8
|
+
//
|
|
9
|
+
// sessions.js is an IIFE with no exports, so the cell renderer is SLICED OUT OF
|
|
10
|
+
// THE SHIPPED SOURCE and executed. That runs the real text rather than a copy.
|
|
11
|
+
import test from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
19
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8').replace(/\r\n/g, '\n');
|
|
20
|
+
|
|
21
|
+
const JS = read('sessions.js');
|
|
22
|
+
const HTML = read('sessions.html');
|
|
23
|
+
|
|
24
|
+
// Slice `const ACCESSIBILITY_TITLE = {...}` through the end of accessibilityCell
|
|
25
|
+
// and run it with the one helper it closes over.
|
|
26
|
+
function cellRenderer() {
|
|
27
|
+
const from = JS.indexOf('const ACCESSIBILITY_TITLE');
|
|
28
|
+
assert.ok(from > 0, 'ACCESSIBILITY_TITLE is still where the slice expects it');
|
|
29
|
+
const marker = '\n function accessibilityCell(s) {';
|
|
30
|
+
const at = JS.indexOf(marker, from);
|
|
31
|
+
assert.ok(at > 0, 'accessibilityCell is still a named function');
|
|
32
|
+
const end = JS.indexOf('\n }', at);
|
|
33
|
+
assert.ok(end > at, 'accessibilityCell still closes at its own indent');
|
|
34
|
+
const src = JS.slice(from, end + 4);
|
|
35
|
+
const escapeHtml = (v) => String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
36
|
+
return new Function('escapeHtml', `${src}\nreturn accessibilityCell;`)(escapeHtml);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('a scored session renders its score, and warns below the 6.0 threshold', () => {
|
|
40
|
+
const cell = cellRenderer();
|
|
41
|
+
const good = cell({ accessibility_score: '8.5', accessibility_band: 'clear' });
|
|
42
|
+
assert.match(good, />8\.5</, 'the score is shown');
|
|
43
|
+
assert.ok(!/\bwarn\b/.test(good), 'a passing score is not painted as a warning');
|
|
44
|
+
|
|
45
|
+
const bad = cell({ accessibility_score: '3.5', accessibility_band: 'opaque' });
|
|
46
|
+
assert.match(bad, /class="[^"]*\bwarn\b/, 'below 6.0 takes the row warning colour');
|
|
47
|
+
assert.match(bad, />3\.5</);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('NOT SCORED renders an em dash with its reason, never a zero', () => {
|
|
51
|
+
const cell = cellRenderer();
|
|
52
|
+
for (const row of [{}, { accessibility_score: null }, { accessibility_score: undefined }]) {
|
|
53
|
+
const html = cell(row);
|
|
54
|
+
assert.match(html, /—/, 'an unscored session shows a dash');
|
|
55
|
+
assert.ok(!/>0/.test(html), 'and is never rendered as a 0, which would read as the worst score');
|
|
56
|
+
assert.ok(!/\bwarn\b/.test(html), 'and is never painted as a failure');
|
|
57
|
+
assert.match(html, /title="[^"]*waived/, 'the reason is on hover, not left as a bare dash');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a non-numeric score degrades to not-scored rather than to NaN', () => {
|
|
62
|
+
const cell = cellRenderer();
|
|
63
|
+
const html = cell({ accessibility_score: 'n/a' });
|
|
64
|
+
assert.ok(!/NaN/.test(html), 'no NaN reaches the page');
|
|
65
|
+
assert.match(html, /—/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the ledger header count matches the empty-state colspan', () => {
|
|
69
|
+
// The drift this catches: adding a column to renderTable and not to the
|
|
70
|
+
// header (or the colspan) leaves a table whose rows and head disagree, which
|
|
71
|
+
// renders as a silently shifted row rather than an error.
|
|
72
|
+
const thead = HTML.slice(HTML.indexOf('<thead>'), HTML.indexOf('</thead>'));
|
|
73
|
+
const headers = (thead.match(/<th\b/g) || []).length;
|
|
74
|
+
const colspan = Number((JS.match(/colspan="(\d+)"/) || [])[1]);
|
|
75
|
+
assert.ok(headers > 0, 'the ledger still has a header row');
|
|
76
|
+
assert.equal(colspan, headers, `empty-state colspan (${colspan}) matches the ${headers} headers`);
|
|
77
|
+
|
|
78
|
+
// And the row itself: every cell renderTable emits inline, plus the one the
|
|
79
|
+
// accessibility cell contributes, minus the empty-state <td> that shares the
|
|
80
|
+
// function body.
|
|
81
|
+
const body = JS.slice(JS.indexOf('function renderTable'), JS.indexOf('\n }', JS.indexOf('function renderTable')));
|
|
82
|
+
const inline = (body.match(/<td\b/g) || []).length - 1; // drop the empty-state cell
|
|
83
|
+
const viaHelper = (body.match(/accessibilityCell\(/g) || []).length;
|
|
84
|
+
assert.equal(inline + viaHelper, headers, `a rendered row has ${inline + viaHelper} cells for ${headers} headers`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the accessibility column is headed in plain language, not "Accessibility"', () => {
|
|
88
|
+
// The shell's OTHER accessibility control is the page zoom (shell.js). Two
|
|
89
|
+
// meanings of one word in one view teaches nobody anything, so the column is
|
|
90
|
+
// headed for what it measures.
|
|
91
|
+
const thead = HTML.slice(HTML.indexOf('<thead>'), HTML.indexOf('</thead>'));
|
|
92
|
+
assert.match(thead, /Plain speech/, 'the column is headed "Plain speech"');
|
|
93
|
+
assert.ok(!/>Accessibility</.test(thead), 'and does not collide with the zoom control\'s word');
|
|
94
|
+
});
|