@bongos/core 1.19.549 → 1.19.551

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,205 @@
1
+ // tests/bug_triage.mjs
2
+ //
3
+ // Regression guard for task 1001478 — /bug-triage's task-shaped reuse of
4
+ // triage.js's generalized clusterRecords (extracted from the idea-only
5
+ // clusterIdeas so the union-find/Jaccard core is written once), the
6
+ // OPEN_STATUSES set that decides which kind=bug tasks a triage walk sees, and
7
+ // the three HTTP-facing functions (listOpenBugTasks/mergeTaskInto/
8
+ // dismissTask) a grading round caught completely untested — which is exactly
9
+ // how a real envelope-shape bug (GET /tasks responds `{ tasks: [...] }`, not
10
+ // a bare array; `(r.data || []).filter` threw on every run) shipped green.
11
+ //
12
+ // cliClient() is mocked by mutating cli-lib.js's exports BEFORE requiring
13
+ // bug-triage.js (same trick as tests/idea_triage.mjs patching
14
+ // `api.pool.query` before requiring inbox.js): bug-triage.js destructures
15
+ // `{ cliClient }` at require time, so the patched function is what it binds.
16
+ //
17
+ // Run: node tests/bug_triage.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import { makeRunner } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+
25
+ // A fake api client whose `tasks.*` methods answer from a per-test script of
26
+ // { ok, data, status } responses, and record every call for assertion.
27
+ const calls = [];
28
+ let responses = {};
29
+ const cliLib = require('../scripts/gds/cli-lib.js');
30
+ cliLib.cliClient = async () => ({
31
+ tasks: {
32
+ getTasks: async (args) => { calls.push({ method: 'getTasks', args }); return responses.getTasks; },
33
+ postTasksIdMerge: async (args) => { calls.push({ method: 'postTasksIdMerge', args }); return responses.merge; },
34
+ postTasksIdAbandon: async (args) => { calls.push({ method: 'postTasksIdAbandon', args }); return responses.abandon; },
35
+ },
36
+ });
37
+
38
+ const { clusterRecords } = require('../scripts/gds/triage.js');
39
+ const { listOpenBugTasks, mergeTaskInto, dismissTask, OPEN_STATUSES } = require('../scripts/gds/bug-triage.js');
40
+
41
+ const { test, summary } = makeRunner();
42
+
43
+ const mkBug = (id, title, { goalId = 1000001, description = '', createdAt = '2026-01-01T00:00:00Z' } = {}) =>
44
+ ({ id, title, description, goal_id: goalId, created_at: createdAt });
45
+
46
+ const bugCluster = (records, opts = {}) => clusterRecords(records, {
47
+ textOf: (t) => `${t.title || ''} ${t.description || ''}`,
48
+ kindOf: (t) => t.goal_id || null,
49
+ timeOf: (t) => new Date(t.created_at),
50
+ ...opts,
51
+ });
52
+
53
+ // ---------- OPEN_STATUSES ----------
54
+
55
+ await test('OPEN_STATUSES: excludes shipped and abandoned (merged tasks reuse abandoned)', () => {
56
+ assert.ok(!OPEN_STATUSES.has('shipped'));
57
+ assert.ok(!OPEN_STATUSES.has('abandoned'));
58
+ });
59
+
60
+ await test('OPEN_STATUSES: includes every live status a bug can sit in', () => {
61
+ for (const s of ['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed']) {
62
+ assert.ok(OPEN_STATUSES.has(s), `${s} should be an open bug status`);
63
+ }
64
+ });
65
+
66
+ // ---------- clusterRecords, task-shaped ----------
67
+
68
+ await test('clusterRecords: near-duplicate bug reports in the SAME goal cluster together', () => {
69
+ const bugs = [
70
+ mkBug(1, 'Login button does nothing on Safari', { createdAt: '2026-01-03T00:00:00Z' }),
71
+ mkBug(2, 'Safari: login button unresponsive', { createdAt: '2026-01-01T00:00:00Z' }),
72
+ mkBug(3, 'The login button is broken in Safari', { createdAt: '2026-01-02T00:00:00Z' }),
73
+ ];
74
+ const { clusters, clusteredIds } = bugCluster(bugs);
75
+ assert.equal(clusters.length, 1, 'expected exactly one cluster');
76
+ assert.equal(clusters[0].length, 3);
77
+ assert.deepEqual(clusteredIds, new Set([1, 2, 3]));
78
+ assert.equal(clusters[0][0].id, 2, 'oldest first — the suggested keeper');
79
+ });
80
+
81
+ await test('clusterRecords: unrelated bug reports never cluster', () => {
82
+ const bugs = [
83
+ mkBug(1, 'Login button does nothing on Safari'),
84
+ mkBug(2, 'Export to CSV drops the last row'),
85
+ mkBug(3, 'Dark mode flickers on page load'),
86
+ ];
87
+ const { clusters, clusteredIds } = bugCluster(bugs);
88
+ assert.equal(clusters.length, 0);
89
+ assert.equal(clusteredIds.size, 0);
90
+ });
91
+
92
+ await test('clusterRecords: same-symptom bugs in DIFFERENT goals do not cluster', () => {
93
+ const bugs = [
94
+ mkBug(1, 'Login button does nothing on Safari', { goalId: 1000001 }),
95
+ mkBug(2, 'Login button does nothing on Safari', { goalId: 1000002 }),
96
+ ];
97
+ const { clusters, clusteredIds } = bugCluster(bugs);
98
+ assert.equal(clusters.length, 0, 'two goals reporting the same symptom are not one triage decision');
99
+ assert.equal(clusteredIds.size, 0);
100
+ });
101
+
102
+ await test('clusterRecords: a single overlapping word alone is not enough (minShared floor)', () => {
103
+ const bugs = [
104
+ mkBug(1, 'Improve the deploy pipeline reliability with retries'),
105
+ mkBug(2, 'Improve onboarding copy for new builders'),
106
+ ];
107
+ const { clusters } = bugCluster(bugs);
108
+ assert.equal(clusters.length, 0, 'sharing only "improve" must not cluster two unrelated reports');
109
+ });
110
+
111
+ await test('clusterRecords: no goal_id (null) still partitions consistently', () => {
112
+ const bugs = [
113
+ mkBug(1, 'Login button does nothing on Safari', { goalId: null }),
114
+ mkBug(2, 'Safari login button is unresponsive', { goalId: null }),
115
+ ];
116
+ const { clusters } = bugCluster(bugs);
117
+ assert.equal(clusters.length, 1, 'two homeless bug reports (goal_id null) can still cluster against each other');
118
+ });
119
+
120
+ await test('clusterRecords: empty input → no clusters', () => {
121
+ const { clusters, clusteredIds } = bugCluster([]);
122
+ assert.equal(clusters.length, 0);
123
+ assert.equal(clusteredIds.size, 0);
124
+ });
125
+
126
+ // ---------- listOpenBugTasks (the envelope-shape blocker) ----------
127
+
128
+ await test('listOpenBugTasks: reads the { tasks: [...] } envelope, not a bare array', async () => {
129
+ responses.getTasks = { ok: true, data: { tasks: [mkBug(1, 'x', {}), { ...mkBug(2, 'y'), status: 'ready' }] } };
130
+ responses.getTasks.data.tasks[0].status = 'ready';
131
+ const out = await listOpenBugTasks();
132
+ assert.equal(out.length, 2, '(r.data || []).filter would have thrown TypeError here — r.data is an object, not an array');
133
+ });
134
+
135
+ await test('listOpenBugTasks: filters out shipped/abandoned, keeps every open status', async () => {
136
+ responses.getTasks = {
137
+ ok: true,
138
+ data: { tasks: [
139
+ { ...mkBug(1, 'a'), status: 'ready' },
140
+ { ...mkBug(2, 'b'), status: 'shipped' },
141
+ { ...mkBug(3, 'c'), status: 'abandoned' },
142
+ { ...mkBug(4, 'd'), status: 'confirmed' },
143
+ ] },
144
+ };
145
+ const out = await listOpenBugTasks();
146
+ assert.deepEqual(out.map((t) => t.id), [1, 4]);
147
+ });
148
+
149
+ await test('listOpenBugTasks: queries kind=bug with a bounded limit', async () => {
150
+ calls.length = 0;
151
+ responses.getTasks = { ok: true, data: { tasks: [] } };
152
+ await listOpenBugTasks();
153
+ assert.equal(calls[0].method, 'getTasks');
154
+ assert.deepEqual(calls[0].args.query, { kind: 'bug', limit: 1000 });
155
+ });
156
+
157
+ await test('listOpenBugTasks: a non-ok response throws with the status + body', async () => {
158
+ responses.getTasks = { ok: false, status: 500, data: { error: 'boom' } };
159
+ await assert.rejects(() => listOpenBugTasks(), /500/);
160
+ });
161
+
162
+ // ---------- mergeTaskInto ----------
163
+
164
+ await test('mergeTaskInto: posts into_task_id + reason to the merge route', async () => {
165
+ calls.length = 0;
166
+ responses.merge = { ok: true, data: { ok: true, taskId: 5, taskStatus: 'abandoned', mergedIntoTaskId: 9 } };
167
+ const out = await mergeTaskInto(5, 9, 'same report');
168
+ assert.equal(calls[0].method, 'postTasksIdMerge');
169
+ assert.deepEqual(calls[0].args, { id: 5, body: { into_task_id: 9, reason: 'same report' } });
170
+ assert.equal(out.mergedIntoTaskId, 9);
171
+ });
172
+
173
+ await test('mergeTaskInto: intoTaskId is coerced to a Number even if passed as a string', async () => {
174
+ calls.length = 0;
175
+ responses.merge = { ok: true, data: {} };
176
+ await mergeTaskInto(5, '9', '');
177
+ assert.equal(calls[0].args.body.into_task_id, 9);
178
+ assert.equal(typeof calls[0].args.body.into_task_id, 'number');
179
+ });
180
+
181
+ await test('mergeTaskInto: a refused merge throws with the server error code attached', async () => {
182
+ responses.merge = { ok: false, status: 409, data: { error: { code: 'MERGE_TARGET_MERGED' } } };
183
+ const err = await mergeTaskInto(5, 9, '').catch((e) => e);
184
+ assert.ok(err instanceof Error);
185
+ assert.equal(err.code, 'MERGE_TARGET_MERGED');
186
+ });
187
+
188
+ // ---------- dismissTask ----------
189
+
190
+ await test('dismissTask: posts a reason to the abandon route', async () => {
191
+ calls.length = 0;
192
+ responses.abandon = { ok: true, data: { ok: true, taskId: 5, taskStatus: 'abandoned' } };
193
+ await dismissTask(5, 'cannot reproduce');
194
+ assert.equal(calls[0].method, 'postTasksIdAbandon');
195
+ assert.deepEqual(calls[0].args, { id: 5, body: { reason: 'cannot reproduce' } });
196
+ });
197
+
198
+ await test('dismissTask: a refused won\'t-fix throws with the server error code attached', async () => {
199
+ responses.abandon = { ok: false, status: 409, data: { error: { code: 'TASK_HAS_ACTIVE_CLAIM' } } };
200
+ const err = await dismissTask(5, '').catch((e) => e);
201
+ assert.equal(err.code, 'TASK_HAS_ACTIVE_CLAIM');
202
+ });
203
+
204
+ // -------------------------------------------------------------------------
205
+ summary();
@@ -0,0 +1,198 @@
1
+ // tests/task_merge.mjs
2
+ //
3
+ // Regression guard for task 1001478 — db.mergeTask, the idea_inbox merge
4
+ // verdict's twin for `tasks` (the /bug-triage counterpart to /idea-triage's
5
+ // merge). See modules/lifecycle/db-ship.js's mergeTask header for the full
6
+ // design rationale (why 'abandoned' + merged_into_task_id, not a new status).
7
+ //
8
+ // Contract under test:
9
+ // - a live task merges into an existing, unmerged target: status flips to
10
+ // 'abandoned', merged_into_task_id/triage_note/value_summary are written,
11
+ // the txn COMMITs.
12
+ // - self-merge → MERGE_SELF, no DB read even reached.
13
+ // - not found → TASK_NOT_FOUND, ROLLBACK.
14
+ // - 'shipped' → CANNOT_MERGE_SHIPPED, ROLLBACK, no UPDATE (immutable history).
15
+ // - already merged into the SAME target → idempotent no-op (alreadyMerged:true).
16
+ // - already merged into a DIFFERENT target → ALREADY_MERGED, names the
17
+ // existing target (no re-merge chains).
18
+ // - active claim → TASK_HAS_ACTIVE_CLAIM, ROLLBACK, no UPDATE.
19
+ // - the shipped/already-merged guards short-circuit BEFORE the claims lookup.
20
+ // - target doesn't exist → MERGE_TARGET_NOT_FOUND.
21
+ // - target is itself merged elsewhere → MERGE_TARGET_MERGED, names where the
22
+ // chain ends (mirrors idea merge's target-COLUMN check, not target status).
23
+ // - no reason → value_summary is PRESERVED (no synthesized default;
24
+ // merged_into_task_id is the queryable source of truth).
25
+ //
26
+ // mergeTask opens its own transaction via pool.connect(); the #538 deps.pool
27
+ // injection seam (honored ONLY when NODE_ENV==='test') drives it with a
28
+ // SQL-aware fakeClient — no live DB — same posture as abandon_task.mjs.
29
+ //
30
+ // Run: node tests/task_merge.mjs
31
+
32
+ import { strict as assert } from 'node:assert';
33
+ import { createRequire } from 'node:module';
34
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
35
+
36
+ process.env.NODE_ENV = 'test';
37
+
38
+ const require = createRequire(import.meta.url);
39
+ const db = require('../modules/lifecycle/db.js');
40
+ const { mergeTask } = db;
41
+
42
+ const { test, summary } = makeRunner();
43
+
44
+ // SQL-aware fake keyed on the two SELECTs mergeTask issues, by which id they
45
+ // target — the source row (FOR UPDATE, taskId) and the target row (no lock,
46
+ // intoTaskId). Distinguishing by bound param (not statement text, since both
47
+ // are structurally different queries here) mirrors abandon_task.mjs's shape.
48
+ function fakePool({
49
+ status = 'ready', notFound = false, mergedInto = null,
50
+ hasActiveClaim = false, targetNotFound = false, targetMergedInto = null,
51
+ updatedStatus = 'abandoned',
52
+ } = {}) {
53
+ return makeSqlAwareClient((s, params) => {
54
+ if (/^SELECT id, title, status, merged_into_task_id FROM tasks WHERE id = \$1 FOR UPDATE/.test(s)) {
55
+ return { rows: notFound ? [] : [{ id: params[0], title: 'fixture bug', status, merged_into_task_id: mergedInto }] };
56
+ }
57
+ if (/^SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL/.test(s)) {
58
+ return { rows: hasActiveClaim ? [{ id: 999 }] : [] };
59
+ }
60
+ if (/^SELECT id, merged_into_task_id FROM tasks WHERE id = \$1/.test(s)) {
61
+ return { rows: targetNotFound ? [] : [{ id: params[0], merged_into_task_id: targetMergedInto }] };
62
+ }
63
+ if (/^UPDATE tasks/.test(s)) {
64
+ return { rows: [{ id: params[0], title: 'fixture bug', status: updatedStatus }] };
65
+ }
66
+ return { rows: [] }; // BEGIN / COMMIT / ROLLBACK
67
+ });
68
+ }
69
+
70
+ const find = (queries, re) => queries.find((q) => re.test(String(q.sql).replace(/\s+/g, ' ')));
71
+ const all = (queries, re) => queries.filter((q) => re.test(String(q.sql).replace(/\s+/g, ' ')));
72
+
73
+ const TASK = 1001;
74
+ const TARGET = 2002;
75
+
76
+ // =========================================================================
77
+ await test('merge: a ready task merges into an existing target, COMMITs', async () => {
78
+ const pool = fakePool({ status: 'ready' });
79
+ const out = await mergeTask({ taskId: TASK, intoTaskId: TARGET, reason: 'same report' }, { pool });
80
+
81
+ assert.equal(out.taskStatus, 'abandoned');
82
+ assert.equal(out.previousStatus, 'ready');
83
+ assert.equal(out.mergedIntoTaskId, TARGET);
84
+ assert.equal(out.alreadyMerged, false);
85
+ assert.ok(find(pool._client.queries, /^UPDATE tasks/), 'the tasks UPDATE must run');
86
+ assert.ok(find(pool._client.queries, /^COMMIT/), 'must COMMIT');
87
+ assert.ok(!find(pool._client.queries, /^ROLLBACK/), 'no ROLLBACK on the happy path');
88
+ });
89
+
90
+ await test('merge: writes merged_into_task_id + triage_note + reason-derived value_summary', async () => {
91
+ const pool = fakePool({ status: 'ready' });
92
+ await mergeTask({ taskId: TASK, intoTaskId: TARGET, reason: 'dup of #2002' }, { pool });
93
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
94
+ assert.equal(upd.params[0], TASK);
95
+ assert.equal(upd.params[1], TARGET);
96
+ assert.equal(upd.params[2], 'dup of #2002');
97
+ assert.match(String(upd.sql), /merged_into_task_id = \$2/);
98
+ assert.match(String(upd.sql), /triage_note = NULLIF/);
99
+ });
100
+
101
+ await test('merge: no reason PRESERVES the existing value_summary (no synthesized default)', async () => {
102
+ // Round-2 grade finding: an earlier version fell back to a synthesized
103
+ // "Merged into #<N>" default, which unconditionally overwrote a
104
+ // completed/confirmed bug task's real value_summary. mergeTask now matches
105
+ // abandonTask's own COALESCE(NULLIF($n,''), value_summary) shape exactly —
106
+ // merged_into_task_id is the queryable source of truth for where it went.
107
+ const pool = fakePool({ status: 'ready' });
108
+ await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool });
109
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
110
+ assert.equal(upd.params[2], '', 'empty reason binds empty string');
111
+ assert.match(String(upd.sql), /COALESCE\(NULLIF\(\$3, ''\), value_summary\)/);
112
+ });
113
+
114
+ await test('merge: self-merge is refused before any SELECT (right after BEGIN)', async () => {
115
+ const pool = fakePool({ status: 'ready' });
116
+ await assert.rejects(
117
+ () => mergeTask({ taskId: TASK, intoTaskId: TASK }, { pool }),
118
+ (err) => err.code === 'MERGE_SELF',
119
+ );
120
+ assert.equal(all(pool._client.queries, /^SELECT/).length, 0, 'no SELECT reached — the check runs before the task row is even locked');
121
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
122
+ });
123
+
124
+ await test('merge: a missing task throws TASK_NOT_FOUND and rolls back', async () => {
125
+ const pool = fakePool({ notFound: true });
126
+ await assert.rejects(
127
+ () => mergeTask({ taskId: 424242, intoTaskId: TARGET }, { pool }),
128
+ (err) => err.code === 'TASK_NOT_FOUND',
129
+ );
130
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
131
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
132
+ });
133
+
134
+ await test('merge: a shipped task is refused (immutable history) and rolls back', async () => {
135
+ const pool = fakePool({ status: 'shipped' });
136
+ await assert.rejects(
137
+ () => mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }),
138
+ (err) => err.code === 'CANNOT_MERGE_SHIPPED',
139
+ );
140
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
141
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
142
+ });
143
+
144
+ await test('merge: the shipped guard short-circuits BEFORE the claims lookup', async () => {
145
+ const pool = fakePool({ status: 'shipped' });
146
+ await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }).catch(() => {});
147
+ assert.equal(all(pool._client.queries, /SELECT id FROM claims/).length, 0,
148
+ 'a shipped task must not even query claims');
149
+ });
150
+
151
+ await test('merge: already merged into the SAME target is an idempotent no-op', async () => {
152
+ const pool = fakePool({ status: 'abandoned', mergedInto: TARGET });
153
+ const out = await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool });
154
+ assert.equal(out.alreadyMerged, true);
155
+ assert.equal(out.mergedIntoTaskId, TARGET);
156
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0, 'idempotent — no second UPDATE');
157
+ assert.equal(all(pool._client.queries, /SELECT id FROM claims/).length, 0, 'no claims lookup needed');
158
+ assert.ok(find(pool._client.queries, /^COMMIT/), 'still COMMITs the (empty) txn');
159
+ });
160
+
161
+ await test('merge: already merged into a DIFFERENT target is refused, naming the existing one', async () => {
162
+ const pool = fakePool({ status: 'abandoned', mergedInto: 5005 });
163
+ const err = await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }).catch((e) => e);
164
+ assert.equal(err.code, 'ALREADY_MERGED');
165
+ assert.equal(err.merged_into, '5005');
166
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
167
+ });
168
+
169
+ await test('merge: a task with an active claim is refused (release first) and rolls back', async () => {
170
+ const pool = fakePool({ status: 'ready', hasActiveClaim: true });
171
+ await assert.rejects(
172
+ () => mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }),
173
+ (err) => err.code === 'TASK_HAS_ACTIVE_CLAIM',
174
+ );
175
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
176
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
177
+ });
178
+
179
+ await test('merge: a nonexistent target throws MERGE_TARGET_NOT_FOUND and rolls back', async () => {
180
+ const pool = fakePool({ status: 'ready', targetNotFound: true });
181
+ await assert.rejects(
182
+ () => mergeTask({ taskId: TASK, intoTaskId: 999999 }, { pool }),
183
+ (err) => err.code === 'MERGE_TARGET_NOT_FOUND',
184
+ );
185
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
186
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
187
+ });
188
+
189
+ await test('merge: a target that is itself merged elsewhere is refused, naming where the chain ends', async () => {
190
+ const pool = fakePool({ status: 'ready', targetMergedInto: 7007 });
191
+ const err = await mergeTask({ taskId: TASK, intoTaskId: TARGET }, { pool }).catch((e) => e);
192
+ assert.equal(err.code, 'MERGE_TARGET_MERGED');
193
+ assert.equal(err.merged_into, '7007');
194
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
195
+ });
196
+
197
+ // -------------------------------------------------------------------------
198
+ summary();
@@ -16,6 +16,9 @@ const {
16
16
  parseDevelopment,
17
17
  loadDevelopment,
18
18
  formatDevelopment,
19
+ clusterIdeas,
20
+ significantWords,
21
+ jaccard,
19
22
  } = require('../scripts/gds/triage.js');
