@bongos/core 1.19.499 → 1.19.500

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.
@@ -28,24 +28,38 @@ function generalGoalTitle(versionId) {
28
28
  }
29
29
 
30
30
  // adviseGoal — PURE, FAIL-OPEN. Returns null when the task has a real home, or the
31
- // advisory body when it does not. Two reasons, checked in order:
31
+ // advisory body when it does not. Three reasons, checked in order:
32
32
  //
33
- // no_goal — goal_id is NULL. The version has no catch-all at all (created after
34
- // migration 160), so the task is structurally orphaned.
35
- // catch_all — the task landed in "<version> — general": a bucket, not a workspace.
36
- // No scope wall, no criteria, no members, no review. Better than NULL,
37
- // still not a home.
33
+ // no_goal — goal_id is NULL. The version has no catch-all at all
34
+ // (created after migration 160), so the task is
35
+ // structurally orphaned.
36
+ // catch_all — the task landed in "<version> — general": a bucket, not a
37
+ // workspace. No scope wall, no criteria, no members, no
38
+ // review. Better than NULL, still not a home.
39
+ // no_criterion_link — the task DOES have a real goal, that goal HAS done-when
40
+ // criteria, and the task links none (task 1003157). Only
41
+ // the planning-session seeder ever links a criterion at
42
+ // create time; every other path — idea promotion, a manual
43
+ // POST /tasks, a captured bug — silently omits it. That
44
+ // mattered less before ADR 0183's auto-satisfy made a
45
+ // criterion close itself the moment every LINKED task
46
+ // ships: an unlinked goal-bound task can never advance any
47
+ // criterion, so it now reads as goal progress that never
48
+ // moves no matter how much ships.
38
49
  //
39
50
  // Fail-OPEN on ambiguity (return null, say nothing) — the opposite posture from an
40
51
  // authorization gate. This is advice appended to a request that ALREADY SUCCEEDED;
41
52
  // a wrong guess here nags a builder about a task that is fine, which is worse than
42
53
  // staying quiet. A missing versionId or an unreadable goal title yields null.
43
54
  //
