@bongos/core 1.19.496 → 1.19.497

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.
@@ -46,10 +46,27 @@ await t('deriveIdea: long first line is capped (≤200, ellipsis)', () => {
46
46
 
47
47
  // ---- recordInboundIdea (fakes) --------------------------------------------
48
48
  function makeDeps(over = {}) {
49
- const calls = { capture: [], audit: [], persist: [] };
49
+ const calls = { capture: [], audit: [], persist: [], suggest: [], listVersions: 0 };
50
50
  const deps = {
51
51
  getBuilderByDiscordUserId: async () => (over.builder === undefined ? null : over.builder),
52
52
  captureIdea: async (a) => { calls.capture.push(a); if (over.captureThrows) throw new Error('db boom'); return { id: over.ideaId ?? 77 }; },
53
+ // task 1003068 (R12): the goal-suggest fake. Default: no confident match
54
+ // (null) — the ordinary case. `over.goal` supplies a match; `over.suggestThrows`
55
+ // simulates the suggester outage this vector must survive.
56
+ suggestGoalForCapture: async (draft) => {
57
+ calls.suggest.push(draft);
58
+ if (over.suggestThrows) throw new Error('suggest boom');
59
+ return over.goal === undefined ? null : over.goal;
60
+ },
61
+ // task 1003068 (R12): the suggested_version DEFAULT fallback — only
62
+ // consulted when no goal matched. Default: no versions at all (empty),
63
+ // so the ordinary fixture keeps the version hint null. `over.versions`
64
+ // supplies rows; `over.listVersionsThrows` simulates the lookup outage.
65
+ listVersions: async () => {
66
+ calls.listVersions += 1;
67
+ if (over.listVersionsThrows) throw new Error('versions boom');
68
+ return over.versions === undefined ? [] : over.versions;
69
+ },
53
70
  insertAuditLog: async (a) => { calls.audit.push(a); if (over.auditThrows) throw new Error('audit boom'); return { id: 1 }; },
54
71
  persistImages: async (urls, opts) => {
55
72
  calls.persist.push({ urls, opts });
@@ -88,6 +105,77 @@ await t('recordInboundIdea: never produces the rank-gated criterion-proposal kin
88
105
  assert.notEqual(calls.capture[0].kind, 'criterion-proposal');
89
106
  });
90
107
 
108
+ // ---- goal/version routing HINTS (task 1003068, BV1.R12) -------------------
109
+ await t('recordInboundIdea: a confident suggester match writes the HINT columns, never routes', async () => {
110
+ const { deps, calls } = makeDeps({
111
+ builder: { id: 9, github_login: 'mona' },
112
+ goal: { id: 42, title: 'The vendor rework', version_id: 'BONGOS-V1', score: 6 },
113
+ });
114
+ const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'Vendors should restock overnight', messageId: 'm' }, deps);
115
+ assert.equal(r.outcome, 'filed');
116
+ assert.equal(calls.capture[0].suggestedGoalId, 42, 'the suggested goal rides as the hint');
117
+ assert.equal(calls.capture[0].suggestedVersion, 'BONGOS-V1', "the goal's own version is the version hint");
118
+ // THE HARD RULE: goalId is never set, however confident the match — this
119
+ // vector suggests, it never routes (unlike discord-bugs.js). captureIdea
120
+ // would ROUTE (create a task) the moment goalId is non-null, so the absence
121
+ // of the key itself is the assertion, not just its value.
122
+ assert.equal('goalId' in calls.capture[0], false, 'goalId must never be passed — never route, never a task');
123
+ assert.equal(calls.listVersions, 0, "a matched goal's own version wins — the default lookup is never consulted");
124
+ });
125
+
126
+ await t('recordInboundIdea: no confident match, no building version → both hints null, still filed', async () => {
127
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona' }, goal: null });
128
+ const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'a vague thought', messageId: 'm' }, deps);
129
+ assert.equal(r.outcome, 'filed');
130
+ assert.equal(calls.capture[0].suggestedGoalId, null);
131
+ assert.equal(calls.capture[0].suggestedVersion, null);
132
+ });
133
+
134
+ await t('recordInboundIdea: no confident match → suggested_version DEFAULTS to the most-recently-started building version', async () => {
135
+ const { deps, calls } = makeDeps({
136
+ builder: { id: 9, github_login: 'mona' },
137
+ goal: null,
138
+ versions: [
139
+ { id: 'GDS-V3', status: 'shipped', started_at: '2026-01-01T00:00:00Z' },
140
+ { id: 'BONGOS-V1', status: 'building', started_at: '2026-06-01T00:00:00Z' },
141
+ { id: 'BONGOS-V2', status: 'building', started_at: '2026-08-15T00:00:00Z' },
142
+ ],
143
+ });
144
+ const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'a vague thought', messageId: 'm' }, deps);
145
+ assert.equal(r.outcome, 'filed');
146
+ assert.equal(calls.capture[0].suggestedGoalId, null, 'still no goal — nothing confident matched');
147
+ assert.equal(calls.capture[0].suggestedVersion, 'BONGOS-V2', 'the LATER-started building version wins, the shipped one is ignored');
148
+ });
149
+
150
+ await t('recordInboundIdea: suggester throws → filing still succeeds, hints null', async () => {
151
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona' }, suggestThrows: true });
152
+ const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'idea despite an outage', messageId: 'm' }, deps);
153
+ assert.equal(r.outcome, 'filed', 'a suggester outage costs the hint, never the idea');
154
+ assert.equal(calls.capture[0].suggestedGoalId, null);
155
+ assert.equal(calls.capture[0].suggestedVersion, null);
156
+ assert.equal('goalId' in calls.capture[0], false);
157
+ });
158
+
159
+ await t('recordInboundIdea: default-version lookup throws → filing still succeeds, version hint null', async () => {
160
+ const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona' }, goal: null, listVersionsThrows: true });
161
+ const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'idea despite an outage', messageId: 'm' }, deps);
162
+ assert.equal(r.outcome, 'filed', 'a version-lookup outage costs the hint, never the idea');
163
+ assert.equal(calls.capture[0].suggestedVersion, null);
164
+ });
165
+
166
+ await t('mostRecentBuildingVersionId: pure — building-only, latest started_at, empty/absent → null', () => {
167
+ assert.equal(ib.mostRecentBuildingVersionId([]), null);
168
+ assert.equal(ib.mostRecentBuildingVersionId(undefined), null);
169
+ assert.equal(ib.mostRecentBuildingVersionId([{ id: 'A', status: 'planning', started_at: '2026-01-01' }]), null);
170
+ assert.equal(
171
+ ib.mostRecentBuildingVersionId([
172
+ { id: 'OLD', status: 'building', started_at: '2026-01-01' },
173
+ { id: 'NEW', status: 'building', started_at: '2026-06-01' },
174
+ ]),
175
+ 'NEW'
176
+ );
177
+ });
178
+
91
179
  await t('recordInboundIdea: empty content → ignored, nothing filed', async () => {
92
180
  const { deps, calls } = makeDeps();
93
181
  const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: ' ', messageId: 'm' }, deps);