20
23
 
21
24
  let passed = 0;
@@ -135,6 +138,108 @@ test('formatDevelopment: empty arrays render nothing', () => {
135
138
  assert.doesNotMatch(out, /challenges:/);
136
139
  });
137
140
 
141
+ // ---------- significantWords / jaccard ----------
142
+
143
+ test('significantWords: lowercases, strips stopwords + short tokens', () => {
144
+ const s = significantWords('Add a Dark Mode Toggle to the Settings Page');
145
+ assert.ok(s.has('dark'));
146
+ assert.ok(s.has('mode'));
147
+ assert.ok(s.has('toggle'));
148
+ assert.ok(s.has('settings'));
149
+ assert.ok(!s.has('a'), 'stopword "a" leaked through');
150
+ assert.ok(!s.has('the'), 'stopword "the" leaked through');
151
+ assert.ok(!s.has('to'), 'stopword "to" leaked through');
152
+ });
153
+
154
+ test('jaccard: identical sets → 1, disjoint sets → 0, empty set → 0', () => {
155
+ assert.equal(jaccard(new Set(['a', 'b']), new Set(['a', 'b'])), 1);
156
+ assert.equal(jaccard(new Set(['a']), new Set(['b'])), 0);
157
+ assert.equal(jaccard(new Set(), new Set(['a'])), 0);
158
+ assert.equal(jaccard(new Set(), new Set()), 0);
159
+ });
160
+
161
+ test('jaccard: partial overlap is intersection / union', () => {
162
+ // {a,b,c} ∩ {b,c,d} = {b,c} (2); ∪ = {a,b,c,d} (4) → 0.5
163
+ assert.equal(jaccard(new Set(['a', 'b', 'c']), new Set(['b', 'c', 'd'])), 0.5);
164
+ });
165
+
166
+ // ---------- clusterIdeas ----------
167
+
168
+ const mkIdea = (id, title, { kind = 'feature', body_md = '', capturedAt = '2026-01-01T00:00:00Z', grade } = {}) =>
169
+ ({ id, title, body_md, kind, grade, captured_at: capturedAt });
170
+
171
+ test('clusterIdeas: near-duplicate titles of the SAME kind cluster together', () => {
172
+ const ideas = [
173
+ mkIdea(1, 'Add dark mode toggle to settings', { capturedAt: '2026-01-03T00:00:00Z' }),
174
+ mkIdea(2, 'Dark mode toggle for settings page', { capturedAt: '2026-01-01T00:00:00Z' }),
175
+ mkIdea(3, 'Support a dark mode toggle in settings', { capturedAt: '2026-01-02T00:00:00Z' }),
176
+ ];
177
+ const { clusters, clusteredIds } = clusterIdeas(ideas);
178
+ assert.equal(clusters.length, 1, 'expected exactly one cluster');
179
+ assert.equal(clusters[0].length, 3);
180
+ assert.deepEqual(clusteredIds, new Set([1, 2, 3]));
181
+ // oldest first — the suggested keeper
182
+ assert.equal(clusters[0][0].id, 2);
183
+ });
184
+
185
+ test('clusterIdeas: unrelated ideas never cluster', () => {
186
+ const ideas = [
187
+ mkIdea(1, 'Add dark mode toggle to settings'),
188
+ mkIdea(2, 'Fix flaky CI test for the deploy pipeline'),
189
+ mkIdea(3, 'Write an ADR about the credit reward formula'),
190
+ ];
191
+ const { clusters, clusteredIds } = clusterIdeas(ideas);
192
+ assert.equal(clusters.length, 0);
193
+ assert.equal(clusteredIds.size, 0);
194
+ });
195
+
196
+ test('clusterIdeas: same-topic ideas of DIFFERENT kind do not cluster (a bug is not a dup of a feature)', () => {
197
+ const ideas = [
198
+ mkIdea(1, 'Dark mode toggle is broken on mobile', { kind: 'bug' }),
199
+ mkIdea(2, 'Add a dark mode toggle', { kind: 'feature' }),
200
+ ];
201
+ const { clusters, clusteredIds } = clusterIdeas(ideas);
202
+ assert.equal(clusters.length, 0, 'a bug report and a feature ask must not be treated as the same duplicate');
203
+ assert.equal(clusteredIds.size, 0);
204
+ });
205
+
206
+ test('clusterIdeas: a Full Idea is never eligible for batch clustering', () => {
207
+ const ideas = [
208
+ mkIdea(1, 'Add dark mode toggle to settings', { grade: 'full' }),
209
+ mkIdea(2, 'Dark mode toggle for settings page', { grade: 'full' }),
210
+ ];
211
+ const { clusters, clusteredIds } = clusterIdeas(ideas);
212
+ assert.equal(clusters.length, 0, 'Full Ideas must go through the shape recommendation, never a batch merge');
213
+ assert.equal(clusteredIds.size, 0);
214
+ });
215
+
216
+ test('clusterIdeas: a single overlapping word alone is not enough (minShared floor)', () => {
217
+ const ideas = [
218
+ mkIdea(1, 'Improve the deploy pipeline reliability with retries'),
219
+ mkIdea(2, 'Improve onboarding copy for new builders'),
220
+ ];
221
+ const { clusters } = clusterIdeas(ideas);
222
+ assert.equal(clusters.length, 0, 'sharing only "improve" must not cluster two unrelated ideas');
223
+ });
224
+
225
+ test('clusterIdeas: three ideas, two of which duplicate — only the pair clusters', () => {
226
+ const ideas = [
227
+ mkIdea(1, 'Add dark mode toggle to settings', { capturedAt: '2026-01-02T00:00:00Z' }),
228
+ mkIdea(2, 'Dark mode toggle for the settings page', { capturedAt: '2026-01-01T00:00:00Z' }),
229
+ mkIdea(3, 'Write an ADR about the credit reward formula'),
230
+ ];
231
+ const { clusters, clusteredIds } = clusterIdeas(ideas);
232
+ assert.equal(clusters.length, 1);
233
+ assert.deepEqual(new Set(clusters[0].map((i) => i.id)), new Set([1, 2]));
234
+ assert.ok(!clusteredIds.has(3));
235
+ });
236
+
237
+ test('clusterIdeas: empty input → no clusters', () => {
238
+ const { clusters, clusteredIds } = clusterIdeas([]);
239
+ assert.equal(clusters.length, 0);
240
+ assert.equal(clusteredIds.size, 0);
241
+ });
242
+
138
243
  // ---------- summary ----------
139
244
  console.log(`\n${passed} passed, ${failed} failed`);
140
245
  process.exit(failed === 0 ? 0 : 1);