@bongos/core 1.19.289 → 1.19.291

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,316 @@
1
+ // tests/promote_goal_id.mjs — a triaged promotion lands IN A GOAL (task 1003071 / R13).
2
+ //
3
+ // Before this, both promote paths — the hall triage overlay and the /idea-triage
4
+ // CLI — created a task with no goal_id at all. That does NOT mean the task came
5
+ // out goal-less: db.createTask (modules/lifecycle/db.js) defaults a goal-less
6
+ // create into the version's catch-all "<version> — general" goal. That bucket IS
7
+ // the defect. It carries no done-when criteria, so since ADR 0183 made criteria
8
+ // close themselves off LINKED tasks, work parked there can never advance
9
+ // anything — and it does not read as real goal work on the board. Falling into it
10
+ // had to stop being the silent default.
11
+ //
12
+ // What these pin:
13
+ // 1. PRECEDENCE. The stored `suggested_goal_id` hint beats the R07 suggester.
14
+ // inbox.captureIdea writes that hint even when the matrix declined to route,
15
+ // specifically so triage "inherits the guess instead of re-deriving it"
16
+ // (core_222's own comment). A heuristic must never overrule a named goal.
17
+ // 2. OPT-OUT IS EXPLICIT. Taking the general bucket is still allowed — it just
18
+ // has to be chosen, not defaulted into.
19
+ // 3. DEGRADES, NEVER BLOCKS. A suggester outage must not stop a triage verdict.
20
+ // 4. BOTH PATHS send goal_id. Two surfaces, one contract — the same class of
21
+ // miss as the two claim-insert sites (task 1003137).
22
+ //
23
+ // Run: node tests/promote_goal_id.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import { readFileSync } from 'node:fs';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ process.env.NODE_ENV = 'test';
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ // triage.js captures `cliClient` by destructuring at require time, so a stub has
35
+ // to be in place BEFORE the module loads. Dropping both from the require cache
36
+ // and re-requiring is what makes that possible per-test.
37
+ function freshTriage() {
38
+ delete require.cache[require.resolve('../scripts/gds/triage.js')];
39
+ return require('../scripts/gds/triage.js');
40
+ }
41
+
42
+ const TRIAGE = readFileSync(new URL('../scripts/gds/triage.js', import.meta.url), 'utf8');
43
+ const HALL = readFileSync(new URL('../modules/hall-ui/public/task-detail.js', import.meta.url), 'utf8');
44
+ const SKILL = readFileSync(new URL('../.claude/skills/idea-triage/SKILL.md', import.meta.url), 'utf8');
45
+
46
+ // ==========================================================================
47
+ // BEHAVIOURAL — these INVOKE the code. The static assertions further down pin
48
+ // the wiring a unit test cannot reach (the DOM strings, the skill doc); they do
49
+ // not stand in for actually running it.
50
+ // ==========================================================================
51
+
52
+ const triage = require('../scripts/gds/triage.js');
53
+
54
+ // A stub of the generated typed client: records the call, answers from a script.
55
+ function stubClient(answer) {
56
+ const calls = [];
57
+ const client = async () => ({
58
+ goals: {
59
+ getGoalsSuggestions: async (req) => {
60
+ calls.push(req);
61
+ if (answer instanceof Error) throw answer;
62
+ return answer;
63
+ },
64
+ },
65
+ });
66
+ return { client, calls };
67
+ }
68
+
69
+ const SUGGESTED = {
70
+ ok: true,
71
+ data: { suggestions: [{ id: 1000071, title: 'Idea routing', score: 7, matched_terms: ['triage', 'goal'] }] },
72
+ };
73
+
74
+ await test('BEHAVIOUR: a junk stored hint is ignored, not turned into goalId NaN', async () => {
75
+ for (const junk of ['not-an-id', '', '0', '-3', '4.5', {}]) {
76
+ const { client, calls } = stubClient(SUGGESTED);
77
+ const r = await triage.resolveGoalDefault({ suggested_goal_id: junk, title: 't' }, 'BV1', { client });
78
+ assert.notEqual(r.source, 'hint', `${JSON.stringify(junk)} is not a usable hint`);
79
+ assert.equal(calls.length, 1, 'and it falls through to the suggester rather than dead-ending');
80
+ }
81
+ });
82
+
83
+ await test('BEHAVIOUR: a stored hint wins and the suggester is never even called', async () => {
84
+ const { client, calls } = stubClient(SUGGESTED);
85
+ const r = await triage.resolveGoalDefault({ suggested_goal_id: 1000058, title: 'x' }, 'BV1', { client });
86
+ assert.equal(r.goalId, 1000058);
87
+ assert.equal(r.source, 'hint');
88
+ assert.equal(calls.length, 0, 'a named goal must not be re-derived — that is the whole point of the column');
89
+ });
90
+
91
+ await test('BEHAVIOUR: with no hint, the top suggestion becomes the default, labelled with its score', async () => {
92
+ const { client, calls } = stubClient(SUGGESTED);
93
+ const r = await triage.resolveGoalDefault({ title: 'goal triage routing', body_md: 'b' }, 'BV1', { client });
94
+ assert.equal(r.goalId, 1000071);
95
+ assert.equal(r.source, 'suggester');
96
+ assert.match(r.label, /score 7/);
97
+ assert.match(r.label, /triage\/goal/, 'the matched terms are what let the operator judge it');
98
+ assert.equal(calls.length, 1);
99
+ assert.equal(calls[0].query.version, 'BV1', 'goals are per-version — the wrong version offers an invalid home');
100
+ assert.equal(calls[0].query.limit, 3);
101
+ });
102
+
103
+ await test('BEHAVIOUR: the body is capped before it goes on a query string', async () => {
104
+ const { client, calls } = stubClient(SUGGESTED);
105
+ await triage.resolveGoalDefault({ title: 't', body_md: 'x'.repeat(9000) }, 'BV1', { client });
106
+ assert.equal(calls[0].query.body.length, 2000, 'an un-capped body would blow the URL length');
107
+ });
108
+
109
+ await test('BEHAVIOUR: a suggester THROW degrades to no default, it does not propagate', async () => {
110
+ const { client } = stubClient(new Error('ECONNREFUSED'));
111
+ const r = await triage.resolveGoalDefault({ title: 't' }, 'BV1', { client });
112
+ assert.deepEqual(r, { goalId: null, label: null, source: 'unavailable' },
113
+ 'a triage verdict must survive the suggester being down');
114
+ });
115
+
116
+ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unavailable"', async () => {
117
+ const empty = await triage.resolveGoalDefault({ title: 't' }, 'BV1',
118
+ { client: stubClient({ ok: true, data: { suggestions: [] } }).client });
119
+ assert.equal(empty.source, 'none');
120
+ const notOk = await triage.resolveGoalDefault({ title: 't' }, 'BV1',
121
+ { client: stubClient({ ok: false, status: 500, data: null }).client });
122
+ assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
123
+ });
124
+
125
+ await test('BEHAVIOUR: blank ACCEPTS the default; only "-"/"none" declines', () => {
126
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
127
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
128
+ 'whitespace is a bare Enter, not a decision');
129
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '-'), { goalId: null, warn: null });
130
+ assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
131
+ assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
132
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
133
+ 'an explicit id overrides the default');
134
+ });
135
+
136
+ await test('BEHAVIOUR: junk clears the goal and SAYS SO rather than landing on a wrong one', () => {
137
+ for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
138
+ const r = triage.applyGoalAnswer(1000071, junk);
139
+ assert.equal(r.goalId, null, `"${junk}" must not resolve to a goal`);
140
+ assert.match(r.warn, /general bucket/, `"${junk}" must say where it will land instead`);
141
+ }
142
+ // Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
143
+ // without the blank check above running first. Guarding the pair together.
144
+ assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
145
+ });
146
+
147
+ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirely when null', async () => {
148
+ const seen = [];
149
+ const fakeClient = {
150
+ tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
151
+ };
152
+ const cliLib = require('../scripts/gds/cli-lib.js');
153
+ const realCliClient = cliLib.cliClient;
154
+ cliLib.cliClient = async () => fakeClient;
155
+ try {
156
+ // triage.js destructured cliClient at require time, so patching the module
157
+ // object cannot reach it — re-require in a fresh registry instead.
158
+ const fresh = freshTriage();
159
+ await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
160
+ await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
161
+ assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
162
+ assert.ok(!('goal_id' in seen[1]),
163
+ 'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
164
+ assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
165
+ assert.equal(seen[0].status, 'backlog');
166
+ } finally {
167
+ cliLib.cliClient = realCliClient;
168
+ }
169
+ });
170
+
171
+ await test('BEHAVIOUR: a failed create throws with the status, it does not return a phantom task', async () => {
172
+ const cliLib = require('../scripts/gds/cli-lib.js');
173
+ const realCliClient = cliLib.cliClient;
174
+ cliLib.cliClient = async () => ({
175
+ tasks: { postTasks: async () => ({ ok: false, status: 422, data: { error: 'NO_SUCH_GOAL' } }) },
176
+ });
177
+ try {
178
+ const fresh = freshTriage();
179
+ await assert.rejects(
180
+ () => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', goalId: 999999999 }),
181
+ /422/,
182
+ );
183
+ } finally {
184
+ cliLib.cliClient = realCliClient;
185
+ }
186
+ });
187
+
188
+ // ==========================================================================
189
+ // STATIC — the surfaces a unit test cannot invoke (browser DOM strings, docs).
190
+ // ==========================================================================
191
+
192
+ // ---- 4. both paths send goal_id -------------------------------------------
193
+
194
+ await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
195
+ assert.match(TRIAGE, /if \(goalId != null\) body\.goal_id = Number\(goalId\);/,
196
+ 'CLI: goal_id must reach the create body');
197
+ assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
198
+ 'CLI: createTask must accept it rather than ignore the caller');
199
+ assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
200
+ 'hall: goal_id must reach the create payload');
201
+ assert.match(HALL, /data-goal-select/, 'hall: and there must be a picker to choose it from');
202
+ });
203
+
204
+ await test('BOTH PATHS: the stale "does not accept kind" note is corrected, not left to mislead', () => {
205
+ assert.ok(!/doesn't currently accept 'kind'/.test(TRIAGE),
206
+ 'that claim was false — routes/tasks.js takes kind and the hall path sends it');
207
+ assert.match(TRIAGE, /no longer true/, 'and the correction says so explicitly');
208
+ });
209
+
210
+ // ---- 1. precedence: the stored hint wins ---------------------------------
211
+
212
+ // (The CLI's precedence is proven by the BEHAVIOUR block above — it drives the
213
+ // real function and asserts the suggester is never called on a hint. A source-shape
214
+ // assertion here would only restate that, less reliably.)
215
+
216
+ await test('PRECEDENCE: the hall picker marks the hint selected, and only auto-selects a suggestion without one', () => {
217
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
218
+ assert.match(fn, /idea\.suggested_goal_id/, 'the hint is read');
219
+ assert.match(fn, /carried from the filing/, 'and labelled as inherited, not guessed');
220
+ assert.match(fn, /hint == null && i === 0 \? ' selected' : ''/,
221
+ 'a suggestion is pre-selected ONLY when there is no hint to honour');
222
+ });
223
+
224
+ // ---- the suggester's weak-evidence signal is shown, not hidden -----------
225
+
226
+ await test('the suggester default shows score + matched terms so it can be judged', () => {
227
+ assert.match(TRIAGE, /score \$\{top\.score\}/, 'CLI shows the score');
228
+ assert.match(TRIAGE, /matched_terms/, 'CLI shows why it matched');
229
+ assert.match(HALL, /matched: /, 'hall shows why it matched');
230
+ // task 1003062 documented that token overlap can match an incidental word; the
231
+ // visible score is what stops that being an invisible wrong default.
232
+ assert.match(HALL, /incidental word/, 'and the reason it is shown is written down');
233
+ });
234
+
235
+ // ---- 2. opting out is explicit ------------------------------------------
236
+
237
+ await test('OPT-OUT: the prompt itself offers the way out, and the branch delegates the meaning', () => {
238
+ const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
239
+ assert.match(branch, /general bucket/, 'the prompt has to say where opting out lands');
240
+ assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
241
+ 'the branch must delegate to the tested helper, not re-implement the parsing inline');
242
+ assert.match(branch, /picked\.warn/, 'and surface its warning');
243
+ assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
244
+ 'the suggester must see the title the operator just confirmed, not the filed one');
245
+ // The semantics themselves are covered by the BEHAVIOUR block above — this
246
+ // only pins that main() is actually wired to them.
247
+ });
248
+
249
+ await test('OPT-OUT: the hall names where opting out actually lands, and only defaults to it last', () => {
250
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
251
+ // "— no goal —" would be a lie: the server files a goal_id-less create under
252
+ // the version's catch-all. The label has to say so.
253
+ assert.match(fn, /general bucket/);
254
+ assert.ok(!/— no goal —/.test(fn), 'the honest label replaced the misleading one');
255
+ assert.match(fn, /opts\.length === 0 \? ' selected' : ''/, 'the opt-out is the fallback, not the default');
256
+ assert.match(fn, /a choice rather than the silent default/, 'and the intent is stated');
257
+ });
258
+
259
+ await test('the CLI result line names the goal, or says the task fell into the general bucket', () => {
260
+ assert.match(TRIAGE, /goal \$\{goalId\}` : ', → general bucket'/,
261
+ 'where it landed must be visible in the output, both ways');
262
+ });
263
+
264
+ // ---- 3. degrades, never blocks -----------------------------------------
265
+
266
+ await test('DEGRADES: a suggester outage yields no default rather than throwing', () => {
267
+ const fn = TRIAGE.slice(TRIAGE.indexOf('async function resolveGoalDefault'), TRIAGE.indexOf('async function createTask'));
268
+ assert.match(fn, /catch \(_\) \{\s*return \{ goalId: null, label: null, source: 'unavailable' \}/,
269
+ 'the CLI must swallow it and report unavailable');
270
+ assert.match(TRIAGE, /goal suggester unreachable/, 'and tell the operator what happened');
271
+ const hallFn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
272
+ assert.match(hallFn, /catch \(_\) \{ suggestions = \[\]; \}/, 'the hall must degrade to no suggestions');
273
+ assert.match(hallFn, /must not stop a triage verdict/, 'the reasoning is recorded');
274
+ });
275
+
276
+ await test('the hall guards the stored hint against a junk value reaching the DOM', () => {
277
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
278
+ assert.match(fn, /Number\.isFinite\(raw\) && raw > 0 \? raw : null/,
279
+ 'value="NaN" would be POSTed as a goal id');
280
+ });
281
+
282
+ await test('the hall degrades a DEAD SESSION to no suggestions instead of navigating away', () => {
283
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
284
+ // getJSON without softAuth calls location.assign on a 401 — from a background
285
+ // fetch that would discard whatever the builder had typed into the form.
286
+ assert.match(fn, /softAuth: true/);
287
+ });
288
+
289
+ await test('DEGRADES: the hall re-fetches goals when the version changes, newest-wins', () => {
290
+ // Two quick version changes race; an out-of-order resolve would paint another
291
+ // version's goals, all of which the server would reject.
292
+ assert.match(HALL, /var mine = \+\+goalFetch;/);
293
+ assert.match(HALL, /if \(mine !== goalFetch\) return;/);
294
+ // Goals belong to a version; keeping stale options would offer a home the
295
+ // server would reject.
296
+ assert.match(HALL, /vsel\.addEventListener\('change'/);
297
+ assert.match(HALL, /await goalOptionsFor\(vsel\.value\)/);
298
+ });
299
+
300
+ await test('the CLI uses the GENERATED typed client method, not the raw escape hatch', () => {
301
+ assert.match(TRIAGE, /api\.goals\.getGoalsSuggestions\(/,
302
+ 'so a path or param rename reds the client build instead of failing at runtime');
303
+ });
304
+
305
+ // ---- the skill contract ------------------------------------------------
306
+
307
+ await test('SKILL DOC: the inbox is declared homeless-only, with both consequences spelled out', () => {
308
+ assert.match(SKILL, /HOMELESS-ONLY/, 'the contract is stated');
309
+ assert.match(SKILL, /Every promote names a goal/, 'consequence 1');
310
+ assert.match(SKILL, /general` goal/, 'and it is honest about what opting out means');
311
+ assert.match(SKILL, /inherited, not guessed/, 'consequence 2 — precedence');
312
+ assert.match(SKILL, /0183/, 'and why a goal-less task now matters more than it used to');
313
+ assert.match(SKILL, /HOMELESS/i, 'the description line carries it too');
314
+ });
315
+
316
+ summary();
@@ -71,8 +71,8 @@ t('pruned lockfile: integrity hashes match the SOURCE lockfile verbatim (not rew
71
71
  assert.equal(mismatched.length, 0, `integrity rewritten for: ${mismatched.slice(0, 10).join(', ')}`);
72
72
  });
73
73
 
74
- t('CORE_DEP_NAMES: the pin domain is exactly the runtime core deps (express + pg)', () => {
75
- assert.ok(Array.isArray(CORE_DEP_NAMES) && CORE_DEP_NAMES.includes('express') && CORE_DEP_NAMES.includes('pg'));
74
+ t('CORE_DEP_NAMES: the pin domain is exactly the runtime core deps (express + pg + pino)', () => {
75
+ assert.ok(Array.isArray(CORE_DEP_NAMES) && CORE_DEP_NAMES.includes('express') && CORE_DEP_NAMES.includes('pg') && CORE_DEP_NAMES.includes('pino'));
76
76
  });
77
77
 
78
78
  console.log(`\npruned_lockfile_integrity: ${passed} passed, ${failed} failed`);