@bongos/core 1.19.1003 → 1.19.1005

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,315 @@
1
+ // tests/ideator_traceable_proof.mjs — THE PROOF for `ideator-nothing-is-lost`
2
+ // (task 1004100 / BV2.R21 of goal 1000095, spec R06).
3
+ //
4
+ // THE SCENARIO THIS EXISTS FOR, stated as it actually happened on 2026-09-19:
5
+ // a thought was filed from the hall, it silently became a task under a goal,
6
+ // and the person who filed it could not find it five minutes later. Everything
7
+ // in this scope was built so that cannot happen again. This file is the
8
+ // re-runnable assertion that it cannot.
9
+ //
10
+ // WHY A PROOF AND NOT MORE UNIT TESTS. Every hop of this journey is already
11
+ // covered on its own: the landing matrix (tests/idea_routing.mjs), the trail
12
+ // projection (tests/idea_trail_descent.mjs), the five states
13
+ // (tests/idea_state.mjs), the room's renderer (tests/hall_thinking_room.mjs).
14
+ // What none of them can see is the JOIN between the filing moment and the
15
+ // reading moment — that the goal a person is TOLD caught their thought is the
16
+ // same goal the room attributes it to an hour later. A break there passes every
17
+ // suite above and reproduces the original failure exactly: two surfaces, each
18
+ // internally correct, disagreeing about one thought.
19
+ //
20
+ // SO THE CHAIN IS NEVER RE-TYPED. The trail row this proof reads an hour later
21
+ // is DERIVED from what `captureIdea` actually did at filing time — the task id
22
+ // it minted and the goal id it actually handed `lifecycle.createTask` — rather
23
+ // than written out by hand beside it. A hand-written fixture would agree with
24
+ // the filing because the author made it agree; this one can only agree if the
25
+ // code does. (tests/helpers.mjs's own rule, and the reason
26
+ // tests/hall_thinking_room.mjs shapes every row the way the route shapes one.)
27
+ //
28
+ // THE HOUR IS REAL, not a sleep. What "an hour later" means operationally is
29
+ // that nothing from the filing session is still in hand: the receipt
30
+ // (modules/hall-ui/public/ideas.js) is localStorage, so it is gone on another
31
+ // device, in a private window, or after a storage clear. bootThinkingPage's
32
+ // stub localStorage answers null to every read, which makes that the ONLY
33
+ // condition this file can run under — the room has to answer from the server
34
+ // or it does not answer at all.
35
+ //
36
+ // DB-free: captureIdea, the trail read and all three attachers take an injected
37
+ // client, so the real SQL and the real composition run against a recorded query
38
+ // log. A real-Postgres suite would SELF-SKIP on a machine with no database, and
39
+ // a skipped test prints ok and counts as passed — which is how a green lane
40
+ // hides what never ran.
41
+ //
42
+ // Run: node tests/ideator_traceable_proof.mjs
43
+
44
+ import { strict as assert } from 'node:assert';
45
+ import { createRequire } from 'node:module';
46
+ import { makeRunner, makeSqlAwareClient, bootThinkingPage, settle } from './helpers.mjs';
47
+
48
+ process.env.NODE_ENV = 'test';
49
+
50
+ const require = createRequire(import.meta.url);
51
+
52
+ // The lifecycle port is how the ideas module reaches createTask (never an
53
+ // import — the one-way module rule, ADR 0083). The CALL LOG is load-bearing
54
+ // here and not incidental: `createTaskCalls[0].args.goalId` is the only
55
+ // first-hand record of which goal actually caught the thought, and the whole
56
+ // continuity assertion below is built on it rather than on the constant this
57
+ // file passed in.
58
+ const seams = require('../src/module-seams.js');
59
+ seams._reset();
60
+ let createTaskCalls = [];
61
+ seams.registerProvider('lifecycle', {
62
+ createTask: async (args, deps) => {
63
+ createTaskCalls.push({ args, deps });
64
+ return { id: 9001, status: 'backlog', goal_id: args.goalId, title: args.title };
65
+ },
66
+ });
67
+
68
+ const inbox = require('../modules/ideas/inbox.js');
69
+ const { captureIdea } = inbox;
70
+ const trail = require('../modules/ideas/trail.js');
71
+ const { attachDescentToTrail } = require('../modules/ideas/trail-descent.js');
72
+ const { attachSizeToTrail } = require('../modules/ideas/trail-size.js');
73
+ const { attachStateToTrail } = require('../modules/ideas/idea-state.js');
74
+
75
+ const { test, summary } = makeRunner();
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // the world
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const FILER = 42;
82
+ // A routable goal: open, on a version that is still building — the shape
83
+ // routing.js requires before it will let a filing land as work.
84
+ const LIVE_GOAL = { id: 7, status: 'open', version_id: 'BONGOS-V2', version_status: 'building' };
85
+ const THOUGHT = 'the filing receipt does not survive a reload';
86
+
87
+ function fakePool(handler) {
88
+ const c = makeSqlAwareClient(handler);
89
+ return {
90
+ query: (sql, params) => c._client.query(sql, params),
91
+ connect: c.connect,
92
+ queries: c._client.queries,
93
+ };
94
+ }
95
+
96
+ // The idea row the INSERT returns. `id` and `captured_by` are the two fields
97
+ // every later read keys on, so they are the two this proof carries forward.
98
+ function ideaRow(over = {}) {
99
+ return {
100
+ id: 555, title: THOUGHT, body_md: '', kind: 'bug',
101
+ suggested_version: null, suggested_priority: null,
102
+ captured_at: '2026-09-19T09:00:00.000Z', captured_by: FILER,
103
+ status: 'open', promoted_to_task_id: null, reviewed_at: null,
104
+ merged_into_idea_id: null, triage_note: null, suggested_goal_id: null,
105
+ ...over,
106
+ };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // THE FILING MOMENT — one line, one gesture
111
+ // ---------------------------------------------------------------------------
112
+
113
+ // Files `THOUGHT` through the REAL captureIdea and hands back both halves of
114
+ // what the hall learns at that instant: the capture result (which is what
115
+ // ideas.js builds its receipt line from — `routed_task.id` and
116
+ // `routing.goal_id`, see fileQuick) and the goal createTask was actually given.
117
+ async function fileIt({ goalId }) {
118
+ createTaskCalls = [];
119
+ const pool = fakePool((s) => {
120
+ if (/FROM goals g JOIN versions v/.test(s)) return { rows: [LIVE_GOAL] };
121
+ if (/INSERT INTO idea_inbox/.test(s)) {
122
+ return { rows: [ideaRow(goalId ? { status: 'promoted', suggested_goal_id: goalId } : {})] };
123
+ }
124
+ return null;
125
+ });
126
+ const captured = await captureIdea({
127
+ title: THOUGHT,
128
+ kind: 'bug',
129
+ capturedBy: FILER,
130
+ filerRank: 'archon',
131
+ ...(goalId ? { goalId } : {}),
132
+ }, { pool });
133
+ return { captured, createTaskCalls: createTaskCalls.slice() };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // AN HOUR LATER — the room, from the server alone
138
+ // ---------------------------------------------------------------------------
139
+
140
+ // Reads the trail the way GET /inbox/by-builder/:id reads it: the real
141
+ // projection, then the same three attachers in the same order the route
142
+ // composes them. The row handed to the SQL layer is BUILT FROM `filed` — what
143
+ // the filing actually did — so nothing in this function can make the two
144
+ // moments agree on its own.
145
+ async function anHourLater(filed) {
146
+ const routed = filed.captured.routing && filed.captured.routing.routed
147
+ ? filed.captured
148
+ : null;
149
+ const call = filed.createTaskCalls[0];
150
+
151
+ const row = {
152
+ ...ideaRow(),
153
+ status: routed ? 'promoted' : 'open',
154
+ // The three fields the trail projection carries about a promotion, each
155
+ // taken from the filing rather than restated: the task it minted, that
156
+ // task's goal, that task's status.
157
+ promoted_to_task_id: routed ? routed.routed_task.id : null,
158
+ promoted_task_goal_id: routed ? call.args.goalId : null,
159
+ promoted_task_status: routed ? routed.routed_task.status : null,
160
+ promoted_task_title: routed ? routed.routed_task.title : null,
161
+ merged_into_idea_title: null, merged_into_idea_status: null,
162
+ lineage_tasks: routed
163
+ ? [{
164
+ task_id: routed.routed_task.id, origin: 'route',
165
+ title: routed.routed_task.title, status: routed.routed_task.status,
166
+ goal_id: call.args.goalId,
167
+ }]
168
+ : [],
169
+ };
170
+
171
+ const client = makeSqlAwareClient((s) => {
172
+ if (/FROM idea_inbox i/.test(s)) return { rows: [row] };
173
+ return { rows: [] };
174
+ })._client;
175
+
176
+ const ideas = await trail.listIdeaTrailForBuilder(FILER, {}, { pool: client });
177
+ // The route's own order, and its reasons: the walk, then what it paid, then
178
+ // which of the five objects it is.
179
+ await attachDescentToTrail(ideas, { client });
180
+ await attachSizeToTrail(ideas, { client });
181
+ await attachStateToTrail(ideas, { client });
182
+ return { ideas, total: ideas.length, limit: 50, offset: 0 };
183
+ }
184
+
185
+ // Boots the REAL room over a REAL trail response and hands back the HTML a
186
+ // reader is actually shown. No receipt is reachable from here: bootThinkingPage's
187
+ // localStorage answers null, which is the "another device, an hour later"
188
+ // condition this proof is about.
189
+ async function room(page) {
190
+ const p = bootThinkingPage({ pages: [{ ...page, offset: 0 }], viewer: { id: FILER } });
191
+ for (let i = 0; i < 10; i += 1) await settle();
192
+ return p;
193
+ }
194
+
195
+ // =========================================================================
196
+ // 1. AIMED — the thought that was caught
197
+ // =========================================================================
198
+
199
+ await test('aimed: filing one line lands it as work, and the hall is told so at that instant', async () => {
200
+ const filed = await fileIt({ goalId: LIVE_GOAL.id });
201
+ const r = filed.captured;
202
+ assert.equal(r.routing.routed, true, 'a goal was named, so the filing lands as work');
203
+ assert.equal(filed.createTaskCalls.length, 1, 'exactly one task is minted');
204
+ // The two fields fileQuick (modules/hall-ui/public/ideas.js) reads to write
205
+ // the receipt line "Filed as task N under <goal>." Both must be on the
206
+ // response or the receipt cannot name either.
207
+ assert.ok(r.routed_task && r.routed_task.id, 'the response names the task it became');
208
+ assert.ok(r.routing.goal_id, 'the response names the goal that caught it');
209
+ assert.equal(r.routing.goal_id, filed.createTaskCalls[0].args.goalId,
210
+ 'the goal the filer is TOLD about is the goal the task was actually created in');
211
+ });
212
+
213
+ await test('aimed: AN HOUR LATER the room still names what it became, with no receipt in reach', async () => {
214
+ const filed = await fileIt({ goalId: LIVE_GOAL.id });
215
+ const page = await anHourLater(filed);
216
+ const rows = (await room(page)).rows();
217
+
218
+ assert.match(rows, /the filing receipt does not survive a reload/, 'the thought is on the page');
219
+ // "What became of it" — the task, by NAME and as a link. This is the answer
220
+ // the 2026-09-19 filing could not get.
221
+ assert.match(rows, new RegExp(`/task/${filed.captured.routed_task.id}\\b`),
222
+ 'the row links the task the thought became');
223
+ assert.doesNotMatch(rows, /nothing yet/,
224
+ 'a thought that became work is never drawn as one that caused none');
225
+ });
226
+
227
+ await test('aimed: the goal that caught it survives the hour — the room attributes it to the SAME well', async () => {
228
+ // THE CONTINUITY ASSERTION, and the only one here that no other suite makes.
229
+ // Everything else in this file could hold while the two moments still named
230
+ // two different goals.
231
+ const filed = await fileIt({ goalId: LIVE_GOAL.id });
232
+ const page = await anHourLater(filed);
233
+ const st = page.ideas[0].state;
234
+
235
+ assert.equal(st.position, 'well', 'an aimed thought sits in a goal, not in open space');
236
+ assert.equal(String(st.wellGoalId), String(filed.captured.routing.goal_id),
237
+ 'the goal the room resolves is the goal the receipt named at filing');
238
+ });
239
+
240
+ // =========================================================================
241
+ // 2. FLOATED — the thought nothing has claimed
242
+ // =========================================================================
243
+
244
+ await test('floated: letting it float is a recorded outcome, not a failure to route', async () => {
245
+ const filed = await fileIt({ goalId: null });
246
+ assert.equal(filed.captured.routing.routed, false, 'no goal named, so nothing is minted');
247
+ assert.equal(filed.captured.routing.reason, 'no_goal', 'and the reason is recorded');
248
+ assert.equal(filed.createTaskCalls.length, 0, 'no task exists to find later');
249
+ });
250
+
251
+ await test('floated: an hour later the room says nothing has claimed it — in those words', async () => {
252
+ const filed = await fileIt({ goalId: null });
253
+ const page = await anHourLater(filed);
254
+ const st = page.ideas[0].state;
255
+
256
+ assert.equal(st.state, 'protostar', 'filed, not yet ignited');
257
+ assert.equal(st.position, 'open-space', 'and nothing caught it');
258
+
259
+ const rows = (await room(page)).rows();
260
+ assert.match(rows, /the filing receipt does not survive a reload/, 'the thought is still on the page');
261
+ assert.match(rows, /nothing has claimed it/,
262
+ 'the room says so in the reader\'s words, not by leaving the cell blank');
263
+ });
264
+
265
+ // =========================================================================
266
+ // 3. THE DISTINCTION — the failure mode this whole scope exists for
267
+ // =========================================================================
268
+
269
+ await test('the two outcomes do not render alike — a caught thought and a floating one are told apart at a glance', async () => {
270
+ // The original failure was not that the answer was WRONG. It was that a
271
+ // thought which had quietly become work looked exactly like one that had gone
272
+ // nowhere. Both rows rendering is not the property; them rendering
273
+ // DIFFERENTLY is.
274
+ const aimedRows = (await room(await anHourLater(await fileIt({ goalId: LIVE_GOAL.id })))).rows();
275
+ const floatRows = (await room(await anHourLater(await fileIt({ goalId: null })))).rows();
276
+
277
+ assert.notEqual(aimedRows, floatRows, 'the two rows are not the same HTML');
278
+ assert.match(aimedRows, /data-object="protostar"/, 'both are protostars — neither has been paid yet');
279
+ assert.match(floatRows, /data-object="protostar"/);
280
+ // …and the object column is what separates them, which is exactly the
281
+ // position axis idea-objects.js says is load-bearing for a protostar alone.
282
+ assert.doesNotMatch(aimedRows, /nothing has claimed it/,
283
+ 'the caught thought must never be described as unclaimed');
284
+ assert.match(floatRows, /nothing has claimed it/);
285
+ });
286
+
287
+ // =========================================================================
288
+ // 4. HOW FAR THE CHAIN GETS BEFORE A GOAL IS NAMED — recorded, not assumed
289
+ // =========================================================================
290
+
291
+ await test('the room reaches the goal by LINK, not by name — pinned so the follow-up is visible', async () => {
292
+ // HONEST LIMIT OF THIS PROOF. The criterion asks that a person can answer
293
+ // "what happened to the thing I filed" unaided, and they can: the row names
294
+ // the task and links it, and the task record names its goal. What the room
295
+ // does NOT do is print the goal's own name, even though `state.wellGoalId`
296
+ // is resolved server-side and sits on the row it draws (idea-objects.js
297
+ // positionWord deliberately returns "has a home" / "waiting for a nod" and
298
+ // never the well's identity).
299
+ //
300
+ // That is a rendering gap, not a traceability one, so it is recorded here
301
+ // rather than fixed under a verify task (it is task 1004225) — and pinned, so that when the room
302
+ // does start naming the well this assertion fails and is deleted along with
303
+ // the follow-up it points at.
304
+ const filed = await fileIt({ goalId: LIVE_GOAL.id });
305
+ const page = await anHourLater(filed);
306
+ const rows = (await room(page)).rows();
307
+
308
+ assert.ok(page.ideas[0].state.wellGoalId, 'the well IS resolved and delivered to the page');
309
+ assert.doesNotMatch(rows, new RegExp(`goal ${LIVE_GOAL.id}\\b`),
310
+ 'and today the page does not print it — the goal is one link away, on the task');
311
+ assert.match(rows, /has a home|waiting for a nod/,
312
+ 'what it prints instead is the position, which says a goal caught it without saying which');
313
+ });
314
+
315
+ summary();
@@ -111,5 +111,30 @@ t('rankTagHtml escapes its label and treats xenos as "no floor"', () => {
111
111
  assert.doesNotMatch(T.rankTagHtml('<img src=x onerror=1>'), /<img/);
112
112
  });
113
113
 
114
+ // ---- conflictPeers / conflictChipHtml (task 1001741) ----------------------
115
+ t('conflictPeers names colliding tasks: excludes self, dedupes, sorts numerically', () => {
116
+ const me = { id: 5, touches: ['src/'] };
117
+ const pool = [
118
+ { id: 5, touches: ['src/'] }, // self
119
+ { id: 20, touches: ['src/a.js'] },
120
+ { id: 3, touches: ['src/b.js'] },
121
+ { id: 20, touches: ['src/a.js'] }, // duplicate id in the pool
122
+ { id: 9, touches: ['docs/'] }, // disjoint
123
+ { id: 11, touches: [] }, // no touches never collides
124
+ ];
125
+ assert.deepEqual(T.conflictPeers(me, pool), [3, 20]);
126
+ assert.deepEqual(T.conflictPeers({ id: 1, touches: [] }, pool), []);
127
+ assert.deepEqual(T.conflictPeers(me, null), []);
128
+ });
129
+
130
+ t('conflictChipHtml: empty for none, count + capped list for many', () => {
131
+ assert.equal(T.conflictChipHtml([]), '');
132
+ assert.match(T.conflictChipHtml([2, 3]), /overlaps 2/);
133
+ const many = T.conflictChipHtml([1, 2, 3, 4, 5, 6, 7]);
134
+ assert.match(many, /overlaps 7/);
135
+ assert.match(many, /#1, #2, #3, #4, #5, …/, 'the tooltip lists five and ellipsises the rest');
136
+ assert.doesNotMatch(many, /#6/);
137
+ });
138
+
114
139
  console.log(`\ntask-lib tests: ${passed} passed, ${failed} failed`);
115
140
  if (failed) process.exit(1);