@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.
- package/.bongos-core.json +22 -22
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +51 -51
- package/docs/copy-registry.json +57 -57
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/goals-render.js +14 -3
- package/modules/lifecycle/db-deps-criteria.js +23 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/goal-advisory.js +80 -22
- package/modules/lifecycle/routes/goals.js +9 -2
- package/modules/lifecycle/routes/task-write-routes.js +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_advisory.mjs +115 -4
- package/tests/goal_criterion_confirm_ui.mjs +32 -2
- package/tests/goal_routes.mjs +29 -1
|
@@ -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.
|
|
31
|
+
// advisory body when it does not. Three reasons, checked in order:
|
|
32
32
|
//
|
|
33
|
-
// no_goal
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
45
|
-
// directive against manufacturing small goals
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|
package/tests/goal_advisory.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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');
|
package/tests/goal_routes.mjs
CHANGED
|
@@ -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.
|