@bongos/core 1.19.559 → 1.19.561
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 +24 -14
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +30 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/goals.js +22 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_archive_disposition.mjs +144 -0
- package/tests/goal_closure_invariants.mjs +332 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.561",
|
|
6
|
+
"core_contract": "1.19.561",
|
|
7
|
+
"source_commit": "c3f698331dac2a2225920780b219c28b7a354dc6",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-06T17:
|
|
9
|
+
"built_at": "2026-09-06T17:18:36.999Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 443,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2024,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2491,
|
|
20
|
+
"tree_sha256": "e6f6d8606c0eeca8894673e7d1e73a986383fab212dc83aedbc0cb8a02f5a631",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2657,7 +2657,7 @@
|
|
|
2657
2657
|
{
|
|
2658
2658
|
"path": "docs/module-api-changelog.md",
|
|
2659
2659
|
"mode": "0000644",
|
|
2660
|
-
"sha256": "
|
|
2660
|
+
"sha256": "b6fc3c1b36687285aa44544babb749b5dfff005f8fb95428e771302ff2f3fe5e"
|
|
2661
2661
|
},
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "docs/modules-contract.md",
|
|
@@ -5432,7 +5432,7 @@
|
|
|
5432
5432
|
{
|
|
5433
5433
|
"path": "modules/lifecycle/db-goals.js",
|
|
5434
5434
|
"mode": "0000644",
|
|
5435
|
-
"sha256": "
|
|
5435
|
+
"sha256": "16c5f2ed9ae4b70a30eb47a4864c4ad6273afbe19e925edcac5f9d965e670f24"
|
|
5436
5436
|
},
|
|
5437
5437
|
{
|
|
5438
5438
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5472,7 +5472,7 @@
|
|
|
5472
5472
|
{
|
|
5473
5473
|
"path": "modules/lifecycle/db.js",
|
|
5474
5474
|
"mode": "0000644",
|
|
5475
|
-
"sha256": "
|
|
5475
|
+
"sha256": "e7f65fbc43a0963ab6a7e6d25e0b52521f46b82f05115ffef80ea64d9e9fac81"
|
|
5476
5476
|
},
|
|
5477
5477
|
{
|
|
5478
5478
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -5652,7 +5652,7 @@
|
|
|
5652
5652
|
{
|
|
5653
5653
|
"path": "modules/lifecycle/routes/goals.js",
|
|
5654
5654
|
"mode": "0000644",
|
|
5655
|
-
"sha256": "
|
|
5655
|
+
"sha256": "9bef42b55351651b188e95e254814edd4e845e77cc67bc230960af77d0f6eaa2"
|
|
5656
5656
|
},
|
|
5657
5657
|
{
|
|
5658
5658
|
"path": "modules/lifecycle/routes/help-requests.js",
|
|
@@ -7477,12 +7477,12 @@
|
|
|
7477
7477
|
{
|
|
7478
7478
|
"path": "package-lock.json",
|
|
7479
7479
|
"mode": "0000644",
|
|
7480
|
-
"sha256": "
|
|
7480
|
+
"sha256": "7dc793d8391910582ab02e387931d90ef58114fc118ed14954d15ee8c4697658"
|
|
7481
7481
|
},
|
|
7482
7482
|
{
|
|
7483
7483
|
"path": "package.json",
|
|
7484
7484
|
"mode": "0000644",
|
|
7485
|
-
"sha256": "
|
|
7485
|
+
"sha256": "f70384732bde00c05eeb33f54faa28fcc77df835866cb35df9dcd8f812e01ed7"
|
|
7486
7486
|
},
|
|
7487
7487
|
{
|
|
7488
7488
|
"path": "public-docs/index.html",
|
|
@@ -9182,7 +9182,7 @@
|
|
|
9182
9182
|
{
|
|
9183
9183
|
"path": "src/module-api.js",
|
|
9184
9184
|
"mode": "0000644",
|
|
9185
|
-
"sha256": "
|
|
9185
|
+
"sha256": "84964fd2a837b5f09f4fc45abef8a9eea2e79cc9072292272e4219b6860af9d7"
|
|
9186
9186
|
},
|
|
9187
9187
|
{
|
|
9188
9188
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10264,6 +10264,11 @@
|
|
|
10264
10264
|
"mode": "0000644",
|
|
10265
10265
|
"sha256": "b8c179ae6c5ad962356e5d82a5c6aa89bd523913de10799e53de16279404d07d"
|
|
10266
10266
|
},
|
|
10267
|
+
{
|
|
10268
|
+
"path": "tests/goal_archive_disposition.mjs",
|
|
10269
|
+
"mode": "0000644",
|
|
10270
|
+
"sha256": "d2056a3ae84b79d64409e6e43c477f6b160f202903be61710b40bc4fd06c00c9"
|
|
10271
|
+
},
|
|
10267
10272
|
{
|
|
10268
10273
|
"path": "tests/goal_category.mjs",
|
|
10269
10274
|
"mode": "0000644",
|
|
@@ -10279,6 +10284,11 @@
|
|
|
10279
10284
|
"mode": "0000644",
|
|
10280
10285
|
"sha256": "8d1f3511b8ecd0bb88f75a936ee29035d9f789a2be4f6aedabb46b9be5bd2385"
|
|
10281
10286
|
},
|
|
10287
|
+
{
|
|
10288
|
+
"path": "tests/goal_closure_invariants.mjs",
|
|
10289
|
+
"mode": "0000644",
|
|
10290
|
+
"sha256": "eace8ebcf39f40683bf9cbcfbc0e554b115e73d1f72894ca57a89e82d119d357"
|
|
10291
|
+
},
|
|
10282
10292
|
{
|
|
10283
10293
|
"path": "tests/goal_conflicts.mjs",
|
|
10284
10294
|
"mode": "0000644",
|
|
@@ -1567,5 +1567,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1567
1567
|
landed since 1.19.557 with no explicit bump. run 34046699120. (task 1002620)
|
|
1568
1568
|
1.19.559 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1569
1569
|
landed since 1.19.558 with no explicit bump. run 34047187381. (task 1002620)
|
|
1570
|
+
1.19.560 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1571
|
+
landed since 1.19.559 with no explicit bump. run 34047736326. (task 1002620)
|
|
1572
|
+
1.19.561 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1573
|
+
landed since 1.19.560 with no explicit bump. run 34048169727. (task 1002620)
|
|
1570
1574
|
---------------------------------------------------------------------------
|
|
1571
1575
|
```
|
|
@@ -324,6 +324,35 @@ async function listGoalMembers(goalId, deps = {}) {
|
|
|
324
324
|
// "linked criteria" half of GET /goals/:id; projection matches done-when.js's
|
|
325
325
|
// listCriteria so the hall renders goal + version criteria identically.
|
|
326
326
|
|
|
327
|
+
// openTasksInGoal — the work a goal is still holding, newest-blocking-first, for
|
|
328
|
+
// the archive preview (BV1.R10, task 1003597, goal 1000086, ADR 0250 D3).
|
|
329
|
+
//
|
|
330
|
+
// Archiving used to be a single call that checked only whether the goal was
|
|
331
|
+
// ALREADY archived — no criterion check and no task check — which is how goal
|
|
332
|
+
// 1000018 was archived with all seven of its tasks still open. It is the hole
|
|
333
|
+
// that bypasses the R08/R09 closure rule entirely: the rule guards `achieved`,
|
|
334
|
+
// and archive is the other way out.
|
|
335
|
+
//
|
|
336
|
+
// "Still holding" is the same question the closure rule asks — non-terminal per
|
|
337
|
+
// task-status.js, smoke fixtures excluded — so a goal cannot be refused for work
|
|
338
|
+
// that would not have held it open on the achieve path. `cap` bounds the returned
|
|
339
|
+
// rows (the caller renders them); `total` is the true count, so a goal holding
|
|
340
|
+
// forty tasks reports forty and shows the first few.
|
|
341
|
+
async function openTasksInGoal(goalId, { cap = 50 } = {}, deps = {}) {
|
|
342
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
343
|
+
const { rows } = await activePool.query(
|
|
344
|
+
`SELECT t.id::text, t.title, t.status,
|
|
345
|
+
count(*) OVER ()::int AS total
|
|
346
|
+
FROM tasks t
|
|
347
|
+
WHERE t.goal_id = $1
|
|
348
|
+
AND ${nonTerminalSql()}
|
|
349
|
+
AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')}
|
|
350
|
+
ORDER BY t.id
|
|
351
|
+
LIMIT $2`,
|
|
352
|
+
[goalId, cap]
|
|
353
|
+
);
|
|
354
|
+
return { total: rows[0]?.total ?? 0, tasks: rows.map(({ total, ...t }) => t) };
|
|
355
|
+
}
|
|
327
356
|
async function listCriteriaForGoal(goalId, deps = {}) {
|
|
328
357
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
329
358
|
// task 1002164: carry the linked-task counts so the caller can derive the
|
|
@@ -784,6 +813,7 @@ module.exports = {
|
|
|
784
813
|
isGoalMember,
|
|
785
814
|
listActiveClaimsForTasks,
|
|
786
815
|
listCriteriaForGoal,
|
|
816
|
+
openTasksInGoal,
|
|
787
817
|
listGoalMembers,
|
|
788
818
|
listGoals,
|
|
789
819
|
listInboxFor,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -78,6 +78,7 @@ const {
|
|
|
78
78
|
isGoalMember,
|
|
79
79
|
listActiveClaimsForTasks,
|
|
80
80
|
listCriteriaForGoal,
|
|
81
|
+
openTasksInGoal,
|
|
81
82
|
listGoalMembers,
|
|
82
83
|
listGoals,
|
|
83
84
|
listInboxFor,
|
|
@@ -243,6 +244,7 @@ module.exports = {
|
|
|
243
244
|
listInboxFor,
|
|
244
245
|
listPendingRequestsForGoal,
|
|
245
246
|
listCriteriaForGoal,
|
|
247
|
+
openTasksInGoal,
|
|
246
248
|
createTask,
|
|
247
249
|
generalGoalIdForVersion,
|
|
248
250
|
findTaskBySourceRef,
|
|
@@ -1048,6 +1048,28 @@ module.exports = function buildGoalsRouter() {
|
|
|
1048
1048
|
if (goal.status === 'archived') {
|
|
1049
1049
|
return res.fail('already_archived', 409);
|
|
1050
1050
|
}
|
|
1051
|
+
// BV1.R10 (task 1003597, goal 1000086, ADR 0250 D3): archive is the OTHER
|
|
1052
|
+
// way out of a goal, and it checked nothing — which is how goal 1000018 was
|
|
1053
|
+
// archived with all seven of its tasks still open. R08/R09 guard `achieved`;
|
|
1054
|
+
// without this, anyone refused there could archive instead and the work
|
|
1055
|
+
// would vanish from every rollup just the same.
|
|
1056
|
+
//
|
|
1057
|
+
// The refusal RETURNS the work rather than merely naming a count, because
|
|
1058
|
+
// the caller's next move is to decide what happens to each task — and an
|
|
1059
|
+
// agent that has to go fetch the list first will guess instead. Applying a
|
|
1060
|
+
// disposition (abandon with the archive's reason, or move to a named
|
|
1061
|
+
// surviving goal) is task 1003601 (R14); until then the honest answer is
|
|
1062
|
+
// "not without saying what happens to these", not a silent discard.
|
|
1063
|
+
//
|
|
1064
|
+
// A goal holding nothing open stays a single call, unchanged.
|
|
1065
|
+
const held = await db.openTasksInGoal(id);
|
|
1066
|
+
if (held.total > 0) {
|
|
1067
|
+
return res.fail('goal_holds_open_tasks', {
|
|
1068
|
+
status: 409,
|
|
1069
|
+
message: `Goal #${id} still holds ${held.total} unfinished task(s). Ship or abandon them, or move them to another goal, before archiving it.`,
|
|
1070
|
+
details: { total: held.total, shown: held.tasks.length, tasks: held.tasks },
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1051
1073
|
const updated = await db.setGoalStatus({ goalId: id, status: 'archived' });
|
|
1052
1074
|
return res.json({ ok: true, goal: updated });
|
|
1053
1075
|
} catch (err) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.561",
|
|
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.561",
|
|
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.561",
|
|
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
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.561'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// tests/goal_archive_disposition.mjs — archiving a goal is a recorded decision,
|
|
2
|
+
// not a silent discard (BV1.R10, task 1003597, goal 1000086, ADR 0250 D3).
|
|
3
|
+
//
|
|
4
|
+
// THE HOLE. `POST /goals/:id/archive` refused exactly one thing: a goal that was
|
|
5
|
+
// ALREADY archived. No criterion check, no task check. R08/R09 guard the flip to
|
|
6
|
+
// `achieved`, so archive was the other way out — anyone refused there could
|
|
7
|
+
// archive instead and the work disappeared from every rollup just the same. Goal
|
|
8
|
+
// 1000018 went that way with all seven of its tasks still open.
|
|
9
|
+
//
|
|
10
|
+
// "Still holding" must be the SAME question the closure rule asks — non-terminal
|
|
11
|
+
// per task-status.js, smoke fixtures excluded — or the two doors disagree about
|
|
12
|
+
// what unfinished means and a goal is refused here for work that would not have
|
|
13
|
+
// held it open there. That equivalence is what most of this file pins.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/goal_archive_disposition.mjs
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { makeSqlAwareClient } from './helpers.mjs';
|
|
22
|
+
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { openTasksInGoal } = require('../modules/lifecycle/db.js');
|
|
26
|
+
const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
|
|
27
|
+
|
|
28
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
29
|
+
|
|
30
|
+
function fakePool(handler) {
|
|
31
|
+
const c = makeSqlAwareClient(handler);
|
|
32
|
+
return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The archive handler slice, so assertions land on the right route.
|
|
36
|
+
const GOALS = src('modules/lifecycle/routes/goals.js');
|
|
37
|
+
const ARCHIVE = (() => {
|
|
38
|
+
const a = GOALS.indexOf("router.post('/goals/:id/archive'");
|
|
39
|
+
assert.notEqual(a, -1, 'archive route not found');
|
|
40
|
+
const b = GOALS.indexOf("router.post('/goals/:id/reopen'", a);
|
|
41
|
+
assert.notEqual(b, -1, 'reopen route should follow archive');
|
|
42
|
+
return GOALS.slice(a, b);
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
// ---- the read ---------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
test('openTasksInGoal asks the SAME question the closure rule asks', async () => {
|
|
48
|
+
let seen = null;
|
|
49
|
+
const pool = fakePool((s) => { seen = s; return { rows: [] }; });
|
|
50
|
+
await openTasksInGoal(77, {}, { pool });
|
|
51
|
+
assert.match(seen, /FROM tasks t WHERE t\.goal_id = \$1/);
|
|
52
|
+
assert.match(seen, /t\.status NOT IN \('shipped', 'abandoned'\)/,
|
|
53
|
+
'the terminal set is task-status.js\'s, not a fourth hand-typed copy');
|
|
54
|
+
assert.match(seen, /t\.title NOT LIKE/,
|
|
55
|
+
'a smoke fixture must not be able to block an archive either');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('openTasksInGoal reports the TRUE total even when it returns a capped page', async () => {
|
|
59
|
+
// A goal holding forty tasks must say forty. Returning only the page length
|
|
60
|
+
// would understate the decision the caller is about to make.
|
|
61
|
+
const rows = [
|
|
62
|
+
{ id: '1', title: 'a', status: 'backlog', total: 40 },
|
|
63
|
+
{ id: '2', title: 'b', status: 'ready', total: 40 },
|
|
64
|
+
];
|
|
65
|
+
const pool = fakePool(() => ({ rows }));
|
|
66
|
+
const out = await openTasksInGoal(77, { cap: 2 }, { pool });
|
|
67
|
+
assert.equal(out.total, 40, 'the window count is the real total');
|
|
68
|
+
assert.equal(out.tasks.length, 2, 'and the page is what was asked for');
|
|
69
|
+
assert.deepEqual(Object.keys(out.tasks[0]), ['id', 'title', 'status'],
|
|
70
|
+
'the window column is stripped from the rows the caller renders');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('openTasksInGoal binds the cap rather than interpolating it', async () => {
|
|
74
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
75
|
+
await openTasksInGoal(77, { cap: 5 }, { pool });
|
|
76
|
+
assert.deepEqual(pool.queries[0].params, [77, 5]);
|
|
77
|
+
assert.match(String(pool.queries[0].sql), /LIMIT \$2/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an empty goal reports nothing held — the single-call path stays open', async () => {
|
|
81
|
+
const pool = fakePool(() => ({ rows: [] }));
|
|
82
|
+
const out = await openTasksInGoal(77, {}, { pool });
|
|
83
|
+
assert.deepEqual(out, { total: 0, tasks: [] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---- the route --------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
test('archive refuses with a NAMED 409 when the goal holds open work', () => {
|
|
89
|
+
assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id\);/,
|
|
90
|
+
'the archive route must consult the work before discarding it');
|
|
91
|
+
assert.match(ARCHIVE, /res\.fail\('goal_holds_open_tasks', \{[\s\S]*?status: 409/,
|
|
92
|
+
'a named code an agent can branch on, not a bare 409');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the refusal RETURNS the tasks, so the caller can decide without a second fetch', () => {
|
|
96
|
+
assert.match(ARCHIVE, /details: \{ total: held\.total, shown: held\.tasks\.length, tasks: held\.tasks \}/,
|
|
97
|
+
'an agent that has to go fetch the list will guess instead');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
|
|
101
|
+
const auth = ARCHIVE.indexOf('isMemberOrArchon');
|
|
102
|
+
const already = ARCHIVE.indexOf("'already_archived'");
|
|
103
|
+
const gate = ARCHIVE.indexOf('openTasksInGoal');
|
|
104
|
+
const write = ARCHIVE.indexOf('db.setGoalStatus');
|
|
105
|
+
assert.ok(auth !== -1 && already !== -1 && gate !== -1 && write !== -1, 'all four landmarks present');
|
|
106
|
+
assert.ok(auth < gate, 'a non-member must not learn what a goal is holding');
|
|
107
|
+
assert.ok(already < gate, 're-archiving an archived goal keeps its own answer');
|
|
108
|
+
assert.ok(gate < write, 'a refusal after the write is not a refusal');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('archiving a goal that holds nothing is still ONE call', () => {
|
|
112
|
+
// The guard must not turn the ordinary case into a two-step. There is exactly
|
|
113
|
+
// one setGoalStatus in this handler and no second round trip in front of it.
|
|
114
|
+
assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1);
|
|
115
|
+
assert.equal(/disposition_required|preview=/.test(ARCHIVE), false,
|
|
116
|
+
'no extra confirm parameter on the empty-goal path');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---- the two doors agree ----------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test('archive and achieve mean the same thing by "unfinished"', () => {
|
|
122
|
+
// If these drift, a goal is refused on one door for work the other would have
|
|
123
|
+
// let through — the confusing half of a rule people then route around.
|
|
124
|
+
const dbGoals = src('modules/lifecycle/db-goals.js');
|
|
125
|
+
const openTasks = dbGoals.slice(dbGoals.indexOf('async function openTasksInGoal'));
|
|
126
|
+
const achieve = dbGoals.slice(dbGoals.indexOf('async function achieveGoalIfComplete'));
|
|
127
|
+
for (const [name, body] of [['openTasksInGoal', openTasks], ['achieveGoalIfComplete', achieve]]) {
|
|
128
|
+
assert.match(body, /\$\{nonTerminalSql\(\)\}/, name + ' must read the shared terminal set');
|
|
129
|
+
assert.match(body, /SMOKE_NOT_LIKE\.replace/, name + ' must exclude smoke rows');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('every non-terminal status is one the archive gate would catch', () => {
|
|
134
|
+
// Stated as data rather than prose: the statuses that hold a goal open are
|
|
135
|
+
// exactly the ones absent from the terminal set, and archive filters on that
|
|
136
|
+
// same set, so the enumeration cannot drift out of step with the constant.
|
|
137
|
+
const HOLDS_OPEN = ['backlog', 'ready', 'blocked', 'active', 'completed', 'confirmed'];
|
|
138
|
+
for (const s of HOLDS_OPEN) {
|
|
139
|
+
assert.equal(TERMINAL_STATUSES.includes(s), false,
|
|
140
|
+
`'${s}' must not be terminal — it is unfinished work an archive would discard`);
|
|
141
|
+
}
|
|
142
|
+
assert.deepEqual([...TERMINAL_STATUSES].sort(), ['abandoned', 'shipped'],
|
|
143
|
+
'and the terminal set is exactly the two deliberate endings');
|
|
144
|
+
});
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// tests/goal_closure_invariants.mjs — the C2 invariant, pinned across BOTH
|
|
2
|
+
// achievement writers (BV1.R13, task 1003600, goal 1000086, ADR 0250 D2).
|
|
3
|
+
//
|
|
4
|
+
// THE INVARIANT: a goal reaches `achieved` only when its criteria are satisfied
|
|
5
|
+
// AND no task in it is left in a non-terminal state. Terminal is `shipped` or
|
|
6
|
+
// deliberately `abandoned`; `backlog`, `ready`, `blocked`, `active`, `completed`
|
|
7
|
+
// and `confirmed` all hold the goal open.
|
|
8
|
+
//
|
|
9
|
+
// WHY A DEDICATED SUITE. The rule is written TWICE on purpose — `db-goals.js`
|
|
10
|
+
// `achieveGoalIfComplete` for the manual `POST /done-when/:id/satisfy`, and the
|
|
11
|
+
// cascade inside `done-when.js` `autoSatisfyShippedCriteria` for the path that
|
|
12
|
+
// fires on every ship and every reconciler tick. They cannot be collapsed into
|
|
13
|
+
// one helper (db-goals requires done-when; sharing creates a require cycle), so
|
|
14
|
+
// the failure mode is DRIFT: one writer moves, the other does not, and a goal
|
|
15
|
+
// closes correctly through one door and wrongly through the other. That is
|
|
16
|
+
// precisely how the two "finished" vocabularies drifted apart before task
|
|
17
|
+
// 1003588. This file states the invariant once and asserts it of both.
|
|
18
|
+
//
|
|
19
|
+
// WHAT THIS FILE IS, AND IS NOT. It runs both writers against a small in-memory
|
|
20
|
+
// model of the two statements they issue — the model implements the SQL's
|
|
21
|
+
// SEMANTICS (who is terminal, which goals qualify) by hand, so what is proven
|
|
22
|
+
// here is the behaviour and the wiring: which view each writer consults, what it
|
|
23
|
+
// decides, and what it leaves alone. The literal SQL TEXT is pinned separately —
|
|
24
|
+
// tests/auto_satisfy_criteria.mjs for the cascade, tests/goal_achievement.mjs for
|
|
25
|
+
// the manual path — and neither file replaces the other. A change that alters the
|
|
26
|
+
// SQL's meaning has to get past both.
|
|
27
|
+
//
|
|
28
|
+
// Run: node --test tests/goal_closure_invariants.mjs
|
|
29
|
+
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
import { test } from 'node:test';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
process.env.NODE_ENV = 'test';
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const { achieveGoalIfComplete } = require('../modules/lifecycle/db.js');
|
|
37
|
+
const { autoSatisfyShippedCriteria, closeCompletedWorkForShip } =
|
|
38
|
+
require('../modules/lifecycle/done-when.js');
|
|
39
|
+
const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The model. A `world` is { goals, criteria, tasks }, where a task carries
|
|
43
|
+
// { id, goal_id, status, title } and a criterion { id, goal_id, satisfied,
|
|
44
|
+
// task_ids }. `makeExec(world)` returns something shaped like a pg client whose
|
|
45
|
+
// two statements answer FROM THAT WORLD — so handing two writers two different
|
|
46
|
+
// worlds is how the uncommitted-flip case below is expressed.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const isTerminal = (t) => TERMINAL_STATUSES.includes(t.status);
|
|
50
|
+
const isSmoke = (t) => String(t.title || '').startsWith('__smoke__');
|
|
51
|
+
|
|
52
|
+
// The terminal set the STATEMENT asks for, read back out of its own text, so a
|
|
53
|
+
// change to task-status.js flows into the model instead of being asserted twice.
|
|
54
|
+
function terminalSetIn(sql) {
|
|
55
|
+
const m = sql.match(/t\.status NOT IN \(([^)]*)\)/);
|
|
56
|
+
assert.ok(m, 'the cascade must name a terminal set the model can read');
|
|
57
|
+
return m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, ''));
|
|
58
|
+
}
|
|
59
|
+
// "Holds the goal open": not terminal, and not a test fixture.
|
|
60
|
+
const holdsOpen = (t) => !isTerminal(t) && !isSmoke(t);
|
|
61
|
+
|
|
62
|
+
function world({ goalStatus = 'open', criteria = [], tasks = [] } = {}) {
|
|
63
|
+
return {
|
|
64
|
+
goals: [{ id: 77, title: 'Strict versioning', version_id: 'BONGOS-V1', status: goalStatus }],
|
|
65
|
+
criteria: criteria.map((c, i) => ({
|
|
66
|
+
id: 100 + i, goal_id: 77, version_id: 'BONGOS-V1',
|
|
67
|
+
criterion_id: 'c' + i, criterion_md: 'x', satisfied: false, task_ids: [], ...c,
|
|
68
|
+
})),
|
|
69
|
+
tasks: tasks.map((t, i) => ({ id: 200 + i, goal_id: 77, title: 'task ' + i, ...t })),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeExec(w) {
|
|
74
|
+
const calls = [];
|
|
75
|
+
const taskById = (id) => w.tasks.find((t) => t.id === id);
|
|
76
|
+
return {
|
|
77
|
+
calls,
|
|
78
|
+
world: w,
|
|
79
|
+
async query(sql, params) {
|
|
80
|
+
const s = String(sql).replace(/\s+/g, ' ').trim();
|
|
81
|
+
calls.push({ sql: s, params });
|
|
82
|
+
|
|
83
|
+
// (1) close every unsatisfied criterion whose linked tasks are ALL terminal
|
|
84
|
+
// and at least one of which actually shipped (the delivery guard).
|
|
85
|
+
if (/UPDATE done_when_criteria/.test(s)) {
|
|
86
|
+
const scoped = params[0] == null ? null : Number(params[0]);
|
|
87
|
+
const closed = w.criteria.filter((c) => {
|
|
88
|
+
if (c.satisfied) return false;
|
|
89
|
+
if (scoped != null && !c.task_ids.includes(scoped)) return false;
|
|
90
|
+
const linked = c.task_ids.map(taskById).filter(Boolean);
|
|
91
|
+
if (linked.length === 0) return false;
|
|
92
|
+
return linked.every(isTerminal) && linked.some((t) => t.status === 'shipped');
|
|
93
|
+
});
|
|
94
|
+
closed.forEach((c) => { c.satisfied = true; });
|
|
95
|
+
return { rows: closed.map((c) => ({ ...c, satisfied_by_task_id: scoped })) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// (2) the cascade. NOT hand-coded: the model reads the statement it was
|
|
99
|
+
// actually handed and honours only the clauses PRESENT in it — including
|
|
100
|
+
// the terminal set, parsed out of the SQL rather than imported. That is
|
|
101
|
+
// what keeps this suite non-vacuous. Delete the R09 clause from
|
|
102
|
+
// done-when.js and the model stops filtering on tasks, the goal closes,
|
|
103
|
+
// and these tests go red — which `the suite can actually fail` below
|
|
104
|
+
// demonstrates rather than asserts.
|
|
105
|
+
if (/UPDATE goals g/.test(s)) {
|
|
106
|
+
const ids = (params[0] || []).map(Number);
|
|
107
|
+
const flipped = w.goals.filter((g) => {
|
|
108
|
+
if (!ids.includes(g.id)) return false;
|
|
109
|
+
if (/g\.status = 'open'/.test(s) && g.status !== 'open') return false;
|
|
110
|
+
const crit = w.criteria.filter((c) => c.goal_id === g.id);
|
|
111
|
+
if (/EXISTS \(SELECT 1 FROM done_when_criteria/.test(s) && crit.length === 0) return false;
|
|
112
|
+
if (/AND NOT d\.satisfied\)/.test(s) && crit.some((c) => !c.satisfied)) return false;
|
|
113
|
+
if (/NOT EXISTS \(SELECT 1 FROM tasks t/.test(s)) {
|
|
114
|
+
const terminal = terminalSetIn(s);
|
|
115
|
+
const smokeFiltered = /t\.title NOT LIKE/.test(s);
|
|
116
|
+
const held = w.tasks.some((t) => t.goal_id === g.id
|
|
117
|
+
&& !terminal.includes(t.status)
|
|
118
|
+
&& !(smokeFiltered && isSmoke(t)));
|
|
119
|
+
if (held) return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
flipped.forEach((g) => { g.status = 'achieved'; });
|
|
124
|
+
return { rows: flipped.map((g) => ({ ...g })) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The manual path: getGoal, then the combined criteria + task counts, then
|
|
128
|
+
// the status flip.
|
|
129
|
+
if (/FROM goals WHERE id = \$1/.test(s)) {
|
|
130
|
+
const g = w.goals.find((x) => x.id === Number(params[0]));
|
|
131
|
+
return { rows: g ? [g] : [] };
|
|
132
|
+
}
|
|
133
|
+
if (/FROM done_when_criteria WHERE goal_id = \$1/.test(s)) {
|
|
134
|
+
const crit = w.criteria.filter((c) => c.goal_id === Number(params[0]));
|
|
135
|
+
return { rows: [{
|
|
136
|
+
total: crit.length,
|
|
137
|
+
unsatisfied: crit.filter((c) => !c.satisfied).length,
|
|
138
|
+
unfinished_tasks: w.tasks.filter((t) => t.goal_id === Number(params[0]) && holdsOpen(t)).length,
|
|
139
|
+
}] };
|
|
140
|
+
}
|
|
141
|
+
if (/^UPDATE goals/.test(s)) {
|
|
142
|
+
const g = w.goals.find((x) => x.id === Number(params[0]));
|
|
143
|
+
g.status = params[1];
|
|
144
|
+
return { rows: [{ ...g }] };
|
|
145
|
+
}
|
|
146
|
+
return { rows: [] };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const asPool = (exec) => ({ query: (sql, p) => exec.query(sql, p), connect: async () => exec, calls: exec.calls });
|
|
151
|
+
|
|
152
|
+
const SHIPPED = (over) => ({ status: 'shipped', ...over });
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// 1. The invariant holds on BOTH writers, for every non-terminal status
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const HOLDS_OPEN = ['backlog', 'ready', 'blocked', 'active', 'completed', 'confirmed'];
|
|
159
|
+
|
|
160
|
+
for (const status of HOLDS_OPEN) {
|
|
161
|
+
test(`a '${status}' sibling holds the goal open — manual path`, async () => {
|
|
162
|
+
const w = world({
|
|
163
|
+
criteria: [{ satisfied: true, task_ids: [200] }],
|
|
164
|
+
tasks: [SHIPPED(), { status }],
|
|
165
|
+
});
|
|
166
|
+
const out = await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(w)) });
|
|
167
|
+
assert.equal(out.achieved, false, `'${status}' is not finished work`);
|
|
168
|
+
assert.equal(w.goals[0].status, 'open', 'and the row is not flipped');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test(`a '${status}' sibling holds the goal open — cascade`, async () => {
|
|
172
|
+
const w = world({
|
|
173
|
+
criteria: [{ satisfied: false, task_ids: [200] }],
|
|
174
|
+
tasks: [SHIPPED(), { status }],
|
|
175
|
+
});
|
|
176
|
+
const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
|
|
177
|
+
assert.equal(out.criteria.length, 1, 'the criterion itself still closes — ADR 0183 is untouched');
|
|
178
|
+
assert.deepEqual(out.goals, [], 'but the goal does not');
|
|
179
|
+
assert.equal(w.goals[0].status, 'open');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const status of TERMINAL_STATUSES) {
|
|
184
|
+
test(`a '${status}' sibling does NOT hold the goal open — both writers agree`, async () => {
|
|
185
|
+
const manual = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
186
|
+
const m = await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(manual)) });
|
|
187
|
+
assert.equal(m.achieved, true, `'${status}' is finished with the project`);
|
|
188
|
+
|
|
189
|
+
const casc = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
190
|
+
const c = await autoSatisfyShippedCriteria(makeExec(casc), { taskId: 200 });
|
|
191
|
+
assert.equal(c.goals.length, 1, 'the cascade reaches the same verdict');
|
|
192
|
+
assert.equal(casc.goals[0].status, 'achieved');
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test('a __smoke__ fixture cannot wedge a real goal open — both writers', async () => {
|
|
197
|
+
const smoke = { status: 'backlog', title: '__smoke__ probe' };
|
|
198
|
+
const manual = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), smoke] });
|
|
199
|
+
assert.equal((await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(manual)) })).achieved, true);
|
|
200
|
+
|
|
201
|
+
const casc = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), smoke] });
|
|
202
|
+
assert.equal((await autoSatisfyShippedCriteria(makeExec(casc), { taskId: 200 })).goals.length, 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the rule does not become "goals never close": clean goal still achieves', async () => {
|
|
206
|
+
const w = world({ criteria: [{ satisfied: false, task_ids: [200, 201] }], tasks: [SHIPPED(), SHIPPED()] });
|
|
207
|
+
const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
|
|
208
|
+
assert.equal(out.goals.length, 1, 'every task terminal + every criterion satisfied = achieved');
|
|
209
|
+
assert.equal(w.goals[0].status, 'achieved');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a task linked to NO criterion still holds the goal open — the original hole', async () => {
|
|
213
|
+
// This is the exact shape that produced 14 wrongly-closed goals on the live
|
|
214
|
+
// instance: achievement was computed from criteria alone, and an unlinked task
|
|
215
|
+
// was therefore invisible to it.
|
|
216
|
+
const w = world({
|
|
217
|
+
criteria: [{ satisfied: false, task_ids: [200] }],
|
|
218
|
+
tasks: [SHIPPED(), { status: 'backlog' }], // 201 is linked to nothing
|
|
219
|
+
});
|
|
220
|
+
const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
|
|
221
|
+
assert.equal(out.criteria.length, 1, 'the criterion is satisfied — its own tasks all shipped');
|
|
222
|
+
assert.deepEqual(out.goals, [], 'and the unlinked backlog task still holds the goal open');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// 2. The cascade sees the ship transaction's UNCOMMITTED flip
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test('the cascade reads through the exec it was handed, so it sees the ship\'s own uncommitted flip', async () => {
|
|
230
|
+
// Two views of the same instant, mid-ship: the transaction has already flipped
|
|
231
|
+
// task 200 confirmed→shipped but has not committed, so anything reading the
|
|
232
|
+
// module pool still sees 'confirmed' — non-terminal.
|
|
233
|
+
const inTxn = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED()] });
|
|
234
|
+
const outsideTxn = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [{ status: 'confirmed' }] });
|
|
235
|
+
|
|
236
|
+
const viaTxn = await autoSatisfyShippedCriteria(makeExec(inTxn), { taskId: 200 });
|
|
237
|
+
assert.equal(viaTxn.goals.length, 1,
|
|
238
|
+
"the shipping task is terminal inside its own transaction, so the goal closes");
|
|
239
|
+
assert.equal(inTxn.goals[0].status, 'achieved');
|
|
240
|
+
|
|
241
|
+
const viaPool = await autoSatisfyShippedCriteria(makeExec(outsideTxn), { taskId: 200 });
|
|
242
|
+
assert.deepEqual(viaPool.goals, [],
|
|
243
|
+
'and on a pre-commit view it would NOT — which is the bug passing the pool would cause');
|
|
244
|
+
assert.equal(outsideTxn.goals[0].status, 'open');
|
|
245
|
+
|
|
246
|
+
// Both halves matter: the first alone would pass even if the function ignored
|
|
247
|
+
// its exec and the world were globally shipped. The pair proves the OUTCOME
|
|
248
|
+
// tracks the view handed in — i.e. that the reads go through `exec`.
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('every statement goes through the caller exec — none reaches the module pool', async () => {
|
|
252
|
+
const exec = makeExec(world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED()] }));
|
|
253
|
+
await autoSatisfyShippedCriteria(exec, { taskId: 200 });
|
|
254
|
+
assert.equal(exec.calls.length, 2, 'the criterion close and the cascade, both on exec');
|
|
255
|
+
assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
|
|
256
|
+
assert.match(exec.calls[1].sql, /UPDATE goals g/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// 3. A ship still LANDS while its goal is held open
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
test('a ship lands even though a sibling backlog task holds its goal open', async () => {
|
|
264
|
+
// The ship-time hook is the only thing the closure rule touches on the ship
|
|
265
|
+
// path. It must complete quietly: the criterion closes, the goal does not, and
|
|
266
|
+
// nothing is thrown into the transaction that is landing the task.
|
|
267
|
+
const w = world({
|
|
268
|
+
criteria: [{ satisfied: false, task_ids: [200] }],
|
|
269
|
+
tasks: [SHIPPED(), { status: 'backlog' }],
|
|
270
|
+
});
|
|
271
|
+
const logged = [];
|
|
272
|
+
const line = await closeCompletedWorkForShip(makeExec(w), 200, (m) => logged.push(m));
|
|
273
|
+
assert.ok(line, 'it reports what it closed');
|
|
274
|
+
assert.match(line, /criteri/i, 'namely the criterion');
|
|
275
|
+
assert.equal(w.goals[0].status, 'open', 'the goal is held open by the sibling');
|
|
276
|
+
assert.equal(w.tasks[0].status, 'shipped', 'and the ship itself is untouched');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the hook swallows a failure rather than failing the ship', async () => {
|
|
280
|
+
// Belt and braces on the same guarantee: even if the cascade throws outright,
|
|
281
|
+
// the ship must not. The reconciler's unscoped sweep retries every tick.
|
|
282
|
+
const exploding = { query: async () => { throw new Error('deadlock detected'); } };
|
|
283
|
+
const logged = [];
|
|
284
|
+
const line = await closeCompletedWorkForShip(exploding, 200, (m) => logged.push(m));
|
|
285
|
+
assert.match(line, /auto-satisfy skipped \(sweep retries\): deadlock detected/,
|
|
286
|
+
'the failure is reported, not raised');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 4. The two writers may not drift apart
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
test('the suite can actually fail: strip the R09 clause and the goal wrongly closes', async () => {
|
|
294
|
+
// A model that answers from its own logic would pass these tests no matter what
|
|
295
|
+
// the code said. This one interprets the statement it is handed, so removing the
|
|
296
|
+
// clause is observable — demonstrated here against the SAME world that is held
|
|
297
|
+
// open above, with the clause edited out of the SQL on its way in.
|
|
298
|
+
const w = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), { status: 'backlog' }] });
|
|
299
|
+
const base = makeExec(w);
|
|
300
|
+
const stripTaskClause = (sql) => {
|
|
301
|
+
const text = String(sql);
|
|
302
|
+
const from = text.indexOf('AND NOT EXISTS (SELECT 1 FROM tasks t');
|
|
303
|
+
if (from === -1) return text;
|
|
304
|
+
const to = text.indexOf('RETURNING', from);
|
|
305
|
+
assert.notEqual(to, -1, 'the clause is expected inside the cascade UPDATE');
|
|
306
|
+
return text.slice(0, from) + text.slice(to);
|
|
307
|
+
};
|
|
308
|
+
const crippled = { query: (sql, params) => base.query(stripTaskClause(sql), params) };
|
|
309
|
+
const out = await autoSatisfyShippedCriteria(crippled, { taskId: 200 });
|
|
310
|
+
assert.equal(out.goals.length, 1,
|
|
311
|
+
'without the clause the backlog sibling is invisible and the goal closes — the pre-R09 bug');
|
|
312
|
+
assert.equal(w.goals[0].status, 'achieved',
|
|
313
|
+
'so the passing tests above are load-bearing, not decorative');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('both writers reach the SAME verdict on every status, one status at a time', async () => {
|
|
317
|
+
// The anti-drift assertion, stated as behaviour rather than as matching source
|
|
318
|
+
// text: for each task status, ask both writers and require they agree.
|
|
319
|
+
const ALL = [...HOLDS_OPEN, ...TERMINAL_STATUSES];
|
|
320
|
+
const disagreements = [];
|
|
321
|
+
for (const status of ALL) {
|
|
322
|
+
const m = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
323
|
+
const manual = (await achieveGoalIfComplete({ goalId: 77 }, { pool: asPool(makeExec(m)) })).achieved;
|
|
324
|
+
|
|
325
|
+
const c = world({ criteria: [{ satisfied: false, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
|
|
326
|
+
const cascade = (await autoSatisfyShippedCriteria(makeExec(c), { taskId: 200 })).goals.length > 0;
|
|
327
|
+
|
|
328
|
+
if (manual !== cascade) disagreements.push(`${status}: manual=${manual} cascade=${cascade}`);
|
|
329
|
+
}
|
|
330
|
+
assert.deepEqual(disagreements, [],
|
|
331
|
+
'the duplicate is deliberate; the DRIFT is the bug — both writers or neither');
|
|
332
|
+
});
|