@bongos/core 1.19.568 → 1.19.570

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.
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
210
210
 
211
211
  t('a task CREATED with NO reward is born with a positive, capped value', async () => {
212
212
  await withStubbedCreatePool(async (captured) => {
213
- const row = await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 40 });
213
+ const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
214
214
  assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
215
215
  assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
216
216
  assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
220
220
 
221
221
  t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
222
222
  await withStubbedCreatePool(async (captured) => {
223
- await db.createTask({ versionId: 1, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
223
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
224
224
  // bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
225
225
  assert.equal(captured.creditsReward, 50);
226
226
  });
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
228
228
 
229
229
  t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
230
230
  await withStubbedCreatePool(async (captured) => {
231
- await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 9999 });
231
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
232
232
  assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
233
233
  });
234
234
  });
235
235
 
236
236
  t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
237
237
  await withStubbedCreatePool(async (captured) => {
238
- await db.createTask({ versionId: 1, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
238
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
239
239
  assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
240
240
  });
241
241
  });
@@ -0,0 +1,223 @@
1
+ // tests/task_goal_required.mjs — a goal-less create is no longer SILENT
2
+ // (BV1.R11, task 1003598, goal 1000086, ADR 0250 D4).
3
+ //
4
+ // WHAT WAS ACTUALLY WRONG. Task 1763 made `createTask` default any create with no
5
+ // `goalId` into its version's catch-all "<version> — general" goal, at the lowest
6
+ // layer, so that no caller had to think about it. The invariant "every task
7
+ // belongs to a goal" then held — technically — while 53 open tasks accumulated in
8
+ // two buckets nobody reads. Unscoped work never snuck past the rule; the rule
9
+ // filed it out of sight.
10
+ //
11
+ // So the defect is the SILENCE, not the bucket. A caller now either names a goal
12
+ // or says out loud that it cannot, by passing `allowCatchAll: true`: same landing
13
+ // place, same never-break-a-creator promise, but the exceptions are a one-command
14
+ // grep instead of an absence, and a create vector added next year gets a thrown
15
+ // error at its first test run rather than a quiet home in a bucket.
16
+ //
17
+ // NOT FULLY DELETED, and that is a recorded decision rather than an oversight.
18
+ // R11 literally asks for the fallback's deletion, but two in-repo vectors have no
19
+ // goal to name yet — `newcomer-restock` (the beginner queue) and `copy-desk`
20
+ // (copy proposals) — whose honest home is the per-version maintenance goal, task
21
+ // 1003605 (R18), still behind R06 and R12. Deleting today would drain the
22
+ // newcomer queue. The owner chose to remove the silent half now and leave the
23
+ // true deletion to R18. This file pins the opt-in roster so it cannot grow
24
+ // quietly in the meantime.
25
+ //
26
+ // Run: node --test tests/task_goal_required.mjs
27
+
28
+ import assert from 'node:assert/strict';
29
+ import { test } from 'node:test';
30
+ import { readFileSync } from 'node:fs';
31
+ import { createRequire } from 'node:module';
32
+
33
+ process.env.NODE_ENV = 'test';
34
+ const require = createRequire(import.meta.url);
35
+ const { createTask } = require('../modules/lifecycle/db.js');
36
+
37
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
38
+
39
+ // A pool that would happily create the row, so a rejection can only come from the
40
+ // guard and never from a database that was never reached.
41
+ const permissivePool = {
42
+ query: async () => ({ rows: [{ id: '1', goal_id: null, title: 'x' }] }),
43
+ connect: async () => ({ query: async () => ({ rows: [] }), release() {} }),
44
+ };
45
+
46
+ // ---- the guard --------------------------------------------------------------
47
+
48
+ test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
49
+ await assert.rejects(
50
+ () => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
51
+ /goalId is required/,
52
+ 'the old behaviour was to succeed and say nothing');
53
+ });
54
+
55
+ test('the error tells the caller both ways out', async () => {
56
+ const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
57
+ .then(() => null, (e) => e);
58
+ assert.ok(err, 'must reject');
59
+ assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
60
+ assert.match(err.message, /allowCatchAll: true/, 'and the escape hatch, spelled exactly');
61
+ });
62
+
63
+ test('only a literal true opts in — a truthy value still throws', async () => {
64
+ for (const v of [1, 'yes', {}, 'true']) {
65
+ await assert.rejects(
66
+ () => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
67
+ /goalId is required/,
68
+ `${JSON.stringify(v)} must not open the hatch`);
69
+ }
70
+ });
71
+
72
+ test('naming a goal needs no flag at all', async () => {
73
+ // The ordinary path must stay ordinary: a caller with a goal never learns this
74
+ // parameter exists.
75
+ await createTask(
76
+ { versionId: 'BONGOS-V1', title: 't', goalId: 77 },
77
+ { pool: permissivePool, client: permissivePool },
78
+ );
79
+ });
80
+
81
+ // ---- the route --------------------------------------------------------------
82
+
83
+ const WRITE = src('modules/lifecycle/routes/task-write-routes.js');
84
+ const POST_TASKS = (() => {
85
+ const a = WRITE.indexOf("router.post('/tasks'");
86
+ assert.notEqual(a, -1);
87
+ return WRITE.slice(a, WRITE.indexOf("router.patch('/tasks/:id'"));
88
+ })();
89
+
90
+ test('POST /tasks refuses a body with no goal_id, as a 400 that says where to look', () => {
91
+ assert.match(POST_TASKS, /if \(body\.goal_id == null\) \{/);
92
+ assert.match(POST_TASKS, /res\.fail\('goal_id_required', \{[\s\S]*?status: 400/,
93
+ 'a missing required field is the caller\'s mistake, not a conflict');
94
+ assert.match(POST_TASKS, /GET \/goals\?version=/,
95
+ 'a refusal that does not say how to comply gets worked around');
96
+ });
97
+
98
+ test('the route refuses BEFORE it validates the goal it was not given', () => {
99
+ const required = POST_TASKS.indexOf("'goal_id_required'");
100
+ const validate = POST_TASKS.indexOf("'bad_goal_id'");
101
+ const create = POST_TASKS.indexOf('await db.createTask(');
102
+ assert.ok(required !== -1 && validate !== -1 && create !== -1);
103
+ assert.ok(required < validate, 'absent and wrong are different answers');
104
+ assert.ok(required < create);
105
+ });
106
+
107
+ test('POST /tasks does NOT pass the escape hatch — this door always has a goal', () => {
108
+ assert.equal(/allowCatchAll/.test(POST_TASKS), false,
109
+ '/builder-start lists work by goal; the human/agent vector can always name one');
110
+ });
111
+
112
+ // ---- the opt-in roster, pinned ----------------------------------------------
113
+
114
+ const OPT_INS = [
115
+ ['modules/onboarding/newcomer-restock.js', 'the beginner queue, stamped from templates'],
116
+ ['modules/copy-desk/routes/copy-desk.js', 'copy-change proposals'],
117
+ ['modules/lifecycle/kickoff-seed.js', "the founder's first board, on an instance with no goals at all"],
118
+ ['modules/lifecycle/cascade.js', 'a follow-up off a parent task that is itself goal-less'],
119
+ ];
120
+
121
+ test('exactly four vectors opt in, and each says why', () => {
122
+ for (const [file, why] of OPT_INS) {
123
+ const body = src(file);
124
+ assert.match(body, /allowCatchAll/, `${file} should opt in (${why})`);
125
+ assert.match(body, /BV1\.R11|1003605|R18/,
126
+ `${file} must explain itself — an undocumented opt-in is the silence again`);
127
+ }
128
+ });
129
+
130
+ test('cascade opts in ONLY when inheritance actually failed', () => {
131
+ // The others opt in unconditionally because they have no goal to name at all.
132
+ // A cascade normally inherits its parent's goal and must keep doing so — the
133
+ // hatch is for a goal-less PARENT (a legacy catch-all row), not for cascades in
134
+ // general. A blanket `true` here would quietly re-open the hole for every
135
+ // follow-up task the system files.
136
+ const body = src('modules/lifecycle/cascade.js');
137
+ assert.match(body, /allowCatchAll: task\.goal_id == null,/);
138
+ assert.equal(/allowCatchAll: true/.test(body), false,
139
+ 'an unconditional hatch here would cover every cascade, not just the legacy ones');
140
+ assert.match(body, /goalId: task\.goal_id != null \? Number\(task\.goal_id\) : null,/,
141
+ 'inheritance stays the primary path');
142
+ });
143
+
144
+ test('a goal-less parent does not silently lose its cascade task', async () => {
145
+ // The regression this pair of fixes closes: under the guard, a null goalId
146
+ // throws; the throw is caught per-rule so the ship is safe, and the cascade
147
+ // task would have vanished with only a console line — a silent goal-less gap,
148
+ // which is the exact thing this rule exists to close.
149
+ const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
150
+ const specs = [];
151
+ const report = await runCascadesForShip({ id: 5 }, {
152
+ getTask: async () => ({
153
+ id: 5, version_id: 'BONGOS-V1', goal_id: null, status: 'shipped',
154
+ discipline: 'artist', kind: 'feature', title: 'legacy', touches: [],
155
+ }),
156
+ findTaskBySourceRef: async () => null,
157
+ createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
158
+ });
159
+ assert.deepEqual(report.errors, [], 'no rule may error on a goal-less parent');
160
+ for (const spec of specs) {
161
+ assert.equal(spec.goalId, null, 'nothing to inherit');
162
+ assert.equal(spec.allowCatchAll, true, 'so it declares the catch-all rather than throwing');
163
+ }
164
+ });
165
+
166
+ test('a cascade off a NORMAL parent inherits and does not open the hatch', async () => {
167
+ const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
168
+ const specs = [];
169
+ await runCascadesForShip({ id: 5 }, {
170
+ getTask: async () => ({
171
+ id: 5, version_id: 'BONGOS-V1', goal_id: 77, status: 'shipped',
172
+ discipline: 'artist', kind: 'feature', title: 'normal', touches: [],
173
+ }),
174
+ findTaskBySourceRef: async () => null,
175
+ createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
176
+ });
177
+ for (const spec of specs) {
178
+ assert.equal(spec.goalId, 77, 'the follow-up belongs where the work belongs');
179
+ assert.equal(spec.allowCatchAll, false, 'and the hatch stays shut');
180
+ }
181
+ });
182
+
183
+ test('no FIFTH vector has appeared', () => {
184
+ // The roster is the point: if this fails, either a new create vector took the
185
+ // easy way out, or R18 landed and one of these can now name a real goal — in
186
+ // which case delete its opt-in and this line, do not extend the list.
187
+ const roster = new Set([...OPT_INS.map(([f]) => f), 'modules/lifecycle/db-tasks.js']);
188
+ const suspects = [
189
+ 'modules/ideas/inbox.js', 'modules/lifecycle/routes/goals.js',
190
+ 'modules/lifecycle/routes/task-write-routes.js',
191
+ ];
192
+ for (const f of suspects) {
193
+ if (roster.has(f)) continue;
194
+ assert.equal(/allowCatchAll/.test(src(f)), false,
195
+ `${f} must name a goal rather than opting out`);
196
+ }
197
+ });
198
+ test('the vectors that CAN name a goal still do', () => {
199
+ assert.match(src('modules/ideas/inbox.js'), /goalId: goal\.id/,
200
+ 'idea routing names the goal it routed to');
201
+ assert.match(src('modules/lifecycle/routes/goals.js'), /db\.createTask\(\{/,
202
+ 'and the goal-authored vector has stamped its goal since BV1.R60');
203
+ });
204
+
205
+ // ---- the fallback itself ----------------------------------------------------
206
+
207
+ test('the catch-all resolver survives, reachable only through the declared hatch', () => {
208
+ const dbTasks = src('modules/lifecycle/db-tasks.js');
209
+ assert.match(dbTasks, /generalGoalIdForVersion\(versionId\)/,
210
+ 'R18 is when this goes; deleting it today would drain the newcomer queue');
211
+ const guard = dbTasks.slice(dbTasks.indexOf('let goalIdEffective = goalId;'));
212
+ const hatch = guard.indexOf('allowCatchAll !== true');
213
+ const resolve = guard.indexOf('generalGoalIdForVersion(versionId)');
214
+ assert.ok(hatch !== -1 && hatch < resolve,
215
+ 'the throw comes first — the resolver is unreachable without the flag');
216
+ });
217
+
218
+ test('the remaining gap is written down where the next reader will stand', () => {
219
+ const dbTasks = src('modules/lifecycle/db-tasks.js');
220
+ assert.match(dbTasks, /WHY NOT DELETE THE FALLBACK OUTRIGHT/,
221
+ 'so the partial job reads as a decision, not an unfinished edit');
222
+ assert.match(dbTasks, /1003605/, 'and names the task that finishes it');
223
+ });