@@ -269,8 +269,14 @@ const MD_SRC = read('md-reader.js');
269
269
  // grade / sessionLogs / claims (task 1003437): left undefined, the stub answers
270
270
  // like a client that never asked (the key is absent); null or an array answers
271
271
  // like the read did. `requests` records every path the page asked for.
272
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
272
+ // goalsList (task 1003063 / R10) feeds GET /goals?version= — the move-to-goal
273
+ // picker's options. `patch` controls the response to PATCH /tasks/:id: null
274
+ // (default) echoes the task back at 200 with goal_id applied from the request
275
+ // body (so a reload's GET reflects the move); { status, data } refuses it with
276
+ // that shape instead, for the refusal-surfacing tests.
277
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
273
278
  const requests = [];
279
+ const toasts = [];
274
280
  const els = new Map();
275
281
  const makeEl = () => ({
276
282
  dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
@@ -294,8 +300,18 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
294
300
  addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
295
301
  };
296
302
  const client = {
297
- request: async (method, p) => {
303
+ request: async (method, p, opts) => {
298
304
  requests.push(p);
305
+ // PATCH must be checked before the generic /tasks/\d+ read below — both
306
+ // match the same path shape, and the read branch ignores method.
307
+ if (method === 'PATCH' && /\/tasks\/\d+$/.test(p)) {
308
+ if (patch && patch.status && patch.status !== 200) {
309
+ return { status: patch.status, ok: false, data: patch.data || null };
310
+ }
311
+ const body = (opts && opts.body) || {};
312
+ if (body.goal_id != null) task.goal_id = body.goal_id; // same object load() re-reads on reload
313
+ return { status: 200, ok: true, data: { task } };
314
+ }
299
315
  if (/\/tasks\/\d+/.test(p)) {
300
316
  if (status !== 200) return { status, ok: false, data: null };
301
317
  const data = { task, last_claim: lastClaim };
@@ -305,6 +321,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
305
321
  return { status: 200, ok: true, data };
306
322
  }
307
323
  if (/\/me$/.test(p)) return { status: 200, ok: true, data: { builder: viewer } };
324
+ if (/\/goals\?/.test(p)) return { status: 200, ok: true, data: { goals: goalsList || [] } };
308
325
  if (/\/goals\//.test(p)) return { status: 200, ok: true, data: { goal } };
309
326
  return { status: 404, ok: false, data: null };
310
327
  },
@@ -320,7 +337,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
320
337
  errCode: (d) => (d && d.error && d.error.code) || null,
321
338
  errMessage: (d) => (d && d.error && d.error.message) || null,
322
339
  categoryChip: () => '',
323
- toast() {},
340
+ toast(msg) { toasts.push(String(msg)); },
324
341
  },
325
342
  location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, search, origin: 'https://builders.cloudbongos.com', assign() {} },
326
343
  addEventListener() {},
@@ -339,6 +356,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
339
356
  sub: () => documentObj.getElementById('task-sub').textContent,
340
357
  el: (id) => documentObj.getElementById(id),
341
358
  requests,
359
+ toasts,
342
360
  };
343
361
  }