44
- // kind='bug' is a THIRD silent case (task 1003214, idea 1000828 — the owner
45
- // directive against manufacturing small goals): a fix sitting in the catch-all,
46
- // or with no goal at all, is already where it belongs, so both reasons above
47
- // collapse to null for a bug. The nudge is for homeless FEATURE work only.
48
- function adviseGoal({ versionId, goalId, goalTitle, kind } = {}) {
55
+ // kind='bug' is silent for ALL THREE reasons (task 1003214, idea 1000828 — the
56
+ // owner directive against manufacturing small goals; the exemption is extended to
57
+ // no_criterion_link here and the decision is written up at task 1003215): a bug
58
+ // fix sitting in the catch-all, with no goal at all, or under a real goal with no
59
+ // criterion named is already where it belongs — a fix is the goal's "fix
60
+ // category", not a delivery against one of its stated outcomes, so it needs no
61
+ // criterion to justify its home. The nudge is for FEATURE-shaped work only.
62
+ function adviseGoal({ versionId, goalId, goalTitle, kind, goalCriteria, hasCriterionLink } = {}) {
49
63
  if (goalId == null) {
50
64
  if (kind === 'bug') return null;
51
65
  return {
@@ -58,15 +72,32 @@ function adviseGoal({ versionId, goalId, goalTitle, kind } = {}) {
58
72
  };
59
73
  }
60
74
  if (versionId == null || typeof goalTitle !== 'string') return null;
61
- if (goalTitle !== generalGoalTitle(versionId)) return null;
62
- if (kind === 'bug') return null;
63
- return {
64
- reason: 'catch_all',
65
- message: `This task landed in "${goalTitle}", the version's catch-all bucket rather than a real goal — `
66
- + 'no scope wall, no criteria, and no owner reviewing it.',
67
- version_id: versionId,
68
- goal_id: goalId,
69
- };
75
+ if (goalTitle === generalGoalTitle(versionId)) {
76
+ if (kind === 'bug') return null;
77
+ return {
78
+ reason: 'catch_all',
79
+ message: `This task landed in "${goalTitle}", the version's catch-all bucket rather than a real goal — `
80
+ + 'no scope wall, no criteria, and no owner reviewing it.',
81
+ version_id: versionId,
82
+ goal_id: goalId,
83
+ };
84
+ }
85
+ // A REAL, named goal (not the catch-all) — the case adviseGoal used to go
86
+ // silent on unconditionally. task 1003157's gap: nothing said so when the
87
+ // goal HAS criteria and this task links none.
88
+ if (kind !== 'bug' && !hasCriterionLink && Array.isArray(goalCriteria) && goalCriteria.length > 0) {
89
+ const named = goalCriteria.map((c) => c.criterion_md).join(' | ');
90
+ return {
91
+ reason: 'no_criterion_link',
92
+ message: `This task is linked to none of this goal's ${goalCriteria.length} done-when criteria: `
93
+ + `${named}. Link one via POST /tasks/:id/criteria if this task delivers toward it, or leave it `
94
+ + 'unlinked if it genuinely serves none of them — this is advice, not a requirement.',
95
+ version_id: versionId,
96
+ goal_id: goalId,
97
+ criteria: goalCriteria.map((c) => ({ id: c.id, criterion_md: c.criterion_md })),
98
+ };
99
+ }
100
+ return null;
70
101
  }
71
102
 
72
103
  // candidateGoals — PURE. The version's OPEN goals minus the catch-all, shaped down
@@ -93,16 +124,43 @@ const CANDIDATE_LIMIT = 5;
93
124
  // retries — and source_ref idempotency only saves callers who set one, so a bare
94
125
  // retry mints a DUPLICATE task. Degrading to null (silence) is always correct here.
95
126
  // Only the unhappy path pays for the second query.
96
- async function buildGoalAdvisory({ db, versionId, task }) {
127
+ //
128
+ // hasCriterionLink (task 1003157) — did THIS task already link at least one
129
+ // done-when criterion (via body.criterion_ids at create)? The caller (POST /tasks)
130
+ // already knows this from the same request; feeding it in avoids re-deriving it
131
+ // from a task_criteria read. db.listCriteriaForGoal is only queried when it
132
+ // might matter (a real, non-catch-all, non-bug task with no link) — the
133
+ // no_goal/catch_all paths, and any task that already linked one, pay nothing
134
+ // extra. The catch-all check is resolved FIRST (grade-round-1 finding, task
135
+ // 1003157): reading goalTitle off the already-fetched homeGoal costs nothing,
136
+ // so the goal's criteria are never queried only to be discarded once adviseGoal
137
+ // hits its catch_all branch.
138
+ async function buildGoalAdvisory({ db, versionId, task, hasCriterionLink = false }) {
97
139
  try {
98
140
  const homeGoal = task.goal_id != null ? await db.getGoal(task.goal_id) : null;
141
+ const goalTitle = homeGoal && homeGoal.title;
142
+ const isCatchAll = task.goal_id != null && versionId != null && typeof goalTitle === 'string'
143
+ && goalTitle === generalGoalTitle(versionId);
144
+ const needsCriteriaRead = task.goal_id != null && !isCatchAll && task.kind !== 'bug' && !hasCriterionLink;
145
+ const goalCriteria = needsCriteriaRead ? await db.listCriteriaForGoal(task.goal_id) : [];
99
146
  const verdict = adviseGoal({
100
147
  versionId,
101
148
  goalId: task.goal_id,
102
- goalTitle: homeGoal && homeGoal.title,
149
+ goalTitle,
103
150
  kind: task.kind,
151
+ goalCriteria,
152
+ hasCriterionLink,
104
153
  });
105
154
  if (!verdict) return null;
155
+ if (verdict.reason === 'no_criterion_link') {
156
+ // The task already has a home — the ask is "link a criterion", not "pick a
157
+ // goal", so no candidate_goals here.
158
+ return {
159
+ ...verdict,
160
+ next_step: `Link one via POST /tasks/${task.id}/criteria, or leave it unlinked if this task `
161
+ + 'genuinely serves none of them.',
162
+ };
163
+ }
106
164
  // LIMIT 6, not 6-in-JS: Postgres truncates instead of shipping every open goal
107
165
  // on the version to be discarded here. Six because candidateGoals drops the
108
166
  // catch-all, so 6 rows still guarantee the 5 candidates below.
@@ -252,13 +252,20 @@ module.exports = function buildGoalsRouter() {
252
252
  try {
253
253
  const goal = await db.getGoal(id);
254
254
  if (!goal) return res.fail('goal_not_found', 404);
255
- const [members, criteria, dependencies] = await Promise.all([
255
+ const [members, criteria, dependencies, criterionless_task_count] = await Promise.all([
256
256
  db.listGoalMembers(id),
257
257
  db.listCriteriaForGoal(id),
258
258
  db.getGoalDependencies(id), // task 1761 — prerequisite goals + satisfied state
259
+ // task 1003157 (DONE WHEN item 2): the count beside the criteria this
260
+ // page already renders, so a builder sees the gap without waiting for
261
+ // the create-time advisory to catch the next task.
262
+ db.countTasksWithoutCriterionLink(id),
259
263
  ]);
260
264
  const byId = await goalCategory.categoryIndex();
261
- return res.json({ goal: goalCategory.decorateGoalCategory(decorateGoalScope(goal), byId), members, criteria, dependencies });
265
+ return res.json({
266
+ goal: goalCategory.decorateGoalCategory(decorateGoalScope(goal), byId),
267
+ members, criteria, dependencies, criterionless_task_count,
268
+ });
262
269
  } catch (err) {
263
270
  log.error('[gds] GET /goals/:id', err);
264
271
  return res.fail('fetch_failed', { status: 500, message: 'internal error' });
@@ -235,8 +235,13 @@ module.exports = function registerTaskWriteRoutes(router) {
235
235
  }
236
236
  // task 1002539 — the orphan-task advisory (the /goal-create auto-pickup
237
237
  // hook). Nothing 4xxs when a task has no goal, so surface it here instead of
238
- // blocking. Non-blocking by construction — see goal-advisory.js.
239
- const goal_advisory = await buildGoalAdvisory({ db, versionId: body.version_id, task });
238
+ // blocking. Non-blocking by construction — see goal-advisory.js. task
239
+ // 1003157 extends it: hasCriterionLink tells it whether THIS create already
240
+ // linked a criterion, so a goal-bound task with unsatisfied criteria and no
241
+ // link gets named too.
242
+ const goal_advisory = await buildGoalAdvisory({
243
+ db, versionId: body.version_id, task, hasCriterionLink: criteria.length > 0,
244
+ });
240
245
  res.status(201).json({
241
246
  task,
242
247
  credits_reward: task.credits_reward,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.499",
3
+ "version": "1.19.500",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.499",
9
+ "version": "1.19.500",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.499",
3
+ "version": "1.19.500",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.499'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.500'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -79,6 +79,53 @@ await test("kind='bug' under a real goal is silent too — already silent, kind
79
79
  assert.equal(adviseGoal({ versionId: V, goalId: 1000025, goalTitle: 'Builder experience — hall, dev box, and skills', kind: 'bug' }), null);
80
80
  });
81
81
 
82
+ // ---- adviseGoal: the third reason — no_criterion_link (task 1003157) ----
83
+
84
+ const REAL_GOAL_TITLE = 'Builder experience — hall, dev box, and skills';
85
+ const SOME_CRITERIA = [
86
+ { id: 11, criterion_md: 'Every onboarding page renders under 2s' },
87
+ { id: 12, criterion_md: 'The hall shows a builder their own karma trend' },
88
+ ];
89
+
90
+ await test('no_criterion_link — a real goal WITH criteria and no link names them', () => {
91
+ const a = adviseGoal({
92
+ versionId: V, goalId: 1000025, goalTitle: REAL_GOAL_TITLE, kind: 'feature',
93
+ goalCriteria: SOME_CRITERIA, hasCriterionLink: false,
94
+ });
95
+ assert.equal(a.reason, 'no_criterion_link');
96
+ assert.equal(a.goal_id, 1000025);
97
+ assert.match(a.message, /Every onboarding page renders under 2s/, 'names the criteria in the message');
98
+ assert.match(a.message, /The hall shows a builder their own karma trend/);
99
+ assert.deepEqual(a.criteria, [
100
+ { id: 11, criterion_md: 'Every onboarding page renders under 2s' },
101
+ { id: 12, criterion_md: 'The hall shows a builder their own karma trend' },
102
+ ], 'the structured list a caller can pick from without parsing the message');
103
+ });
104
+
105
+ await test('a criterion link already present is quiet — the seeder-path invariant holds', () => {
106
+ const a = adviseGoal({
107
+ versionId: V, goalId: 1000025, goalTitle: REAL_GOAL_TITLE, kind: 'feature',
108
+ goalCriteria: SOME_CRITERIA, hasCriterionLink: true,
109
+ });
110
+ assert.equal(a, null);
111
+ });
112
+
113
+ await test("kind='bug' under a real goal with criteria and no link is quiet too — exempt (task 1003215)", () => {
114
+ const a = adviseGoal({
115
+ versionId: V, goalId: 1000025, goalTitle: REAL_GOAL_TITLE, kind: 'bug',
116
+ goalCriteria: SOME_CRITERIA, hasCriterionLink: false,
117
+ });
118
+ assert.equal(a, null);
119
+ });
120
+
121
+ await test('a real goal with NO criteria is silent — nothing to link to, unchanged behaviour', () => {
122
+ const a = adviseGoal({
123
+ versionId: V, goalId: 1000025, goalTitle: REAL_GOAL_TITLE, kind: 'feature',
124
+ goalCriteria: [], hasCriterionLink: false,
125
+ });
126
+ assert.equal(a, null);
127
+ });
128
+
82
129
  // The bug this guards: a near-miss title must not be mistaken for the catch-all.
83
130
  // db.generalGoalIdForVersion resolves the SAME string, so if these two ever disagree
84
131
  // a task would be filed into a goal the advisory then calls orphaned (or vice versa).
@@ -138,7 +185,10 @@ await test('db.js resolves the catch-all through generalGoalTitle — no second
138
185
  // ---- the non-blocking contract, asserted rather than commented ----
139
186
 
140
187
  await test('the route delegates to buildGoalAdvisory in one line — no inline logic to drift', () => {
141
- assert.match(tasksSrc, /const goal_advisory = await buildGoalAdvisory\(\{ db, versionId: body\.version_id, task \}\);/);
188
+ // task 1003157: the call now also passes hasCriterionLink (from THIS create's
189
+ // own criteria array), spread across the call site — match the tokens, not one
190
+ // literal line.
191
+ assert.match(tasksSrc, /await buildGoalAdvisory\(\{[\s\S]{0,120}?db, versionId: body\.version_id, task, hasCriterionLink: criteria\.length > 0,[\s\S]{0,20}?\}\);/);
142
192
  // task 1003148: was /goal_advisory,\s*\}\);/ — it required goal_advisory to be
143
193
  // the LAST key, which the sibling est_advisory broke. The assertion's intent is
144
194
  // "present in the 201 body", so assert that, not the key order.
@@ -149,9 +199,10 @@ await test('the route delegates to buildGoalAdvisory in one line — no inline l
149
199
 
150
200
  // ---- buildGoalAdvisory: the real function, driven with a stub db ----
151
201
 
152
- const stubDb = ({ goal = null, goals = [], throwOn = null } = {}) => ({
202
+ const stubDb = ({ goal = null, goals = [], criteria = [], throwOn = null } = {}) => ({
153
203
  async getGoal() { if (throwOn === 'getGoal') throw new Error('db down'); return goal; },
154
204
  async listGoals() { if (throwOn === 'listGoals') throw new Error('db down'); return goals; },
205
+ async listCriteriaForGoal() { if (throwOn === 'listCriteriaForGoal') throw new Error('db down'); return criteria; },
155
206
  });
156
207
 
157
208
  await test('buildGoalAdvisory: a goal-less task yields the no_goal advisory + candidates', async () => {
@@ -167,14 +218,57 @@ await test('buildGoalAdvisory: a goal-less task yields the no_goal advisory + ca
167
218
  assert.match(a.next_step, /PATCH \/tasks\/42/, 'names the actual task id');
168
219
  });
169
220
 
170
- await test('buildGoalAdvisory: a task in a REAL goal is silent, and never reads the goal list', async () => {
221
+ await test('buildGoalAdvisory: a task in a REAL goal with no criteria is silent, and never reads the goal list', async () => {
171
222
  let listed = false;
172
223
  const db = {
173
224
  async getGoal() { return { id: 9, title: 'Builder experience' }; },
174
225
  async listGoals() { listed = true; return []; },
226
+ async listCriteriaForGoal() { return []; }, // this goal has none — nothing to link to
175
227
  };
176
228
  assert.equal(await buildGoalAdvisory({ db, versionId: V, task: { id: 1, goal_id: 9 } }), null);
177
- assert.equal(listed, false, 'the happy path costs exactly one query');
229
+ assert.equal(listed, false, 'the happy path never reads the version-wide goal list');
230
+ });
231
+
232
+ // ---- buildGoalAdvisory: the no_criterion_link reason, end to end (task 1003157) ----
233
+
234
+ await test('buildGoalAdvisory: a real goal WITH criteria and no link surfaces no_criterion_link', async () => {
235
+ const db = stubDb({
236
+ goal: { id: 9, title: 'Builder experience' },
237
+ criteria: [{ id: 11, criterion_md: 'Karma trend renders on the profile' }],
238
+ });
239
+ const a = await buildGoalAdvisory({ db, versionId: V, task: { id: 55, goal_id: 9, kind: 'feature' } });
240
+ assert.equal(a.reason, 'no_criterion_link');
241
+ assert.equal(a.goal_id, 9);
242
+ assert.equal(a.candidate_goals, undefined, 'the task already has a home — no goal picker here');
243
+ assert.match(a.next_step, /POST \/tasks\/55\/criteria/, 'names the actual task id');
244
+ });
245
+
246
+ await test('buildGoalAdvisory: hasCriterionLink=true skips the criteria read entirely', async () => {
247
+ let read = false;
248
+ const db = {
249
+ async getGoal() { return { id: 9, title: 'Builder experience' }; },
250
+ async listCriteriaForGoal() { read = true; return [{ id: 11, criterion_md: 'x' }]; },
251
+ };
252
+ const a = await buildGoalAdvisory({ db, versionId: V, task: { id: 1, goal_id: 9, kind: 'feature' }, hasCriterionLink: true });
253
+ assert.equal(a, null);
254
+ assert.equal(read, false, 'a task that already linked one pays no extra query');
255
+ });
256
+
257
+ await test("buildGoalAdvisory: kind='bug' skips the criteria read entirely — exempt (task 1003215)", async () => {
258
+ let read = false;
259
+ const db = {
260
+ async getGoal() { return { id: 9, title: 'Builder experience' }; },
261
+ async listCriteriaForGoal() { read = true; return [{ id: 11, criterion_md: 'x' }]; },
262
+ };
263
+ const a = await buildGoalAdvisory({ db, versionId: V, task: { id: 1, goal_id: 9, kind: 'bug' } });
264
+ assert.equal(a, null);
265
+ assert.equal(read, false, 'a bug pays no extra query either — same exemption as no_goal/catch_all');
266
+ });
267
+
268
+ await test('NON-BLOCKING: buildGoalAdvisory returns null when listCriteriaForGoal throws', async () => {
269
+ const db = stubDb({ goal: { id: 9, title: 'Builder experience' }, throwOn: 'listCriteriaForGoal' });
270
+ const a = await buildGoalAdvisory({ db, versionId: V, task: { id: 1, goal_id: 9, kind: 'feature' } });
271
+ assert.equal(a, null, 'degrades to silence — never propagates into the already-committed create');
178
272
  });
179
273
 
180
274
  await test('buildGoalAdvisory: the catch-all is detected and named', async () => {
@@ -184,6 +278,23 @@ await test('buildGoalAdvisory: the catch-all is detected and named', async () =>
184
278
  assert.equal(a.goal_id, 3);
185
279
  });
186
280
 
281
+ // Grade-round-1 finding (task 1003157): a feature-kind task landing in the
282
+ // catch-all used to fire listCriteriaForGoal anyway — its result discarded the
283
+ // moment adviseGoal hit its catch_all branch — because needsCriteriaRead didn't
284
+ // yet know the goal was the catch-all. buildGoalAdvisory now resolves that off
285
+ // the SAME getGoal read before deciding whether the criteria read is needed.
286
+ await test('buildGoalAdvisory: a catch-all landing never reads the goal criteria (no wasted query)', async () => {
287
+ let read = false;
288
+ const db = {
289
+ async getGoal() { return { id: 3, title: generalGoalTitle(V) }; },
290
+ async listCriteriaForGoal() { read = true; return [{ id: 1, criterion_md: 'x' }]; },
291
+ async listGoals() { return []; },
292
+ };
293
+ const a = await buildGoalAdvisory({ db, versionId: V, task: { id: 7, goal_id: 3, kind: 'feature' } });
294
+ assert.equal(a.reason, 'catch_all', 'the catch-all nudge still fires — only the criteria read is skipped');
295
+ assert.equal(read, false, 'the catch-all is structurally criteria-less; nothing here needs the query');
296
+ });
297
+
187
298
  // task 1003214: the full POST /tasks path (buildGoalAdvisory reads task.kind
188
299
  // off the already-committed row) for a bug landed in the catch-all — silent.
189
300
  await test("buildGoalAdvisory: kind='bug' in the catch-all yields null end to end", async () => {
@@ -56,7 +56,7 @@ const CRIT = {
56
56
 
57
57
  // Boot goals-page.js against a fake API and return what it rendered + recorded.
58
58
  // `rank` drives the client-side control gate; `goalStatus` drives the goal state.
59
- async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.values(CRIT), satisfyResponse } = {}) {
59
+ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.values(CRIT), satisfyResponse, criterionlessTaskCount } = {}) {
60
60
  const calls = { requests: [], toasts: [] };
61
61
  const els = new Map();
62
62
  const listeners = new Map(); // selector -> [handler]
@@ -83,7 +83,7 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
83
83
  };
84
84
 
85
85
  const goal = { id: 7, version_id: 'V1', title: 'Builder experience', description: '', status: goalStatus, visibility: 'public', created_by: 999, scope_modules: [] };
86
- const detail = { goal, members: [], criteria, dependencies: [] };
86
+ const detail = { goal, members: [], criteria, dependencies: [], criterionless_task_count: criterionlessTaskCount };
87
87
 
88
88
  const api = {
89
89
  request(method, p) {
@@ -239,6 +239,36 @@ await test('with nothing pending, the summary explains when a criterion becomes
239
239
  assert.match(band, /once every task linked to it has shipped/, 'explains the precondition instead');
240
240
  });
241
241
 
242
+ // =========================================================================
243
+ // The criterionless-task count (task 1003157 DONE WHEN item 2) — the
244
+ // create-time advisory's gap, also visible on the goal's own detail view.
245
+ // =========================================================================
246
+
247
+ await test('a nonzero criterionless_task_count renders a line beside the criteria rollup', async () => {
248
+ const { band } = await render({ criterionlessTaskCount: 4 });
249
+ assert.match(band, /4 tasks in this goal are linked to no criterion/);
250
+ });
251
+
252
+ await test('a count of exactly 1 uses singular phrasing', async () => {
253
+ const { band } = await render({ criterionlessTaskCount: 1 });
254
+ assert.match(band, /1 task in this goal is linked to no criterion/);
255
+ });
256
+
257
+ await test('a zero count renders nothing — quiet, not a false "all clear" line', async () => {
258
+ const { band } = await render({ criterionlessTaskCount: 0 });
259
+ assert.doesNotMatch(band, /linked to no criterion/);
260
+ });
261
+
262
+ await test('an absent count (older server / field omitted) renders nothing, never throws', async () => {
263
+ const { band } = await render({ criterionlessTaskCount: undefined });
264
+ assert.doesNotMatch(band, /linked to no criterion/);
265
+ });
266
+
267
+ await test('a goal with NO criteria never shows the line even if tasks are unlinked — nothing to link to', async () => {
268
+ const { band } = await render({ criteria: [], criterionlessTaskCount: 5 });
269
+ assert.doesNotMatch(band, /linked to no criterion/, 'crit.length === 0 makes the count meaningless — matches goal-advisory.js\'s own null-when-no-criteria rule');
270
+ });
271
+
242
272
  await test('an achieved goal keeps its own summary line', async () => {
243
273
  const { band } = await render({ goalStatus: 'achieved', criteria: [CRIT.satisfied] });
244
274
  assert.match(band, /Goal achieved/, 'achieved wording is preserved');
@@ -23,7 +23,7 @@ process.env.NODE_ENV = 'test';
23
23
  const require = createRequire(import.meta.url);
24
24
  const { validate, LIMITS } = require('../src/bongos/routes/_helpers.js');
25
25
  const buildGoalsRouter = require('../modules/lifecycle/routes/goals.js');
26
- const { listCriteriaForGoal } = require('../modules/lifecycle/db.js');
26
+ const { listCriteriaForGoal, countTasksWithoutCriterionLink } = require('../modules/lifecycle/db.js');
27
27
  const { TASK_FIELD_BOUNDS } = require('../modules/lifecycle/task-classifier.js');
28
28
 
29
29
  const { test, summary } = makeRunner();
@@ -217,6 +217,34 @@ await test('listCriteriaForGoal: counts arrive as numbers even if the driver ret
217
217
  assert.equal(out.pending_review, true);
218
218
  });
219
219
 
220
+ // ---- countTasksWithoutCriterionLink (db helper, executor seam) — task 1003157
221
+ // DONE WHEN item 2: the count GET /goals/:id now returns beside its criteria, so
222
+ // the create-time advisory's gap is visible on the goal detail view too. Unlike
223
+ // listCriteriaForGoal's `{ pool }` deps object, this takes the executor
224
+ // POSITIONALLY (the findCycle/unsatisfiedDeps seam in db-deps-criteria.js) — same
225
+ // file, same convention. ----
226
+ const UNLINKED_SQL_RE = /FROM tasks t\s+WHERE t\.goal_id = \$1/;
227
+
228
+ await test('countTasksWithoutCriterionLink: scoped to the goal, kind<>bug, NOT EXISTS a link', async () => {
229
+ const pool = fakePool((s) => (UNLINKED_SQL_RE.test(s) ? { rows: [{ n: 3 }] } : null));
230
+ const out = await countTasksWithoutCriterionLink(9, pool);
231
+ assert.equal(out, 3);
232
+ const sel = find(pool.queries, UNLINKED_SQL_RE);
233
+ assert.equal(sel.params[0], 9);
234
+ assert.match(sel.sql, /AND t\.kind <> 'bug'/, "a bug fix is the goal's fix category and needs no link (task 1003214/1003215)");
235
+ assert.match(sel.sql, /NOT EXISTS \(SELECT 1 FROM task_criteria tc WHERE tc\.task_id = t\.id\)/);
236
+ });
237
+
238
+ await test('countTasksWithoutCriterionLink: zero rows resolves to 0, not undefined', async () => {
239
+ const pool = fakePool(() => ({ rows: [{ n: 0 }] }));
240
+ assert.equal(await countTasksWithoutCriterionLink(9, pool), 0);
241
+ });
242
+
243
+ await test('countTasksWithoutCriterionLink: a missing row still resolves to 0 (defensive fallback)', async () => {
244
+ const pool = fakePool(() => ({ rows: [] }));
245
+ assert.equal(await countTasksWithoutCriterionLink(9, pool), 0);
246
+ });
247
+
220
248
  // ---- POST /goals/:id/tasks — request-validation schema (the bounded-authoring
221
249
  // gate's DECISION logic + abuse matrix live in tests/goal_abuse_matrix.mjs) ----
222
250
  // The EXACT schema declared in goals.js's POST /goals/:id/tasks — keep in sync.