@bongos/core 1.19.454 → 1.19.455
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 -19
- package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/discord/db.js +0 -3
- package/modules/discord/discord-bot.js +8 -1
- package/modules/discord/discord-bugs.js +133 -74
- package/modules/discord/module.json +1 -1
- package/modules/lifecycle/goal-suggest.js +70 -2
- package/modules/lifecycle/lifecycle.js +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/discord_bugs.mjs +110 -31
- package/tests/filing_abuse_matrix.mjs +59 -20
- package/tests/goal_suggest.mjs +107 -1
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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.455",
|
|
6
|
+
"core_contract": "1.19.455",
|
|
7
|
+
"source_commit": "73e271f9813d11dde6c2ceaca0eedeb4cf71ed4e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-01T21:02:15.357Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 371,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2185,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "65ad277694ad3b8aed5a0a975bc77754be62f4e305062a558e5608dda4d49bbc",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -682,7 +682,7 @@
|
|
|
682
682
|
{
|
|
683
683
|
"path": "docs/adr/0047-discord-bugs-intake-anti-abuse.md",
|
|
684
684
|
"mode": "0000644",
|
|
685
|
-
"sha256": "
|
|
685
|
+
"sha256": "013c11580a411d12691c1d79a60e54483faea08c056a71ebd953364e4eca4754"
|
|
686
686
|
},
|
|
687
687
|
{
|
|
688
688
|
"path": "docs/adr/0048-works-in-progress-ia-split.md",
|
|
@@ -2092,7 +2092,7 @@
|
|
|
2092
2092
|
{
|
|
2093
2093
|
"path": "docs/module-api-changelog.md",
|
|
2094
2094
|
"mode": "0000644",
|
|
2095
|
-
"sha256": "
|
|
2095
|
+
"sha256": "4b48503d7e83ce2f3678aa1e17f57f859a3d659ac12438deb3ad9382acd2ea47"
|
|
2096
2096
|
},
|
|
2097
2097
|
{
|
|
2098
2098
|
"path": "docs/modules-contract.md",
|
|
@@ -3472,7 +3472,7 @@
|
|
|
3472
3472
|
{
|
|
3473
3473
|
"path": "modules/discord/db.js",
|
|
3474
3474
|
"mode": "0000644",
|
|
3475
|
-
"sha256": "
|
|
3475
|
+
"sha256": "e265e853cf153a76e7b84f304e6f4338bd2bbfc2098383c358a0df7ac02a6817"
|
|
3476
3476
|
},
|
|
3477
3477
|
{
|
|
3478
3478
|
"path": "modules/discord/discord-approvals.js",
|
|
@@ -3487,12 +3487,12 @@
|
|
|
3487
3487
|
{
|
|
3488
3488
|
"path": "modules/discord/discord-bot.js",
|
|
3489
3489
|
"mode": "0000644",
|
|
3490
|
-
"sha256": "
|
|
3490
|
+
"sha256": "15d3bf047731fa50cca8bc3b06ef1c2b27e9b8a0067f3151a00974f99eb2ac3d"
|
|
3491
3491
|
},
|
|
3492
3492
|
{
|
|
3493
3493
|
"path": "modules/discord/discord-bugs.js",
|
|
3494
3494
|
"mode": "0000644",
|
|
3495
|
-
"sha256": "
|
|
3495
|
+
"sha256": "083569e4fe2a621dc6c8663fafc5ec1d6f5e1e1893a3b4a9bdb74ecf4854cd24"
|
|
3496
3496
|
},
|
|
3497
3497
|
{
|
|
3498
3498
|
"path": "modules/discord/discord-channels.js",
|
|
@@ -3527,7 +3527,7 @@
|
|
|
3527
3527
|
{
|
|
3528
3528
|
"path": "modules/discord/module.json",
|
|
3529
3529
|
"mode": "0000644",
|
|
3530
|
-
"sha256": "
|
|
3530
|
+
"sha256": "6800aead0d24b615912d2711dbe90ef284e8a31579da7c8a8ce642de81274307"
|
|
3531
3531
|
},
|
|
3532
3532
|
{
|
|
3533
3533
|
"path": "modules/discord/pollers/discord-bot.js",
|
|
@@ -4712,7 +4712,7 @@
|
|
|
4712
4712
|
{
|
|
4713
4713
|
"path": "modules/lifecycle/goal-suggest.js",
|
|
4714
4714
|
"mode": "0000644",
|
|
4715
|
-
"sha256": "
|
|
4715
|
+
"sha256": "85117db645accd023f4f61735f9f22a8c5f6ef08a29c61db99fbade51d081147"
|
|
4716
4716
|
},
|
|
4717
4717
|
{
|
|
4718
4718
|
"path": "modules/lifecycle/help-requests.js",
|
|
@@ -4737,7 +4737,7 @@
|
|
|
4737
4737
|
{
|
|
4738
4738
|
"path": "modules/lifecycle/lifecycle.js",
|
|
4739
4739
|
"mode": "0000644",
|
|
4740
|
-
"sha256": "
|
|
4740
|
+
"sha256": "8631c6ce8a2d33631f9dc549a47102cdb8121484b98cb759151e10e2be9af4c5"
|
|
4741
4741
|
},
|
|
4742
4742
|
{
|
|
4743
4743
|
"path": "modules/lifecycle/merge-lock.js",
|
|
@@ -6302,12 +6302,12 @@
|
|
|
6302
6302
|
{
|
|
6303
6303
|
"path": "package-lock.json",
|
|
6304
6304
|
"mode": "0000644",
|
|
6305
|
-
"sha256": "
|
|
6305
|
+
"sha256": "f797395e1506e7142c1e0e200fce54996022fbe3547bcf44cab043b7da5cdc2c"
|
|
6306
6306
|
},
|
|
6307
6307
|
{
|
|
6308
6308
|
"path": "package.json",
|
|
6309
6309
|
"mode": "0000644",
|
|
6310
|
-
"sha256": "
|
|
6310
|
+
"sha256": "977df81d28e616a4f9dafd80e10288b68712c590dbf87201ab959e52b9c6630a"
|
|
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": "bb1314a41a5f8447e2251d885dd2983c6be5e7bbfee1043e1e2dbb26c67742f0"
|
|
7961
7961
|
},
|
|
7962
7962
|
{
|
|
7963
7963
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8757,7 +8757,7 @@
|
|
|
8757
8757
|
{
|
|
8758
8758
|
"path": "tests/discord_bugs.mjs",
|
|
8759
8759
|
"mode": "0000644",
|
|
8760
|
-
"sha256": "
|
|
8760
|
+
"sha256": "488c68953274278e7569136705307f89f25d4bd4436839a699cbcef07d789730"
|
|
8761
8761
|
},
|
|
8762
8762
|
{
|
|
8763
8763
|
"path": "tests/discord_channels.mjs",
|
|
@@ -8867,7 +8867,7 @@
|
|
|
8867
8867
|
{
|
|
8868
8868
|
"path": "tests/filing_abuse_matrix.mjs",
|
|
8869
8869
|
"mode": "0000644",
|
|
8870
|
-
"sha256": "
|
|
8870
|
+
"sha256": "dd63216406ff8d76a6250944c03fb8e0f2028c599d01ee4720ba3dd79d547a63"
|
|
8871
8871
|
},
|
|
8872
8872
|
{
|
|
8873
8873
|
"path": "tests/first_admin_bootstrap.mjs",
|
|
@@ -9052,7 +9052,7 @@
|
|
|
9052
9052
|
{
|
|
9053
9053
|
"path": "tests/goal_suggest.mjs",
|
|
9054
9054
|
"mode": "0000644",
|
|
9055
|
-
"sha256": "
|
|
9055
|
+
"sha256": "d61e2bd5b5a65239f1410011a5035b124bf8e20c41eac08018635ec8cd3a49c7"
|
|
9056
9056
|
},
|
|
9057
9057
|
{
|
|
9058
9058
|
"path": "tests/goal_tier.mjs",
|
|
@@ -32,5 +32,6 @@ Every filing is audited with `surface='discord_bot'` (`route='discord:bugs'`), s
|
|
|
32
32
|
|
|
33
33
|
- **Config / go-live:** inert until `DISCORD_BUGS_CHANNEL_ID` (env, or `bugs_channel_id` in `~/.config/otb/discord-bot.json`) is set **and** the bot is enabled (`DISCORD_BOT_ENABLED=true`) with the Message Content intent — exactly like `#ideas`. The channel itself is created/edited by the ADR 0037 channel reconciler from `channels.json`.
|
|
34
34
|
- **Version:** filed bugs default to `DISCORD_BUGS_VERSION_ID` (default `GDS-V3`, the open internal version, since most builder-filed bugs are about the dev system). An Archon can re-home a game bug to a product version. Reward defaults to `DISCORD_BUGS_CREDITS` (10).
|
|
35
|
+
- **SUPERSEDED by task 1003067 (BV1.R11, goal 1000071).** `GDS-V3` shipped, and this default kept filing live bug reports into a closed version's catch-all goal — the failure idea 1000771 was raised over. Both env knobs are gone: #bugs now files through the ideas capture-time routing path, so the version comes from the goal the report routes into and the reward auto-assigns at the create layer (ADR 0096). Guard 1 below (linked-builder-only) is also superseded — see `modules/discord/discord-bugs.js` for the guard set as it now stands.
|
|
35
36
|
- **Trust boundary intact (ADR 0016):** the bot asserts only *who* posted; the landing state is derived from the live GDS rank, never trusted from the message. The bot grants nothing.
|
|
36
37
|
- **Deferred:** a dedicated abuse-flag widget on the `/watch` red-team view. The audit trail (`surface='discord_bot'`, the 429 rejections) already makes abuse queryable; a bespoke view is a follow-up if volume warrants it.
|
|
@@ -1345,5 +1345,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1345
1345
|
landed since 1.19.452 with no explicit bump. run 33553290345. (task 1002620)
|
|
1346
1346
|
1.19.454 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1347
1347
|
landed since 1.19.453 with no explicit bump. run 33554965618. (task 1002620)
|
|
1348
|
+
1.19.455 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1349
|
+
landed since 1.19.454 with no explicit bump. run 33558738299. (task 1002620)
|
|
1348
1350
|
---------------------------------------------------------------------------
|
|
1349
1351
|
```
|
package/modules/discord/db.js
CHANGED
|
@@ -143,9 +143,6 @@ module.exports = {
|
|
|
143
143
|
markBoardMessageEdited,
|
|
144
144
|
// kernel db helpers, re-exported off the doorway (one `db.*` namespace)
|
|
145
145
|
insertAuditLog: (...a) => api.insertAuditLog(...a),
|
|
146
|
-
// createTask is a LIFECYCLE write — resolve the `lifecycle` kernel port at call
|
|
147
|
-
// time (the doorway leak retired at BV1.R86); used by discord-bugs (#bugs → task).
|
|
148
|
-
createTask: (...a) => api.resolveOptional('lifecycle').createTask(...a),
|
|
149
146
|
getBuilderById: (...a) => api.getBuilderById(...a),
|
|
150
147
|
get LIVE_RANK_LADDER() { return api.LIVE_RANK_LADDER; },
|
|
151
148
|
// the shared pool, for any module file that wants it via db.pool
|
|
@@ -348,7 +348,14 @@ async function start() {
|
|
|
348
348
|
// real filing path (recordInboundBug). Built once at startup.
|
|
349
349
|
const bugAggregator = bugs.createBugAggregator({
|
|
350
350
|
onFlush: (combined) => bugs.recordInboundBug(combined).then((result) => {
|
|
351
|
-
|
|
351
|
+
// task 1003067: a filing now always yields an IDEA; whether it also became
|
|
352
|
+
// a task depends on the landing matrix. Log both, so a homeless report is
|
|
353
|
+
// traceable instead of showing as a bare "filed" with no id to chase.
|
|
354
|
+
log.info(`[discord-bot] #bugs burst → ${result.outcome}${
|
|
355
|
+
result.ideaId
|
|
356
|
+
? ` (idea ${result.ideaId}${result.taskId ? `, task ${result.taskId}, ${result.status}` : ', inbox'}, ${result.images || 0} img)`
|
|
357
|
+
: (result.reason ? ` (${result.reason})` : '')
|
|
358
|
+
}`);
|
|
352
359
|
return result;
|
|
353
360
|
}),
|
|
354
361
|
});
|
|
@@ -1,28 +1,45 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Discord #bugs →
|
|
3
|
+
// Discord #bugs → a routed filing — Phase 3 F8 (ADR 0033 §F8, ADR 0047),
|
|
4
|
+
// retired onto the capture-time routing path by task 1003067 (BV1.R11).
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// is the deliberate difference from #ideas (discord-inbound.js): a bug is a
|
|
8
|
-
// small fix a builder wants to drop without context-switching, and it becomes
|
|
9
|
-
// claimable work directly.
|
|
6
|
+
// WHAT CHANGED AND WHY. This path used to call createTask DIRECTLY, against a
|
|
7
|
+
// hardcoded version id. Two things followed from that, and both were bugs:
|
|
10
8
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
9
|
+
// * The hardcoded id was GDS-V3, a SHIPPED version. Every bug reported through
|
|
10
|
+
// Discord resolved into that version's catch-all goal — a bucket nobody ever
|
|
11
|
+
// opens again. That is idea 1000771, the report goal 1000071 was filed over.
|
|
12
|
+
// * Creating the task directly meant NO idea row and NO lineage, so the person
|
|
13
|
+
// who reported the bug vanished from the record the moment it became work.
|
|
14
|
+
// The ideator credit lane (goal 1000067) pays against that lineage, so this
|
|
15
|
+
// vector was quietly minting unattributable work.
|
|
16
|
+
//
|
|
17
|
+
// Now the message becomes a CAPTURE (ideas.capture, the same port #ideas uses)
|
|
18
|
+
// with a suggested goal, and the ideas module's landing matrix decides the rest.
|
|
19
|
+
// The version is no longer named here at all: the goal carries it, and lifecycle
|
|
20
|
+
// resolves which version is open to work (suggestGoalForCapture). A vector that
|
|
21
|
+
// does not know a version id cannot hardcode a dead one.
|
|
22
|
+
//
|
|
23
|
+
// ANTI-ABUSE GUARD (ADR 0047 — the red-team resolution), as it stands now:
|
|
24
|
+
// 1. AN UNLINKED AUTHOR CANNOT CREATE WORK. The original lever was
|
|
25
|
+
// linked-builder-ONLY: an unlinked message was dropped entirely, because
|
|
26
|
+
// this path minted claimable work with no triage gate. It no longer mints
|
|
27
|
+
// anything — an unlinked author's report becomes an idea_inbox row with
|
|
28
|
+
// captured_by NULL and NO goal, which is precisely what #ideas has always
|
|
29
|
+
// done for the same person. The security property ADR 0047 actually bought
|
|
30
|
+
// ("an unlinked author cannot inject claimable work") is intact and now
|
|
31
|
+
// structural; what is gone is the silent drop of a real bug report, which
|
|
32
|
+
// only ever protected us from reading it.
|
|
33
|
+
// 2. RANK-AWARE LANDING, now the ideas landing matrix rather than a local
|
|
34
|
+
// copy of it: a linked Metic+ builder's bug lands 'ready', a linked
|
|
35
|
+
// sub-Metic builder's lands 'backlog' awaiting a nod. Same outcomes as
|
|
36
|
+
// before, one definition instead of two (pinned in
|
|
37
|
+
// tests/filing_abuse_matrix.mjs).
|
|
38
|
+
// 3. PER-BUILDER RATE-LIMIT. At most N filings per LINKED builder per rolling
|
|
39
|
+
// hour (counted from audit_log). Over-limit filings are ignored AND audited
|
|
40
|
+
// so a spammer surfaces in forensics / the red-team view. An unlinked author
|
|
41
|
+
// has no builder id to count against, so they are held to the #ideas posture
|
|
42
|
+
// — an inbox row, never a task — rather than a cap this table cannot express.
|
|
26
43
|
// 4. BODY SCREEN. Empty / too-short / text-free messages are ignored before
|
|
27
44
|
// any DB write; title + body are capped to the GDS limits.
|
|
28
45
|
//
|
|
@@ -40,11 +57,15 @@ const { auditDiscordBot } = require('./discord-audit');
|
|
|
40
57
|
const auth = require('./auth');
|
|
41
58
|
const attachments = require('./bug-attachments');
|
|
42
59
|
const { combineParts, createMessageAggregator } = require('./discord-message-buffer');
|
|
43
|
-
const
|
|
60
|
+
const api = require('../../src/module-api');
|
|
61
|
+
const { LIMITS } = api;
|
|
62
|
+
const log = api.logger('discord');
|
|
44
63
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
64
|
+
// NO LOCAL RANK RULE LIVES HERE (task 1003067). READY_RANKS + decideBugLanding
|
|
65
|
+
// used to decide ready-vs-backlog for this channel alone. The ideas landing matrix
|
|
66
|
+
// (modules/ideas/routing.js) now decides it for EVERY filing vector, so a second
|
|
67
|
+
// copy here could only ever drift from it — which is what
|
|
68
|
+
// tests/filing_abuse_matrix.mjs exists to prevent.
|
|
48
69
|
|
|
49
70
|
// Smallest message we'll treat as a real bug report. Anything shorter is noise.
|
|
50
71
|
const MIN_BUG_CHARS = 8;
|
|
@@ -53,13 +74,11 @@ const MIN_BUG_CHARS = 8;
|
|
|
53
74
|
// DISCORD_BUGS_RATE_PER_HOUR overrides (a number); falls back to this.
|
|
54
75
|
const DEFAULT_RATE_PER_HOUR = 5;
|
|
55
76
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Modest reward for fixing a drive-by bug. Env DISCORD_BUGS_CREDITS overrides.
|
|
62
|
-
const DEFAULT_BUGS_CREDITS = 10;
|
|
77
|
+
// NO DEFAULT VERSION LIVES HERE ANY MORE (task 1003067). DEFAULT_BUGS_VERSION_ID
|
|
78
|
+
// used to be 'GDS-V3' and DISCORD_BUGS_CREDITS set a per-task reward. The version
|
|
79
|
+
// is now carried by the goal the filing routes into — a vector that cannot name a
|
|
80
|
+
// version cannot name a dead one, which is idea 1000771 in one sentence — and the
|
|
81
|
+
// reward auto-assigns at the lowest create layer like every other vector (ADR 0096).
|
|
63
82
|
|
|
64
83
|
// Auto-pairing window: consecutive #bugs messages from the SAME author within
|
|
65
84
|
// this window are combined into ONE bug-task (so "screenshot" + "here's what
|
|
@@ -75,27 +94,11 @@ function rateLimitPerHour(env = process.env) {
|
|
|
75
94
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_RATE_PER_HOUR;
|
|
76
95
|
}
|
|
77
96
|
|
|
78
|
-
function bugsVersionId(env = process.env) {
|
|
79
|
-
return env.DISCORD_BUGS_VERSION_ID || DEFAULT_BUGS_VERSION_ID;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function bugsCredits(env = process.env) {
|
|
83
|
-
const n = Number(env.DISCORD_BUGS_CREDITS);
|
|
84
|
-
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_BUGS_CREDITS;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
97
|
function pairWindowMs(env = process.env) {
|
|
88
98
|
const n = Number(env.DISCORD_BUGS_PAIR_WINDOW_MS);
|
|
89
99
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_PAIR_WINDOW_MS;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
// PURE — decide where a bug lands from the reporter's LIVE rank. Metic+ →
|
|
93
|
-
// 'ready' (straight-through, claimable now); any other linked rank → 'backlog'
|
|
94
|
-
// (a Metic+ promotes it, the lightweight confirm gate). Unit-tested.
|
|
95
|
-
function decideBugLanding(rank) {
|
|
96
|
-
return READY_RANKS.has(String(rank || '').toLowerCase()) ? 'ready' : 'backlog';
|
|
97
|
-
}
|
|
98
|
-
|
|
99
102
|
// PURE — screen + shape a raw message into { ok, title, bodyMd } or
|
|
100
103
|
// { ok:false, reason }. The body screen rejects blank / too-short / text-free
|
|
101
104
|
// (no letters) messages before any DB write — UNLESS the message carries
|
|
@@ -127,7 +130,18 @@ function screenBug(content, { hasImages = false } = {}) {
|
|
|
127
130
|
const realDeps = {
|
|
128
131
|
getBuilderByDiscordUserId: db.getBuilderByDiscordUserId,
|
|
129
132
|
countRecentDiscordBugFilings: db.countRecentDiscordBugFilings,
|
|
130
|
-
|
|
133
|
+
// The two kernel ports this vector files through (never an import — the one-way
|
|
134
|
+
// module rule, ADR 0083/0093). `ideas.capture` is the same door #ideas uses;
|
|
135
|
+
// `lifecycle` answers which goal, on which still-open version.
|
|
136
|
+
captureIdea: (...a) => api.resolve('ideas.capture')(...a),
|
|
137
|
+
// resolveOptional, not resolve: an instance running without the lifecycle module
|
|
138
|
+
// has no goals to suggest, and that is a reason to file the report WITHOUT a
|
|
139
|
+
// home — never a reason to lose it. Returns null, which lands it in the inbox.
|
|
140
|
+
suggestGoalForCapture: async (...a) => {
|
|
141
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
142
|
+
if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
|
|
143
|
+
return lifecycle.suggestGoalForCapture(...a);
|
|
144
|
+
},
|
|
131
145
|
insertAuditLog: db.insertAuditLog,
|
|
132
146
|
persistImages: attachments.persistImages,
|
|
133
147
|
};
|
|
@@ -144,14 +158,13 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
|
|
|
144
158
|
if (!screened.ok) return { outcome: 'ignored', reason: screened.reason };
|
|
145
159
|
|
|
146
160
|
const limit = config.rateLimitPerHour ?? rateLimitPerHour();
|
|
147
|
-
const versionId = config.versionId ?? bugsVersionId();
|
|
148
|
-
const credits = config.credits ?? bugsCredits();
|
|
149
161
|
|
|
150
162
|
try {
|
|
151
|
-
// (1)
|
|
152
|
-
// unlinked author
|
|
163
|
+
// (1) WHO POSTED. A linked builder files with their rank and is rate-limited;
|
|
164
|
+
// an unlinked author still gets their report recorded, but only ever as an
|
|
165
|
+
// inbox row (no goal, no rank ⇒ the matrix cannot route it into work). See
|
|
166
|
+
// the guard note in the header for why this replaced the outright drop.
|
|
153
167
|
const builder = await deps.getBuilderByDiscordUserId(discordUserId);
|
|
154
|
-
if (!builder) return { outcome: 'ignored', reason: 'unlinked' };
|
|
155
168
|
|
|
156
169
|
// (3) PER-BUILDER RATE-LIMIT. Count this builder's bug filings in the last
|
|
157
170
|
// hour; over the cap → reject AND audit (so a spammer is visible in
|
|
@@ -160,7 +173,7 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
|
|
|
160
173
|
let recent = 0;
|
|
161
174
|
let countFailed = false;
|
|
162
175
|
try {
|
|
163
|
-
recent = await deps.countRecentDiscordBugFilings(builder.id, sinceIso);
|
|
176
|
+
if (builder) recent = await deps.countRecentDiscordBugFilings(builder.id, sinceIso);
|
|
164
177
|
} catch (e) {
|
|
165
178
|
// L4 (red-team 2026-06-12): FAIL CLOSED on a count error. The old behaviour
|
|
166
179
|
// proceeded ("fail-open"), which let a DB error be used to slip past the
|
|
@@ -170,7 +183,7 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
|
|
|
170
183
|
console.error('[discord-bot] bug rate-limit count failed (denying — fail-closed):', e.message);
|
|
171
184
|
countFailed = true;
|
|
172
185
|
}
|
|
173
|
-
if (countFailed || recent >= limit) {
|
|
186
|
+
if (builder && (countFailed || recent >= limit)) {
|
|
174
187
|
const reason = countFailed ? 'rate_limit_count_error' : 'rate_limited';
|
|
175
188
|
await auditDiscordBot(deps, {
|
|
176
189
|
builderId: builder.id,
|
|
@@ -182,8 +195,11 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
|
|
|
182
195
|
return { outcome: 'ignored', reason, recent };
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
// (2) RANK-AWARE LANDING
|
|
186
|
-
|
|
198
|
+
// (2) RANK-AWARE LANDING is no longer decided here. The rank still comes from
|
|
199
|
+
// the LIVE builder row and never from the message — it is simply handed to the
|
|
200
|
+
// ideas landing matrix, which owns the ready-vs-backlog split for every filing
|
|
201
|
+
// vector. One definition, so this channel cannot drift from the others.
|
|
202
|
+
const filerRank = builder ? builder.rank : null;
|
|
187
203
|
|
|
188
204
|
// Re-host any images on our server (durable — Discord CDN links expire). The
|
|
189
205
|
// guard (host allowlist + content-type + magic-byte sniff + size/count caps)
|
|
@@ -201,7 +217,9 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
|
|
|
201
217
|
// Provenance footer so a claimer knows where the bug came from + who to ask.
|
|
202
218
|
// The raw Discord snowflake is NOT embedded (it lives in the Archon-only
|
|
203
219
|
// audit_log); only the builder login. Then the durable attachment links.
|
|
204
|
-
const origin =
|
|
220
|
+
const origin = builder
|
|
221
|
+
? `\n\n— reported from Discord #bugs by ${builder.github_login}`
|
|
222
|
+
: '\n\n— reported from Discord #bugs (unlinked author)';
|
|
205
223
|
let description = `${screened.bodyMd}${origin}`;
|
|
206
224
|
if (stored.length > 0) {
|
|
207
225
|
const imgs = stored.map((s, i) => ``).join('\n');
|
|
@@ -209,27 +227,71 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
|
|
|
209
227
|
}
|
|
210
228
|
description = description.slice(0, LIMITS.DESCRIPTION);
|
|
211
229
|
|
|
212
|
-
|
|
213
|
-
|
|
230
|
+
// THE HOME. Only a linked builder's report is offered one: an unlinked author
|
|
231
|
+
// stays strictly less privileged than any signed-in builder (the #ideas rule),
|
|
232
|
+
// and with no goal the matrix files them to the inbox, which is the point.
|
|
233
|
+
// A null suggestion is the ordinary case and lands in the inbox too.
|
|
234
|
+
let goal = null;
|
|
235
|
+
if (builder) {
|
|
236
|
+
goal = await deps.suggestGoalForCapture({ title: screened.title, body: screened.bodyMd });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The capture. One transaction inside the ideas module writes the idea row,
|
|
240
|
+
// the task (when the matrix routes it) and the lineage that keeps the reporter
|
|
241
|
+
// attached to the work — the whole reason this vector stopped calling
|
|
242
|
+
// createTask. kind is forced to 'bug' because the channel IS the classification.
|
|
243
|
+
const capture = {
|
|
214
244
|
title: screened.title,
|
|
215
|
-
description,
|
|
216
|
-
status,
|
|
245
|
+
bodyMd: description,
|
|
217
246
|
kind: 'bug',
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
247
|
+
capturedBy: builder ? builder.id : null,
|
|
248
|
+
filerRank,
|
|
249
|
+
goalId: goal ? goal.id : null,
|
|
250
|
+
suggestedPriority: 3,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// THE HOME IS A SUGGESTION, NOT A CONDITION OF BEING HEARD. routeIdeaToGoal is
|
|
254
|
+
// ONE transaction, so a goal that refuses the landing — protected scope, a
|
|
255
|
+
// scope-wall verdict, a goal archived mid-flight — rolls the idea row back too
|
|
256
|
+
// and the report is gone. That is the exact failure this task exists to end, and
|
|
257
|
+
// it would be caused by a goal WE guessed at, not by anything the reporter did.
|
|
258
|
+
// So a refusal drops the guess and files the report homeless: it lands in the
|
|
259
|
+
// inbox for triage, which is strictly what would have happened before we tried.
|
|
260
|
+
let idea;
|
|
261
|
+
try {
|
|
262
|
+
idea = await deps.captureIdea(capture);
|
|
263
|
+
} catch (routeErr) {
|
|
264
|
+
if (!capture.goalId) throw routeErr; // nothing to drop — a real failure
|
|
265
|
+
log.warn({ err: routeErr && (routeErr.code || routeErr.message), goalId: capture.goalId },
|
|
266
|
+
'routed capture refused — filing the report to the inbox instead');
|
|
267
|
+
idea = await deps.captureIdea({ ...capture, goalId: null });
|
|
268
|
+
}
|
|
223
269
|
|
|
270
|
+
const routed = idea && idea.routing && idea.routing.routed ? idea.routing : null;
|
|
224
271
|
await auditDiscordBot(deps, {
|
|
225
|
-
builderId: builder.id,
|
|
272
|
+
builderId: builder ? builder.id : null,
|
|
226
273
|
route: 'discord:bugs',
|
|
227
|
-
requestBodyRedacted: {
|
|
274
|
+
requestBodyRedacted: {
|
|
275
|
+
discord_user_id: discordUserId,
|
|
276
|
+
message_id: messageId,
|
|
277
|
+
idea_id: idea.id,
|
|
278
|
+
task_id: routed ? routed.task_id : null,
|
|
279
|
+
goal_id: routed ? routed.goal_id : null,
|
|
280
|
+
status: routed ? routed.status : null,
|
|
281
|
+
images: stored.length,
|
|
282
|
+
},
|
|
228
283
|
responseStatus: 201,
|
|
229
284
|
logLabel: 'bug audit-log write failed',
|
|
230
285
|
});
|
|
231
286
|
|
|
232
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
outcome: 'filed',
|
|
289
|
+
ideaId: idea.id,
|
|
290
|
+
taskId: routed ? routed.task_id : null,
|
|
291
|
+
status: routed ? routed.status : null,
|
|
292
|
+
builderId: builder ? builder.id : null,
|
|
293
|
+
images: stored.length,
|
|
294
|
+
};
|
|
233
295
|
} catch (err) {
|
|
234
296
|
console.error('[discord-bot] recordInboundBug failed:', err.message);
|
|
235
297
|
return { outcome: 'error', error: err.message };
|
|
@@ -245,13 +307,10 @@ function createBugAggregator({ onFlush, windowMs = pairWindowMs(), timers = glob
|
|
|
245
307
|
|
|
246
308
|
module.exports = {
|
|
247
309
|
screenBug,
|
|
248
|
-
decideBugLanding,
|
|
249
310
|
recordInboundBug,
|
|
250
311
|
combineParts,
|
|
251
312
|
createBugAggregator,
|
|
252
313
|
realDeps,
|
|
253
314
|
rateLimitPerHour,
|
|
254
|
-
bugsVersionId,
|
|
255
|
-
bugsCredits,
|
|
256
315
|
pairWindowMs,
|
|
257
316
|
};
|
|
@@ -26,10 +26,14 @@
|
|
|
26
26
|
// really does belong with a goal scoped to `hall-ui`.
|
|
27
27
|
|
|
28
28
|
const { tokenize } = require('./criterion-suggest.js');
|
|
29
|
-
const {
|
|
29
|
+
const { versionAcceptsNewWork } = require('./db-tasks.js');
|
|
30
|
+
const { candidateGoals, generalGoalTitle } = require('./goal-advisory.js');
|
|
30
31
|
const api = require('../../src/module-api');
|
|
31
32
|
// Through the published logger seam, not a raw console call (est-advisory.js precedent).
|
|
32
33
|
const proseErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/suggestions failed');
|
|
34
|
+
// The capture-time twin has its own message: it is not an HTTP route, and a log
|
|
35
|
+
// line that names the wrong caller is a wrong turn taken during an incident.
|
|
36
|
+
const captureErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'suggestGoalForCapture failed — filing without a goal');
|
|
33
37
|
|
|
34
38
|
// Matches suggestCriteria's default so the two suggesters feel the same.
|
|
35
39
|
const SUGGEST_LIMIT = 5;
|
|
@@ -82,6 +86,70 @@ function suggestGoals(goals, draft = {}, opts = {}) {
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
|
|
89
|
+
// suggestGoalForCapture — ONE goal for a filing that arrived with no goal named
|
|
90
|
+
// (task 1003067 / BV1.R11). The HTTP suggester above hands a human five options to
|
|
91
|
+
// choose from; a Discord message has nobody to choose, so this collapses the same
|
|
92
|
+
// ranking to a single confident answer or none at all.
|
|
93
|
+
//
|
|
94
|
+
// WHY IT RESOLVES THE VERSION ITSELF. The vector this was built for — Discord
|
|
95
|
+
// #bugs — used to carry a hardcoded version id, and because that id was a SHIPPED
|
|
96
|
+
// version every bug reported through it landed in a tomb nobody reads (idea
|
|
97
|
+
// 1000771, the failure goal 1000071 exists to fix). A filing vector should not be
|
|
98
|
+
// in the business of naming a version at all, so it does not: the GOAL carries the
|
|
99
|
+
// version, and this picks the goal.
|
|
100
|
+
//
|
|
101
|
+
// It therefore ranks across EVERY version still open to work (versionAcceptsNewWork,
|
|
102
|
+
// task 1003061), not one. An earlier cut of this declined whenever more than one
|
|
103
|
+
// version was open — prudent-looking, and a dead feature: this instance runs two
|
|
104
|
+
// building versions concurrently, so it would have returned null for every report
|
|
105
|
+
// ever filed. "Which version" is not a question worth asking when the answer is
|
|
106
|
+
// already attached to the goal that won.
|
|
107
|
+
//
|
|
108
|
+
// Catch-all goals are excluded for every one of those versions. candidateGoals
|
|
109
|
+
// drops the catch-all for ONE version id, which is the wrong shape here, so the
|
|
110
|
+
// filtering happens up front — a "<version> — general" bucket is never a home
|
|
111
|
+
// (that is R03's fallback question, a different one).
|
|
112
|
+
//
|
|
113
|
+
// Returns { id, title, version_id, score } or NULL. Null is the ordinary case and
|
|
114
|
+
// a good one: no goal means the filing lands in the inbox, exactly as every
|
|
115
|
+
// goal-unaware capture always has. Never throws — a suggester that takes down a
|
|
116
|
+
// bug report is worse than one that shrugs.
|
|
117
|
+
async function suggestGoalForCapture({ title = '', body = '' } = {}, deps = {}) {
|
|
118
|
+
const db = deps.db || require('./db.js');
|
|
119
|
+
try {
|
|
120
|
+
if (!String(title || '').trim() && !String(body || '').trim()) return null;
|
|
121
|
+
|
|
122
|
+
// Independent reads — issued together rather than in sequence.
|
|
123
|
+
const [versions, allGoals] = await Promise.all([db.listVersions(), db.listGoals({ status: 'open' })]);
|
|
124
|
+
const openIds = new Set(
|
|
125
|
+
(versions || []).filter((v) => versionAcceptsNewWork(v && v.status)).map((v) => String(v.id))
|
|
126
|
+
);
|
|
127
|
+
// Nothing is being built ⇒ nowhere for new work to go ⇒ the inbox.
|
|
128
|
+
if (openIds.size === 0) return null;
|
|
129
|
+
|
|
130
|
+
const goals = allGoals || [];
|
|
131
|
+
const homes = goals.filter((g) => g
|
|
132
|
+
&& openIds.has(String(g.version_id))
|
|
133
|
+
&& g.title !== generalGoalTitle(g.version_id));
|
|
134
|
+
if (homes.length === 0) return null;
|
|
135
|
+
|
|
136
|
+
// versionId is passed null: the catch-alls are already gone, and passing one
|
|
137
|
+
// version's id here would only re-filter for that version. includeZero stays
|
|
138
|
+
// false, so a filing that matches nothing gets NO goal rather than the
|
|
139
|
+
// first one dressed up as a suggestion.
|
|
140
|
+
const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1 });
|
|
141
|
+
if (!best) return null;
|
|
142
|
+
|
|
143
|
+
// suggestGoals normalises to { id, title, scope_modules, … } and drops
|
|
144
|
+
// version_id, so read it back off the row that won.
|
|
145
|
+
const row = homes.find((g) => String(g.id) === String(best.id));
|
|
146
|
+
return { id: best.id, title: best.title, version_id: row ? row.version_id : null, score: best.score };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
captureErr(err);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
85
153
|
// goalSuggestHandler — GET /goals/suggestions. Handler lives here, not inline in
|
|
86
154
|
// routes/goals.js, because that file is over the fitness oversize threshold and
|
|
87
155
|
// the ratchet is shrink-only; collaborators are injected so this module still
|
|
@@ -168,4 +236,4 @@ function criteriaSuggestHandler({ db, suggestCriteria, parseId }) {
|
|
|
168
236
|
};
|
|
169
237
|
}
|
|
170
238
|
|
|
171
|
-
module.exports = { suggestGoals, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
|
|
239
|
+
module.exports = { suggestGoals, suggestGoalForCapture, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const db = require('./db');
|
|
20
20
|
const classifier = require('./task-classifier');
|
|
21
|
+
const goalSuggest = require('./goal-suggest');
|
|
21
22
|
const helpRequests = require('./help-requests');
|
|
22
23
|
const taskRecommendations = require('./task-recommendations');
|
|
23
24
|
const activity = require('./activity');
|
|
@@ -35,6 +36,12 @@ module.exports = {
|
|
|
35
36
|
// (consumer: core cascade-dispatch.js, the dormant System-3 lib).
|
|
36
37
|
get VOTABLE_TASK_STATUSES() { return classifier.VOTABLE_TASK_STATUSES; },
|
|
37
38
|
classifyTaskKind: (...a) => classifier.classifyTaskKind(...a),
|
|
39
|
+
// suggestGoalForCapture: ONE goal for a filing that named none, or null (task
|
|
40
|
+
// 1003067). Consumer: the discord #bugs vector, which files through the ideas
|
|
41
|
+
// routing path and needs a home to offer it. Lives behind the port because
|
|
42
|
+
// "which goal, on which version" is a lifecycle judgement and discord may not
|
|
43
|
+
// import lifecycle (the one-way module rule).
|
|
44
|
+
suggestGoalForCapture: (...a) => goalSuggest.suggestGoalForCapture(...a),
|
|
38
45
|
|
|
39
46
|
// --- work-tracking READS the staying-core read routes resolve --------------
|
|
40
47
|
// public.js (the status surface): version/ship feed + grade trend.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.455",
|
|
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.455",
|
|
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.455",
|
|
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.455'; // 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/discord_bugs.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Unit tests for Discord #bugs → GDS tasks (kind='bug') (Phase 3 F8, ADR 0047).
|
|
4
4
|
// Pure screening/landing + orchestration via injected fakes — no Discord, no DB:
|
|
5
5
|
// - screenBug: message → { ok, title, bodyMd }; blank/short/text-free rejected.
|
|
6
|
-
// -
|
|
6
|
+
// - the landing: Metic+ → 'ready'; everyone else linked → 'backlog'. Decided by
|
|
7
|
+
// the ideas landing matrix since task 1003067, not by this module.
|
|
7
8
|
// - recordInboundBug: files via createTask under the anti-abuse guard —
|
|
8
9
|
// linked-only (unlinked rejected), rank-aware landing, per-builder
|
|
9
10
|
// rate-limit, body screen; audits surface='discord_bot'; never throws.
|
|
@@ -23,7 +24,9 @@ function t(name, fn) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const GOOD_ID = '123456789012345678';
|
|
26
|
-
|
|
27
|
+
// task 1003067: versionId and credits are gone from this vector's config — the
|
|
28
|
+
// goal carries the version and the reward auto-assigns at the create layer.
|
|
29
|
+
const CFG = { rateLimitPerHour: 5 };
|
|
27
30
|
|
|
28
31
|
console.log('Discord #bugs → GDS tasks (Phase 3 F8):');
|
|
29
32
|
|
|
@@ -51,21 +54,16 @@ await t('screenBug: long first line is capped (≤200, ellipsis)', () => {
|
|
|
51
54
|
assert.ok(s.title.endsWith('…'));
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
// ---- decideBugLanding ------------------------------------------------------
|
|
55
|
-
await t('decideBugLanding: Metic+ → ready', () => {
|
|
56
|
-
assert.equal(bg.decideBugLanding('metic'), 'ready');
|
|
57
|
-
assert.equal(bg.decideBugLanding('archon'), 'ready');
|
|
58
|
-
assert.equal(bg.decideBugLanding('ARCHON'), 'ready');
|
|
59
|
-
});
|
|
60
|
-
await t('decideBugLanding: Xenos/Thetes/unknown → backlog', () => {
|
|
61
|
-
for (const r of ['xenos', 'thetes', 'periokoi', '', null, undefined]) {
|
|
62
|
-
assert.equal(bg.decideBugLanding(r), 'backlog');
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
57
|
|
|
66
58
|
// ---- recordInboundBug (fakes) ---------------------------------------------
|
|
59
|
+
// The fake capture mirrors the real landing rule rather than hardcoding a status,
|
|
60
|
+
// so a matrix change shows up here as a failure instead of a stale fixture.
|
|
61
|
+
// task 1003067: the fake defers to the REAL landing matrix, so this fixture can
|
|
62
|
+
// never quietly disagree with the rule the server actually applies.
|
|
63
|
+
const { decideLanding } = require('../modules/ideas/routing.js');
|
|
64
|
+
const READY_OR_BACKLOG = (rank) => (rank == null ? null : decideLanding({ goalId: 7, kind: 'bug', filerRank: rank }).status);
|
|
67
65
|
function makeDeps(over = {}) {
|
|
68
|
-
const calls = { create: [], audit: [], count: [], persist: [] };
|
|
66
|
+
const calls = { create: [], audit: [], count: [], persist: [], suggest: [] };
|
|
69
67
|
const deps = {
|
|
70
68
|
getBuilderByDiscordUserId: async () => (over.builder === undefined ? null : over.builder),
|
|
71
69
|
countRecentDiscordBugFilings: async (id, since) => {
|
|
@@ -73,7 +71,29 @@ function makeDeps(over = {}) {
|
|
|
73
71
|
if (over.countThrows) throw new Error('count boom');
|
|
74
72
|
return over.recent ?? 0;
|
|
75
73
|
},
|
|
76
|
-
|
|
74
|
+
// task 1003067: the vector files through ideas.capture now. `calls.create`
|
|
75
|
+
// keeps its name — it is still "what this vector asked to be created" — but
|
|
76
|
+
// the argument shape is a CAPTURE, and the landing status comes back from the
|
|
77
|
+
// matrix rather than being posted in.
|
|
78
|
+
suggestGoalForCapture: async (draft) => {
|
|
79
|
+
calls.suggest.push(draft);
|
|
80
|
+
if (over.suggestThrows) throw new Error('suggest boom');
|
|
81
|
+
return over.goal === undefined ? { id: 7, title: 'A goal', version_id: 'BONGOS-V1', score: 4 } : over.goal;
|
|
82
|
+
},
|
|
83
|
+
captureIdea: async (a) => {
|
|
84
|
+
calls.create.push(a);
|
|
85
|
+
if (over.createThrows) throw new Error('db boom');
|
|
86
|
+
// routeIdeaToGoal refuses a landing by THROWING, which rolls the idea row
|
|
87
|
+
// back with it — the case the goal-dropping retry exists for.
|
|
88
|
+
if (over.refuseRouted && a.goalId != null) throw { code: 'ROUTE_NOT_AUTHORIZED', gate: 'protected_scope' };
|
|
89
|
+
const routes = a.goalId != null && READY_OR_BACKLOG(a.filerRank) !== null;
|
|
90
|
+
return {
|
|
91
|
+
id: over.ideaId ?? 900,
|
|
92
|
+
routing: routes
|
|
93
|
+
? { routed: true, task_id: over.taskId ?? 555, goal_id: a.goalId, status: READY_OR_BACKLOG(a.filerRank) }
|
|
94
|
+
: { routed: false, reason: 'no_goal' },
|
|
95
|
+
};
|
|
96
|
+
},
|
|
77
97
|
insertAuditLog: async (a) => { calls.audit.push(a); if (over.auditThrows) throw new Error('audit boom'); return { id: 1 }; },
|
|
78
98
|
persistImages: async (urls, opts) => {
|
|
79
99
|
calls.persist.push({ urls, opts });
|
|
@@ -90,13 +110,16 @@ await t('recordInboundBug: linked Metic → filed at ready, kind=bug, attributed
|
|
|
90
110
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'Boats vanish at the dock', messageId: 'm1' }, deps, CFG);
|
|
91
111
|
assert.equal(r.outcome, 'filed');
|
|
92
112
|
assert.equal(r.taskId, 555);
|
|
113
|
+
assert.equal(r.ideaId, 900, 'the idea row is the attribution the old path threw away');
|
|
93
114
|
assert.equal(r.status, 'ready');
|
|
94
|
-
assert.equal(calls.create[0].kind, 'bug');
|
|
95
|
-
assert.equal(calls.create[0].
|
|
96
|
-
assert.equal(calls.create[0].
|
|
97
|
-
assert.equal(calls.create[0].
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
assert.equal(calls.create[0].kind, 'bug', 'the channel IS the classification');
|
|
116
|
+
assert.equal(calls.create[0].filerRank, 'metic', 'the LIVE rank is handed to the matrix');
|
|
117
|
+
assert.equal(calls.create[0].capturedBy, 9, 'and the reporter is attached to the capture');
|
|
118
|
+
assert.equal(calls.create[0].goalId, 7, 'the suggester found it a home');
|
|
119
|
+
// task 1003067: no versionId is passed AT ALL. It used to be the hardcoded
|
|
120
|
+
// 'GDS-V3' — a shipped version, which is idea 1000771. The goal carries it now.
|
|
121
|
+
assert.equal(calls.create[0].versionId, undefined, 'this vector no longer names a version');
|
|
122
|
+
assert.match(calls.create[0].bodyMd, /reported from Discord #bugs by mona/);
|
|
100
123
|
assert.equal(calls.audit[0].surface, 'discord_bot');
|
|
101
124
|
assert.equal(calls.audit[0].responseStatus, 201);
|
|
102
125
|
assert.equal(calls.audit[0].builderId, 9);
|
|
@@ -106,18 +129,74 @@ await t('recordInboundBug: linked Xenos → filed at backlog (needs confirm)', a
|
|
|
106
129
|
const { deps, calls } = makeDeps({ builder: { id: 4, github_login: 'newbie', rank: 'xenos' } });
|
|
107
130
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'Typo on the welcome sign', messageId: 'm2' }, deps, CFG);
|
|
108
131
|
assert.equal(r.outcome, 'filed');
|
|
109
|
-
assert.equal(r.status, 'backlog');
|
|
110
|
-
assert.equal(calls.create[0].
|
|
132
|
+
assert.equal(r.status, 'backlog', 'a sub-Metic report waits for a nod, as before');
|
|
133
|
+
assert.equal(calls.create[0].filerRank, 'xenos');
|
|
111
134
|
});
|
|
112
135
|
|
|
113
|
-
await t('recordInboundBug: UNLINKED author →
|
|
136
|
+
await t('recordInboundBug: UNLINKED author → an inbox idea, never a task [changed by 1003067]', async () => {
|
|
137
|
+
// WAS: dropped entirely ('unlinked'), because this path minted claimable work
|
|
138
|
+
// and ADR 0047 would not let an unaccountable author near that. It no longer
|
|
139
|
+
// mints work, so the report is kept — as an idea row with no author and NO
|
|
140
|
+
// GOAL, which is exactly what #ideas has always done for the same person.
|
|
141
|
+
// The security property survives structurally: no goal ⇒ the matrix cannot
|
|
142
|
+
// route ⇒ no task exists to claim.
|
|
114
143
|
const { deps, calls } = makeDeps({ builder: null });
|
|
115
144
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'something is broken here', messageId: 'm3' }, deps, CFG);
|
|
116
|
-
assert.equal(r.outcome, '
|
|
117
|
-
assert.equal(r.
|
|
145
|
+
assert.equal(r.outcome, 'filed');
|
|
146
|
+
assert.equal(r.ideaId, 900, 'the report is recorded rather than silently dropped');
|
|
147
|
+
assert.equal(r.taskId, null, 'and it is NOT work');
|
|
148
|
+
assert.equal(r.status, null);
|
|
149
|
+
assert.equal(calls.create[0].capturedBy, null, 'unattributed, and honestly so');
|
|
150
|
+
assert.equal(calls.create[0].goalId, null, 'no home is offered to an unlinked author');
|
|
151
|
+
assert.equal(calls.suggest.length, 0, 'we do not even ask for one');
|
|
152
|
+
assert.match(calls.create[0].bodyMd, /unlinked author/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('recordInboundBug: an unlinked author is NOT rate-limited by builder id (there is none)', async () => {
|
|
156
|
+
const { deps, calls } = makeDeps({ builder: null, recent: 999 });
|
|
157
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'another unlinked report', messageId: 'm3b' }, deps, CFG);
|
|
158
|
+
assert.equal(r.outcome, 'filed', 'the per-builder cap cannot express an author with no builder row');
|
|
159
|
+
assert.equal(calls.count.length, 0, 'so it is not consulted');
|
|
160
|
+
assert.equal(r.taskId, null, 'the bound that matters is that none of it becomes work');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('recordInboundBug: a suggester failure still files — to the inbox [1003067]', async () => {
|
|
164
|
+
// A suggester that throws must not lose the bug report. No goal ⇒ inbox row.
|
|
165
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, suggestThrows: true });
|
|
166
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'the suggester is down', messageId: 'm3c' }, deps, CFG);
|
|
167
|
+
assert.equal(r.outcome, 'error', 'it surfaces as an error rather than pretending to have filed');
|
|
118
168
|
assert.equal(calls.create.length, 0);
|
|
119
169
|
});
|
|
120
170
|
|
|
171
|
+
await t('recordInboundBug: a REFUSED goal drops the guess and still files the report [1003067]', async () => {
|
|
172
|
+
// The suggester picks the home; the goal can still refuse it (protected scope, a
|
|
173
|
+
// scope-wall verdict, an archive mid-flight). Because routing is one transaction
|
|
174
|
+
// that refusal takes the idea row with it — so a goal WE guessed at would lose a
|
|
175
|
+
// report the reporter did nothing wrong to file. Drop the guess, keep the report.
|
|
176
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, refuseRouted: true });
|
|
177
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'a bug whose words match a protected goal', messageId: 'm7' }, deps, CFG);
|
|
178
|
+
assert.equal(r.outcome, 'filed', 'the report survives the refusal');
|
|
179
|
+
assert.equal(calls.create.length, 2, 'one routed attempt, then one homeless retry');
|
|
180
|
+
assert.equal(calls.create[0].goalId, 7, 'the first carried the suggested home');
|
|
181
|
+
assert.equal(calls.create[1].goalId, null, 'the retry drops it');
|
|
182
|
+
assert.equal(r.taskId, null, 'and it lands in the inbox, where triage will see it');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await t('recordInboundBug: a refusal with NO goal to drop is a real error, not a retry loop', async () => {
|
|
186
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, goal: null, refuseRouted: true, createThrows: true });
|
|
187
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'the database is simply down', messageId: 'm8' }, deps, CFG);
|
|
188
|
+
assert.equal(r.outcome, 'error', 'nothing to drop ⇒ surface it rather than retrying forever');
|
|
189
|
+
assert.equal(calls.create.length, 1, 'exactly one attempt');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await t('recordInboundBug: NO confident goal → the report still lands, in the inbox', async () => {
|
|
193
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, goal: null });
|
|
194
|
+
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'a bug matching no goal at all', messageId: 'm3d' }, deps, CFG);
|
|
195
|
+
assert.equal(r.outcome, 'filed');
|
|
196
|
+
assert.equal(r.taskId, null, 'no home ⇒ no task, which is the homeless-inbox contract (C2)');
|
|
197
|
+
assert.equal(calls.create[0].goalId, null);
|
|
198
|
+
});
|
|
199
|
+
|
|
121
200
|
await t('recordInboundBug: over rate-limit → ignored + audited 429, nothing created', async () => {
|
|
122
201
|
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, recent: 5 });
|
|
123
202
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'yet another bug report', messageId: 'm4' }, deps, CFG);
|
|
@@ -170,7 +249,7 @@ await t('recordInboundBug: audit failure does NOT undo the filing', async () =>
|
|
|
170
249
|
assert.equal(calls.create.length, 1);
|
|
171
250
|
});
|
|
172
251
|
|
|
173
|
-
await t('recordInboundBug:
|
|
252
|
+
await t('recordInboundBug: a capture error → {error}, never throws', async () => {
|
|
174
253
|
const { deps } = makeDeps({ builder: { id: 9, github_login: 'mona', rank: 'metic' }, createThrows: true });
|
|
175
254
|
const r = await bg.recordInboundBug({ discordUserId: GOOD_ID, content: 'valid bug body here', messageId: 'm' }, deps, CFG);
|
|
176
255
|
assert.equal(r.outcome, 'error');
|
|
@@ -202,8 +281,8 @@ await t('recordInboundBug: text + images → persists images + embeds durable li
|
|
|
202
281
|
assert.equal(r.images, 1);
|
|
203
282
|
assert.equal(calls.persist.length, 1);
|
|
204
283
|
assert.deepEqual(calls.persist[0].urls, ['https://cdn.discordapp.com/a.png']);
|
|
205
|
-
assert.match(calls.create[0].
|
|
206
|
-
assert.match(calls.create[0].
|
|
284
|
+
assert.match(calls.create[0].bodyMd, /Attachments \(1\)/);
|
|
285
|
+
assert.match(calls.create[0].bodyMd, /!\[attachment 1\]\(\/api\/gds\/bug-attachments\//);
|
|
207
286
|
});
|
|
208
287
|
|
|
209
288
|
await t('recordInboundBug: image-only (empty text) → filed with screenshot title', async () => {
|
|
@@ -225,7 +304,7 @@ await t('recordInboundBug: image persist failure → still files (without images
|
|
|
225
304
|
);
|
|
226
305
|
assert.equal(r.outcome, 'filed');
|
|
227
306
|
assert.equal(r.images, 0);
|
|
228
|
-
assert.doesNotMatch(calls.create[0].
|
|
307
|
+
assert.doesNotMatch(calls.create[0].bodyMd, /Attachments/);
|
|
229
308
|
});
|
|
230
309
|
|
|
231
310
|
await t('recordInboundBug: all images refused (stored empty) → filed, no Attachments block', async () => {
|
|
@@ -236,7 +315,7 @@ await t('recordInboundBug: all images refused (stored empty) → filed, no Attac
|
|
|
236
315
|
);
|
|
237
316
|
assert.equal(r.outcome, 'filed');
|
|
238
317
|
assert.equal(r.images, 0);
|
|
239
|
-
assert.doesNotMatch(calls.create[0].
|
|
318
|
+
assert.doesNotMatch(calls.create[0].bodyMd, /Attachments/);
|
|
240
319
|
});
|
|
241
320
|
|
|
242
321
|
// ---- combineParts ----------------------------------------------------------
|
|
@@ -29,19 +29,21 @@
|
|
|
29
29
|
// F10 make the rate-limit COUNT fail, to slip past the cap → denied (fail-closed)
|
|
30
30
|
// F11 file through Discord #bugs at all, as a sub-Metic → 'backlog', never 'ready'
|
|
31
31
|
// F12 file through Discord #ideas naming a goal → that vector cannot route
|
|
32
|
+
// F15 reach #bugs' old direct-createTask bypass → it no longer exists
|
|
33
|
+
// F16 file through #bugs as an UNLINKED author → an inbox row, never work
|
|
32
34
|
// F13 declare touches[] outside the goal to widen blast area → the real wall refuses
|
|
33
35
|
// F14 file against a SHIPPED version's goal → refused (version not building)
|
|
34
36
|
// P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
|
|
35
37
|
// P2 a Metic+ files into protected scope → allowed, floored to metic
|
|
36
38
|
//
|
|
37
39
|
// AND THE ANTI-DRIFT PIN (the point of the file, really). The rows above are true
|
|
38
|
-
// of the
|
|
40
|
+
// of the capture vectors that exist TODAY. Nothing stops a fifth being added
|
|
39
41
|
// next month that quietly skips all of it — which is exactly how the 1000771 bug
|
|
40
42
|
// got in. So the last section ENUMERATES the vectors from source and fails when
|
|
41
43
|
// the roster changes, forcing a new vector's author to come here and add its row.
|
|
42
44
|
//
|
|
43
45
|
// PURE: the decision cores only (decideLanding, authorizeRoutedLanding,
|
|
44
|
-
//
|
|
46
|
+
// rateLimitPerHour) — no DB, no network. The transaction that
|
|
45
47
|
// obeys them is proven in tests/idea_routing.mjs; the end-to-end attribution trail
|
|
46
48
|
// in tests/idea_route_attribution.mjs.
|
|
47
49
|
//
|
|
@@ -72,7 +74,7 @@ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
|
|
|
72
74
|
const routing = require('../modules/ideas/routing.js');
|
|
73
75
|
const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR } = routing;
|
|
74
76
|
const bugs = require('../modules/discord/discord-bugs.js');
|
|
75
|
-
const {
|
|
77
|
+
const { rateLimitPerHour } = bugs;
|
|
76
78
|
const { scopeIncludesProtected, isModuleKey } = require('../src/bongos/module-scope-map.js');
|
|
77
79
|
|
|
78
80
|
const { test, summary } = makeRunner();
|
|
@@ -215,11 +217,15 @@ await test('[F10] a rate-limit COUNT failure DENIES — you cannot break the cou
|
|
|
215
217
|
});
|
|
216
218
|
|
|
217
219
|
await test('[F11] #bugs lands a sub-Metic at backlog and only Metic+ at ready', () => {
|
|
220
|
+
// Since R11 this is the SAME rule as F1/P1 — the channel has no landing logic of
|
|
221
|
+
// its own. Asserted through the matrix, which is now the only place it exists.
|
|
218
222
|
for (const rank of SUB_METIC) {
|
|
219
|
-
assert.equal(
|
|
223
|
+
assert.equal(landing({ kind: 'bug', filerRank: rank }).status, 'backlog',
|
|
224
|
+
`${JSON.stringify(rank)} must not reach ready`);
|
|
220
225
|
}
|
|
221
226
|
for (const rank of TRUSTED) {
|
|
222
|
-
assert.equal(
|
|
227
|
+
assert.equal(landing({ kind: 'bug', filerRank: rank }).status, 'ready',
|
|
228
|
+
`${rank} is the C1 straight-through grant`);
|
|
223
229
|
}
|
|
224
230
|
});
|
|
225
231
|
|
|
@@ -228,7 +234,7 @@ await test('[F9+F11] the flood, composed: at the cap, EVERY landed row is still
|
|
|
228
234
|
const landedStatuses = [];
|
|
229
235
|
for (let i = 0; i < cap + 25; i++) {
|
|
230
236
|
if (i >= cap) continue; // the counter refuses past the cap (F9/F10)
|
|
231
|
-
landedStatuses.push(
|
|
237
|
+
landedStatuses.push(landing({ kind: 'bug', filerRank: 'xenos' }).status);
|
|
232
238
|
}
|
|
233
239
|
assert.equal(landedStatuses.length, cap, 'the cap bounds how many rows exist at all');
|
|
234
240
|
assert.ok(landedStatuses.every((s) => s === 'backlog'),
|
|
@@ -297,6 +303,42 @@ await test('priority cannot be weaponised: an out-of-band suggestion is clamped,
|
|
|
297
303
|
assert.equal(routedPriority(1), 1, 'a sane suggestion still rides through');
|
|
298
304
|
});
|
|
299
305
|
|
|
306
|
+
// =========================================================================
|
|
307
|
+
// F15–F16 — the #bugs bypass is retired (task 1003067 / BV1.R11)
|
|
308
|
+
// =========================================================================
|
|
309
|
+
|
|
310
|
+
await test('[F15] #bugs mints NO task of its own — the bypass that lost attribution is gone', () => {
|
|
311
|
+
const src = read('modules/discord/discord-bugs.js');
|
|
312
|
+
const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
313
|
+
assert.ok(!/\bdeps\.createTask\(/.test(code),
|
|
314
|
+
'the vector must file through ideas.capture, so every report leaves an idea row + lineage');
|
|
315
|
+
assert.match(code, /deps\.captureIdea\(/, 'and it does');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await test('[F15b] #bugs names NO version — a vector that cannot name one cannot name a dead one', () => {
|
|
319
|
+
const src = read('modules/discord/discord-bugs.js');
|
|
320
|
+
const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
321
|
+
// This is idea 1000771 in one assertion: the old default was 'GDS-V3', shipped,
|
|
322
|
+
// so every bug reported here landed in a closed version's catch-all.
|
|
323
|
+
assert.ok(!/GDS-V3|DEFAULT_BUGS_VERSION_ID|DISCORD_BUGS_VERSION_ID/.test(code),
|
|
324
|
+
'no version id may be hardcoded or env-configured on this path any more');
|
|
325
|
+
assert.ok(!/versionId:/.test(code), 'and none is passed into the capture');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await test('[F16] an unlinked #bugs author gets an inbox row and CANNOT reach work', () => {
|
|
329
|
+
// The report is kept (it used to be dropped), but with no rank and no goal, so
|
|
330
|
+
// the matrix has nothing to route: structurally incapable of becoming a task.
|
|
331
|
+
const noRank = decideLanding({ goalId: null, kind: 'bug', filerRank: null });
|
|
332
|
+
assert.equal(noRank.route, false, 'no goal ⇒ the inbox');
|
|
333
|
+
// And even if a goal somehow reached it, an unknown filer never hard-lands:
|
|
334
|
+
const withGoal = decideLanding({ goalId: 5, kind: 'bug', filerRank: null });
|
|
335
|
+
assert.equal(withGoal.status, 'backlog', 'never claimable on an unknown filer');
|
|
336
|
+
const src = read('modules/discord/discord-bugs.js');
|
|
337
|
+
const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
338
|
+
assert.match(code, /if \(builder\) \{[\s\S]{0,200}suggestGoalForCapture/,
|
|
339
|
+
'a goal is only ever sought for a LINKED builder');
|
|
340
|
+
});
|
|
341
|
+
|
|
300
342
|
// =========================================================================
|
|
301
343
|
// THE ANTI-DRIFT PIN — a fifth vector cannot arrive unaudited
|
|
302
344
|
// =========================================================================
|
|
@@ -308,7 +350,7 @@ const KNOWN_FILING_VECTORS = {
|
|
|
308
350
|
'modules/ideas/inbox.js': 'routeIdeaToGoal — the ONE transaction every routed filing runs (F1–F8, F13, F14)',
|
|
309
351
|
'modules/ideas/routes/inbox.js': 'POST /inbox — the web + CLI door onto it (F3)',
|
|
310
352
|
'modules/discord/discord-inbound.js': 'Discord #ideas — files an idea, never routes (F12)',
|
|
311
|
-
'modules/discord/discord-bugs.js': 'Discord #bugs —
|
|
353
|
+
'modules/discord/discord-bugs.js': 'Discord #bugs — files through ideas.capture since R11 (F9–F11, F15, F16)',
|
|
312
354
|
};
|
|
313
355
|
|
|
314
356
|
await test('[pin] every declared vector still reaches a task-minting path', () => {
|
|
@@ -336,20 +378,17 @@ await test('[pin] no OTHER module file mints a task from a filing without a row
|
|
|
336
378
|
`a new filing vector must be added to KNOWN_FILING_VECTORS and given a matrix row: ${unaudited.join(', ')}`);
|
|
337
379
|
});
|
|
338
380
|
|
|
339
|
-
await test('[pin]
|
|
340
|
-
//
|
|
341
|
-
//
|
|
381
|
+
await test('[pin] there is ONE trusted-tier definition, and #bugs no longer keeps a copy', () => {
|
|
382
|
+
// This pin used to assert that two spellings of "Metic+" AGREED — routing.js and
|
|
383
|
+
// discord-bugs.js each carried one. R11 deleted the second, which is the better
|
|
384
|
+
// outcome: agreement you have to test for is a duplication you could have removed.
|
|
342
385
|
const routingSrc = read('modules/ideas/routing.js');
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const viaBugs = decideBugLanding(rank);
|
|
350
|
-
assert.equal(viaRouting, viaBugs,
|
|
351
|
-
`the two vectors must agree on ${JSON.stringify(rank)} (routing=${viaRouting}, bugs=${viaBugs})`);
|
|
352
|
-
}
|
|
386
|
+
assert.match(routingSrc, /METIC_PLUS = new Set\(\['metic', 'archon'\]\)/,
|
|
387
|
+
'the one definition, in the matrix');
|
|
388
|
+
const bugsCode = read('modules/discord/discord-bugs.js')
|
|
389
|
+
.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
390
|
+
assert.ok(!/READY_RANKS/.test(bugsCode), '#bugs must not reintroduce a local rank set');
|
|
391
|
+
assert.ok(!/decideBugLanding/.test(bugsCode), 'nor a local landing decision');
|
|
353
392
|
});
|
|
354
393
|
|
|
355
394
|
summary();
|
package/tests/goal_suggest.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { makeRunner } from './helpers.mjs';
|
|
|
24
24
|
|
|
25
25
|
process.env.NODE_ENV = 'test';
|
|
26
26
|
const require = createRequire(import.meta.url);
|
|
27
|
-
const { suggestGoals, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
|
|
27
|
+
const { suggestGoals, suggestGoalForCapture, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
|
|
28
28
|
const { test, summary } = makeRunner();
|
|
29
29
|
|
|
30
30
|
const V = 'BONGOS-V1';
|
|
@@ -192,4 +192,110 @@ await test('the handler clamps limit into a sane range', async () => {
|
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
// =========================================================================
|
|
196
|
+
// suggestGoalForCapture — ONE goal, or none, for a filing with nobody to choose
|
|
197
|
+
// (task 1003067 / BV1.R11). The HTTP suggester offers a human five options; a
|
|
198
|
+
// Discord message has no human in the loop, so this collapses to a single
|
|
199
|
+
// confident answer and declines whenever "confident" is not available.
|
|
200
|
+
// =========================================================================
|
|
201
|
+
|
|
202
|
+
const fakeDb = ({ versions = [], goals = [], throws = null, calls = null } = {}) => ({
|
|
203
|
+
listVersions: async () => { if (throws === 'versions') throw new Error('boom'); return versions; },
|
|
204
|
+
listGoals: async (args) => {
|
|
205
|
+
if (calls) calls.push(args);
|
|
206
|
+
if (throws === 'goals') throw new Error('boom');
|
|
207
|
+
return goals;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const BUILDING = { id: 'BONGOS-V1', status: 'building' };
|
|
212
|
+
const SHIPPED = { id: 'GDS-V3', status: 'shipped' };
|
|
213
|
+
const HALL_GOAL = { id: 81, title: 'Builders hall in the world', scope_modules: ['hall-ui'], version_id: 'BONGOS-V1' };
|
|
214
|
+
const ECON_GOAL = { id: 78, title: 'Project money out', scope_modules: ['economy'], version_id: 'BONGOS-V1' };
|
|
215
|
+
|
|
216
|
+
await test('suggestGoalForCapture: the one building version, and the goal the words match', async () => {
|
|
217
|
+
const got = await suggestGoalForCapture(
|
|
218
|
+
{ title: 'the hall sidebar collapses on mobile', body: '' },
|
|
219
|
+
{ db: fakeDb({ versions: [BUILDING, SHIPPED], goals: [ECON_GOAL, HALL_GOAL] }) }
|
|
220
|
+
);
|
|
221
|
+
assert.equal(got.id, 81, 'the hall goal, not the economy one');
|
|
222
|
+
assert.equal(got.version_id, 'BONGOS-V1', 'and the version comes from the version row, never a hardcode');
|
|
223
|
+
assert.ok(got.score > 0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await test('suggestGoalForCapture: a SHIPPED version is never the home (idea 1000771)', async () => {
|
|
227
|
+
const got = await suggestGoalForCapture(
|
|
228
|
+
{ title: 'the hall sidebar collapses', body: '' },
|
|
229
|
+
{ db: fakeDb({ versions: [SHIPPED], goals: [HALL_GOAL] }) }
|
|
230
|
+
);
|
|
231
|
+
assert.equal(got, null, 'nothing is open to work, so there is no home — the filing goes to the inbox');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// This instance runs BONGOS-V1 and CB-V1 concurrently. An earlier cut declined
|
|
235
|
+
// whenever more than one version was open, which would have returned null for
|
|
236
|
+
// every report ever filed — a dead feature that looked like prudence. The goal
|
|
237
|
+
// carries the version, so rank across all of them and let the winner say.
|
|
238
|
+
await test('suggestGoalForCapture: TWO open versions ⇒ rank across BOTH, winner names its version', async () => {
|
|
239
|
+
const CB_GOAL = { id: 99, title: 'The front door', scope_modules: ['public-landing'], version_id: 'CB-V1' };
|
|
240
|
+
const got = await suggestGoalForCapture(
|
|
241
|
+
{ title: 'the front door hero is broken', body: '' },
|
|
242
|
+
{ db: fakeDb({ versions: [BUILDING, { id: 'CB-V1', status: 'building' }], goals: [HALL_GOAL, CB_GOAL] }) }
|
|
243
|
+
);
|
|
244
|
+
assert.equal(got.id, 99, 'the goal on the OTHER open version can win');
|
|
245
|
+
assert.equal(got.version_id, 'CB-V1', 'and it reports its own version, not the first one open');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('suggestGoalForCapture: a goal on a CLOSED version is never a home, even if it matches best', async () => {
|
|
249
|
+
const DEAD_GOAL = { id: 12, title: 'the hall sidebar and hall chrome', scope_modules: ['hall-ui'], version_id: 'GDS-V3' };
|
|
250
|
+
const got = await suggestGoalForCapture(
|
|
251
|
+
{ title: 'the hall sidebar collapses', body: '' },
|
|
252
|
+
{ db: fakeDb({ versions: [BUILDING, SHIPPED], goals: [DEAD_GOAL, ECON_GOAL] }) }
|
|
253
|
+
);
|
|
254
|
+
assert.equal(got, null, 'the better-matching goal sits on a shipped version — idea 1000771 exactly');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('suggestGoalForCapture: asks for open goals across ALL versions, not one', async () => {
|
|
258
|
+
const calls = [];
|
|
259
|
+
await suggestGoalForCapture(
|
|
260
|
+
{ title: 'the hall sidebar collapses', body: '' },
|
|
261
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL], calls }) }
|
|
262
|
+
);
|
|
263
|
+
assert.equal(calls.length, 1, 'one query, not one per version');
|
|
264
|
+
assert.equal(calls[0].status, 'open');
|
|
265
|
+
assert.equal(calls[0].versionId, undefined, 'unscoped — the open-version filter is applied in JS');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await test('suggestGoalForCapture: no confident match ⇒ null, not the first goal dressed up', async () => {
|
|
269
|
+
const got = await suggestGoalForCapture(
|
|
270
|
+
{ title: 'zzzz qqqq nothing matches this', body: '' },
|
|
271
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL] }) }
|
|
272
|
+
);
|
|
273
|
+
assert.equal(got, null);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('suggestGoalForCapture: empty draft ⇒ null without touching the db', async () => {
|
|
277
|
+
let touched = false;
|
|
278
|
+
const db = { listVersions: async () => { touched = true; return [BUILDING]; }, listGoals: async () => [] };
|
|
279
|
+
assert.equal(await suggestGoalForCapture({ title: ' ', body: '' }, { db }), null);
|
|
280
|
+
assert.equal(touched, false, 'nothing to rank against ⇒ no query');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await test('suggestGoalForCapture: a db failure SHRUGS — it never throws at a bug report', async () => {
|
|
284
|
+
for (const throws of ['versions', 'goals']) {
|
|
285
|
+
const got = await suggestGoalForCapture(
|
|
286
|
+
{ title: 'the hall sidebar collapses', body: '' },
|
|
287
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL], throws }) }
|
|
288
|
+
);
|
|
289
|
+
assert.equal(got, null, `a ${throws} failure degrades to "no home", never to a lost report`);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('suggestGoalForCapture: the catch-all is never offered as a home', async () => {
|
|
294
|
+
const got = await suggestGoalForCapture(
|
|
295
|
+
{ title: 'general problem in BONGOS', body: '' },
|
|
296
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [{ id: 3, title: 'BONGOS-V1 — general', scope_modules: [], version_id: 'BONGOS-V1' }] }) }
|
|
297
|
+
);
|
|
298
|
+
assert.equal(got, null, 'a bucket is not a workspace — dropped for EVERY open version, not just one');
|
|
299
|
+
});
|
|
300
|
+
|
|
195
301
|
summary();
|