344
362
 
@@ -750,3 +768,53 @@ test('the mocks no longer mark the two regions proposed — they are built', ()
750
768
  assert.ok(!mock.includes('<em>proposed</em>'), `${v}: no proposed eyebrow`);
751
769
  }
752
770
  });
771
+
772
+ // ---- 11. the move-to-goal affordance (task 1003063 / R10) -------------------
773
+ //
774
+ // PATCH /tasks/:id {goal_id} (task 1763) existed with no hall control, so a
775
+ // wrong routing pick meant a re-file. Metic+ gets a picker on the record now;
776
+ // the server re-enforces rank + the goal-in-same-version rule (ADR 0016) — this
777
+ // only draws what to offer and reads the server's own refusal verbatim.
778
+
779
+ test('the move-to-goal picker renders for Metic/Archon and not for Xenos/Thetes', async () => {
780
+ for (const rank of ['metic', 'archon']) {
781
+ const r = renderTask(BASE_TASK, { viewer: { rank } });
782
+ await r.boot(); await settle();
783
+ assert.match(r.html(), /id="move-goal-select"/, `${rank} sees the move-to-goal control`);
784
+ }
785
+ for (const rank of ['xenos', 'thetes']) {
786
+ const r = renderTask(BASE_TASK, { viewer: { rank } });
787
+ await r.boot(); await settle();
788
+ assert.doesNotMatch(r.html(), /id="move-goal-select"/, `${rank} must not see the move-to-goal control`);
789
+ }
790
+ });
791
+
792
+ test('a successful move-to-goal PATCH re-renders the goal line against the new goal', async () => {
793
+ const task = { ...BASE_TASK, goal_id: 1000071 };
794
+ const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];
795
+ const r = renderTask(task, { viewer: { rank: 'metic' }, goal: { id: 1000071, title: 'Old goal' }, goalsList });
796
+ await r.boot(); await settle();
797
+ assert.match(r.html(), /Goal <a href="\/goals#\/goal\/1000071">1000071/, 'starts on the original goal');
798
+ r.el('move-goal-select').value = '555';
799
+ await r.el('move-goal-btn')._handlers.click();
800
+ await settle();
801
+ assert.match(r.html(), /Goal <a href="\/goals#\/goal\/555">555/,
802
+ 'the goal line reflects the new goal after the PATCH triggers a reload');
803
+ });
804
+
805
+ test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim", async () => {
806
+ const task = { ...BASE_TASK, goal_id: 1000071 };
807
+ const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];
808
+ const serverMsg = 'Only an Archon may reassign a security-sensitive task.';
809
+ const r = renderTask(task, {
810
+ viewer: { rank: 'metic' }, goalsList,
811
+ patch: { status: 403, data: { error: { code: 'rank_forbidden', message: serverMsg } } },
812
+ });
813
+ await r.boot(); await settle();
814
+ r.el('move-goal-select').value = '555';
815
+ await r.el('move-goal-btn')._handlers.click();
816
+ await settle();
817
+ assert.ok(r.toasts.some((m) => m.includes(serverMsg)),
818
+ `the server's own sentence must reach the toast, not a generic fallback — got: ${r.toasts.join(' | ')}`);
819
+ assert.equal(task.goal_id, 1000071, 'a refused PATCH must not move the task locally');
820
+ });
@@ -213,6 +213,39 @@ await test('a named goal the matrix DECLINES is kept as the core_222 hint, not d
213
213
  'and the reason says WHY it declined, so a filer is not left guessing');
