@bongos/core 1.19.592 → 1.19.594
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 +61 -41
- package/.claude/skills/planning-session/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +3 -1
- package/clients/bongos-client/index.cjs +3 -1
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +3 -1
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
- package/docs/api/openapi.json +103 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +135 -116
- package/docs/copy-registry.json +319 -138
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
- package/modules/government/catalog.js +20 -11
- package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
- package/modules/hall-ui/public/goals-render.js +182 -6
- package/modules/hall-ui/public/goals.css +51 -0
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/hall-ui/public/task-detail.js +22 -11
- package/modules/lifecycle/db-goals.js +34 -2
- package/modules/lifecycle/db-versions.js +195 -3
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/routes/goals.js +15 -1
- package/modules/lifecycle/routes/version-route-authz.js +131 -1
- package/modules/lifecycle/routes/versions.js +79 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +60 -26
- package/src/module-api.js +1 -1
- package/tests/goal_archive_hall.mjs +198 -0
- package/tests/government_require_permission.mjs +7 -3
- package/tests/government_seed.mjs +101 -9
- package/tests/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_close_route.mjs +261 -0
- package/tests/version_override_visibility.mjs +176 -0
|
@@ -122,29 +122,61 @@ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unav
|
|
|
122
122
|
assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
await test('BEHAVIOUR: blank ACCEPTS the default
|
|
126
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
|
|
127
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
|
|
125
|
+
await test('BEHAVIOUR: blank ACCEPTS the default when there is one', () => {
|
|
126
|
+
assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { ok: true, goalId: 1000071, warn: null });
|
|
127
|
+
assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { ok: true, goalId: 1000071, warn: null },
|
|
128
128
|
'whitespace is a bare Enter, not a decision');
|
|
129
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, '
|
|
130
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
|
|
131
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
|
|
132
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
|
|
129
|
+
assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { ok: true, goalId: 1000058, warn: null },
|
|
133
130
|
'an explicit id overrides the default');
|
|
134
131
|
});
|
|
135
132
|
|
|
136
|
-
|
|
133
|
+
// BV1.R22 (task 1003609). These three used to resolve to `goalId: null`, which was
|
|
134
|
+
// fine while a goal-less create fell into the version's "<version> — general"
|
|
135
|
+
// catch-all. R11 (task 1003598) deleted that bucket and made goal_id required, so
|
|
136
|
+
// null is now a server 400 arriving AFTER the operator has typed a title, a
|
|
137
|
+
// description and a priority — and losing all of it. Each is a local refusal now.
|
|
138
|
+
|
|
139
|
+
await test('BEHAVIOUR: "-"/"none" no longer opt out — the bucket they opted into is gone', () => {
|
|
140
|
+
for (const decline of ['-', 'none', 'NONE']) {
|
|
141
|
+
const r = triage.applyGoalAnswer(1000071, decline);
|
|
142
|
+
assert.equal(r.ok, false, `"${decline}" must not resolve`);
|
|
143
|
+
assert.equal(r.goalId, null);
|
|
144
|
+
assert.match(r.warn, /no longer exists|must name a real goal/,
|
|
145
|
+
`"${decline}" must say the bucket is gone, not merely refuse`);
|
|
146
|
+
assert.doesNotMatch(r.warn, /falling back/, 'nothing falls back any more');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('BEHAVIOUR: blank with NO default refuses rather than sending null', () => {
|
|
151
|
+
const r = triage.applyGoalAnswer(null, '');
|
|
152
|
+
assert.equal(r.ok, false, 'no default and no answer is not a decision');
|
|
153
|
+
assert.match(r.warn, /a goal is required/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('BEHAVIOUR: junk REFUSES rather than downgrading a typo into a decision', () => {
|
|
137
157
|
for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
|
|
138
158
|
const r = triage.applyGoalAnswer(1000071, junk);
|
|
139
|
-
assert.equal(r.
|
|
140
|
-
assert.
|
|
159
|
+
assert.equal(r.ok, false, `"${junk}" must not resolve to a goal`);
|
|
160
|
+
assert.equal(r.goalId, null);
|
|
161
|
+
assert.match(r.warn, /not a goal id/, `"${junk}" must name the problem`);
|
|
162
|
+
assert.doesNotMatch(r.warn, /general bucket/, 'and must not offer a bucket that no longer exists');
|
|
141
163
|
}
|
|
142
164
|
// Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
|
|
143
165
|
// without the blank check above running first. Guarding the pair together.
|
|
144
166
|
assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
|
|
145
167
|
});
|
|
146
168
|
|
|
147
|
-
await test('BEHAVIOUR: createTask
|
|
169
|
+
await test('BEHAVIOUR: createTask refuses to post without a goal, at its own call site', async () => {
|
|
170
|
+
// The prompt loop re-asks until it has a real id, so the only route here with
|
|
171
|
+
// null is a NEW call site that forgot — which should fail at its author rather
|
|
172
|
+
// than as a 400 in production.
|
|
173
|
+
await assert.rejects(
|
|
174
|
+
() => triage.createTask({ versionId: 'BV1', title: 't', description: '', priority: 3, sourceRef: null }),
|
|
175
|
+
/goal_id is required/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('BEHAVIOUR: createTask puts goal_id on the body, and REFUSES when it is null', async () => {
|
|
148
180
|
const seen = [];
|
|
149
181
|
const fakeClient = {
|
|
150
182
|
tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
|
|
@@ -157,12 +189,18 @@ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirel
|
|
|
157
189
|
// object cannot reach it — re-require in a fresh registry instead.
|
|
158
190
|
const fresh = freshTriage();
|
|
159
191
|
await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
|
|
160
|
-
await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
|
|
161
192
|
assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
|
|
162
|
-
assert.ok(!('goal_id' in seen[1]),
|
|
163
|
-
'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
|
|
164
193
|
assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
|
|
165
194
|
assert.equal(seen[0].status, 'backlog');
|
|
195
|
+
// BV1.R22 (task 1003609): omitting the goal used to send no goal_id key and let
|
|
196
|
+
// the server file it in the version catch-all. R11 deleted that catch-all, so a
|
|
197
|
+
// goal-less call is now a caller bug — and it must fail HERE, not as a 400 after
|
|
198
|
+
// the operator has typed everything.
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
() => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' }),
|
|
201
|
+
/goal_id is required/,
|
|
202
|
+
);
|
|
203
|
+
assert.equal(seen.length, 1, 'and nothing was posted for the goal-less call');
|
|
166
204
|
} finally {
|
|
167
205
|
cliLib.cliClient = realCliClient;
|
|
168
206
|
}
|
|
@@ -192,8 +230,10 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
|
|
|
192
230
|
// ---- 4. both paths send goal_id -------------------------------------------
|
|
193
231
|
|
|
194
232
|
await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
|
|
195
|
-
assert.match(TRIAGE, /
|
|
233
|
+
assert.match(TRIAGE, /body\.goal_id = Number\(goalId\);/,
|
|
196
234
|
'CLI: goal_id must reach the create body');
|
|
235
|
+
assert.doesNotMatch(TRIAGE, /if \(goalId != null\) body\.goal_id/,
|
|
236
|
+
'CLI: and unconditionally — a conditional send is what made a goal-less create look survivable');
|
|
197
237
|
assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
|
|
198
238
|
'CLI: createTask must accept it rather than ignore the caller');
|
|
199
239
|
assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
|
|
@@ -234,31 +274,46 @@ await test('the suggester default shows score + matched terms so it can be judge
|
|
|
234
274
|
|
|
235
275
|
// ---- 2. opting out is explicit ------------------------------------------
|
|
236
276
|
|
|
237
|
-
|
|
277
|
+
// BV1.R22 (task 1003609) removed the opt-out from BOTH surfaces, because the
|
|
278
|
+
// bucket it opted into no longer exists. These three tests pinned the affordance;
|
|
279
|
+
// they now pin its ABSENCE, which is the assertion that actually protects the
|
|
280
|
+
// invariant — a re-added opt-out would silently start producing 400s again.
|
|
281
|
+
|
|
282
|
+
await test('OPT-OUT: the CLI prompt RE-ASKS instead of offering a bucket that is gone', () => {
|
|
238
283
|
const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
|
|
239
|
-
assert.
|
|
284
|
+
assert.doesNotMatch(branch, /general bucket/, 'there is no general bucket to offer');
|
|
240
285
|
assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
|
|
241
286
|
'the branch must delegate to the tested helper, not re-implement the parsing inline');
|
|
242
|
-
assert.match(branch, /picked\.
|
|
287
|
+
assert.match(branch, /picked\.ok/, 'and act on its verdict');
|
|
288
|
+
assert.match(branch, /picked\.warn/, 'and surface its reason');
|
|
289
|
+
assert.match(branch, /for \(;;\)/, 'a refusal re-asks rather than falling through');
|
|
290
|
+
assert.match(branch, /'s'/, "and skipping stays available, so one unplaceable idea cannot wedge the run");
|
|
243
291
|
assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
|
|
244
292
|
'the suggester must see the title the operator just confirmed, not the filed one');
|
|
245
|
-
// The semantics themselves are covered by the BEHAVIOUR block above — this
|
|
246
|
-
// only pins that main() is actually wired to them.
|
|
247
293
|
});
|
|
248
294
|
|
|
249
|
-
await test('OPT-OUT: the hall
|
|
295
|
+
await test('OPT-OUT: the hall offers no empty goal, and refuses one before it posts', () => {
|
|
250
296
|
const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
|
|
251
|
-
//
|
|
252
|
-
// the
|
|
253
|
-
|
|
254
|
-
assert.
|
|
255
|
-
assert.
|
|
256
|
-
|
|
297
|
+
// Scoped to the emitted <option> markup: the code comment above it explains WHY
|
|
298
|
+
// the bucket is gone and must be allowed to name it.
|
|
299
|
+
const optionMarkup = fn.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
300
|
+
assert.doesNotMatch(optionMarkup, /general bucket/, 'the bucket is gone from the label too');
|
|
301
|
+
assert.ok(!/— no goal —/.test(fn), 'and no misleading "no goal" option came back');
|
|
302
|
+
// What remains when nothing is suggested must be a PLACEHOLDER, not a value:
|
|
303
|
+
// `disabled` is what stops a blank-looking select from being submittable.
|
|
304
|
+
assert.match(fn, /selected disabled/, 'the empty row is an unsubmittable placeholder');
|
|
305
|
+
const submit = HALL.slice(HALL.indexOf('async function submitPromote'), HALL.indexOf('function renderMerge'));
|
|
306
|
+
assert.match(submit, /form\.goal\.value === ''/, 'and the submit refuses an empty goal');
|
|
307
|
+
assert.match(submit, /payload\.goal_id = Number\(form\.goal\.value\)/,
|
|
308
|
+
'goal_id is sent unconditionally now, not behind an if');
|
|
309
|
+
assert.doesNotMatch(submit, /if \(form\.goal && form\.goal\.value !== ''\) payload\.goal_id/,
|
|
310
|
+
'the conditional send is what made an empty select look survivable');
|
|
257
311
|
});
|
|
258
312
|
|
|
259
|
-
await test('the CLI result line names the goal
|
|
260
|
-
assert.match(TRIAGE, /goal \$\{goalId\}
|
|
261
|
-
'where it landed must be visible in the output
|
|
313
|
+
await test('the CLI result line names the goal it landed in', () => {
|
|
314
|
+
assert.match(TRIAGE, /P\$\{priority\}, goal \$\{goalId\}/,
|
|
315
|
+
'where it landed must be visible in the output');
|
|
316
|
+
assert.doesNotMatch(TRIAGE, /→ general bucket/, 'and there is no second way for it to land');
|
|
262
317
|
});
|
|
263
318
|
|
|
264
319
|
// ---- 3. degrades, never blocks -----------------------------------------
|
|
@@ -1651,13 +1651,43 @@ test('vector 2: shipped_at is stamped ONLY by shipTask, and shipTask requires co
|
|
|
1651
1651
|
// task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
|
|
1652
1652
|
const dbSrc = lifecycleDbSource();
|
|
1653
1653
|
|
|
1654
|
-
// Every statement that writes shipped_at, ignoring SELECT projections
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1654
|
+
// Every statement that writes a TASK's shipped_at, ignoring SELECT projections
|
|
1655
|
+
// and comments.
|
|
1656
|
+
//
|
|
1657
|
+
// THE TABLE MATTERS, and this guard learned that the hard way (BV1.R12, task
|
|
1658
|
+
// 1003599). `versions` has a `shipped_at` too, and the version-close route
|
|
1659
|
+
// stamps it — a legitimate write on a different table that has nothing to do
|
|
1660
|
+
// with the grader-bypass vector. A bare /shipped_at = now()/ over the whole
|
|
1661
|
+
// lifecycle db family counted it as a second TASK stamp site and reddened main.
|
|
1662
|
+
//
|
|
1663
|
+
// Widening the count to 2 would have been the wrong repair: it buys silence
|
|
1664
|
+
// now and re-opens the hole the moment a third site appears anywhere. What this
|
|
1665
|
+
// guard is actually about is stated in its own comment above — "no other write
|
|
1666
|
+
// path can stamp shipped_at ON A TASK" — so the fix is to say `tasks`, not to
|
|
1667
|
+
// raise the number. Each match is attributed to the nearest preceding UPDATE
|
|
1668
|
+
// target, and only `tasks` counts.
|
|
1669
|
+
const stamps = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
|
|
1670
|
+
const taskStamps = stamps.filter((m) => {
|
|
1671
|
+
const before = dbSrc.slice(0, m.index);
|
|
1672
|
+
const lastUpdate = before.lastIndexOf('UPDATE ');
|
|
1673
|
+
if (lastUpdate === -1) return false;
|
|
1674
|
+
return /^UPDATE\s+tasks\b/.test(before.slice(lastUpdate));
|
|
1675
|
+
});
|
|
1676
|
+
assert.equal(taskStamps.length, 1,
|
|
1677
|
+
`a task's shipped_at is written in ${taskStamps.length} places — a second stamp site is how the grader-bypass vector reopens`);
|
|
1678
|
+
// And the attribution itself must be working: if the regex above ever stopped
|
|
1679
|
+
// matching the real site, `taskStamps` would be 0 and the assert would still be
|
|
1680
|
+
// meaningful — but a silent 0 total would mean the family moved and this guard
|
|
1681
|
+
// is now watching nothing at all.
|
|
1682
|
+
assert.ok(stamps.length >= 1, 'no shipped_at stamp found anywhere — the lifecycle db family moved and this guard is blind');
|
|
1658
1683
|
|
|
1659
1684
|
// …and that one site is guarded on the confirmed state.
|
|
1660
|
-
|
|
1685
|
+
// Anchored on the ATTRIBUTED task stamp, not `indexOf` — with two shipped_at
|
|
1686
|
+
// sites in the family, "the first one in file order" is whichever file the
|
|
1687
|
+
// carve happens to concatenate first, and that is not a property anyone is
|
|
1688
|
+
// maintaining. The versions stamp has no `status = 'confirmed'` guard and
|
|
1689
|
+
// should not: it is a different table with a different rule.
|
|
1690
|
+
const stampIdx = taskStamps[0].index;
|
|
1661
1691
|
const window = dbSrc.slice(stampIdx, stampIdx + 500);
|
|
1662
1692
|
assert.match(window, /WHERE id = \$1 AND status = 'confirmed'/,
|
|
1663
1693
|
'the stamp must refuse any task not already confirmed — that is what stops a confirm-time ship');
|
package/tests/task_detail_ui.mjs
CHANGED
|
@@ -259,7 +259,7 @@ await test('opening Promote loads open versions only, and preselects the idea\'s
|
|
|
259
259
|
assert.equal(vsel.value, 'BV2', 'the idea\'s suggested_version is honoured');
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
-
await test('the goal picker leads with the STORED hint, then the suggester
|
|
262
|
+
await test('the goal picker leads with the STORED hint, then the suggester — and offers NO opt-out', async () => {
|
|
263
263
|
const h = await boot();
|
|
264
264
|
await h.clickGo('promote');
|
|
265
265
|
const opts = h.goalSelect().querySelectorAll('option');
|
|
@@ -268,7 +268,14 @@ await test('the goal picker leads with the STORED hint, then the suggester, then
|
|
|
268
268
|
assert.match(opts[0].textContent, /carried from the filing/);
|
|
269
269
|
assert.equal(opts[1].attrs.value, '1000062', 'the suggester fills in below it');
|
|
270
270
|
assert.match(opts[1].textContent, /matched: promote, merge/, 'with the evidence for judging it');
|
|
271
|
-
|
|
271
|
+
// BV1.R22 (task 1003609) DELETED the trailing opt-out, and this assertion
|
|
272
|
+
// flipped with it. R11 (task 1003598) removed db.createTask's catch-all
|
|
273
|
+
// fallback and made goal_id required, so an empty selection stopped meaning
|
|
274
|
+
// "file it in the version's general bucket" and started meaning a server 400
|
|
275
|
+
// landing on a builder who had already filled the form. A choice that always
|
|
276
|
+
// fails is worse than no choice.
|
|
277
|
+
assert.equal(opts.filter((o) => o.attrs.value === '').length, 0,
|
|
278
|
+
'no empty-valued option may be offered once a goal is required — it could only ever 400');
|
|
272
279
|
});
|
|
273
280
|
|
|
274
281
|
await test('with no stored hint the top suggestion is preselected instead', async () => {
|
|
@@ -277,12 +284,19 @@ await test('with no stored hint the top suggestion is preselected instead', asyn
|
|
|
277
284
|
assert.equal(h.goalSelect().value, '1000062', 'the suggester supplies the default when nothing was named');
|
|
278
285
|
});
|
|
279
286
|
|
|
280
|
-
await test('a suggester outage degrades to
|
|
287
|
+
await test('a suggester outage degrades to a DISABLED placeholder that says what to do', async () => {
|
|
281
288
|
const h = await boot({ suggestThrows: true, idea: { ...DEFAULT_IDEA, suggested_goal_id: null } });
|
|
282
289
|
await h.clickGo('promote');
|
|
283
290
|
const opts = h.goalSelect().querySelectorAll('option');
|
|
284
|
-
assert.equal(opts.length, 1, 'only the
|
|
285
|
-
|
|
291
|
+
assert.equal(opts.length, 1, 'only the placeholder remains');
|
|
292
|
+
// Before BV1.R22 this row was a submittable opt-out and this test said so. It
|
|
293
|
+
// is now a PLACEHOLDER, not a value: `disabled`, so the select can never be
|
|
294
|
+
// blank-but-valid-looking, and the text tells the builder where to go instead.
|
|
295
|
+
// The old assertion passed either way, because it never checked `disabled` —
|
|
296
|
+
// which is exactly how a contract change hides inside a green test.
|
|
297
|
+
assert.ok('disabled' in opts[0].attrs, 'the placeholder must not be submittable');
|
|
298
|
+
assert.match(opts[0].textContent, /open the goal board and pick one/,
|
|
299
|
+
'a builder with no suggestion is told what to do, not handed a choice that fails');
|
|
286
300
|
});
|
|
287
301
|
|
|
288
302
|
await test('promote with a goal chosen POSTs goal_id, then marks the idea promoted', async () => {
|
|
@@ -320,14 +334,27 @@ await test('the title survives escaping — what the form renders is what gets c
|
|
|
320
334
|
'quotes and angle brackets must round-trip through the value attribute unmangled');
|
|
321
335
|
});
|
|
322
336
|
|
|
323
|
-
await test('
|
|
337
|
+
await test('there is no way to promote WITHOUT a goal any more', async () => {
|
|
338
|
+
// THIS TEST'S PREMISE INVERTED at BV1.R22 (task 1003609). It used to assert
|
|
339
|
+
// that clearing the picker sent no `goal_id` key, because an absent key meant
|
|
340
|
+
// "the version's general bucket". R11 (task 1003598) deleted that bucket and
|
|
341
|
+
// made `goal_id` required, so the behaviour it pinned is now a 400 — and the
|
|
342
|
+
// test itself crashed with a TypeError once the option it selected stopped
|
|
343
|
+
// existing, which is what surfaced this.
|
|
344
|
+
//
|
|
345
|
+
// What replaces it is the invariant that actually matters now: the form cannot
|
|
346
|
+
// be made to submit without a goal. Deleting the test outright would leave that
|
|
347
|
+
// unguarded.
|
|
324
348
|
const h = await boot();
|
|
325
349
|
await h.clickGo('promote');
|
|
326
|
-
h.goalSelect().
|
|
350
|
+
const opts = h.goalSelect().querySelectorAll('option');
|
|
351
|
+
const submittableBlank = opts.filter((o) => o.attrs.value === '' && !('disabled' in o.attrs));
|
|
352
|
+
assert.equal(submittableBlank.length, 0, 'no blank option may be both offered and submittable');
|
|
353
|
+
// And the picker's default is a real goal, so an untouched form is already valid.
|
|
354
|
+
assert.ok(h.goalSelect().value, 'the picker opens on a real goal, never on nothing');
|
|
327
355
|
await h.submitForm('promote');
|
|
328
356
|
const body = h.requestsTo('POST', /\/tasks$/)[0].body;
|
|
329
|
-
assert.ok(
|
|
330
|
-
'the version\'s general bucket applies only on an ABSENT key — sending null or "" would be a different create');
|
|
357
|
+
assert.ok('goal_id' in body && body.goal_id, 'every promote now names a goal');
|
|
331
358
|
});
|
|
332
359
|
|
|
333
360
|
await test('an empty title is refused in the browser, before a doomed request', async () => {
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// tests/version_close_route.mjs — a version can finally close
|
|
2
|
+
// (BV1.R12, task 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
|
|
3
|
+
//
|
|
4
|
+
// THE HOLE. Tasks close, criteria close, goals close — and a version only ever
|
|
5
|
+
// moved by hand-run SQL on the droplet. `scripts/gds/version-close.js` still
|
|
6
|
+
// prints that the API exposes no version status transitions, and the
|
|
7
|
+
// `version.close` permission has sat at the archon floor since BV1.R105 with
|
|
8
|
+
// NOTHING referencing it. A thing with no closing move does not close, which is
|
|
9
|
+
// how two versions ended up building on one project at the same time.
|
|
10
|
+
//
|
|
11
|
+
// Two things this file is careful about, because both are places a correct
|
|
12
|
+
// planner and a correct writer still add up to a broken endpoint:
|
|
13
|
+
//
|
|
14
|
+
// THE MAINTENANCE GOAL IS FILTERED BY THE READ, not by the planner. It is
|
|
15
|
+
// exempt from the close count and always carries forward (ADR 0263 §7), so it
|
|
16
|
+
// must never appear in a refusal asking the caller to decide about it — asking
|
|
17
|
+
// someone to disposition a goal they are not allowed to disposition is worse
|
|
18
|
+
// than not asking.
|
|
19
|
+
//
|
|
20
|
+
// ROLL_FORWARD WITHOUT A PLANNING VERSION IS A REFUSAL, never an auto-create.
|
|
21
|
+
// Cutting the successor implicitly inside a close is exactly the "fake hotfix
|
|
22
|
+
// version" ADR 0250 §3 built the override counter to prevent.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/version_close_route.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
process.env.NODE_ENV = 'test';
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const { planVersionClose, rollForwardNeedsPlanningVersion } = require('../modules/lifecycle/routes/version-route-authz.js');
|
|
34
|
+
const { closeVersion, maintenanceGoalExemptSql, openNonMaintenanceGoals } = require('../modules/lifecycle/db.js');
|
|
35
|
+
const { test, summary } = makeRunner();
|
|
36
|
+
|
|
37
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
38
|
+
const BUILDING = { id: 'BONGOS-V1', status: 'building' };
|
|
39
|
+
const GOALS = (...n) => n.map((i) => ({ id: String(i), title: `goal ${i}`, open_tasks: 0 }));
|
|
40
|
+
const plan = (over = {}) => planVersionClose({ version: BUILDING, openGoals: GOALS(1, 2), reason: 'V1 is done', ...over });
|
|
41
|
+
|
|
42
|
+
function txPool(handler) {
|
|
43
|
+
const c = makeSqlAwareClient(handler);
|
|
44
|
+
return { connect: c.connect, query: (sql, p) => c._client.query(sql, p), queries: c._client.queries };
|
|
45
|
+
}
|
|
46
|
+
const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
|
|
47
|
+
|
|
48
|
+
// ---- only a building version closes ----------------------------------------
|
|
49
|
+
|
|
50
|
+
await test('a missing version is a 404, not a refusal about its state', () => {
|
|
51
|
+
const r = planVersionClose({ version: null });
|
|
52
|
+
assert.equal(r.ok, false); assert.equal(r.status, 404); assert.equal(r.body.error, 'version_not_found');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await test('only a BUILDING version closes — every other status is refused BY NAME', () => {
|
|
56
|
+
for (const status of ['planning', 'shipped', 'frozen', 'nonsense', null]) {
|
|
57
|
+
const r = planVersionClose({ version: { id: 'V', status } });
|
|
58
|
+
assert.equal(r.ok, false, `'${status}' must not close`);
|
|
59
|
+
assert.equal(r.body.error, 'version_not_building');
|
|
60
|
+
assert.equal(r.body.status, status, 'the refusal says WHICH status refused, rather than asserting one');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await test('a building version holding nothing open closes in ONE call', () => {
|
|
65
|
+
const r = planVersionClose({ version: BUILDING, openGoals: [] });
|
|
66
|
+
assert.equal(r.ok, true);
|
|
67
|
+
assert.deepEqual(r.plan, [], 'and needs no reason — nothing is being cut');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---- the two-step ----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
await test('open goals refuse with a NAMED 409 that RETURNS the goals', () => {
|
|
73
|
+
const r = plan({ dispositions: null });
|
|
74
|
+
assert.equal(r.status, 409);
|
|
75
|
+
assert.equal(r.body.error, 'version_holds_open_goals');
|
|
76
|
+
assert.deepEqual(r.body.details.goals.map((g) => g.id), ['1', '2'],
|
|
77
|
+
'the caller decides per goal, so the goals travel with the refusal');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('a partial map is refused — nothing is carried or cut by omission', () => {
|
|
81
|
+
const r = plan({ dispositions: { 1: { verb: 'abandon' } } });
|
|
82
|
+
assert.equal(r.ok, false);
|
|
83
|
+
assert.equal(r.body.error, 'disposition_incomplete');
|
|
84
|
+
assert.deepEqual(r.body.details.goals.map((g) => g.id), ['2'], 'and it names which');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('cutting a goal without a reason is refused — the silent discard, one tier up', () => {
|
|
88
|
+
for (const reason of ['', ' ', null, undefined]) {
|
|
89
|
+
const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } }, reason });
|
|
90
|
+
assert.equal(r.ok, false, `reason ${JSON.stringify(reason)} must not pass`);
|
|
91
|
+
assert.equal(r.body.error, 'close_reason_required');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('only roll_forward and abandon are verbs', () => {
|
|
96
|
+
for (const verb of ['carry', 'ROLL_FORWARD', 'delete', '', null, 7]) {
|
|
97
|
+
const r = plan({ dispositions: { 1: { verb }, 2: { verb: 'abandon' } } });
|
|
98
|
+
assert.equal(r.ok, false, `verb ${JSON.stringify(verb)} must not pass`);
|
|
99
|
+
assert.equal(r.body.error, 'bad_disposition');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('a complete map plans one step per goal, in the order the goals came in', () => {
|
|
104
|
+
const r = plan({ dispositions: { 1: { verb: 'roll_forward' }, 2: { verb: 'abandon' } } });
|
|
105
|
+
assert.equal(r.ok, true);
|
|
106
|
+
assert.deepEqual(r.plan, [{ goalId: 1, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---- roll_forward needs somewhere to go ------------------------------------
|
|
110
|
+
|
|
111
|
+
await test('roll_forward with NO planning version is refused, never auto-created', () => {
|
|
112
|
+
const r = rollForwardNeedsPlanningVersion({ plan: [{ goalId: 1, verb: 'roll_forward' }], planning: [] });
|
|
113
|
+
assert.ok(r, 'it must refuse');
|
|
114
|
+
assert.equal(r.status, 409);
|
|
115
|
+
assert.equal(r.body.error, 'no_planning_version');
|
|
116
|
+
assert.match(r.body.message, /Scope the next version first/,
|
|
117
|
+
'and say what to do — auto-cutting one here is the escape hatch ADR 0250 closed');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('an all-abandon close needs no planning version at all', () => {
|
|
121
|
+
assert.equal(rollForwardNeedsPlanningVersion({ plan: [{ goalId: 1, verb: 'abandon' }], planning: [] }), null);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('roll_forward WITH a planning version proceeds', () => {
|
|
125
|
+
assert.equal(rollForwardNeedsPlanningVersion({
|
|
126
|
+
plan: [{ goalId: 1, verb: 'roll_forward' }], planning: [{ id: 'BONGOS-V2' }],
|
|
127
|
+
}), null);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---- the maintenance exemption ---------------------------------------------
|
|
131
|
+
|
|
132
|
+
await test('the exemption has ONE definition, and the open-goals read is its only caller', () => {
|
|
133
|
+
// R18 (task 1003605) swaps this body for `NOT g.is_maintenance` once the column
|
|
134
|
+
// exists. One call site is what makes that a one-line change instead of a hunt.
|
|
135
|
+
assert.equal(maintenanceGoalExemptSql('$1'), "g.title <> ($1 || ' — maintenance')");
|
|
136
|
+
const dbv = src('modules/lifecycle/db-versions.js');
|
|
137
|
+
const uses = (dbv.match(/maintenanceGoalExemptSql\(/g) || []).length;
|
|
138
|
+
assert.equal(uses, 3, 'declaration + the open-goals read + the post-apply re-count, and nothing else');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('the open-goals read excludes the maintenance goal and counts unfinished work', async () => {
|
|
142
|
+
const pool = txPool(() => ({ rows: [] }));
|
|
143
|
+
await openNonMaintenanceGoals('BONGOS-V1', { pool });
|
|
144
|
+
const q = flat(pool.queries[0]);
|
|
145
|
+
assert.match(q, /maintenance/, 'the maintenance goal must not be offered for disposition');
|
|
146
|
+
assert.match(q, /status = 'open'/);
|
|
147
|
+
assert.match(q, /open_tasks/, 'each goal carries how much work it still holds — most of the decision');
|
|
148
|
+
assert.match(q, /NOT IN \('shipped', 'abandoned'\)/, 'and counts it with the shared terminal set');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---- the write is one transaction ------------------------------------------
|
|
152
|
+
|
|
153
|
+
function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } = {}) {
|
|
154
|
+
return txPool((sql) => {
|
|
155
|
+
const q = String(sql).replace(/\s+/g, ' ');
|
|
156
|
+
if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
|
|
157
|
+
if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
|
|
158
|
+
if (/UPDATE tasks SET status = 'abandoned'/.test(q)) return { rows: [{ id: 11 }, { id: 12 }] };
|
|
159
|
+
if (/count\(\*\)::int AS n FROM goals/.test(q)) return { rows: [{ n: remaining }] };
|
|
160
|
+
if (/UPDATE versions SET status = 'shipped'/.test(q)) return { rows: [{ id: 'V1', status: 'shipped' }] };
|
|
161
|
+
return { rows: [] };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await test('the whole close runs inside ONE transaction, version locked first', async () => {
|
|
166
|
+
const pool = closePool();
|
|
167
|
+
const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'done' }, { pool });
|
|
168
|
+
assert.equal(out.version.status, 'shipped');
|
|
169
|
+
assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'abandon', result: 'archived', tasks_abandoned: 2 }]);
|
|
170
|
+
const sqls = pool.queries.map(flat);
|
|
171
|
+
assert.equal(sqls.filter((q) => q === 'BEGIN').length, 1);
|
|
172
|
+
assert.equal(sqls.filter((q) => q === 'COMMIT').length, 1);
|
|
173
|
+
const lock = sqls.findIndex((q) => /FROM versions WHERE id = \$1 FOR UPDATE/.test(q));
|
|
174
|
+
const firstWrite = sqls.findIndex((q) => /^UPDATE tasks/.test(q));
|
|
175
|
+
assert.ok(lock !== -1 && lock < firstWrite, 'lock the version, then apply — a goal must not be created mid-close');
|
|
176
|
+
assert.ok(sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q)) < sqls.indexOf('COMMIT'));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('an abandoned goal takes its open tasks with it, stamped with the close reason', async () => {
|
|
180
|
+
const pool = closePool();
|
|
181
|
+
await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'cut in the ten-areas review' }, { pool });
|
|
182
|
+
const kill = pool.queries.find((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q)));
|
|
183
|
+
assert.ok(kill, 'the tasks must not survive their goal');
|
|
184
|
+
assert.equal(kill.params[1], 'Abandoned: cut in the ten-areas review',
|
|
185
|
+
'the same stamp R14 writes — the ledger reads identically whichever door retired the work');
|
|
186
|
+
assert.match(flat(kill), /NOT IN \('shipped', 'abandoned'\)/, 'and only the unfinished ones');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await test('a roll_forward abandons NOTHING and says the successor is still pending', async () => {
|
|
190
|
+
const pool = closePool();
|
|
191
|
+
const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
|
|
192
|
+
assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'roll_forward', result: 'carried_pending_successor' }]);
|
|
193
|
+
assert.equal(pool.queries.some((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q))), false,
|
|
194
|
+
'carrying a goal forward must never cut its work');
|
|
195
|
+
assert.equal(pool.queries.some((q) => /UPDATE goals SET status = 'archived'/.test(flat(q))), false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await test('a carried goal is EXCLUDED from the post-apply re-count, or a close could never carry anything', async () => {
|
|
199
|
+
const pool = closePool();
|
|
200
|
+
await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
|
|
201
|
+
const recount = pool.queries.find((q) => /count\(\*\)::int AS n FROM goals/.test(flat(q)));
|
|
202
|
+
assert.match(flat(recount), /NOT \(g\.id = ANY\(\$2::bigint\[\]\)\)/);
|
|
203
|
+
assert.deepEqual(recount.params[1], [1], 'the carried goal is expected to still be open');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await test('the re-check runs against committed state and rolls back rather than half-closing', async () => {
|
|
207
|
+
const pool = closePool({ remaining: 2 });
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool }),
|
|
210
|
+
(e) => e.code === 'CLOSE_INCOMPLETE_AFTER_APPLY' && e.remaining === 2
|
|
211
|
+
);
|
|
212
|
+
const sqls = pool.queries.map(flat);
|
|
213
|
+
assert.ok(sqls.includes('ROLLBACK'));
|
|
214
|
+
assert.equal(sqls.some((q) => /UPDATE versions SET status = 'shipped'/.test(q)), false, 'the version is not closed');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('a version that stopped building mid-call rolls back', async () => {
|
|
218
|
+
const pool = closePool({ status: 'shipped' });
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
closeVersion({ versionId: 'V1', plan: [], reason: 'r' }, { pool }),
|
|
221
|
+
(e) => e.code === 'VERSION_NOT_BUILDING' && e.versionStatus === 'shipped'
|
|
222
|
+
);
|
|
223
|
+
assert.ok(pool.queries.map(flat).includes('ROLLBACK'));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await test('the promotion seam runs INSIDE the transaction, after the flip', async () => {
|
|
227
|
+
// R20 (task 1003607) hooks here. ensureMaintenanceGoal returns null for a
|
|
228
|
+
// non-building version, so a carry-over run before the flip silently carries
|
|
229
|
+
// nothing — the ordering is load-bearing, not stylistic (ADR 0263 §8).
|
|
230
|
+
const pool = closePool();
|
|
231
|
+
let sawStatus = null; let ranAt = -1;
|
|
232
|
+
await closeVersion({ versionId: 'V1', plan: [], reason: 'r' }, {
|
|
233
|
+
pool,
|
|
234
|
+
onClosed: async (client) => { ranAt = pool.queries.length; sawStatus = 'called'; await client.query('SELECT 1 AS seam'); return { promoted: null }; },
|
|
235
|
+
});
|
|
236
|
+
const sqls = pool.queries.map(flat);
|
|
237
|
+
assert.equal(sawStatus, 'called', 'the seam must be offered');
|
|
238
|
+
assert.ok(sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q)) < ranAt, 'after the flip');
|
|
239
|
+
assert.ok(sqls.indexOf('SELECT 1 AS seam') < sqls.indexOf('COMMIT'), 'and before the commit');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---- the route is wired to all of it ---------------------------------------
|
|
243
|
+
|
|
244
|
+
await test('the route declares its body, so a typo cannot read as "no map given"', () => {
|
|
245
|
+
const V = src('modules/lifecycle/routes/versions.js');
|
|
246
|
+
const route = V.slice(V.indexOf("router.post('/versions/:id/close'"));
|
|
247
|
+
assert.match(route, /validateOrRespond\(req, res, \{[\s\S]*?dispositions: \{ type: 'object' \}/);
|
|
248
|
+
assert.match(route, /requirePermission\('version\.close'\)/, 'behind the permission nothing referenced until now');
|
|
249
|
+
assert.match(route, /db\.openNonMaintenanceGoals\(versionId\)/, 'the maintenance goal is filtered by the READ');
|
|
250
|
+
assert.match(route, /rollForwardNeedsPlanningVersion/);
|
|
251
|
+
const planIdx = route.indexOf('planVersionClose(');
|
|
252
|
+
const writeIdx = route.indexOf('db.closeVersion(');
|
|
253
|
+
assert.ok(planIdx !== -1 && writeIdx !== -1 && planIdx < writeIdx, 'decide, then write');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await test('version-close.js no longer being the only door is R23; this route exists for it to call', () => {
|
|
257
|
+
const V = src('modules/lifecycle/routes/versions.js');
|
|
258
|
+
assert.match(V, /router\.post\('\/versions\/:id\/close'/, 'the route the CLI will drive');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
summary();
|