@bongos/core 1.19.920 → 1.19.922

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.bongos-core.json +71 -31
  2. package/.claude/skills/grade-audit/SKILL.md +9 -0
  3. package/docs/adr/0320-an-empty-diff-is-not-an-ungraded-diff.md +159 -0
  4. package/docs/adr/0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md +163 -0
  5. package/docs/adr/README.md +2 -0
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/economy/idea-brightness.js +212 -0
  8. package/modules/economy/idea-size-historical.js +22 -3
  9. package/modules/economy/idea-size.js +30 -3
  10. package/modules/grading/grader-no-artifact.js +347 -0
  11. package/modules/grading/grader.js +6 -0
  12. package/modules/lifecycle/cascade.js +36 -0
  13. package/modules/lifecycle/migrations/lifecycle_010_removal_targets.sql +116 -0
  14. package/modules/lifecycle/module.json +2 -1
  15. package/modules/lifecycle/removal-targets.js +217 -0
  16. package/modules/lifecycle/routes/lifecycle.js +9 -0
  17. package/modules/lifecycle/routes/removal-proposals.js +33 -0
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/scripts/gds/provision-core-upgrade.js +26 -2
  21. package/scripts/gds/run-unit-tests.js +17 -1
  22. package/scripts/gds/ship-flow.js +54 -7
  23. package/scripts/gds/ship-grade-steps.js +43 -5
  24. package/scripts/gds/ship-grade.js +36 -0
  25. package/src/module-api.js +1 -1
  26. package/tests/core_upgrade_runner.mjs +56 -0
  27. package/tests/git_merge_regen.mjs +87 -3
  28. package/tests/grader_no_artifact.mjs +360 -0
  29. package/tests/grader_prompt_fencing.mjs +46 -0
  30. package/tests/idea_brightness.mjs +550 -0
  31. package/tests/no_artifact_escalation.mjs +13 -3
  32. package/tests/skip_is_not_pass.mjs +91 -1
