@bongos/core 1.19.640 → 1.19.641

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 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.640",
6
- "core_contract": "1.19.640",
7
- "source_commit": "2ab73a01133b60e930ce77745cd7bab2be53d517",
5
+ "core_version": "1.19.641",
6
+ "core_contract": "1.19.641",
7
+ "source_commit": "7af21a0086bc6cbc069a459b0a30b90f1646f82e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-10T17:05:44.826Z",
9
+ "built_at": "2026-09-10T17:12:24.520Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 470,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2104,
14
+ "functional_verbatim": 2106,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2598,
20
- "tree_sha256": "5d4e0f8138d98ccd04fe0ae74d565738470c6b6210f6ccad1084e067ed8eab63",
19
+ "file_count": 2600,
20
+ "tree_sha256": "1fd37a4de38d4265a2f0956b03da63ce66a9e84de7ce8a24e1c66ae4465ea028",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "27b63233558ace04674687e72734ac62bf14b5ff1bc8f0f218ace0b708121a82"
2775
+ "sha256": "967b335b7e06715fb8c07cea7af1141fa2dd3acb0da7873017048bb63aad15d0"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -4297,7 +4297,7 @@
4297
4297
  {
4298
4298
  "path": "modules/discord/discord-bugs.js",
4299
4299
  "mode": "0000644",
4300
- "sha256": "52b463955cd7e57f8d194c161a76e2ade83481f3ee0a16759019a5b1f873b374"
4300
+ "sha256": "000c766b483804e00ac523850e5cd8bc06a8f25aecf622a8629e1987014447b7"
4301
4301
  },