214
214
  });
215
215
 
216
+ await test('suggestedGoalId (task 1003068, BV1.R12): writes the hint, never routes, for an ORDINARY kind', async () => {
217
+ // The case goalId can't cover: an ordinary feature/bug-classified kind with
218
+ // NO human routing decision (goalId absent) still needs the hint recorded —
219
+ // this is the Discord #ideas classifier's vector. Passing goalId here would
220
+ // ROUTE (create a task) for any non-judgment kind; suggestedGoalId must not.
221
+ reset();
222
+ const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
223
+ ? { rows: [{ ...IDEA_ROW, kind: 'feature', status: 'open', suggested_goal_id: 42 }] } : null));
224
+ const idea = await captureIdea(
225
+ { title: 'restock overnight', kind: 'feature', suggestedGoalId: 42, capturedBy: 9 },
226
+ { pool }
227
+ );
228
+ const sqls = seq(pool.queries);
229
+ assert.equal(sqls.length, 1, 'exactly one statement — no transaction, no route');
230
+ const ins = find(pool.queries, /INSERT INTO idea_inbox/);
231
+ assert.match(norm(ins.sql), /'open'/, "status stays 'open'");
232
+ assert.equal(ins.params[6], 42, 'suggestedGoalId is written as the suggested_goal_id hint');
233
+ assert.equal(createTaskCalls.length, 0, 'no task is created — a suggestion is not a decision');
234
+ assert.deepEqual(idea.routing, { routed: false, reason: 'no_goal' },
235
+ 'decideLanding never saw a goalId, so the ordinary no-goal reason applies');
236
+ });
237
+
238
+ await test('suggestedGoalId is ignored when goalId is ALSO set — a real decision always wins', async () => {
239
+ reset();
240
+ const pool = fakePool(routableHandler());
241
+ await captureIdea(
242
+ { title: 'broken', kind: 'bug', goalId: 7, suggestedGoalId: 999, capturedBy: 42, filerRank: 'archon' },
243
+ { pool }
244
+ );
245
+ assert.equal(createTaskCalls.length, 1, 'goalId still routes exactly as before');
246
+ assert.equal(createTaskCalls[0].args.goalId, 7, 'the routed goal is the real decision, not the hint');
247
+ });
248
+
216
249
  await test('the pre-routing validations still fire before any write', async () => {
217
250
  reset();
218
251
  const pool = fakePool(() => null);
@@ -0,0 +1,57 @@
1
+ // tests/planning_session_skill.mjs
2
+ //
3
+ // Static prose assertions on .claude/skills/planning-session/SKILL.md — the
4
+ // skill file IS the deliverable for its own retro tasks (goal 1000071's
5
+ // planning-session retro), so what's checkable is the documented contract,
6
+ // not runtime behavior. Prose is asserted against a whitespace-normalised
7
+ // copy (markdown is hard-wrapped and the checkout reads CRLF back, so a
8
+ // phrase worth asserting will straddle a newline or a \r) — the
9
+ // idea_triage_recommends.mjs precedent.
10
+ //
11
+ // Run: node --test --test-reporter=tap tests/planning_session_skill.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const SKILL = fs.readFileSync(path.join(ROOT, '.claude/skills/planning-session/SKILL.md'), 'utf8');
20
+ const NORM = SKILL.replace(/\s+/g, ' ');
21
+
22
+ let passed = 0;
23
+ let failed = 0;
24
+ function test(name, fn) {
25
+ try { fn(); passed++; console.log(` ok ${name}`); }
26
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
27
+ }
28
+
29
+ // --- task 1003076: seed scripts verify their own criterion links -----------
30
+
31
+ test('the seeding contract documents the self-check convention', () => {
32
+ assert.ok(NORM.includes('verifySeedCriteriaLinks'), 'names the helper function');
33
+ assert.ok(NORM.includes('seed-verify-criteria.js'), 'names the shared helper file');
34
+ assert.ok(NORM.includes('GET /api/gds/goals/<id>'), 'names the endpoint the self-check fetches');
35
+ });
36
+
37
+ test('the self-check is described as failing loudly on any mismatch, zero included', () => {
38
+ assert.ok(/exit\(1\)`?s? with a loud/.test(NORM), 'documents the loud non-zero exit');
39
+ assert.ok(NORM.includes('zero included'), 'zero is called out explicitly, not just "a mismatch"');
40
+ });
41
+
42
+ test('the reference implementation is named', () => {
43
+ assert.ok(NORM.includes('seed-bongos-v1-idea-routing-tasks.js'), 'names the reference seed script');
44
+ });
45
+
46
+ test('the retro incident (RETRO2 / learning 1000188) is cited as the reason', () => {
47
+ assert.ok(NORM.includes('learning 1000188'));
48
+ assert.ok(NORM.includes('goal 1000071'));
49
+ });
50
+
51
+ test('step 5 (run the seed script) points at the self-check rather than treating exit 0 as sufficient on its own', () => {
52
+ const step5 = NORM.slice(NORM.indexOf('Run the seed script.'));
53
+ assert.ok(step5.slice(0, 400).includes('verifySeedCriteriaLinks'), 'step 5 references the self-check by name');
54
+ });
55
+
56
+ console.log(`\nplanning_session_skill: ${passed} passed, ${failed} failed`);
57
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,149 @@
1
+ // tests/seed_verify_criteria.mjs
2
+ //
3
+ // task 1003076 — the retro on goal 1000071's idea-routing seed: it linked all
4
+ // 15 tasks to the WRONG criteria (positional "Cn" tokens resolving version-
5
+ // wide instead of per-goal, learning 1000188) and still printed success. The
6
+ // mislink was only caught by a manual GET /goals rollup, after the fact.
7
+ //
8
+ // scripts/gds/seed-verify-criteria.js is the fix: every seed script tallies
9
+ // the criterion_ids it actually POSTed, then diffs that plan against
10
+ // GET /api/gds/goals/<id>'s real linked_tasks counts — loud and non-zero-exit
11
+ // on any mismatch, zero included. This file pins the pure diff/tally
12
+ // functions and the exit(1)-on-mismatch behavior with no network dependency.
13
+ //
14
+ // Run: node --test --test-reporter=tap tests/seed_verify_criteria.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const {
21
+ diffCriterionLinks,
22
+ expectedCriterionCounts,
23
+ verifySeedCriteriaLinks,
24
+ } = require('../scripts/gds/seed-verify-criteria.js');
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ function test(name, fn) {
29
+ try { fn(); passed++; console.log(` ok ${name}`); }
30
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
31
+ }
32
+ async function testAsync(name, fn) {
33
+ try { await fn(); passed++; console.log(` ok ${name}`); }
34
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
35
+ }
36
+
37
+ // --- expectedCriterionCounts: the plan, tallied from what was POSTed -------
38
+
39
+ test('tallies one count per criterion_id across all tasks', () => {
40
+ const counts = expectedCriterionCounts([
41
+ ['bug-lands-in-its-goal', 'no-route-loses-the-author'],
42
+ ['bug-lands-in-its-goal'],
43
+ ['inbox-holds-only-homeless'],
44
+ ]);
45
+ assert.deepEqual(counts, {
46
+ 'bug-lands-in-its-goal': 2,
47
+ 'no-route-loses-the-author': 1,
48
+ 'inbox-holds-only-homeless': 1,
49
+ });
50
+ });
51
+
52
+ test('a criterion no task cites is simply absent from the plan', () => {
53
+ const counts = expectedCriterionCounts([['a'], ['a']]);
54
+ assert.equal(counts.b, undefined);
55
+ });
56
+
57
+ // --- diffCriterionLinks: expected vs the real GET /goals/:id counts --------
58
+
59
+ test('matching counts produce no mismatches', () => {
60
+ const diff = diffCriterionLinks(
61
+ { a: 2, b: 1 },
62
+ [{ criterion_id: 'a', linked_tasks: 2 }, { criterion_id: 'b', linked_tasks: 1 }],
63
+ );
64
+ assert.deepEqual(diff, []);
65
+ });
66
+
67
+ test('a criterion linked to zero tasks when N were planned is caught', () => {
68
+ // The exact shape of the RETRO2 incident: the goal's own criteria row comes
69
+ // back with linked_tasks: 0 because every task actually landed under a
70
+ // DIFFERENT (wrong) criterion.
71
+ const diff = diffCriterionLinks(
72
+ { 'bug-lands-in-its-goal': 3 },
73
+ [{ criterion_id: 'bug-lands-in-its-goal', linked_tasks: 0 }],
74
+ );
75
+ assert.equal(diff.length, 1);
76
+ assert.deepEqual(diff[0], { criterion_id: 'bug-lands-in-its-goal', expected: 3, actual: 0 });
77
+ });
78
+
79
+ test('an over-linked criterion is caught too, not just zero', () => {
80
+ const diff = diffCriterionLinks(
81
+ { a: 1 },
82
+ [{ criterion_id: 'a', linked_tasks: 4 }],
83
+ );
84
+ assert.deepEqual(diff, [{ criterion_id: 'a', expected: 1, actual: 4 }]);
85
+ });
86
+
87
+ test('linked_tasks arrives as a string (pg count) and still compares numerically', () => {
88
+ const diff = diffCriterionLinks({ a: 2 }, [{ criterion_id: 'a', linked_tasks: '2' }]);
89
+ assert.deepEqual(diff, []);
90
+ });
91
+
92
+ test('a criterion missing from the GET /goals response reads as zero, not skipped', () => {
93
+ const diff = diffCriterionLinks({ a: 1, b: 2 }, [{ criterion_id: 'a', linked_tasks: 1 }]);
94
+ assert.deepEqual(diff, [{ criterion_id: 'b', expected: 2, actual: 0 }]);
95
+ });
96
+
97
+ // --- verifySeedCriteriaLinks: the loud exit(1) wrapper ----------------------
98
+
99
+ await testAsync('zero links where N were planned exits non-zero with a loud message', async () => {
100
+ const logs = [];
101
+ let exitCode = null;
102
+ await verifySeedCriteriaLinks({
103
+ goalId: 1000071,
104
+ expectedCounts: { 'bug-lands-in-its-goal': 3 },
105
+ fetchGoal: async () => ({
106
+ ok: true, status: 200,
107
+ json: { criteria: [{ criterion_id: 'bug-lands-in-its-goal', linked_tasks: 0 }] },
108
+ }),
109
+ log: (line) => logs.push(line),
110
+ exit: (code) => { exitCode = code; },
111
+ });
112
+ assert.equal(exitCode, 1);
113
+ assert.ok(logs.some((l) => l.includes('VERIFY FAILED')), 'expected a loud VERIFY FAILED line');
114
+ assert.ok(logs.some((l) => l.includes('bug-lands-in-its-goal')), 'expected the offending criterion named');
115
+ });
116
+
117
+ await testAsync('a plan that matches reality does not exit and logs VERIFY OK', async () => {
118
+ const logs = [];
119
+ let exitCalled = false;
120
+ await verifySeedCriteriaLinks({
121
+ goalId: 1000071,
122
+ expectedCounts: { a: 2, b: 1 },
123
+ fetchGoal: async () => ({
124
+ ok: true, status: 200,
125
+ json: { criteria: [{ criterion_id: 'a', linked_tasks: 2 }, { criterion_id: 'b', linked_tasks: 1 }] },
126
+ }),
127
+ log: (line) => logs.push(line),
128
+ exit: () => { exitCalled = true; },
129
+ });
130
+ assert.equal(exitCalled, false);
131
+ assert.ok(logs.some((l) => l.includes('VERIFY OK')));
132
+ });
133
+
134
+ await testAsync('a failed/malformed GET is treated as a verification failure, not silence', async () => {
135
+ let exitCode = null;
136
+ const logs = [];
137
+ await verifySeedCriteriaLinks({
138
+ goalId: 1000071,
139
+ expectedCounts: { a: 1 },
140
+ fetchGoal: async () => ({ ok: false, status: 500, json: null }),
141
+ log: (line) => logs.push(line),
142
+ exit: (code) => { exitCode = code; },
143
+ });
144
+ assert.equal(exitCode, 1);
145
+ assert.ok(logs.some((l) => l.includes('VERIFY FAILED')));
146
+ });
147
+
148
+ console.log(`\nseed_verify_criteria: ${passed} passed, ${failed} failed`);
149
+ if (failed > 0) process.exit(1);