@@ -0,0 +1,550 @@
1
+ // tests/idea_brightness.mjs
2
+ //
3
+ // task 1004099 (BV2.R16, goal 1000095) — BRIGHTNESS: the live share of an
4
+ // idea's descent. Spec docs/specs/bongos-v2-ideator-surface.md decisions 4 and
5
+ // 20; ADR 0313 (a removal is proposed, then confirmed by a person) and ADR 0319
6
+ // (the verdict is a row beside the question).
7
+ //
8
+ // WHAT CAN GO SILENTLY WRONG HERE, which is what this file is aimed at:
9
+ //
10
+ // * ZERO OVER ZERO DRAWN AS DARK. An idea that caused nothing has had nothing
11
+ // removed, so it is LIT. Return 0 for it and a freshly-filed thought becomes
12
+ // pixel-identical to one whose version the project tore out — which is
13
+ // precisely the distinction BV2.R19's proof exists to check, and the reason
14
+ // size and brightness are two channels at all. §2 pins both ends of it.
15
+ // * WEIGHTING BY COUNT. Three-of-five tasks alive is not 0.6 brightness unless
16
+ // the five hops earned the same. Counting rows makes deleting a one-line fix
17
+ // dim a star as hard as deleting the feature — nothing errors, the sky is
18
+ // just wrong. §3.
19
+ // * THE SHARE TAKEN OF THE WRONG WHOLE. Brightness divides by the same
20
+ // `credits` size draws, so `byTask` must SUM to it. It is assembled from two
21
+ // places (the priced descent, plus ledger payments for tasks the descent no
22
+ // longer reaches), and dropping the second overstates brightness silently.
23
+ // §4 pins the sum itself, which is the invariant the rest rests on.
24
+ // * A DISMISSED PROPOSAL DIMMING. Refactors, renames and moves all look like
25
+ // deletions to a diff — ADR 0313's central finding. Only 'confirmed' may
26
+ // reach the read, and the join is where that is enforced. §5.
27
+ // * "COULD NOT LOOK" RENDERED AS "NOTHING WAS REMOVED". With the lifecycle
28
+ // module off there are no removal facts, and returning a confident 1 draws
29
+ // every torn-out thought at full brightness. §6.
30
+ // * THE WRONG DIRECTION. lifecycle_009 holds `ship_task_id` — the task that
31
+ // DID the deleting — and brightness needs the tasks that WERE deleted. They
32
+ // are different rows under different ideas. §7 pins that the read is keyed
33
+ // on removed_task_id.
34
+ // * A SECOND DERIVATION ERASING A GOOD FIRST ONE. The commit-history walk is
35
+ // capped and rate-limited, so a re-ship can legitimately find fewer paths.
36
+ // ON CONFLICT DO NOTHING, never DO UPDATE. §8.
37
+ // * THE ATTRIBUTION NEVER BEING STORED AT ALL. The writer is wired into the
38
+ // cascade through a dep, and this module's own history says a closure there
39
+ // is unreachable from a test and is how a dead rule passed its suite. §9
40
+ // drives the real runner.
41
+ //
42
+ // DB-free: the clients are stubs. Where a stub stands in for SQL, it reads the
43
+ // STATEMENT rather than reimplementing it from memory, so a change to the query
44
+ // moves the twin with it instead of leaving it agreeing with a stale copy.
45
+ //
46
+ // Run: node tests/idea_brightness.mjs
47
+
48
+ import { strict as assert } from 'node:assert';
49
+ import { createRequire } from 'node:module';
50
+ import fs from 'node:fs';
51
+ import path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+
54
+ const require = createRequire(import.meta.url);
55
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
56
+
57
+ const api = require('../src/module-api.js');
58
+ const credits = require('../modules/economy/credits.js');
59
+ const brightnessLib = require('../modules/economy/idea-brightness.js');
60
+ const removalTargets = require('../modules/lifecycle/removal-targets.js');
61
+ const cascade = require('../modules/lifecycle/cascade.js');
62
+
63
+ let passed = 0;
64
+ let failed = 0;
65
+ async function test(name, fn) {
66
+ try { await fn(); passed += 1; console.log(`ok ${name}`); }
67
+ catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
68
+ }
69
+
70
+ const PREFIX = credits.IDEA_STREAM_REASON_PREFIXES.task_ship;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // the stubs
74
+ // ---------------------------------------------------------------------------
75
+
76
+ // A client standing in for the two reads ideaSizes makes: the credit_log
77
+ // rollup, and the grades lookup. Faithful to the statement on the one point
78
+ // that matters here — it groups per (idea, task), which is where `byTask` comes
79
+ // from.
80
+ function sizeClient({ ledger = [], grades = [] } = {}) {
81
+ return {
82
+ async query(sql, params) {
83
+ if (/FROM credit_log/i.test(sql)) {
84
+ const field = /split_part\(reason, ':', (\d+)\)/.exec(sql);
85
+ assert.ok(field, 'the read must take the idea id off the separator');
86
+ const idx = Number(field[1]) - 1;
87
+ const guard = /reason LIKE '([^']*)%'/.exec(sql);
88
+ const prefix = guard && guard[1].replace(/\\([\\%_])/g, (m, ch) => ch);
89
+ const ids = (params[0] || []).map(String);
90
+ const grouped = new Map();
91
+ for (const row of ledger) {
92
+ if (prefix && !String(row.reason).startsWith(prefix)) continue;
93
+ const parts = String(row.reason).split(':');
94
+ const ideaId = parts[idx];
95
+ if (!ids.includes(ideaId)) continue;
96
+ const taskId = row.task_id != null ? String(row.task_id) : parts[2];
97
+ const key = `${ideaId}|${taskId}`;
98
+ const acc = grouped.get(key) || { idea_id: ideaId, task_id: taskId, credits: 0, payments: 0 };
99
+ acc.credits += Number(row.delta);
100
+ acc.payments += 1;
101
+ grouped.set(key, acc);
102
+ }
103
+ return { rows: [...grouped.values()] };
104
+ }
105
+ if (/FROM idea_inbox/i.test(sql)) return { rows: grades };
106
+ return { rows: [] };
107
+ },
108
+ };
109
+ }
110
+
111
+ // A twin of the confirmedRemovalsForTasks join, driven off fixture rows. It
112
+ // reads the verdict filter out of the SQL rather than hardcoding it, so
113
+ // loosening the join to include dismissals reds §5 instead of passing.
114
+ function removalsClient({ targets = [], verdicts = [] } = {}) {
115
+ const log = [];
116
+ return {
117
+ log,
118
+ async query(sql, params) {
119
+ log.push({ sql, params });
120
+ if (/lifecycle_removal_targets/i.test(sql) && /lifecycle_removal_verdicts/i.test(sql)) {
121
+ const want = /v\.verdict\s*=\s*'([a-z]+)'/.exec(sql);
122
+ assert.ok(want, 'the join must filter on a literal verdict — a read that takes every answer dims on dismissals');
123
+ const asked = (params[0] || []).map(String);
124
+ // The keyed column is read out of the statement too: keying on
125
+ // ship_task_id would dim the ship's own idea instead of the one whose
126
+ // work died, and it would still look like a working query.
127
+ assert.ok(/tg\.removed_task_id = ANY/.test(sql),
128
+ 'the set must be matched on removed_task_id — ship_task_id is the task that DID the deleting');
129
+ const byProposal = new Map(verdicts.map((v) => [String(v.proposal_task_id), v]));
130
+ const rows = [];
131
+ for (const t of targets) {
132
+ const v = byProposal.get(String(t.proposal_task_id));
133
+ if (!v || v.verdict !== want[1]) continue;
134
+ if (!asked.includes(String(t.removed_task_id))) continue;
135
+ rows.push({
136
+ removed_task_id: t.removed_task_id,
137
+ proposal_task_id: t.proposal_task_id,
138
+ paths: t.paths || [],
139
+ ship_task_id: v.ship_task_id ?? null,
140
+ decided_at: v.decided_at || null,
141
+ });
142
+ }
143
+ rows.sort((a, b) => String(a.removed_task_id).localeCompare(String(b.removed_task_id))
144
+ || String(a.decided_at).localeCompare(String(b.decided_at))
145
+ || String(a.proposal_task_id).localeCompare(String(b.proposal_task_id)));
146
+ return { rows };
147
+ }
148
+ return { rows: [] };
149
+ },
150
+ };
151
+ }
152
+
153
+ // The port, registered ONCE. registerProvider throws on a duplicate (a port is
154
+ // single-provider), and the client the port closes over is swapped per test so
155
+ // each case reads its own fixture.
156
+ let portClient = removalsClient();
157
+ if (!api.hasProvider('lifecycle.removals')) {
158
+ api.registerProvider('lifecycle.removals', {
159
+ confirmedRemovalsForTasks: (ids) => removalTargets.confirmedRemovalsForTasks(ids, { client: portClient }),
160
+ countUnattributedConfirmed: () => removalTargets.countUnattributedConfirmed({ client: portClient }),
161
+ });
162
+ }
163
+
164
+ // One idea, `descent` hops, each paid `perTask` credits under stream 1.
165
+ function ledgerFor(ideaId, perTask) {
166
+ return Object.entries(perTask).map(([taskId, delta]) => ({
167
+ reason: `${PREFIX}${ideaId}:${taskId}`, task_id: taskId, delta,
168
+ }));
169
+ }
170
+ function descentFor(ideaId, taskIds) {
171
+ return new Map([[String(ideaId), taskIds.map((t, i) => ({ task_id: String(t), depth: i === 0 ? 0 : 1, status: 'shipped' }))]]);
172
+ }
173
+
174
+ async function brightnessOf(ideaId, { perTask, targets = [], verdicts = [], grades = null } = {}) {
175
+ portClient = removalsClient({ targets, verdicts });
176
+ const client = sizeClient({
177
+ ledger: ledgerFor(ideaId, perTask),
178
+ grades: grades || [{ id: Number(ideaId), grade: 'quick', completeness: null }],
179
+ });
180
+ const all = await brightnessLib.ideaBrightnesses([String(ideaId)], {
181
+ client,
182
+ descentByIdea: descentFor(ideaId, Object.keys(perTask)),
183
+ });
184
+ return all.get(String(ideaId));
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // §1 — graded, not binary (spec decision 20)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ await test('§1 a descent with some work removed is PARTLY lit — not on, not off', async () => {
192
+ const b = await brightnessOf(7, {
193
+ perTask: { 100: 10, 101: 10, 102: 10, 103: 10, 104: 10 },
194
+ targets: [{ proposal_task_id: 900, removed_task_id: 101, paths: ['a.js'] },
195
+ { proposal_task_id: 900, removed_task_id: 102, paths: ['b.js'] }],
196
+ verdicts: [{ proposal_task_id: 900, verdict: 'confirmed', ship_task_id: 555, decided_at: '2026-09-01' }],
197
+ });
198
+ assert.equal(b.credits, 50);
199
+ assert.equal(b.removed, 20);
200
+ assert.equal(b.live, 30);
201
+ assert.equal(b.share, 0.6, 'three of five equal hops alive is 0.6, not 0 and not 1');
202
+ assert.ok(b.share > 0 && b.share < 1, 'graded means strictly between the ends');
203
+ });
204
+
205
+ await test('§1 a star goes out only when NOTHING survives', async () => {
206
+ const b = await brightnessOf(8, {
207
+ perTask: { 200: 25, 201: 25 },
208
+ targets: [{ proposal_task_id: 901, removed_task_id: 200, paths: [] },
209
+ { proposal_task_id: 901, removed_task_id: 201, paths: [] }],
210
+ verdicts: [{ proposal_task_id: 901, verdict: 'confirmed', ship_task_id: 556, decided_at: '2026-09-02' }],
211
+ });
212
+ assert.equal(b.share, 0);
213
+ assert.equal(b.live, 0);
214
+ });
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // §2 — LARGE AND DEAD vs NEVER MATTERED (the BV2.R19 distinction)
218
+ // ---------------------------------------------------------------------------
219
+ //
220
+ // The two states one number cannot express, and the reason brightness exists
221
+ // beside size rather than replacing it.
222
+
223
+ await test('§2 a large dead thought keeps FULL size and reaches zero brightness', async () => {
224
+ const b = await brightnessOf(9, {
225
+ perTask: { 300: 200, 301: 150 },
226
+ targets: [{ proposal_task_id: 902, removed_task_id: 300, paths: [] },
227
+ { proposal_task_id: 902, removed_task_id: 301, paths: [] }],
228
+ verdicts: [{ proposal_task_id: 902, verdict: 'confirmed', ship_task_id: 557, decided_at: '2026-09-03' }],
229
+ });
230
+ assert.equal(b.credits, 350, 'size does not fall — ADR 0313, the ledger is never rewritten');
231
+ assert.equal(b.share, 0);
232
+ assert.equal(b.hasWork, true, 'it DID cause work; that is what makes it large');
233
+ });
234
+
235
+ await test('§2 a thought that never caused anything is LIT, not dark — zero over zero is not zero', async () => {
236
+ const b = await brightnessOf(10, { perTask: {} });
237
+ assert.equal(b.credits, 0);
238
+ assert.equal(b.share, 1, 'nothing has been removed from it; a protostar is not a remnant');
239
+ assert.equal(b.hasWork, false, 'and this is what lets BV2.R17 tell it from a lit star that earned its light');
240
+ });
241
+
242
+ await test('§2 the two are distinguishable on the pair, which is the whole point', async () => {
243
+ const dead = await brightnessOf(11, {
244
+ perTask: { 400: 120 },
245
+ targets: [{ proposal_task_id: 903, removed_task_id: 400, paths: [] }],
246
+ verdicts: [{ proposal_task_id: 903, verdict: 'confirmed', ship_task_id: 558, decided_at: '2026-09-04' }],
247
+ });
248
+ const never = await brightnessOf(12, { perTask: {} });
249
+ assert.notEqual(`${dead.credits}|${dead.share}`, `${never.credits}|${never.share}`,
250
+ 'a large dead thought and one that never mattered must not render alike');
251
+ assert.ok(dead.credits > never.credits, 'size separates them');
252
+ assert.ok(dead.share < never.share, 'and brightness separates them the other way');
253
+ });
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // §3 — weighted by PAYOUT, not by count
257
+ // ---------------------------------------------------------------------------
258
+
259
+ await test('§3 removing a small hop dims less than removing a large one', async () => {
260
+ const small = await brightnessOf(13, {
261
+ perTask: { 500: 90, 501: 10 },
262
+ targets: [{ proposal_task_id: 904, removed_task_id: 501, paths: [] }],
263
+ verdicts: [{ proposal_task_id: 904, verdict: 'confirmed', ship_task_id: 559, decided_at: '2026-09-05' }],
264
+ });
265
+ const large = await brightnessOf(14, {
266
+ perTask: { 600: 90, 601: 10 },
267
+ targets: [{ proposal_task_id: 905, removed_task_id: 600, paths: [] }],
268
+ verdicts: [{ proposal_task_id: 905, verdict: 'confirmed', ship_task_id: 560, decided_at: '2026-09-06' }],
269
+ });
270
+ assert.equal(small.share, 0.9);
271
+ assert.equal(large.share, 0.1);
272
+ assert.ok(small.share > large.share,
273
+ 'one task of two removed either way — a count-weighted brightness would make these equal');
274
+ });
275
+
276
+ await test('§3 what went out is reported, largest loss first', async () => {
277
+ const b = await brightnessOf(15, {
278
+ perTask: { 700: 5, 701: 50 },
279
+ targets: [{ proposal_task_id: 906, removed_task_id: 700, paths: ['small.js'] },
280
+ { proposal_task_id: 906, removed_task_id: 701, paths: ['big.js'] }],
281
+ verdicts: [{ proposal_task_id: 906, verdict: 'confirmed', ship_task_id: 561, decided_at: '2026-09-07' }],
282
+ });
283
+ assert.deepEqual(b.removedTasks.map((r) => r.taskId), ['701', '700'],
284
+ 'a surface explaining why a star dimmed wants the piece that mattered first');
285
+ assert.deepEqual(b.removedTasks[0].paths, ['big.js'], 'the receipt travels with it');
286
+ assert.equal(b.removedTasks[0].proposalTaskId, '906', 'and so does the question that was answered');
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // §4 — the share is taken of the SAME whole size draws
291
+ // ---------------------------------------------------------------------------
292
+
293
+ await test('§4 byTask SUMS to credits — the invariant every share above rests on', async () => {
294
+ const ideaSizeLib = require('../modules/economy/idea-size.js');
295
+ const client = sizeClient({
296
+ ledger: ledgerFor(21, { 800: 7, 801: 13 }),
297
+ grades: [{ id: 21, grade: 'quick', completeness: null }],
298
+ });
299
+ const sizes = await ideaSizeLib.ideaSizes(['21'], { client, descentByIdea: descentFor(21, [800, 801]) });
300
+ const s = sizes.get('21');
301
+ const summed = [...s.byTask.values()].reduce((a, b) => a + b, 0);
302
+ assert.equal(summed, s.credits, 'a breakdown that does not sum to the whole makes every brightness a fraction of the wrong number');
303
+ });
304
+
305
+ await test('§4 a payout for a task the descent no longer reaches stays LIVE, not lost', async () => {
306
+ // The ledger paid task 900 under this idea, but the descent walk does not
307
+ // reach it any more (a re-parented task, a deleted edge). idea-size.js keeps
308
+ // it in `credits` on purpose so size cannot fall; brightness must keep it in
309
+ // `live` for the same reason, or a re-parenting would dim a star.
310
+ portClient = removalsClient();
311
+ const client = sizeClient({
312
+ ledger: ledgerFor(22, { 850: 10, 900: 40 }),
313
+ grades: [{ id: 22, grade: 'quick', completeness: null }],
314
+ });
315
+ const all = await brightnessLib.ideaBrightnesses(['22'], {
316
+ client,
317
+ descentByIdea: descentFor(22, [850]), // 900 is absent from the descent
318
+ });
319
+ const b = all.get('22');
320
+ assert.equal(b.credits, 50, 'the orphan payout is still money this idea was paid');
321
+ assert.equal(b.removed, 0);
322
+ assert.equal(b.live, 50, 'and it is alive — nothing said otherwise');
323
+ assert.equal(b.share, 1);
324
+ });
325
+
326
+ await test('§4 an orphan payout whose work IS confirmed removed still dims the star', async () => {
327
+ // The case that makes the byTask merge load-bearing, and the one the test
328
+ // above cannot see. `live = credits - removed` means an orphan the descent
329
+ // misses is counted as alive whether or not the merge exists — so only a
330
+ // REMOVED orphan can tell the two implementations apart. Drop the merge in
331
+ // idea-size.js and task 900 is absent from byTask, nothing matches the
332
+ // confirmed removal, and the idea reads fully lit while the work is gone.
333
+ portClient = removalsClient({
334
+ targets: [{ proposal_task_id: 920, removed_task_id: 900, paths: ['orphan.js'] }],
335
+ verdicts: [{ proposal_task_id: 920, verdict: 'confirmed', ship_task_id: 580, decided_at: '2026-09-11' }],
336
+ });
337
+ const client = sizeClient({
338
+ ledger: ledgerFor(23, { 850: 10, 900: 40 }),
339
+ grades: [{ id: 23, grade: 'quick', completeness: null }],
340
+ });
341
+ const all = await brightnessLib.ideaBrightnesses(['23'], {
342
+ client,
343
+ descentByIdea: descentFor(23, [850]), // 900 is paid but off the descent
344
+ });
345
+ const b = all.get('23');
346
+ assert.equal(b.credits, 50, 'size still counts it — the money moved');
347
+ assert.equal(b.removed, 40, 'and brightness must see it die');
348
+ assert.equal(b.share, 0.2);
349
+ assert.deepEqual(b.removedTasks.map((r) => r.taskId), ['900']);
350
+ });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // §5 — only a CONFIRMED removal dims (ADR 0313)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ await test('§5 a DISMISSED proposal dims nothing — a refactor is not a removal', async () => {
357
+ const b = await brightnessOf(16, {
358
+ perTask: { 750: 30, 751: 30 },
359
+ targets: [{ proposal_task_id: 907, removed_task_id: 750, paths: ['moved.js'] }],
360
+ verdicts: [{ proposal_task_id: 907, verdict: 'dismissed', ship_task_id: 562, decided_at: '2026-09-08' }],
361
+ });
362
+ assert.equal(b.share, 1);
363
+ assert.equal(b.removed, 0);
364
+ assert.deepEqual(b.removedTasks, []);
365
+ });
366
+
367
+ await test('§5 an UNANSWERED proposal dims nothing — the question is not the verdict', async () => {
368
+ const b = await brightnessOf(17, {
369
+ perTask: { 760: 30, 761: 30 },
370
+ targets: [{ proposal_task_id: 908, removed_task_id: 760, paths: [] }],
371
+ verdicts: [], // filed, nobody has answered it
372
+ });
373
+ assert.equal(b.share, 1, 'a derived-only signal would dim the sky continuously — ADR 0313');
374
+ });
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // §6 — "could not look" is not "nothing was removed"
378
+ // ---------------------------------------------------------------------------
379
+
380
+ await test('§6 with no removal port the share is NULL and says so, never a confident 1', async () => {
381
+ // Resolve the port away by asking for a name nothing registers. The module
382
+ // resolves OPTIONALLY, so this is the lifecycle-module-is-off path.
383
+ const mod = require('../modules/economy/idea-brightness.js');
384
+ const src = fs.readFileSync(path.join(ROOT, 'modules/economy/idea-brightness.js'), 'utf8');
385
+ assert.ok(/resolveOptional\(['"]lifecycle\.removals['"]\)/.test(src),
386
+ 'the port must be resolved OPTIONALLY — a required resolve throws and takes the whole read down with the module');
387
+ assert.ok(/attributable: false/.test(src) && /share: null/.test(src),
388
+ 'and the unreadable case must report itself rather than defaulting to lit');
389
+ assert.ok(typeof mod.ideaBrightnesses === 'function');
390
+ });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // §7 — the direction of the read
394
+ // ---------------------------------------------------------------------------
395
+
396
+ await test('§7 a task named by TWO confirmed proposals collapses to the earliest', async () => {
397
+ const client = removalsClient({
398
+ targets: [{ proposal_task_id: 910, removed_task_id: 770, paths: ['first.js'] },
399
+ { proposal_task_id: 911, removed_task_id: 770, paths: ['second.js'] }],
400
+ verdicts: [{ proposal_task_id: 911, verdict: 'confirmed', ship_task_id: 571, decided_at: '2026-09-20' },
401
+ { proposal_task_id: 910, verdict: 'confirmed', ship_task_id: 570, decided_at: '2026-09-10' }],
402
+ });
403
+ const got = await removalTargets.confirmedRemovalsForTasks(['770'], { client });
404
+ assert.equal(got.size, 1, 'the question is binary — how many times it died is not the fact');
405
+ assert.equal(got.get('770').proposalTaskId, '910', 'the earliest verdict is the one kept');
406
+ assert.deepEqual(got.get('770').paths, ['first.js']);
407
+ });
408
+
409
+ await test('§7 the SQL keys on removed_task_id, not on the ship that did the deleting', async () => {
410
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/removal-targets.js'), 'utf8');
411
+ const stmt = /FROM lifecycle_removal_targets tg[\s\S]*?ORDER BY/.exec(src);
412
+ assert.ok(stmt, 'the join must be findable');
413
+ assert.ok(/tg\.removed_task_id = ANY/.test(stmt[0]),
414
+ 'brightness asks which tasks WERE removed; ship_task_id is the one that did the removing, under a different idea');
415
+ assert.ok(/v\.verdict = 'confirmed'/.test(stmt[0]), 'and only a confirmed answer may reach it');
416
+ });
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // §8 — the writer
420
+ // ---------------------------------------------------------------------------
421
+
422
+ function captureClient() {
423
+ const calls = [];
424
+ return { calls, async query(sql, params) { calls.push({ sql, params }); return { rowCount: 1, rows: [] }; } };
425
+ }
426
+
427
+ await test('§8 the writer sends ONE jsonb payload — a text[][] would flatten and mispair', async () => {
428
+ const c = captureClient();
429
+ await removalTargets.recordRemovalTargets(
430
+ { proposalTaskId: 1000, removals: [{ taskId: 11, paths: ['a.js', 'b.js'] }, { taskId: 12, paths: ['c.js'] }] },
431
+ { client: c }
432
+ );
433
+ assert.equal(c.calls.length, 1, 'one statement, one round trip');
434
+ const { sql, params } = c.calls[0];
435
+ assert.ok(/jsonb_array_elements/.test(sql), 'nested paths need jsonb; unnest on text[][] flattens completely');
436
+ assert.ok(!/unnest\(\$\d+::text\[\]\[\]\)/.test(sql), 'the flattening shape must not come back');
437
+ const payload = JSON.parse(params[1]);
438
+ assert.deepEqual(payload, [{ taskId: '11', paths: ['a.js', 'b.js'] }, { taskId: '12', paths: ['c.js'] }],
439
+ 'each task keeps its OWN paths — the bug a flattened array causes is silent mispairing');
440
+ });
441
+
442
+ await test('§8 a re-run cannot erase a better first attribution — DO NOTHING, never DO UPDATE', async () => {
443
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/removal-targets.js'), 'utf8');
444
+ // Scoped to the STATEMENT, not the file. The header explains at length why
445
+ // there is no DO UPDATE, so a file-wide scan for that string matches the
446
+ // prose arguing against it and fails on a correct implementation — the same
447
+ // trap §7 avoids by slicing the query out first.
448
+ const stmt = /INSERT INTO lifecycle_removal_targets[\s\S]*?DO NOTHING`/.exec(src);
449
+ assert.ok(stmt, 'the insert must be findable');
450
+ assert.ok(/ON CONFLICT \(proposal_task_id, removed_task_id\) DO NOTHING/.test(stmt[0]));
451
+ assert.ok(!/DO UPDATE/.test(stmt[0]),
452
+ 'a capped, rate-limited second walk can find FEWER paths; overwriting would let it erase a good first read');
453
+ });
454
+
455
+ await test('§8 two entries for one task MERGE their paths rather than dropping half the receipt', async () => {
456
+ const c = captureClient();
457
+ await removalTargets.recordRemovalTargets(
458
+ { proposalTaskId: 1001, removals: [{ taskId: 20, paths: ['x.js'] }, { taskId: 20, paths: ['y.js'] }] },
459
+ { client: c }
460
+ );
461
+ const payload = JSON.parse(c.calls[0].params[1]);
462
+ assert.equal(payload.length, 1, 'a doubled pair inside ONE insert is not something ON CONFLICT rescues');
463
+ assert.deepEqual(payload[0].paths.sort(), ['x.js', 'y.js']);
464
+ });
465
+
466
+ await test('§8 nothing to attribute writes nothing at all', async () => {
467
+ const c = captureClient();
468
+ assert.equal(await removalTargets.recordRemovalTargets({ proposalTaskId: 1002, removals: [] }, { client: c }), 0);
469
+ assert.equal(await removalTargets.recordRemovalTargets({ proposalTaskId: 1003, removals: [{ taskId: 'nope' }] }, { client: c }), 0);
470
+ assert.equal(await removalTargets.recordRemovalTargets({ proposalTaskId: 0, removals: [{ taskId: 5 }] }, { client: c }), 0);
471
+ assert.equal(c.calls.length, 0, 'an empty attribution is a real answer, not a query');
472
+ });
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // §9 — the wiring, through the REAL cascade runner
476
+ // ---------------------------------------------------------------------------
477
+ //
478
+ // The dep is what makes this reachable at all, and this module's own history is
479
+ // the argument for driving the runner rather than the writer: the removal rule
480
+ // shipped dead in production because its resolver was a closure inside the
481
+ // listener registration that no test could see.
482
+
483
+ function cascadeDeps({ recorded, existing = null }) {
484
+ return {
485
+ getTask: async () => ({ id: '4242', source: 'idea-route', goal_id: '1', version_id: 'V', touches: [] }),
486
+ findTaskBySourceRef: async () => existing,
487
+ createTask: async (spec) => ({ id: '5555', ...spec }),
488
+ removalCandidates: async () => ({
489
+ removals: [{ taskId: '31', title: 'the removed one', paths: ['gone.js'] }],
490
+ truncated: false, deletedCount: 1, errors: [],
491
+ }),
492
+ recordRemovalTargets: async (args) => { recorded.push(args); return 1; },
493
+ };
494
+ }
495
+
496
+ await test('§9 filing a removal proposal stores its targets against the proposal task', async () => {
497
+ const recorded = [];
498
+ const report = await cascade.runCascadesForShip({ id: '4242' }, cascadeDeps({ recorded }));
499
+ assert.equal(report.errors.length, 0, report.errors.join(' | '));
500
+ assert.equal(recorded.length, 1, 'the attribution must be persisted, not only rendered into the body');
501
+ assert.equal(recorded[0].proposalTaskId, '5555', 'against the PROPOSAL, which is what the verdict joins on');
502
+ assert.deepEqual(recorded[0].removals.map((r) => r.taskId), ['31']);
503
+ });
504
+
505
+ await test('§9 a RE-SHIP still stores them — the repair path for a first run that derived nothing', async () => {
506
+ const recorded = [];
507
+ // The proposal already exists, so the runner reports it as `skipped`.
508
+ const report = await cascade.runCascadesForShip({ id: '4242' }, cascadeDeps({ recorded, existing: { id: '5555' } }));
509
+ assert.equal(report.created.length, 0);
510
+ assert.ok(report.skipped.some((s) => s.taskId === '5555'), 'the proposal was already filed');
511
+ assert.equal(recorded.length, 1, 'and the targets are written anyway — idempotent, so it costs a no-op and buys the repair');
512
+ assert.equal(recorded[0].proposalTaskId, '5555');
513
+ });
514
+
515
+ await test('§9 a writer that throws is REPORTED and never costs the ship', async () => {
516
+ const deps = cascadeDeps({ recorded: [] });
517
+ deps.recordRemovalTargets = async () => { throw new Error('db is down'); };
518
+ const report = await cascade.runCascadesForShip({ id: '4242' }, deps);
519
+ assert.ok(report.errors.some((e) => /recordRemovalTargets/.test(e) && /db is down/.test(e)),
520
+ 'the failure must be named in the report');
521
+ assert.ok(Array.isArray(report.created), 'and the run still returns a report rather than rejecting');
522
+ });
523
+
524
+ await test('§9 no derivation means no write — an empty attribution is not an empty proposal', async () => {
525
+ const recorded = [];
526
+ const deps = cascadeDeps({ recorded });
527
+ deps.removalCandidates = async () => ({ removals: [], truncated: false, deletedCount: 0, errors: [] });
528
+ await cascade.runCascadesForShip({ id: '4242' }, deps);
529
+ assert.equal(recorded.length, 0);
530
+ });
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // §10 — the gap brightness cannot close, made visible instead of silent
534
+ // ---------------------------------------------------------------------------
535
+
536
+ await test('§10 confirmed removals that name no target are COUNTED, not ignored', async () => {
537
+ const client = {
538
+ async query(sql) {
539
+ assert.ok(/NOT EXISTS/.test(sql) && /lifecycle_removal_targets/.test(sql),
540
+ 'the count is of confirmed verdicts with no target rows');
541
+ assert.ok(/v\.verdict = 'confirmed'/.test(sql));
542
+ return { rows: [{ n: 3 }] };
543
+ },
544
+ };
545
+ assert.equal(await removalTargets.countUnattributedConfirmed({ client }), 3,
546
+ 'brightness cannot dim for these — it does not know which idea — so the honest move is to say how many it could not see');
547
+ });
548
+
549
+ console.log(`\n${passed} passed, ${failed} failed`);
550
+ process.exit(failed === 0 ? 0 : 1);
@@ -139,9 +139,19 @@ await test('ONLY a no-artifact close is auto-escalated, never a bypass of a FAIL
139
139
  assert.match(src, /noArtifactClose = true;/,
140
140
  'and something must actually SET it, or the branch is unreachable and the guard is a lie');
141
141
  // Reachability: the assignment has to live in the allow-empty branch specifically.
142
- const allowEmptyBranch = src.slice(src.indexOf("verdict.reason === 'allow-empty'"));
143
- assert.ok(allowEmptyBranch.indexOf('noArtifactClose = true;') > -1
144
- && allowEmptyBranch.indexOf('noArtifactClose = true;') < 1200,
142
+ //
143
+ // This used to assert the assignment appeared within 1200 characters of the branch
144
+ // marker. That is a proxy for "inside the branch", and task 1004011 redded it by
145
+ // adding a COMMENT — the invariant held, the character budget did not. A distance
146
+ // pin fails on prose and passes on a move of 1100 characters into the wrong block,
147
+ // so it is wrong in both directions. Bound it by the branch's own END instead: the
148
+ // next top-level step in shipMain. That is the thing the guard's reachability
149
+ // actually depends on.
150
+ const fromMarker = src.slice(src.indexOf("verdict.reason === 'allow-empty'"));
151
+ const branchEnd = fromMarker.indexOf('---- Module drift check');
152
+ assert.ok(branchEnd > -1, 'the marker that ends the allow-empty branch must still exist');
153
+ const allowEmptyBranch = fromMarker.slice(0, branchEnd);
154
+ assert.ok(allowEmptyBranch.includes('noArtifactClose = true;'),
145
155
  'noArtifactClose must be set in the allow-empty branch, not somewhere it can never run');
146
156
  });
147
157