@bongos/core 1.19.287 → 1.19.289
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.
- package/.bongos-core.json +45 -25
- package/.claude/skills/goal-review/SKILL.md +15 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0183-criteria-close-themselves.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +35 -22
- package/docs/api-reference.md +3 -2
- package/modules/lifecycle/db.js +1 -1
- package/modules/lifecycle/done-when.js +145 -0
- package/modules/lifecycle/goal-suggest.js +171 -0
- package/modules/lifecycle/publish-reconciler.js +51 -6
- package/modules/lifecycle/routes/goals.js +9 -19
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +17 -2
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +212 -0
- package/tests/goal_suggest.mjs +195 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// tests/auto_satisfy_criteria.mjs — task 1002069 / ADR 0183.
|
|
2
|
+
//
|
|
3
|
+
// A done-when criterion closes ITSELF once every gating task has shipped, and a
|
|
4
|
+
// goal whose criteria are all satisfied flips open→achieved. The manual
|
|
5
|
+
// /goal-review confirm stops being the only door (owner request 2026-07-05).
|
|
6
|
+
//
|
|
7
|
+
// What these tests actually protect, in order of how badly it would hurt:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE DELIVERY GUARD, and the DELIBERATE ASYMMETRY around it. `abandoned` is
|
|
10
|
+
// terminal (task 1002651), so "no task is still pending" is TRUE for a
|
|
11
|
+
// criterion whose every linked task was CUT. Auto-closing on that alone would
|
|
12
|
+
// satisfy criteria on zero delivered work and cascade goals to `achieved`, so
|
|
13
|
+
// the auto-close SQL additionally requires >=1 SHIPPED.
|
|
14
|
+
// The review flag does NOT get that guard, on purpose. `pending_review` means
|
|
15
|
+
// "a human should LOOK at this", and task 1002651 chose that an all-abandoned
|
|
16
|
+
// criterion surfaces there — its work was cut, so someone must decide whether
|
|
17
|
+
// the criterion still means anything. Auto-close is the narrower question.
|
|
18
|
+
// Everything that auto-closes was flagged; not everything flagged auto-closes.
|
|
19
|
+
// An earlier draft of this task "aligned" the two and broke that intent —
|
|
20
|
+
// tests/criterion_progress.mjs caught it. Do not re-align them.
|
|
21
|
+
// 2. THE EXEC CONTRACT. The helper writes through the caller's `exec`, never a
|
|
22
|
+
// module pool, because shipTask passes its open transaction client — a
|
|
23
|
+
// rolled-back ship must not leave a goal achieved.
|
|
24
|
+
// 3. THE CASCADE RULE. Mirrors db.achieveGoalIfComplete: open goals only, >=1
|
|
25
|
+
// criterion, zero unsatisfied. Re-derived from the table, not from the rows
|
|
26
|
+
// that closed in this call.
|
|
27
|
+
//
|
|
28
|
+
// DB-free: a fake exec records the SQL and returns scripted rows, which is the
|
|
29
|
+
// right level for a pure-SQL helper — the shapes and the branching are the risk,
|
|
30
|
+
// and a live Postgres would test Postgres.
|
|
31
|
+
//
|
|
32
|
+
// Run: node tests/auto_satisfy_criteria.mjs
|
|
33
|
+
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { makeRunner } from './helpers.mjs';
|
|
38
|
+
|
|
39
|
+
process.env.NODE_ENV = 'test';
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const doneWhen = require('../modules/lifecycle/done-when.js');
|
|
42
|
+
const { autoSatisfyShippedCriteria, isPendingReview, buildCriterionProgress } = doneWhen;
|
|
43
|
+
const { test, summary } = makeRunner();
|
|
44
|
+
|
|
45
|
+
// A fake exec: hands back queued result sets in order, recording every call.
|
|
46
|
+
function fakeExec(...results) {
|
|
47
|
+
const calls = [];
|
|
48
|
+
return {
|
|
49
|
+
calls,
|
|
50
|
+
async query(sql, params) {
|
|
51
|
+
calls.push({ sql, params });
|
|
52
|
+
return results[calls.length - 1] || { rows: [] };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CRIT = { id: 10, version_id: 'BONGOS-V1', criterion_id: 'c-one', criterion_md: 'x', goal_id: 77, satisfied_by_task_id: 5 };
|
|
58
|
+
|
|
59
|
+
// ---- 1. the delivery guard, in all three derivations -----------------------
|
|
60
|
+
|
|
61
|
+
await test('GUARD (sql): the update requires BOTH nothing-pending AND >=1 shipped', () => {
|
|
62
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
63
|
+
const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'));
|
|
64
|
+
assert.match(body, /HAVING count\(\*\) FILTER \(WHERE t\.status NOT IN \('shipped', 'abandoned'\)\) = 0/,
|
|
65
|
+
'nothing may still be pending');
|
|
66
|
+
assert.match(body, /AND count\(\*\) FILTER \(WHERE t\.status = 'shipped'\) >= 1/,
|
|
67
|
+
'THE GUARD: an all-abandoned gating set delivered nothing and must not close a criterion');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('ASYMMETRY (sql): the review queue deliberately does NOT carry the delivery guard', () => {
|
|
71
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
72
|
+
const start = src.indexOf('async function listPendingReviewCriteria');
|
|
73
|
+
const body = src.slice(start, src.indexOf('async function criterionProgress', start));
|
|
74
|
+
assert.ok(body.length > 200, 'the slice must actually contain the function');
|
|
75
|
+
assert.match(body, /HAVING count\(\*\) FILTER \(WHERE t\.status NOT IN \('shipped', 'abandoned'\)\) = 0/);
|
|
76
|
+
assert.ok(!/status = 'shipped'\) >= 1/.test(body),
|
|
77
|
+
'task 1002651 intends an all-abandoned criterion to SURFACE for review; only auto-close is gated');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('ASYMMETRY (pure): isPendingReview stays the WIDER question — unchanged by this task', () => {
|
|
81
|
+
// An all-abandoned criterion still flags for review (1002651's intent) even
|
|
82
|
+
// though it will never auto-close. That gap is the feature, not a bug.
|
|
83
|
+
assert.equal(isPendingReview({ satisfied: false, linked: 1, unshipped: 0 }), true,
|
|
84
|
+
'all-abandoned still SURFACES — a human decides whether the criterion survives');
|
|
85
|
+
assert.equal(isPendingReview({ satisfied: false, linked: 2, unshipped: 1 }), false, 'still work pending');
|
|
86
|
+
assert.equal(isPendingReview({ satisfied: true, linked: 1, unshipped: 0 }), false, 'already confirmed');
|
|
87
|
+
assert.equal(isPendingReview({ satisfied: false, linked: 0, unshipped: 0 }), false, 'no linked tasks');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('ASYMMETRY (rollup): the /status rollup is untouched by this task', () => {
|
|
91
|
+
const row = (tid, status) => ({
|
|
92
|
+
cnum: 1, id: 10, criterion_id: 'c-cut', criterion_md: 'x', satisfied: false, goal_id: 1,
|
|
93
|
+
task_id: tid, task_title: 't', task_status: status,
|
|
94
|
+
});
|
|
95
|
+
const g = [{ id: 1, title: 'g', status: 'open', scope_modules: [] }];
|
|
96
|
+
const cut = buildCriterionProgress('V1', [row(301, 'abandoned')], 0, g);
|
|
97
|
+
assert.equal(cut.criteria[0].counts.remaining, 0, 'abandoned is not "remaining" (task 1002651)');
|
|
98
|
+
assert.equal(cut.criteria[0].pending_review, true, 'and it still flags for review — pinned in criterion_progress.mjs');
|
|
99
|
+
assert.equal(buildCriterionProgress('V1', [row(302, 'shipped')], 0, g).criteria[0].pending_review, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---- 2. the exec contract --------------------------------------------------
|
|
103
|
+
|
|
104
|
+
await test('EXEC: every write goes through the caller-supplied exec (so a ship txn is atomic)', async () => {
|
|
105
|
+
const exec = fakeExec({ rows: [CRIT] }, { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] });
|
|
106
|
+
const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
107
|
+
assert.equal(exec.calls.length, 2, 'one criterion update + one goal cascade');
|
|
108
|
+
assert.deepEqual(out.criteria, [CRIT]);
|
|
109
|
+
assert.deepEqual(out.goals.map((g) => g.id), [77]);
|
|
110
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
111
|
+
const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'), src.indexOf('module.exports'));
|
|
112
|
+
assert.ok(!/\bpool\.query\b/.test(body), 'must never reach for the module pool — that would escape the caller txn');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('EXEC: shipTask passes its transaction CLIENT, and the reconciler passes the pool', () => {
|
|
116
|
+
const db = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
|
|
117
|
+
// db.js is over the fitness oversize threshold, so the hook is ONE line there
|
|
118
|
+
// and its body lives in done-when.closeCompletedWorkForShip. What must hold is
|
|
119
|
+
// that the thing handed in is the txn client, not a pool.
|
|
120
|
+
assert.match(db, /closeCompletedWorkForShip\(client, taskId\)/,
|
|
121
|
+
'the ship hook must run on the txn client, not the pool');
|
|
122
|
+
const dw = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
123
|
+
assert.match(dw, /autoSatisfyShippedCriteria\(client, \{ taskId \}\)/, 'and it forwards that client through');
|
|
124
|
+
const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
|
|
125
|
+
assert.match(rec, /autoSatisfyShippedCriteria\(exec, \{\}\)/, 'the sweep is UNSCOPED (no taskId)');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---- 3. scoping, cascade, idempotence -------------------------------------
|
|
129
|
+
|
|
130
|
+
await test('SCOPED vs UNSCOPED: the taskId parameter is what narrows the candidate set', async () => {
|
|
131
|
+
const scoped = fakeExec({ rows: [] });
|
|
132
|
+
await autoSatisfyShippedCriteria(scoped, { taskId: 42 });
|
|
133
|
+
assert.deepEqual(scoped.calls[0].params, [42]);
|
|
134
|
+
assert.match(scoped.calls[0].sql, /\$1::bigint IS NULL OR c\.id IN/, 'one SQL serves both shapes');
|
|
135
|
+
|
|
136
|
+
const sweep = fakeExec({ rows: [] });
|
|
137
|
+
await autoSatisfyShippedCriteria(sweep, {});
|
|
138
|
+
assert.deepEqual(sweep.calls[0].params, [null], 'unscoped passes NULL, which disables the narrowing clause');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('NO-OP: nothing closed means no cascade query at all', async () => {
|
|
142
|
+
const exec = fakeExec({ rows: [] });
|
|
143
|
+
const out = await autoSatisfyShippedCriteria(exec, {});
|
|
144
|
+
assert.deepEqual(out, { criteria: [], goals: [] });
|
|
145
|
+
assert.equal(exec.calls.length, 1, 'must not run the goal cascade when no criterion moved');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('CASCADE: only OPEN goals, only with >=1 criterion and zero unsatisfied', async () => {
|
|
149
|
+
const exec = fakeExec({ rows: [CRIT] }, { rows: [] });
|
|
150
|
+
await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
151
|
+
const cascade = exec.calls[1].sql;
|
|
152
|
+
assert.match(cascade, /g\.status = 'open'/, 'never re-touch an achieved/archived goal');
|
|
153
|
+
assert.match(cascade, /EXISTS\s*\(SELECT 1 FROM done_when_criteria d WHERE d\.goal_id = g\.id\)/, 'an empty goal cannot auto-achieve');
|
|
154
|
+
assert.match(cascade, /NOT EXISTS \(SELECT 1 FROM done_when_criteria d WHERE d\.goal_id = g\.id AND NOT d\.satisfied\)/, 'every criterion must be satisfied');
|
|
155
|
+
assert.deepEqual(exec.calls[1].params, [[77]]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('CASCADE: a criterion with no goal closes nothing (goal_id NULL completes no goal)', async () => {
|
|
159
|
+
const exec = fakeExec({ rows: [{ ...CRIT, goal_id: null }] });
|
|
160
|
+
const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
161
|
+
assert.equal(exec.calls.length, 1, 'no cascade query for a goal-less criterion');
|
|
162
|
+
assert.deepEqual(out.goals, []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('CASCADE: goal ids are deduped, so N criteria under one goal is ONE cascade', async () => {
|
|
166
|
+
const exec = fakeExec({ rows: [CRIT, { ...CRIT, id: 11, criterion_id: 'c-two' }] }, { rows: [] });
|
|
167
|
+
await autoSatisfyShippedCriteria(exec, {});
|
|
168
|
+
assert.deepEqual(exec.calls[1].params, [[77]], 'one goal id, not two');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await test('IDEMPOTENT: the candidate set reads only unsatisfied rows, so a re-run is a no-op', () => {
|
|
172
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
173
|
+
const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'), src.indexOf('module.exports'));
|
|
174
|
+
assert.match(body, /WHERE c\.satisfied = false/, 'this is what makes the every-tick sweep safe');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('a non-numeric taskId is rejected rather than silently becoming an unscoped sweep', async () => {
|
|
178
|
+
const exec = fakeExec({ rows: [] });
|
|
179
|
+
await assert.rejects(() => autoSatisfyShippedCriteria(exec, { taskId: 'oops' }), /must be numeric/);
|
|
180
|
+
assert.equal(exec.calls.length, 0, 'nothing ran');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---- the ship hook is best-effort, the sweep is the retry ------------------
|
|
184
|
+
|
|
185
|
+
await test('the ship hook cannot fail a ship, and the sweep exists to retry it', async () => {
|
|
186
|
+
// Behavioural, not textual: a throwing exec must not propagate out of the hook.
|
|
187
|
+
const boom = { query: async () => { throw new Error('db exploded'); } };
|
|
188
|
+
const logged = [];
|
|
189
|
+
const line = await doneWhen.closeCompletedWorkForShip(boom, 5, (m) => logged.push(m));
|
|
190
|
+
assert.match(line, /auto-satisfy skipped \(sweep retries\)/, 'the failure is reported, not thrown');
|
|
191
|
+
assert.match(logged.join('\n'), /shipTask 5/, 'and it names the ship it happened on');
|
|
192
|
+
const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
|
|
193
|
+
assert.match(rec, /runCriterionSweep\(deps\)\.catch\(/, 'the sweep is fire-and-forget on the tick, like runCostSweep');
|
|
194
|
+
assert.match(rec, /BACKLOG DRAIN/, 'and documents why it is not merely belt-and-braces');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await test('closeCompletedWorkForShip: silent on the common no-op, and logs what closed', async () => {
|
|
198
|
+
const quiet = [];
|
|
199
|
+
const none = await doneWhen.closeCompletedWorkForShip({ query: async () => ({ rows: [] }) }, 5, (m) => quiet.push(m));
|
|
200
|
+
assert.equal(none, null, 'nothing closed => nothing returned');
|
|
201
|
+
assert.deepEqual(quiet, [], 'and nothing logged — a ship that closes nothing stays quiet');
|
|
202
|
+
|
|
203
|
+
const loud = [];
|
|
204
|
+
let call = 0;
|
|
205
|
+
const exec = { async query() { call += 1; return call === 1 ? { rows: [CRIT] } : { rows: [{ id: 77, title: 'G' }] }; } };
|
|
206
|
+
const line = await doneWhen.closeCompletedWorkForShip(exec, 9, (m) => loud.push(m));
|
|
207
|
+
assert.match(line, /auto-satisfied criteria \[c-one\]/);
|
|
208
|
+
assert.match(line, /auto-ACHIEVED goals \[77 "G"\]/);
|
|
209
|
+
assert.match(loud[0], /shipTask 9/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
summary();
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// tests/goal_suggest.mjs — the pre-creation goal suggester (task 1003062 / R07 of
|
|
2
|
+
// goal 1000071).
|
|
3
|
+
//
|
|
4
|
+
// "Which goal should this work live in?", answered BEFORE anything is created.
|
|
5
|
+
// buildGoalAdvisory already answered it, but only AFTER a task existed; R08 (hall
|
|
6
|
+
// picker), R09 (CLI) and R11 (Discord) all have a draft title and no row yet.
|
|
7
|
+
//
|
|
8
|
+
// What these pin:
|
|
9
|
+
// 1. It CREATES NOTHING. That is the entire point of a pre-creation read, and the
|
|
10
|
+
// easiest thing for a later refactor to break by routing it through a writer.
|
|
11
|
+
// 2. RANKING, not "the first five open goals" — with the honest limits of token
|
|
12
|
+
// overlap written down rather than papered over (see the false-positive test).
|
|
13
|
+
// 3. The catch-all is never suggested (it is the fallback when there is no home —
|
|
14
|
+
// a different question, owned by R03).
|
|
15
|
+
// 4. No confident match returns [] rather than noise, and the route still tells
|
|
16
|
+
// the caller how many goals exist so it can offer "browse all" honestly.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/goal_suggest.mjs
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { suggestGoals, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
|
|
28
|
+
const { test, summary } = makeRunner();
|
|
29
|
+
|
|
30
|
+
const V = 'BONGOS-V1';
|
|
31
|
+
const GOALS = [
|
|
32
|
+
{ id: 1000024, title: 'Build-pipeline integrity', scope_modules: ['lifecycle', 'grading'] },
|
|
33
|
+
{ id: 1000025, title: 'Builder experience — hall, dev box, and skills', scope_modules: ['hall-ui', 'dev-box'] },
|
|
34
|
+
{ id: 1000071, title: 'Idea routing — work that has a home lands in it', scope_modules: ['ideas', 'discord'] },
|
|
35
|
+
{ id: 1000003, title: `${V} — general`, scope_modules: [] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// ---- 2. it ranks, and the ranking is explainable --------------------------
|
|
39
|
+
|
|
40
|
+
await test('RANKS: a draft about the ship pipeline puts Build-pipeline integrity first', () => {
|
|
41
|
+
const r = suggestGoals(GOALS, { title: 'ship pipeline grading is flaky' }, { versionId: V });
|
|
42
|
+
assert.equal(r[0].id, 1000024);
|
|
43
|
+
assert.ok(r[0].score >= 4, `two title terms should score >=4, got ${r[0].score}`);
|
|
44
|
+
assert.deepEqual(r[0].matched_terms, ['grading', 'pipeline'], 'and it SAYS why it matched');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await test('RANKS: a module key is a matchable signal, not just the title', () => {
|
|
48
|
+
// 'discord' appears only in scope_modules for goal 1000071.
|
|
49
|
+
const r = suggestGoals(GOALS, { title: 'discord filing loses the author' }, { versionId: V });
|
|
50
|
+
assert.equal(r[0].id, 1000071);
|
|
51
|
+
assert.ok(r[0].matched_terms.includes('discord'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await test('RANKS: title terms outweigh body terms (2 vs 1), same rule as suggestCriteria', () => {
|
|
55
|
+
const inTitle = suggestGoals(GOALS, { title: 'grading' }, { versionId: V })[0];
|
|
56
|
+
const inBody = suggestGoals(GOALS, { title: 'unrelated', body: 'grading' }, { versionId: V })[0];
|
|
57
|
+
assert.equal(inTitle.score, 2);
|
|
58
|
+
assert.equal(inBody.score, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test('RANKS: a term in BOTH title and body counts once, at the title weight', () => {
|
|
62
|
+
const r = suggestGoals(GOALS, { title: 'grading', body: 'grading grading' }, { versionId: V })[0];
|
|
63
|
+
assert.equal(r.score, 2, 'the stronger signal wins; it does not accumulate');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await test('RANKS: ties break by ascending id so a picker renders stably', () => {
|
|
67
|
+
const goals = [
|
|
68
|
+
{ id: 900, title: 'routing', scope_modules: [] },
|
|
69
|
+
{ id: 100, title: 'routing', scope_modules: [] },
|
|
70
|
+
{ id: 500, title: 'routing', scope_modules: [] },
|
|
71
|
+
];
|
|
72
|
+
assert.deepEqual(suggestGoals(goals, { title: 'routing' }, { versionId: V }).map((g) => g.id), [100, 500, 900]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---- the honest limit, written down --------------------------------------
|
|
76
|
+
|
|
77
|
+
await test('LIMIT (known + accepted): token overlap can match on an incidental word', () => {
|
|
78
|
+
// Real example found while building this: "the goals page search BOX is broken"
|
|
79
|
+
// scores the DEV BOX goal, because both contain the token 'box'. Bag-of-words
|
|
80
|
+
// matching cannot tell "search box" from "dev box".
|
|
81
|
+
//
|
|
82
|
+
// This is NOT hidden — it is why every suggestion carries `score` and
|
|
83
|
+
// `matched_terms`: a caller can show WHY it matched and a human decides. A
|
|
84
|
+
// suggester that silently guessed, with no visible reason, would be worse.
|
|
85
|
+
// Fixing it properly means real relevance scoring (embeddings/FTS), which is a
|
|
86
|
+
// separate decision, not something to smuggle into R07.
|
|
87
|
+
const r = suggestGoals(GOALS, { title: 'the goals page search box is broken' }, { versionId: V });
|
|
88
|
+
assert.equal(r[0].id, 1000025, 'the incidental match is what happens today');
|
|
89
|
+
assert.deepEqual(r[0].matched_terms, ['box'], 'and the single weak term is VISIBLE to the caller');
|
|
90
|
+
assert.equal(r[0].score, 2, 'one title term — a low score, which is the signal it is weak');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---- 3. the catch-all is never a suggestion ------------------------------
|
|
94
|
+
|
|
95
|
+
await test('CATCH-ALL: the "<version> — general" goal is never suggested, even on an exact hit', () => {
|
|
96
|
+
const r = suggestGoals(GOALS, { title: `${V} general` }, { versionId: V });
|
|
97
|
+
assert.ok(!r.some((g) => g.id === 1000003), 'the catch-all is the no-home FALLBACK (R03), not a suggestion');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('CATCH-ALL: without a versionId it cannot be identified, so nothing is dropped', () => {
|
|
101
|
+
// candidateGoals keys the catch-all off the version name; no version => no filter.
|
|
102
|
+
const r = suggestGoals(GOALS, { title: 'general' }, {});
|
|
103
|
+
assert.ok(r.some((g) => g.id === 1000003), 'documented consequence of omitting versionId');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---- 4. no confident match, and bounds ----------------------------------
|
|
107
|
+
|
|
108
|
+
await test('NO MATCH: a draft matching nothing returns [] rather than arbitrary goals', () => {
|
|
109
|
+
assert.deepEqual(suggestGoals(GOALS, { title: 'xyzzy plugh frobnicate' }, { versionId: V }), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await test('NO MATCH: includeZero:true is available for a caller that wants the full list', () => {
|
|
113
|
+
const r = suggestGoals(GOALS, { title: 'xyzzy' }, { versionId: V, includeZero: true });
|
|
114
|
+
assert.equal(r.length, 3, 'all three non-catch-all goals, all scoring 0');
|
|
115
|
+
assert.ok(r.every((g) => g.score === 0));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('BOUNDS: limit defaults to SUGGEST_LIMIT and is respected; degenerate input is safe', () => {
|
|
119
|
+
assert.equal(SUGGEST_LIMIT, 5);
|
|
120
|
+
assert.equal(suggestGoals(GOALS, { title: 'routing' }, { versionId: V, limit: 1 }).length, 1);
|
|
121
|
+
assert.deepEqual(suggestGoals(GOALS, { title: 'routing' }, { versionId: V, limit: 0 }), []);
|
|
122
|
+
assert.deepEqual(suggestGoals(null, { title: 'x' }, {}), []);
|
|
123
|
+
assert.deepEqual(suggestGoals(GOALS, {}, { versionId: V }), [], 'no draft text => no suggestions');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- 1. IT CREATES NOTHING ----------------------------------------------
|
|
127
|
+
|
|
128
|
+
function fakeRes() {
|
|
129
|
+
return {
|
|
130
|
+
code: null, payload: null, statusCode: 200,
|
|
131
|
+
json(p) { this.payload = p; return this; },
|
|
132
|
+
status(c) { this.statusCode = c; return this; },
|
|
133
|
+
fail(code, s, extra) { this.code = code; this.statusCode = typeof s === 'number' ? s : (s && s.status) || 500; this.payload = extra || s; return this; },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await test('CREATES NOTHING: the handler only READS goals — no writer is reachable from it', async () => {
|
|
138
|
+
const calls = [];
|
|
139
|
+
const db = { listGoals: async (a) => { calls.push(['listGoals', a]); return GOALS; } };
|
|
140
|
+
const handler = goalSuggestHandler({ db, LIMITS: { TITLE: 200 } });
|
|
141
|
+
const res = fakeRes();
|
|
142
|
+
await handler({ query: { version: V, title: 'ship pipeline grading' } }, res);
|
|
143
|
+
assert.equal(res.code, null, `expected success, got ${res.code}`);
|
|
144
|
+
assert.equal(res.payload.suggestions[0].id, 1000024);
|
|
145
|
+
assert.deepEqual(calls.map((c) => c[0]), ['listGoals'], 'exactly one read, and it is a read');
|
|
146
|
+
assert.equal(res.payload.created, null, 'the response says so explicitly');
|
|
147
|
+
// Source contract: nothing in this module may call a create/insert/update path.
|
|
148
|
+
const src = readFileSync(new URL('../modules/lifecycle/goal-suggest.js', import.meta.url), 'utf8');
|
|
149
|
+
assert.ok(!/\b(createTask|createGoal|insert|INSERT|UPDATE|captureIdea)\b/.test(src),
|
|
150
|
+
'a pre-creation suggester must contain no write path at all');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await test('the handler refuses without a version, and without any draft text', async () => {
|
|
154
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
155
|
+
let res = fakeRes();
|
|
156
|
+
await handler({ query: { title: 'x' } }, res);
|
|
157
|
+
assert.equal(res.code, 'version_required');
|
|
158
|
+
res = fakeRes();
|
|
159
|
+
await handler({ query: { version: V } }, res);
|
|
160
|
+
assert.equal(res.code, 'draft_text_required', 'ranking against nothing is not a suggestion');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await test('the handler reports open_goal_count so an empty result is not mistaken for an empty version', async () => {
|
|
164
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
165
|
+
const res = fakeRes();
|
|
166
|
+
await handler({ query: { version: V, title: 'xyzzy plugh' } }, res);
|
|
167
|
+
assert.deepEqual(res.payload.suggestions, []);
|
|
168
|
+
assert.equal(res.payload.open_goal_count, 4, 'four goals exist; none matched — different facts');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await test('kind is ECHOED but never ranked on (it decides landing, not which goal)', async () => {
|
|
172
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
173
|
+
const withKind = fakeRes();
|
|
174
|
+
await handler({ query: { version: V, title: 'grading', kind: 'bug' } }, withKind);
|
|
175
|
+
const noKind = fakeRes();
|
|
176
|
+
await handler({ query: { version: V, title: 'grading' } }, noKind);
|
|
177
|
+
assert.equal(withKind.payload.kind, 'bug', 'echoed');
|
|
178
|
+
assert.equal(noKind.payload.kind, null);
|
|
179
|
+
assert.deepEqual(
|
|
180
|
+
withKind.payload.suggestions.map((s) => [s.id, s.score]),
|
|
181
|
+
noKind.payload.suggestions.map((s) => [s.id, s.score]),
|
|
182
|
+
'kind must not change the ranking — the R05 landing matrix owns kind',
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('the handler clamps limit into a sane range', async () => {
|
|
187
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
188
|
+
for (const [q, expectMax] of [[{ limit: '999' }, 20], [{ limit: '0' }, 1], [{ limit: 'abc' }, SUGGEST_LIMIT]]) {
|
|
189
|
+
const res = fakeRes();
|
|
190
|
+
await handler({ query: { version: V, title: 'routing grading hall', ...q } }, res);
|
|
191
|
+
assert.ok(res.payload.suggestions.length <= expectMax, `limit ${JSON.stringify(q)} must clamp`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
summary();
|