@bongos/core 1.19.451 → 1.19.453
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 +19 -14
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-tasks.js +42 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_advisory.mjs +1 -1
- package/tests/idea_route_attribution.mjs +330 -0
- package/tests/task_goal_link.mjs +62 -4
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.453",
|
|
6
|
+
"core_contract": "1.19.453",
|
|
7
|
+
"source_commit": "d608d7888d16f919cc2775428848dcfff82d333b",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-01T20:06:17.187Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 371,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1789,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2184,
|
|
20
|
+
"tree_sha256": "b32b4f4ed373293772fa27594b928449ab80778248125d70b8401f71e0b34ac3",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2092,7 +2092,7 @@
|
|
|
2092
2092
|
{
|
|
2093
2093
|
"path": "docs/module-api-changelog.md",
|
|
2094
2094
|
"mode": "0000644",
|
|
2095
|
-
"sha256": "
|
|
2095
|
+
"sha256": "9626b5a975e660041681a9d58615224db812a74a705251d12f53cd51bcc88f1f"
|
|
2096
2096
|
},
|
|
2097
2097
|
{
|
|
2098
2098
|
"path": "docs/modules-contract.md",
|
|
@@ -4647,7 +4647,7 @@
|
|
|
4647
4647
|
{
|
|
4648
4648
|
"path": "modules/lifecycle/db-tasks.js",
|
|
4649
4649
|
"mode": "0000644",
|
|
4650
|
-
"sha256": "
|
|
4650
|
+
"sha256": "cf646edc2f71e39aee26cb5648cc25915b5688c7d6356345c0365ada37fdb400"
|
|
4651
4651
|
},
|
|
4652
4652
|
{
|
|
4653
4653
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -6302,12 +6302,12 @@
|
|
|
6302
6302
|
{
|
|
6303
6303
|
"path": "package-lock.json",
|
|
6304
6304
|
"mode": "0000644",
|
|
6305
|
-
"sha256": "
|
|
6305
|
+
"sha256": "a9e7b10b99360f563204364b2a444ff18b701669723ef0a9a76a04a0046df317"
|
|
6306
6306
|
},
|
|
6307
6307
|
{
|
|
6308
6308
|
"path": "package.json",
|
|
6309
6309
|
"mode": "0000644",
|
|
6310
|
-
"sha256": "
|
|
6310
|
+
"sha256": "ea1047b8ac17de0376d21a3a07d9f22f2854e20397b72b0626c896e4129bbfdf"
|
|
6311
6311
|
},
|
|
6312
6312
|
{
|
|
6313
6313
|
"path": "public-docs/index.html",
|
|
@@ -7957,7 +7957,7 @@
|
|
|
7957
7957
|
{
|
|
7958
7958
|
"path": "src/module-api.js",
|
|
7959
7959
|
"mode": "0000644",
|
|
7960
|
-
"sha256": "
|
|
7960
|
+
"sha256": "118d1bcac62c551bf53344abd1b77785f9875d4a6e6c29a86123f4587ac79906"
|
|
7961
7961
|
},
|
|
7962
7962
|
{
|
|
7963
7963
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8977,7 +8977,7 @@
|
|
|
8977
8977
|
{
|
|
8978
8978
|
"path": "tests/goal_advisory.mjs",
|
|
8979
8979
|
"mode": "0000644",
|
|
8980
|
-
"sha256": "
|
|
8980
|
+
"sha256": "1b1c403574132def4c4beb98595987e90b720a851f29e42f643f1f00f285da5c"
|
|
8981
8981
|
},
|
|
8982
8982
|
{
|
|
8983
8983
|
"path": "tests/goal_category.mjs",
|
|
@@ -9479,6 +9479,11 @@
|
|
|
9479
9479
|
"mode": "0000644",
|
|
9480
9480
|
"sha256": "51e1286235cbde1b56188f29b0e2bd12ac73a9637dbf1c16b325f10147256f27"
|
|
9481
9481
|
},
|
|
9482
|
+
{
|
|
9483
|
+
"path": "tests/idea_route_attribution.mjs",
|
|
9484
|
+
"mode": "0000644",
|
|
9485
|
+
"sha256": "99a4665c1ddd97bb27238082c0e6e420eb66ab3c18ec92b543c3b35ba607ebda"
|
|
9486
|
+
},
|
|
9482
9487
|
{
|
|
9483
9488
|
"path": "tests/idea_routing.mjs",
|
|
9484
9489
|
"mode": "0000644",
|
|
@@ -10747,7 +10752,7 @@
|
|
|
10747
10752
|
{
|
|
10748
10753
|
"path": "tests/task_goal_link.mjs",
|
|
10749
10754
|
"mode": "0000644",
|
|
10750
|
-
"sha256": "
|
|
10755
|
+
"sha256": "514be815c0466e4e2865ad85cb7a2ca206e5b46abc7b345da2c36278667c8bb2"
|
|
10751
10756
|
},
|
|
10752
10757
|
{
|
|
10753
10758
|
"path": "tests/task_lib.mjs",
|
|
@@ -1339,5 +1339,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1339
1339
|
landed since 1.19.449 with no explicit bump. run 33414986466. (task 1002620)
|
|
1340
1340
|
1.19.451 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1341
1341
|
landed since 1.19.450 with no explicit bump. run 33549781105. (task 1002620)
|
|
1342
|
+
1.19.452 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1343
|
+
landed since 1.19.451 with no explicit bump. run 33552353620. (task 1002620)
|
|
1344
|
+
1.19.453 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1345
|
+
landed since 1.19.452 with no explicit bump. run 33553290345. (task 1002620)
|
|
1342
1346
|
---------------------------------------------------------------------------
|
|
1343
1347
|
```
|
|
@@ -23,6 +23,20 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
23
23
|
// -------------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
// The version statuses that can still receive new work. 'shipped' and 'frozen' are
|
|
27
|
+
// closed: a version whose scope is sealed cannot be told about a bug found afterwards.
|
|
28
|
+
// Mirrors the CHECK constraint on versions.status (migrations/003_pms.sql).
|
|
29
|
+
const VERSION_STATUSES_OPEN_TO_WORK = new Set(['planning', 'building']);
|
|
30
|
+
|
|
31
|
+
// versionAcceptsNewWork — PURE, FAIL-CLOSED. Is this version still a place work can
|
|
32
|
+
// land? Exported so the truth table is pinned by a DB-free unit test, and so a caller
|
|
33
|
+
// that needs the same judgement asks this instead of respelling the status list.
|
|
34
|
+
// Anything unrecognised (null, a typo, a status added later) reads false: filing into
|
|
35
|
+
// a version we cannot vouch for is the failure this predicate exists to prevent.
|
|
36
|
+
function versionAcceptsNewWork(status) {
|
|
37
|
+
return typeof status === 'string' && VERSION_STATUSES_OPEN_TO_WORK.has(status);
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
// task 1763: resolve a version's catch-all "<version> — general" goal (created by
|
|
27
41
|
// migration 160, created_by NULL). This is the fallback goal a goal-less task
|
|
28
42
|
// defaults into (see createTask) so the "every task belongs to a goal" invariant
|
|
@@ -32,14 +46,39 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
32
46
|
// unit-testable DB-free. The title mirrors migration 160's insert exactly (a spaced
|
|
33
47
|
// em-dash); it is built by ../goal-advisory.generalGoalTitle so this SQL and the
|
|
34
48
|
// advisory that detects the same goal can never disagree (task 1002539).
|
|
49
|
+
//
|
|
50
|
+
// task 1003061 (BV1.R03): it also refuses a CLOSED version's catch-all. That is the
|
|
51
|
+
// 1000771 failure shape — Discord #bugs hardcoded GDS-V3, a shipped version, so every
|
|
52
|
+
// bug reported through it resolved into "GDS-V3 — general" and was filed somewhere
|
|
53
|
+
// nobody would ever look again. routeIdeaToGoal (BV1.R01) already refuses an EXPLICIT
|
|
54
|
+
// goal on a closed version; this closes the IMPLICIT door that every other create
|
|
55
|
+
// vector walks through. Returning null is deliberately NOT an error: the create still
|
|
56
|
+
// succeeds with goal_id NULL, and the orphan advisory then says so out loud — a task
|
|
57
|
+
// visibly without a home beats one quietly entombed.
|
|
58
|
+
//
|
|
59
|
+
// The status is judged in JS rather than the WHERE clause so the rule is one exported,
|
|
60
|
+
// unit-tested predicate instead of a string list duplicated into SQL. The JOIN is inner
|
|
61
|
+
// on purpose: a goal whose version row is missing is not a home either.
|
|
35
62
|
async function generalGoalIdForVersion(versionId, deps = {}) {
|
|
36
63
|
if (versionId == null) return null;
|
|
37
64
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
38
65
|
const { rows } = await activePool.query(
|
|
39
|
-
|
|
66
|
+
`SELECT g.id, v.status AS version_status
|
|
67
|
+
FROM goals g
|
|
68
|
+
JOIN versions v ON v.id = g.version_id
|
|
69
|
+
WHERE g.version_id = $1 AND g.title = $2
|
|
70
|
+
LIMIT 1`,
|
|
40
71
|
[versionId, generalGoalTitle(versionId)]
|
|
41
72
|
);
|
|
42
|
-
|
|
73
|
+
if (!rows[0]) return null;
|
|
74
|
+
if (!versionAcceptsNewWork(rows[0].version_status)) {
|
|
75
|
+
log.debug(
|
|
76
|
+
{ versionId, versionStatus: rows[0].version_status ?? null, goalId: rows[0].id },
|
|
77
|
+
'catch-all goal refused: version is closed to new work (task 1003061)'
|
|
78
|
+
);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return rows[0].id;
|
|
43
82
|
}
|
|
44
83
|
|
|
45
84
|
async function createTask({
|
|
@@ -870,4 +909,5 @@ module.exports = {
|
|
|
870
909
|
updateTaskRequiresRank,
|
|
871
910
|
updateTaskSecuritySensitive,
|
|
872
911
|
updateTaskStatus,
|
|
912
|
+
versionAcceptsNewWork,
|
|
873
913
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.453",
|
|
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.453",
|
|
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.453",
|
|
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
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.453'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/goal_advisory.mjs
CHANGED
|
@@ -108,7 +108,7 @@ await test('candidateGoals is total — non-array, empty, and missing scope_modu
|
|
|
108
108
|
|
|
109
109
|
await test('db.js resolves the catch-all through generalGoalTitle — no second spelling', () => {
|
|
110
110
|
assert.match(dbSrc, /require\('\.\/goal-advisory\.js'\)/, 'db.js imports the shared helper');
|
|
111
|
-
assert.match(dbSrc, /title = \$2
|
|
111
|
+
assert.match(dbSrc, /g\.title = \$2/, 'the SQL binds the title rather than inlining it');
|
|
112
112
|
assert.equal(
|
|
113
113
|
(dbSrc.match(/' — general'/g) || []).length, 0,
|
|
114
114
|
'the em-dash literal must exist in exactly ONE place (goal-advisory.js), not in db.js too'
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// tests/idea_route_attribution.mjs
|
|
2
|
+
//
|
|
3
|
+
// THE PROOF — task 1003072 (BV1.R04, goal 1000071 "Idea routing"). Criterion
|
|
4
|
+
// C3, "no route loses the person who thought of it":
|
|
5
|
+
//
|
|
6
|
+
// (1) every routed task has an ideas_task_lineage row (origin='route') back to
|
|
7
|
+
// an idea_inbox row with captured_by;
|
|
8
|
+
// (3) sourceIdeaForTask on a routed, SHIPPED task returns the filer — the trail
|
|
9
|
+
// goal 1000067's ideator credit will pay against;
|
|
10
|
+
// (4) an unlinked Discord author still yields an idea row (captured_by null)
|
|
11
|
+
// rather than an attribution-free task.
|
|
12
|
+
//
|
|
13
|
+
// WHY THIS FILE EXISTS ALONGSIDE tests/idea_routing.mjs. That file proves the
|
|
14
|
+
// WRITE: a routed capture emits idea + task + lineage in one transaction, in FK
|
|
15
|
+
// order, and rolls all three back together. tests/idea_task_lineage.mjs proves
|
|
16
|
+
// the READ: given a hand-seeded lineage row, sourceIdeaForTask names the idea and
|
|
17
|
+
// its author. Neither proves the two HALVES COMPOSE — that the row routing
|
|
18
|
+
// actually writes is the row the credit hook actually finds. A typo'd origin, a
|
|
19
|
+
// task id written where an idea id was meant, a captured_by dropped on the way
|
|
20
|
+
// in: each passes both existing files and still loses the ideator their credit,
|
|
21
|
+
// silently, months before anyone tries to pay them.
|
|
22
|
+
//
|
|
23
|
+
// So this file runs the write and the read against ONE store. Nothing is
|
|
24
|
+
// hand-seeded: every row the assertions read was put there by captureIdea.
|
|
25
|
+
//
|
|
26
|
+
// THE STORE, not a matcher table. The other two files answer per-statement with
|
|
27
|
+
// canned rows, which is right for asserting statement SEQUENCE. Here the point is
|
|
28
|
+
// end-to-end agreement, so the fake speaks the three tables as maps and the
|
|
29
|
+
// queries genuinely join across them. A canned answer would prove nothing: it
|
|
30
|
+
// would be the test asserting its own fixture.
|
|
31
|
+
//
|
|
32
|
+
// MUTATION-VERIFIED (task 1003072). A proof that has never failed is a rumour, so
|
|
33
|
+
// each of these was applied to modules/ideas/inbox.js and the file was confirmed RED
|
|
34
|
+
// before being reverted:
|
|
35
|
+
//
|
|
36
|
+
// origin 'route' -> 'promote' in the lineage insert ......... 4 of 9 fail
|
|
37
|
+
// createdBy: capturedBy -> null on the task ................. 1 of 9 fail
|
|
38
|
+
// lineage params [task.id, idea.id] swapped ................. 5 of 9 fail
|
|
39
|
+
// captured_by -> null on the idea insert .................... 3 of 9 fail
|
|
40
|
+
// the lineage INSERT deleted (the #bugs bypass shape) ....... 5 of 9 fail
|
|
41
|
+
//
|
|
42
|
+
// Re-run that check if this file is ever rewritten: the store fake is expressive
|
|
43
|
+
// enough that a careless edit could make it answer its own questions.
|
|
44
|
+
//
|
|
45
|
+
// Run: node tests/idea_route_attribution.mjs
|
|
46
|
+
|
|
47
|
+
import { strict as assert } from 'node:assert';
|
|
48
|
+
import { createRequire } from 'node:module';
|
|
49
|
+
import { makeRunner } from './helpers.mjs';
|
|
50
|
+
|
|
51
|
+
process.env.NODE_ENV = 'test';
|
|
52
|
+
|
|
53
|
+
const require = createRequire(import.meta.url);
|
|
54
|
+
const api = require('../src/module-api.js');
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// The store: idea_inbox, tasks, ideas_task_lineage as three maps.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const GOAL = {
|
|
61
|
+
id: 7, status: 'open', version_id: 'BONGOS-V1', version_status: 'building', scope_modules: ['ideas'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function makeStore() {
|
|
65
|
+
return {
|
|
66
|
+
ideas: new Map(), // id -> row
|
|
67
|
+
tasks: new Map(), // id -> row
|
|
68
|
+
lineage: new Map(), // task_id -> { idea_id, origin }
|
|
69
|
+
nextIdeaId: 500,
|
|
70
|
+
nextTaskId: 9000,
|
|
71
|
+
goal: { ...GOAL },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One SQL handler over the store, shared by the transaction client and the
|
|
76
|
+
// module-level pool — so the write path and the read path cannot disagree about
|
|
77
|
+
// what is in the database.
|
|
78
|
+
function handlerFor(store) {
|
|
79
|
+
return async (s, params = []) => {
|
|
80
|
+
// the locked goal read (routeIdeaToGoal step a)
|
|
81
|
+
if (/FROM goals g JOIN versions v/.test(s)) {
|
|
82
|
+
return { rows: store.goal ? [store.goal] : [] };
|
|
83
|
+
}
|
|
84
|
+
// the idea row (step b)
|
|
85
|
+
if (/INSERT INTO idea_inbox/.test(s)) {
|
|
86
|
+
const [title, bodyMd, kind, suggestedVersion, suggestedPriority, capturedBy, suggestedGoalId] = params;
|
|
87
|
+
const id = store.nextIdeaId++;
|
|
88
|
+
const row = {
|
|
89
|
+
id,
|
|
90
|
+
title,
|
|
91
|
+
body_md: bodyMd,
|
|
92
|
+
kind,
|
|
93
|
+
suggested_version: suggestedVersion ?? null,
|
|
94
|
+
suggested_priority: suggestedPriority ?? null,
|
|
95
|
+
// captured_by is THE field under test: it must survive the write
|
|
96
|
+
// unmodified, including when it is null (the unlinked-Discord case).
|
|
97
|
+
captured_by: capturedBy ?? null,
|
|
98
|
+
status: 'promoted',
|
|
99
|
+
suggested_goal_id: suggestedGoalId ?? null,
|
|
100
|
+
promoted_to_task_id: null,
|
|
101
|
+
reviewed_at: 'T0',
|
|
102
|
+
merged_into_idea_id: null,
|
|
103
|
+
triage_note: null,
|
|
104
|
+
captured_at: 'T0',
|
|
105
|
+
};
|
|
106
|
+
store.ideas.set(String(id), row);
|
|
107
|
+
return { rows: [row] };
|
|
108
|
+
}
|
|
109
|
+
// the lineage row (step d)
|
|
110
|
+
if (/INSERT INTO ideas_task_lineage/.test(s)) {
|
|
111
|
+
const [taskId, ideaId] = params;
|
|
112
|
+
// origin is a literal in the statement, not a param — read it from the SQL
|
|
113
|
+
// so a change to that literal is caught here rather than assumed.
|
|
114
|
+
const origin = (s.match(/VALUES \(\$1, \$2, '(\w+)'\)/) || [])[1] ?? null;
|
|
115
|
+
store.lineage.set(String(taskId), { idea_id: String(ideaId), origin });
|
|
116
|
+
return { rowCount: 1, rows: [] };
|
|
117
|
+
}
|
|
118
|
+
// closing the FK loop (step e)
|
|
119
|
+
if (/UPDATE idea_inbox SET promoted_to_task_id/.test(s)) {
|
|
120
|
+
const [ideaId, taskId] = params;
|
|
121
|
+
const row = store.ideas.get(String(ideaId));
|
|
122
|
+
if (row) row.promoted_to_task_id = taskId;
|
|
123
|
+
return { rows: row ? [row] : [] };
|
|
124
|
+
}
|
|
125
|
+
// sourceIdeaForTask — the read the ship hook does
|
|
126
|
+
if (/l\.idea_id, l\.origin, i\.captured_by/.test(s)) {
|
|
127
|
+
const link = store.lineage.get(String(params[0]));
|
|
128
|
+
if (!link) return { rows: [] };
|
|
129
|
+
const idea = store.ideas.get(String(link.idea_id));
|
|
130
|
+
if (!idea) return { rows: [] }; // an INNER JOIN finds nothing
|
|
131
|
+
return { rows: [{ idea_id: link.idea_id, origin: link.origin, captured_by: idea.captured_by }] };
|
|
132
|
+
}
|
|
133
|
+
// countShippedTasksForIdea
|
|
134
|
+
if (/COUNT\(\*\)::int AS n/.test(s)) {
|
|
135
|
+
const ideaId = String(params[0]);
|
|
136
|
+
let n = 0;
|
|
137
|
+
for (const [taskId, link] of store.lineage) {
|
|
138
|
+
if (link.idea_id === ideaId && (store.tasks.get(taskId) || {}).status === 'shipped') n++;
|
|
139
|
+
}
|
|
140
|
+
return { rows: [{ n }] };
|
|
141
|
+
}
|
|
142
|
+
return { rows: [] }; // BEGIN / COMMIT / ROLLBACK and anything unmatched
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A pool whose query() and connect().query() both run the same handler against
|
|
147
|
+
// the same store, and which records every statement for ordering assertions.
|
|
148
|
+
function poolFor(store) {
|
|
149
|
+
const handler = handlerFor(store);
|
|
150
|
+
const queries = [];
|
|
151
|
+
const client = {
|
|
152
|
+
queries,
|
|
153
|
+
async query(sql, params) {
|
|
154
|
+
queries.push({ sql: String(sql).replace(/\s+/g, ' ').trim(), params });
|
|
155
|
+
return handler(String(sql).replace(/\s+/g, ' ').trim(), params);
|
|
156
|
+
},
|
|
157
|
+
release() {},
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
queries,
|
|
161
|
+
query: client.query,
|
|
162
|
+
connect: async () => client,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Wiring: the lifecycle port (createTask) writes into the SAME store.
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const seams = require('../src/module-seams.js');
|
|
171
|
+
seams._reset();
|
|
172
|
+
|
|
173
|
+
let activeStore = makeStore();
|
|
174
|
+
|
|
175
|
+
seams.registerProvider('lifecycle', {
|
|
176
|
+
createTask: async (args) => {
|
|
177
|
+
const id = activeStore.nextTaskId++;
|
|
178
|
+
const row = {
|
|
179
|
+
id,
|
|
180
|
+
status: args.status,
|
|
181
|
+
goal_id: args.goalId,
|
|
182
|
+
version_id: args.versionId,
|
|
183
|
+
title: args.title,
|
|
184
|
+
kind: args.kind,
|
|
185
|
+
source: args.source,
|
|
186
|
+
source_ref: args.sourceRef,
|
|
187
|
+
created_by: args.createdBy ?? null,
|
|
188
|
+
};
|
|
189
|
+
activeStore.tasks.set(String(id), row);
|
|
190
|
+
return row;
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
195
|
+
const { captureIdea, sourceIdeaForTask, countShippedTasksForIdea } = inbox;
|
|
196
|
+
|
|
197
|
+
// sourceIdeaForTask and countShippedTasksForIdea read the MODULE-LEVEL pool (no
|
|
198
|
+
// deps seam), so point that at the live store too. Same object the module
|
|
199
|
+
// captured at load, so replacing the method is enough.
|
|
200
|
+
api.pool.query = async (sql, params) => handlerFor(activeStore)(String(sql).replace(/\s+/g, ' ').trim(), params);
|
|
201
|
+
|
|
202
|
+
const { test, summary } = makeRunner();
|
|
203
|
+
|
|
204
|
+
// Route one bug and hand back everything the assertions need.
|
|
205
|
+
async function routeABug({ capturedBy = 42, filerRank = 'metic', title = 'the thing is broken' } = {}) {
|
|
206
|
+
activeStore = makeStore();
|
|
207
|
+
const pool = poolFor(activeStore);
|
|
208
|
+
const idea = await captureIdea(
|
|
209
|
+
{ title, bodyMd: 'steps to reproduce', kind: 'bug', goalId: GOAL.id, capturedBy, filerRank },
|
|
210
|
+
{ pool }
|
|
211
|
+
);
|
|
212
|
+
return { idea, pool, store: activeStore };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ship = (store, taskId) => { store.tasks.get(String(taskId)).status = 'shipped'; };
|
|
216
|
+
|
|
217
|
+
// =========================================================================
|
|
218
|
+
// C3 (1) — the routed task HAS a lineage row, and it points at a real idea
|
|
219
|
+
// =========================================================================
|
|
220
|
+
|
|
221
|
+
await test('a routed bug writes lineage origin=route from the task back to the idea', async () => {
|
|
222
|
+
const { idea, store } = await routeABug();
|
|
223
|
+
const taskId = idea.routing.task_id;
|
|
224
|
+
|
|
225
|
+
assert.ok(taskId, 'the capture became a task');
|
|
226
|
+
const link = store.lineage.get(String(taskId));
|
|
227
|
+
assert.ok(link, 'a lineage row exists for the task that was just created');
|
|
228
|
+
assert.equal(link.origin, 'route', "origin is 'route' — not 'promote', which would misattribute the lane");
|
|
229
|
+
assert.equal(link.idea_id, String(idea.id), 'and it points at the idea this very capture wrote');
|
|
230
|
+
assert.ok(store.ideas.has(String(idea.id)), 'that idea id is a real row, not a dangling reference');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('the idea row carries the filer, and the task records them as its creator', async () => {
|
|
234
|
+
const { idea, store } = await routeABug({ capturedBy: 42 });
|
|
235
|
+
assert.equal(store.ideas.get(String(idea.id)).captured_by, 42, 'the filer is on the idea row');
|
|
236
|
+
assert.equal(store.tasks.get(String(idea.routing.task_id)).created_by, 42,
|
|
237
|
+
'and on the task — routing an idea attributes the work to whoever reported it');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// =========================================================================
|
|
241
|
+
// C3 (3) — THE COMPOSITION. What routing wrote is what the ship hook reads.
|
|
242
|
+
// =========================================================================
|
|
243
|
+
|
|
244
|
+
await test('sourceIdeaForTask on the routed, SHIPPED task returns the filer', async () => {
|
|
245
|
+
const { idea, store } = await routeABug({ capturedBy: 42 });
|
|
246
|
+
const taskId = idea.routing.task_id;
|
|
247
|
+
ship(store, taskId);
|
|
248
|
+
|
|
249
|
+
const src = await sourceIdeaForTask(taskId);
|
|
250
|
+
assert.ok(src, 'the ship hook finds a source idea — nothing to pay is the failure this goal exists to prevent');
|
|
251
|
+
assert.equal(String(src.idea_id), String(idea.id), 'it names the idea the filing created');
|
|
252
|
+
assert.equal(src.origin, 'route', 'and says the route is where it came from');
|
|
253
|
+
assert.equal(src.captured_by, 42, 'and names the person to pay');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await test('the shipped-task count for that idea is exactly one — the credit lane can meter it', async () => {
|
|
257
|
+
const { idea, store } = await routeABug();
|
|
258
|
+
assert.equal(await countShippedTasksForIdea(idea.id), 0, 'nothing is owed before it ships');
|
|
259
|
+
ship(store, idea.routing.task_id);
|
|
260
|
+
assert.equal(await countShippedTasksForIdea(idea.id), 1, 'and exactly one afterwards — never double-paid');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await test('the trail survives with NOTHING hand-seeded — every row came from the capture', async () => {
|
|
264
|
+
const { idea, store } = await routeABug({ capturedBy: 77 });
|
|
265
|
+
// The point of the whole file, asserted directly: three tables, one act.
|
|
266
|
+
assert.equal(store.ideas.size, 1, 'one idea');
|
|
267
|
+
assert.equal(store.tasks.size, 1, 'one task');
|
|
268
|
+
assert.equal(store.lineage.size, 1, 'one lineage row');
|
|
269
|
+
ship(store, idea.routing.task_id);
|
|
270
|
+
const src = await sourceIdeaForTask(idea.routing.task_id);
|
|
271
|
+
assert.deepEqual(
|
|
272
|
+
{ idea: String(src.idea_id), origin: src.origin, who: src.captured_by },
|
|
273
|
+
{ idea: String(idea.id), origin: 'route', who: 77 }
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// =========================================================================
|
|
278
|
+
// C3 (4) — an unlinked author still leaves a trail
|
|
279
|
+
// =========================================================================
|
|
280
|
+
|
|
281
|
+
await test('an UNLINKED filer (captured_by null) still yields an idea row, not an attribution-free task', async () => {
|
|
282
|
+
// A Discord author with no Bongos account. The report must not become a task
|
|
283
|
+
// that exists with no idea behind it — that is precisely the #bugs bypass this
|
|
284
|
+
// goal retires. The trail exists and is honestly empty at the person-shaped
|
|
285
|
+
// end, which is recoverable later; a missing lineage row never is.
|
|
286
|
+
const { idea, store } = await routeABug({ capturedBy: null, filerRank: 'metic' });
|
|
287
|
+
const taskId = idea.routing.task_id;
|
|
288
|
+
|
|
289
|
+
assert.ok(store.lineage.get(String(taskId)), 'the lineage row exists even with no known author');
|
|
290
|
+
ship(store, taskId);
|
|
291
|
+
const src = await sourceIdeaForTask(taskId);
|
|
292
|
+
assert.ok(src, 'the ship hook still finds the idea');
|
|
293
|
+
assert.equal(src.origin, 'route');
|
|
294
|
+
assert.equal(src.captured_by, null, 'unattributed, and honestly so — never guessed');
|
|
295
|
+
assert.equal(store.tasks.get(String(taskId)).created_by, null, 'the task does not invent a creator either');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// =========================================================================
|
|
299
|
+
// The negative: a task with no route has no trail, so a passing test above
|
|
300
|
+
// cannot be the fake answering itself.
|
|
301
|
+
// =========================================================================
|
|
302
|
+
|
|
303
|
+
await test('a task that was never routed has no source idea (the read is not vacuously true)', async () => {
|
|
304
|
+
activeStore = makeStore();
|
|
305
|
+
activeStore.tasks.set('4242', { id: 4242, status: 'shipped' });
|
|
306
|
+
assert.equal(await sourceIdeaForTask(4242), null,
|
|
307
|
+
'no lineage row → null; if this returned a row the store would be answering blind');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await test('lineage pointing at a missing idea reads as no source, never as a half-row', async () => {
|
|
311
|
+
activeStore = makeStore();
|
|
312
|
+
activeStore.tasks.set('4243', { id: 4243, status: 'shipped' });
|
|
313
|
+
activeStore.lineage.set('4243', { idea_id: '999', origin: 'route' }); // idea absent
|
|
314
|
+
assert.equal(await sourceIdeaForTask(4243), null,
|
|
315
|
+
"the INNER JOIN drops it — the credit lane must never see an author-less 'source'");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// =========================================================================
|
|
319
|
+
// The provenance pair, so one idea can only ever mint one routed task
|
|
320
|
+
// =========================================================================
|
|
321
|
+
|
|
322
|
+
await test('the routed task carries the idempotency provenance the create-vector read keys on', async () => {
|
|
323
|
+
const { idea, store } = await routeABug();
|
|
324
|
+
const task = store.tasks.get(String(idea.routing.task_id));
|
|
325
|
+
assert.equal(task.source, 'idea-route', 'source names the vector');
|
|
326
|
+
assert.equal(task.source_ref, `idea-${idea.id}`,
|
|
327
|
+
'and source_ref names the idea — db.findTaskBySourceRef is what stops a second task being minted');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
summary();
|
package/tests/task_goal_link.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// - null versionId → null and NO SQL (nothing to resolve).
|
|
13
13
|
// - a version id → exactly one SELECT keyed on (version_id, the spaced
|
|
14
14
|
// em-dash title match), bound param [versionId], returning the id or null.
|
|
15
|
+
// - a CLOSED version (shipped/frozen) → null even when the goal exists, so
|
|
16
|
+
// the create lands goal-less rather than in a tomb (task 1003061).
|
|
15
17
|
//
|
|
16
18
|
// routes/tasks.js — the router still BUILDS after the PATCH /tasks/:id goal_id
|
|
17
19
|
// extension (imports resolve; the factory returns a router). The SQL + the
|
|
@@ -29,6 +31,10 @@ process.env.NODE_ENV = 'test';
|
|
|
29
31
|
const require = createRequire(import.meta.url);
|
|
30
32
|
const db = require('../modules/lifecycle/db.js');
|
|
31
33
|
const { generalGoalIdForVersion } = db;
|
|
34
|
+
// versionAcceptsNewWork is INTERNAL to the tasks slice (task 1003061): db.js is the
|
|
35
|
+
// facade for the call sites, and re-exporting a predicate nothing outside this file
|
|
36
|
+
// calls would only widen the facade. Required from where it lives instead.
|
|
37
|
+
const { versionAcceptsNewWork } = require('../modules/lifecycle/db-tasks.js');
|
|
32
38
|
const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
|
|
33
39
|
|
|
34
40
|
const { test, summary } = makeRunner();
|
|
@@ -55,17 +61,21 @@ await test('null / undefined versionId → null, and NO SQL is issued', async ()
|
|
|
55
61
|
}
|
|
56
62
|
});
|
|
57
63
|
|
|
58
|
-
await test('a version with a general goal → returns its id via one SELECT on the em-dash title', async () => {
|
|
59
|
-
const pool = fakePool([{ id: 3 }]);
|
|
64
|
+
await test('a BUILDING version with a general goal → returns its id via one SELECT on the em-dash title', async () => {
|
|
65
|
+
const pool = fakePool([{ id: 3, version_status: 'building' }]);
|
|
60
66
|
const out = await generalGoalIdForVersion('BONGOS-V1', { pool });
|
|
61
67
|
|
|
62
68
|
assert.equal(out, 3, 'returns the general goal id');
|
|
63
69
|
assert.equal(pool.queries.length, 1, 'exactly one SELECT');
|
|
64
70
|
const q = pool.queries[0];
|
|
71
|
+
// task 1003061 (BV1.R03): the lookup now JOINs versions and brings the version
|
|
72
|
+
// status back, so the resolver can refuse a closed version's catch-all.
|
|
73
|
+
assert.match(q.sql, /JOIN versions/, 'the lookup reaches the version, not just the goal');
|
|
74
|
+
assert.match(q.sql, /AS version_status/, 'and carries its status back to be judged');
|
|
65
75
|
// task 1002539: the title is no longer inlined in SQL — it is BOUND, built by the
|
|
66
76
|
// shared goal-advisory.generalGoalTitle so this lookup and the orphan advisory that
|
|
67
77
|
// names the same goal cannot drift apart.
|
|
68
|
-
assert.match(q.sql, /
|
|
78
|
+
assert.match(q.sql, /WHERE g\.version_id = \$1 AND g\.title = \$2/,
|
|
69
79
|
'keyed on version_id + a BOUND title (one definition, in goal-advisory.js)');
|
|
70
80
|
assert.match(q.sql, /LIMIT 1/, 'single row');
|
|
71
81
|
assert.deepEqual(q.params, ['BONGOS-V1', 'BONGOS-V1 — general'],
|
|
@@ -81,7 +91,7 @@ await test('a version with no general goal → null (caller leaves goal_id NULL,
|
|
|
81
91
|
});
|
|
82
92
|
|
|
83
93
|
await test('the em-dash in the title match is U+2014 (not a hyphen/en-dash) — or the backfill silently matches nothing', async () => {
|
|
84
|
-
const pool = fakePool([{ id: 9 }]);
|
|
94
|
+
const pool = fakePool([{ id: 9, version_status: 'building' }]);
|
|
85
95
|
await generalGoalIdForVersion('V2', { pool });
|
|
86
96
|
// The em-dash moved from the SQL text into the BOUND param (task 1002539); assert
|
|
87
97
|
// it there, which is stricter — the exact string Postgres compares.
|
|
@@ -92,6 +102,54 @@ await test('the em-dash in the title match is U+2014 (not a hyphen/en-dash) —
|
|
|
92
102
|
assert.ok(!title.includes(' – general'), 'not an en dash');
|
|
93
103
|
});
|
|
94
104
|
|
|
105
|
+
// =========================================================================
|
|
106
|
+
// task 1003061 (BV1.R03) — the catch-all must never be a TOMB.
|
|
107
|
+
//
|
|
108
|
+
// The 1000771 failure: Discord #bugs hardcoded GDS-V3, a SHIPPED version, so every
|
|
109
|
+
// reported bug resolved into "GDS-V3 — general" and was filed where nobody would look
|
|
110
|
+
// again. routeIdeaToGoal (BV1.R01) already refuses an EXPLICIT goal on a closed
|
|
111
|
+
// version; this closes the IMPLICIT door — the goal-less fallback that every other
|
|
112
|
+
// create vector (POST /tasks, capture, newcomer-restock, seeds) walks through.
|
|
113
|
+
//
|
|
114
|
+
// A closed version's catch-all resolves to null, so createTask leaves goal_id NULL and
|
|
115
|
+
// the orphan advisory (goal-advisory.js) speaks up. Loud silence beats a quiet tomb.
|
|
116
|
+
|
|
117
|
+
await test('versionAcceptsNewWork: only a version still open for work can receive it', () => {
|
|
118
|
+
assert.equal(versionAcceptsNewWork('building'), true, 'building — the live case');
|
|
119
|
+
assert.equal(versionAcceptsNewWork('planning'), true, 'planning — a legitimate future home');
|
|
120
|
+
assert.equal(versionAcceptsNewWork('shipped'), false, 'shipped — the 1000771 tomb');
|
|
121
|
+
assert.equal(versionAcceptsNewWork('frozen'), false, 'frozen — closed to new work');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('versionAcceptsNewWork fails CLOSED on anything it does not recognise', () => {
|
|
125
|
+
for (const v of [null, undefined, '', 'BUILDING', 'archived', 42, {}]) {
|
|
126
|
+
assert.equal(versionAcceptsNewWork(v), false, `refuses ${JSON.stringify(v)}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await test("a SHIPPED version's catch-all does not resolve — the create lands goal-less, not in a tomb", async () => {
|
|
131
|
+
const pool = fakePool([{ id: 3, version_status: 'shipped' }]);
|
|
132
|
+
const out = await generalGoalIdForVersion('GDS-V3', { pool });
|
|
133
|
+
assert.equal(out, null, 'the goal EXISTS and is still refused — that is the whole point');
|
|
134
|
+
assert.equal(pool.queries.length, 1, 'it did look — it just declined what it found');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test("a FROZEN version's catch-all does not resolve either", async () => {
|
|
138
|
+
const pool = fakePool([{ id: 4, version_status: 'frozen' }]);
|
|
139
|
+
assert.equal(await generalGoalIdForVersion('OLD-V1', { pool }), null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await test("a PLANNING version's catch-all still resolves (a future home is a real home)", async () => {
|
|
143
|
+
const pool = fakePool([{ id: 5, version_status: 'planning' }]);
|
|
144
|
+
assert.equal(await generalGoalIdForVersion('NEXT-V1', { pool }), 5);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('a row with no readable version status resolves to null (fail closed)', async () => {
|
|
148
|
+
const pool = fakePool([{ id: 6 }]);
|
|
149
|
+
assert.equal(await generalGoalIdForVersion('MYSTERY-V1', { pool }), null,
|
|
150
|
+
'an unjoinable or absent version is not a home');
|
|
151
|
+
});
|
|
152
|
+
|
|
95
153
|
// -------------------------------------------------------------------------
|
|
96
154
|
await test('routes/tasks.js still builds after the goal_id PATCH extension', () => {
|
|
97
155
|
assert.equal(typeof buildTasksRouter, 'function', 'module exports the router factory');
|