4302
4302
  {
4303
4303
  "path": "modules/discord/discord-channels.js",
@@ -4307,7 +4307,7 @@
4307
4307
  {
4308
4308
  "path": "modules/discord/discord-inbound.js",
4309
4309
  "mode": "0000644",
4310
- "sha256": "86099070386ff606ec14330375ac8c3145365307ae2ac354539731aebcc16563"
4310
+ "sha256": "4779118709c5e5365c1acc7b7348bf2d812726530876ac99d9f28777db0477b2"
4311
4311
  },
4312
4312
  {
4313
4313
  "path": "modules/discord/discord-message-buffer.js",
@@ -4317,13 +4317,18 @@
4317
4317
  {
4318
4318
  "path": "modules/discord/discord-reaction.js",
4319
4319
  "mode": "0000644",
4320
- "sha256": "010766b857bd4c6727acdb2272de0442febaf81da6ba2da497b525a3e9d07df6"
4320
+ "sha256": "6d5cd39f63c1f8d6d84e713606844eead96fc2366e720523067f46d845c10c8d"
4321
4321
  },
4322
4322
  {
4323
4323
  "path": "modules/discord/discord-role-sync.js",
4324
4324
  "mode": "0000644",
4325
4325
  "sha256": "852abc58bdec011b6d3bf222a35e198c6ea98a761d57a76be85477ef85ae54f5"
4326
4326
  },
4327
+ {
4328
+ "path": "modules/discord/lifecycle-port.js",
4329
+ "mode": "0000644",
4330
+ "sha256": "f5c7bb703d5c230f334322128129dfc02c6cc03fa9d8d37e00b67493de80a25d"
4331
+ },
4327
4332
  {
4328
4333
  "path": "modules/discord/migrations/discord_001_board_messages.sql",
4329
4334
  "mode": "0000644",
@@ -7717,12 +7722,12 @@
7717
7722
  {
7718
7723
  "path": "package-lock.json",
7719
7724
  "mode": "0000644",
7720
- "sha256": "cd5ac6c00223a78f16aae563bafeadb8d919a15eb46aa70a955b31f1e4a4d14b"
7725
+ "sha256": "9a655dfca70f3edf6083c6533004860a8a6da53455d732526dea6c0c37f3fcf1"
7721
7726
  },
7722
7727
  {
7723
7728
  "path": "package.json",
7724
7729
  "mode": "0000644",
7725
- "sha256": "3220018d398311cd5dad8ad43c895021eb38edcfa607bcbd72d16afae8323a6f"
7730
+ "sha256": "80d6ffe532ebbd4d2165171ec5e2887487599e42c53e5411bb4048074d2eb0fb"
7726
7731
  },
7727
7732
  {
7728
7733
  "path": "public-docs/index.html",
@@ -9477,7 +9482,7 @@
9477
9482
  {
9478
9483
  "path": "src/module-api.js",
9479
9484
  "mode": "0000644",
9480
- "sha256": "ae0011557d1a9e04d4193538369dfc2c1cad08dfcefa0898c904566fd2374fe5"
9485
+ "sha256": "cc274e241228f0ad4bb534674500d4daf228abcf6434241fed7152e49590f43f"
9481
9486
  },
9482
9487
  {
9483
9488
  "path": "src/module-loader/catalog.js",
@@ -10389,6 +10394,11 @@
10389
10394
  "mode": "0000644",
10390
10395
  "sha256": "c4e01ac01a3f01fc1a7aece34457a1660034164ab7cb65039f26d4a72d92a403"
10391
10396
  },
10397
+ {
10398
+ "path": "tests/discord_lifecycle_port.mjs",
10399
+ "mode": "0000644",
10400
+ "sha256": "eaa289f4e1fa413b30f23cc23a0aea166bd1d9c18a1992d07a0694b28c9fa3c2"
10401
+ },
10392
10402
  {
10393
10403
  "path": "tests/discord_message_buffer.mjs",
10394
10404
  "mode": "0000644",
@@ -1729,5 +1729,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1729
1729
  landed since 1.19.638 with no explicit bump. run 34502609272. (task 1002620)
1730
1730
  1.19.640 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1731
1731
  landed since 1.19.639 with no explicit bump. run 34506041899. (task 1002620)
1732
+ 1.19.641 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1733
+ landed since 1.19.640 with no explicit bump. run 34506718627. (task 1002620)
1732
1734
  ---------------------------------------------------------------------------
1733
1735
  ```
@@ -57,6 +57,7 @@ const { auditDiscordBot } = require('./discord-audit');
57
57
  const auth = require('./auth');
58
58
  const attachments = require('./bug-attachments');
59
59
  const { combineParts, createMessageAggregator } = require('./discord-message-buffer');
60
+ const { lifecycleMethod } = require('./lifecycle-port');
60
61
  const api = require('../../src/module-api');
61
62
  const { LIMITS } = api;
62
63
  const log = api.logger('discord');
@@ -134,14 +135,10 @@ const realDeps = {
134
135
  // module rule, ADR 0083/0093). `ideas.capture` is the same door #ideas uses;
135
136
  // `lifecycle` answers which goal, on which still-open version.
136
137
  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
- },
138
+ // Optional, not required: an instance running without the lifecycle module has
139
+ // no goals to suggest, and that is a reason to file the report WITHOUT a home
140
+ // — never a reason to lose it. Resolves null, which lands it in the inbox.
141
+ suggestGoalForCapture: lifecycleMethod('suggestGoalForCapture', () => null),
145
142
  insertAuditLog: db.insertAuditLog,
146
143
  persistImages: attachments.persistImages,
147
144
  };
@@ -49,6 +49,7 @@ const { auditDiscordBot } = require('./discord-audit');
49
49
  const auth = require('./auth');
50
50
  const attachments = require('./bug-attachments');
51
51
  const { createMessageAggregator } = require('./discord-message-buffer');
52
+ const { lifecycleMethod } = require('./lifecycle-port');
52
53
  const { LIMITS } = api;
53
54
  const log = api.logger('discord');
54
55
 
@@ -88,21 +89,13 @@ const realDeps = {
88
89
  // Lazy: resolve the `ideas.capture` port at CALL time, so the ideas module's
89
90
  // registration (at its route-factory mount) need not precede this module's load.
90
91
  captureIdea: (...a) => api.resolve('ideas.capture')(...a),
91
- // resolveOptional, not resolve: a vanilla instance without the lifecycle
92
- // module has no goals to suggest, and that is a reason to skip the hint —
93
- // never a reason to lose the idea (the discord-bugs.js precedent).
94
- suggestGoalForCapture: async (...a) => {
95
- const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
96
- if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
97
- return lifecycle.suggestGoalForCapture(...a);
98
- },
99
- // Same resolveOptional posture — a vanilla instance without lifecycle has no
100
- // versions to default to, which is a reason to skip the default, never the idea.
101
- listVersions: async () => {
102
- const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
103
- if (!lifecycle || !lifecycle.listVersions) return [];
104
- return lifecycle.listVersions();
105
- },
92
+ // Optional, not required: a vanilla instance without the lifecycle module has
93
+ // no goals to suggest, and that is a reason to skip the hint — never a reason
94
+ // to lose the idea (the discord-bugs.js precedent).
95
+ suggestGoalForCapture: lifecycleMethod('suggestGoalForCapture', () => null),
96
+ // Same posture — a vanilla instance without lifecycle has no versions to
97
+ // default to, which is a reason to skip the default, never the idea.
98
+ listVersions: lifecycleMethod('listVersions', () => []),
106
99
  insertAuditLog: db.insertAuditLog,
107
100
  persistImages: attachments.persistImages,
108
101
  };
@@ -32,14 +32,13 @@ const { pool } = require('../../src/module-api');
32
32
  const db = require('./db');
33
33
  const { auditDiscordBot } = require('./discord-audit');
34
34
  const auth = require('./auth');
35
- const api = require('../../src/module-api');
36
- // VOTABLE_TASK_STATUSES is the lifecycle module's votable-status set, resolved via
35
+ const { lifecycleMember } = require('./lifecycle-port');
36
+ // VOTABLE_TASK_STATUSES is the lifecycle module's votable-status set, read via
37
37
  // the `lifecycle` kernel PORT (the doorway leak retired at BV1.R86) — a vote only
38
- // lands on confirmed/shipped work. Resolved at call time (this file runs under the
39
- // discord bot poller, well after the lifecycle port registers at boot). A
40
- // #ship-feed message is always about a shipped task, but we re-check rather than
41
- // trust the channel.
42
- const votableStatuses = () => api.resolveOptional('lifecycle')?.VOTABLE_TASK_STATUSES || new Set();
38
+ // lands on confirmed/shipped work. lifecycle-port.js keeps the resolve late and
39
+ // the absent-module fallback empty. A #ship-feed message is always about a
40
+ // shipped task, but we re-check rather than trust the channel.
41
+ const votableStatuses = () => lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
43
42
  const VOTING_RANKS = new Set(['metic', 'archon']);
44
43
 
45
44
  // PURE — extract the GDS task id from a #ship-feed message. The producer
@@ -0,0 +1,69 @@
1
+ // modules/discord/lifecycle-port.js — the one place this module reaches the
2
+ // OPTIONAL `lifecycle` kernel port.
3
+ //
4
+ // Why this file exists: four call sites across discord-bugs.js,
5
+ // discord-inbound.js and discord-reaction.js each hand-rolled the same
6
+ // resolveOptional('lifecycle') dance — resolve the port at call time, check the
7
+ // member is actually there, fall back to an empty value when it is not. The
8
+ // duplication was a grader nit on task 1003068 and the goal 1000071 R11 ship.
9
+ //
10
+ // THE POSTURE — resolveOptional, never resolve. `lifecycle` is an optional
11
+ // module: a vanilla Cloud Bongos instance runs without it, so it has no goals
12
+ // to suggest and no versions to default to. That is a reason to skip the hint,
13
+ // NEVER a reason to lose the idea or drop the bug report. Every accessor here
14
+ // degrades to an empty value, and nothing in this file throws.
15
+ //
16
+ // LATE BINDING IS LOAD-BEARING. The port is resolved inside each accessor, not
17
+ // once at require time, because this module's files are loaded before the
18
+ // lifecycle module registers its provider at boot. Hoisting the resolve to
19
+ // module scope would pin the fallback forever — which is why every accessor
20
+ // below is a function, and why none of them caches.
21
+ //
22
+ // A module may import only the published doorway (src/module-api.js), so this
23
+ // helper lives beside its callers rather than in core (ADR 0083/0093).
24
+
25
+ const api = require('../../src/module-api');
26
+
27
+ // The port itself, or null. The `api.resolveOptional &&` guard is kept because
28
+ // three of the four original copies carried it; discord-reaction.js was the odd
29
+ // one out, so routing every caller through here also settles that inconsistency
30
+ // in favour of the guard.
31
+ function lifecyclePort() {
32
+ return (api.resolveOptional && api.resolveOptional('lifecycle')) || null;
33
+ }
34
+
35
+ // Read one member off the `lifecycle` port, or produce an empty stand-in.
36
+ //
37
+ // `makeFallback` is a FACTORY, not a value, and deliberately so: the fallbacks
38
+ // in play are mutable (`[]`, `new Set()`), and handing every caller one shared
39
+ // instance would turn a mutation by any one of them into a cross-call leak.
40
+ // Each call gets a fresh fallback, exactly as the hand-rolled copies did.
41
+ //
42
+ // Reading as `lifecycle[member]` also keeps the port's GETTERS working —
43
+ // VOTABLE_TASK_STATUSES is one (`get VOTABLE_TASK_STATUSES()`), so it must be
44
+ // read off the object rather than destructured ahead of time.
45
+ function lifecycleMember(member, makeFallback) {
46
+ const lifecycle = lifecyclePort();
47
+ const value = lifecycle ? lifecycle[member] : undefined;
48
+ return value === undefined || value === null ? makeFallback() : value;
49
+ }
50
+
51
+ // Wrap an optional lifecycle METHOD as an always-callable async function: it
52
+ // forwards to the port when the module is present, and resolves to a fresh
53
+ // fallback when either the module or that method is absent. The caller cannot
54
+ // tell the two absences apart, and does not need to.
55
+ //
56
+ // `fn.call(lifecycle, …)`, not `fn(…)`: the call sites this replaced invoked
57
+ // `lifecycle.method(...)`, so the port was the receiver. Every member the port
58
+ // exports today is a delegating arrow that ignores `this`, so nothing currently
59
+ // depends on it — which is exactly why dropping it would go unnoticed until a
60
+ // provider that DOES use `this` broke silently. Preserve it.
61
+ function lifecycleMethod(member, makeFallback) {
62
+ return async (...args) => {
63
+ const lifecycle = lifecyclePort();
64
+ const fn = lifecycle ? lifecycle[member] : undefined;
65
+ return typeof fn === 'function' ? fn.call(lifecycle, ...args) : makeFallback();
66
+ };
67
+ }
68
+
69
+ module.exports = { lifecycleMember, lifecycleMethod };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.640",
3
+ "version": "1.19.641",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.640",
9
+ "version": "1.19.641",
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.640",
3
+ "version": "1.19.641",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.640'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.641'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,153 @@
1
+ // tests/discord_lifecycle_port.mjs — the optional `lifecycle` port accessor
2
+ // (task 1003630).
3
+ //
4
+ // The four hand-rolled resolveOptional('lifecycle') wrappers this helper
5
+ // replaced were never covered: every existing discord test injects `deps`, so
6
+ // the real `realDeps` wrappers — and specifically their absent-module branch —
7
+ // ran only in production. That branch is the whole point of the file (a vanilla
8
+ // Cloud Bongos instance has no lifecycle module), so it is what these pin:
9
+ //
10
+ // 1. present module → the port's own member/method is used
11
+ // 2. absent module → a fresh empty fallback, and NO throw
12
+ // 3. absent MEMBER on a present module → the same fallback (the caller cannot
13
+ // tell the two absences apart, and must not need to)
14
+ // 4. the fallback is a FACTORY — each call gets its own instance, so one
15
+ // caller mutating an empty [] or Set() cannot leak into the next
16
+ // 5. binding is LATE — a provider registered after require() is still seen
17
+ // 6. the RECEIVER survives — `lifecycle.method(...)` was the original call
18
+ // shape, so `this` inside a method must still be the port, and a getter
19
+ // member must still fire
20
+ //
21
+ // DB-free and bot-free: it drives the seam registry directly.
22
+ //
23
+ // WHY IT LIVES HERE and not in modules/discord/tests/: module-owned tests are
24
+ // discovered but gated by isModuleEnabled('discord'), and `discord` is OFF in
25
+ // the vanilla pack (config/modules.neutral.json) with no config/modules.json in
26
+ // the repo — so a module-owned copy is skipped in CI and would never actually
27
+ // run. The runner's MODULE_TESTS map is curated and does not list discord, so
28
+ // top-level tests/discord_*.mjs run unconditionally. This sits beside its three
29
+ // siblings (discord_reaction, discord_inbound, discord_bugs) for that reason.
30
+
31
+ import { strict as assert } from 'node:assert';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const seams = require('../src/module-seams.js');
36
+ const { lifecycleMember, lifecycleMethod } = require('../modules/discord/lifecycle-port.js');
37
+
38
+ let passed = 0;
39
+ let failed = 0;
40
+ async function test(name, fn) {
41
+ try { await fn(); passed++; console.log(` ok ${name}`); }
42
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
43
+ }
44
+
45
+ console.log('\ndiscord: the optional lifecycle port degrades instead of throwing');
46
+
47
+ // ---- 1. the module is present ---------------------------------------------
48
+
49
+ await test('lifecycleMember reads the real member off a registered port', () => {
50
+ seams._reset();
51
+ const statuses = new Set(['confirmed', 'shipped']);
52
+ seams.registerProvider('lifecycle', { VOTABLE_TASK_STATUSES: statuses });
53
+ assert.equal(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), statuses);
54
+ });
55
+
56
+ await test('lifecycleMethod forwards args to the real method and returns its value', async () => {
57
+ seams._reset();
58
+ const seen = [];
59
+ seams.registerProvider('lifecycle', {
60
+ suggestGoalForCapture: async (draft, extra) => { seen.push([draft, extra]); return { goal_id: '1000091' }; },
61
+ });
62
+ const suggest = lifecycleMethod('suggestGoalForCapture', () => null);
63
+ assert.deepEqual(await suggest({ title: 'x' }, 7), { goal_id: '1000091' });
64
+ assert.deepEqual(seen, [[{ title: 'x' }, 7]], 'every argument is forwarded, in order');
65
+ });
66
+
67
+ // ---- 2. the module is absent (the vanilla instance) ------------------------
68
+
69
+ await test('lifecycleMember falls back when NO provider is registered', () => {
70
+ seams._reset();
71
+ const out = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
72
+ assert.ok(out instanceof Set);
73
+ assert.equal(out.size, 0, 'an empty votable set, so no vote can land');
74
+ });
75
+
76
+ await test('lifecycleMethod resolves the fallback rather than throwing', async () => {
77
+ seams._reset();
78
+ assert.equal(await lifecycleMethod('suggestGoalForCapture', () => null)({ title: 'x' }), null);
79
+ assert.deepEqual(await lifecycleMethod('listVersions', () => [])(), []);
80
+ });
81
+
82
+ // ---- 3. present module, absent member -------------------------------------
83
+
84
+ await test('a present port MISSING the member falls back the same way', async () => {
85
+ seams._reset();
86
+ seams.registerProvider('lifecycle', { somethingElse: () => 'nope' });
87
+ assert.deepEqual(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), new Set());
88
+ assert.equal(await lifecycleMethod('suggestGoalForCapture', () => null)({}), null);
89
+ });
90
+
91
+ await test('a member present but NOT a function still falls back', async () => {
92
+ seams._reset();
93
+ seams.registerProvider('lifecycle', { listVersions: 'not callable' });
94
+ assert.deepEqual(await lifecycleMethod('listVersions', () => [])(), []);
95
+ });
96
+
97
+ // ---- 4. the fallback is a factory, not a shared instance -------------------
98
+
99
+ await test('each fallback is a FRESH instance — a mutation cannot leak forward', async () => {
100
+ seams._reset();
101
+ const first = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
102
+ first.add('poisoned');
103
+ const second = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
104
+ assert.equal(second.size, 0, 'the second caller must not see the first caller\'s write');
105
+ assert.notEqual(first, second);
106
+
107
+ const listVersions = lifecycleMethod('listVersions', () => []);
108
+ const a = await listVersions();
109
+ a.push('poisoned');
110
+ assert.deepEqual(await listVersions(), [], 'the same holds across method calls');
111
+ });
112
+
113
+ // ---- 6. the receiver and getters survive the indirection -------------------
114
+
115
+ await test('a method still sees the PORT as `this` (not undefined)', async () => {
116
+ seams._reset();
117
+ const port = {
118
+ marker: 'the-port',
119
+ // A plain method, so `this` is meaningful — unlike the delegating arrows the
120
+ // real lifecycle module exports today.
121
+ async suggestGoalForCapture() { return this && this.marker; },
122
+ };
123
+ seams.registerProvider('lifecycle', port);
124
+ assert.equal(
125
+ await lifecycleMethod('suggestGoalForCapture', () => null)({}),
126
+ 'the-port',
127
+ 'fn.call(lifecycle, …) — dropping the receiver would return undefined here',
128
+ );
129
+ });
130
+
131
+ await test('a GETTER member is read off the object, so it actually fires', () => {
132
+ seams._reset();
133
+ let reads = 0;
134
+ seams.registerProvider('lifecycle', {
135
+ get VOTABLE_TASK_STATUSES() { reads += 1; return new Set(['shipped']); },
136
+ });
137
+ assert.deepEqual(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), new Set(['shipped']));
138
+ assert.equal(reads, 1, 'the real port defines this member as a getter');
139
+ });
140
+
141
+ // ---- 5. binding is late ----------------------------------------------------
142
+
143
+ await test('a provider registered AFTER require() is still picked up', async () => {
144
+ seams._reset();
145
+ const suggest = lifecycleMethod('suggestGoalForCapture', () => null);
146
+ assert.equal(await suggest({}), null, 'nothing registered yet — the fallback');
147
+ seams.registerProvider('lifecycle', { suggestGoalForCapture: async () => ({ goal_id: '42' }) });
148
+ assert.deepEqual(await suggest({}), { goal_id: '42' }, 'the late registration is seen without a re-require');
149
+ });
150
+
151
+ seams._reset();
152
+ console.log(`\n ${passed} passed, ${failed} failed`);
153
+ if (failed > 0) process.exit(1);