@bongos/core 1.19.971 → 1.19.972

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.
@@ -215,7 +215,7 @@ module.exports = function buildGoalsRouter() {
215
215
  // rank: any-builder — GET /goals/graph, the goal board's map (task 1003479, ADR 0227); before /goals/:id.
216
216
  router.get('/goals/graph', auth.requireBuilder, goalGraph.goalGraphHandler({ log }));
217
217
 
218
- // GET /goals?version=&status= — list goals (read).
218
+ // GET /goals?version=&status= — list goals (read); each row carries `viewer_is_member` for the reader.
219
219
  // rank: any-builder — viewing the goal board.
220
220
  router.get('/goals', auth.requireBuilder, async (req, res) => {
221
221
  const { version, status } = req.query;
@@ -223,7 +223,7 @@ module.exports = function buildGoalsRouter() {
223
223
  return res.fail('bad_status', 400, { valid: VALID_GOAL_STATUSES });
224
224
  }
225
225
  try {
226
- const goals = await db.listGoals({ versionId: version || null, status: status || null });
226
+ const goals = await db.listGoals({ versionId: version || null, status: status || null, viewerId: req.builder.id });
227
227
  // task 1003275: ONE category query decorates the whole board (never a join
228
228
  // per goal). Purely a label for the reader — nothing downstream branches on it.
229
229
  const byId = await goalCategory.categoryIndex();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.971",
3
+ "version": "1.19.972",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.971",
9
+ "version": "1.19.972",
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.971",
3
+ "version": "1.19.972",
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",
@@ -1308,6 +1308,9 @@ const CHECKS = [
1308
1308
  checkCoreNeverImportsModule,
1309
1309
  checkModulesImportOnlyApi,
1310
1310
  checkScopeMapImportsProtected,
1311
+ // Check 36 — task 1002900: a carved module with no roster key walls nothing. Extracted
1312
+ // to its own file (the role-pack-guard.js precedent) to keep fitness.js under the size ratchet.
1313
+ () => require('./scope-roster-guard.js').checkScopeRosterCoversModules({ repoRoot: REPO_ROOT, discoveredModuleKeys }),
1311
1314
  checkCoreCarriesNoModuleCode,
1312
1315
  checkKernelImportsNoDomain,
1313
1316
  checkModuleMigrationsAdditive,
@@ -0,0 +1,118 @@
1
+ // scripts/gds/scope-roster-guard.js
2
+ //
3
+ // WHAT. The fitness check behind the module scope roster (task 1002900): every module
4
+ // discovered at modules/<key>/module.json must have a covering key in MODULE_GLOBS
5
+ // (src/bongos/module-scope-map.js).
6
+ //
7
+ // WHY IT IS A HARD FAIL, when the runtime symptom is a denial rather than a crash.
8
+ // The roster is maintained by HAND. A module lands, nobody adds the entry, and any goal
9
+ // scoped to that work hits an authorize-NOTHING wall: goal-scope-check.check() denies
10
+ // with empty_scope / unknown_scope. Nothing announces it — it surfaces only when someone
11
+ // tries to set a goal's wall and cannot, which is long after the module landed and far
12
+ // from the edit that caused it. CI is the only place the gap is visible at the moment it
13
+ // is created.
14
+ //
15
+ // It has happened three times by hand: task 1002690 (provisioning + public-landing,
16
+ // goal 1000059), task 1002322 (platform-identity, goal 1000046) and `agents`, the
17
+ // instance this check was filed for. Each was fixed after someone tripped over it.
18
+ //
19
+ // WHY SUBSET AND NOT EQUALITY. The roster legitimately carries keys with no
20
+ // modules/<key>/ directory, for two different reasons, and equality would fail on all
21
+ // five of them in this tree:
22
+ // - NON-MODULE SCOPES — `kernel`, `methodology`, `devsecops` are surfaces, not modules.
23
+ // - ABSENT MODULES — `art-pipeline`, `game` are rostered but not present in this
24
+ // instance (ADR 0108: a host may carry a different module set).
25
+ // So the invariant is one-directional: every DISCOVERED module must be rostered; a
26
+ // rostered key with no module is not a fault. Getting that backwards would produce a
27
+ // check nobody can keep green, which is worse than no check.
28
+ //
29
+ // Extracted from fitness.js (which the size ratchet caps) following the
30
+ // role-pack-guard.js precedent. The pure scanner is exported for tests/fitness_scope_roster.mjs.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ // The machinery already existed — fitness.js's discoveredModuleKeys() feeds the
38
+ // kernel-machinery check — but nothing cross-checked it against moduleKeys().
39
+ // checkScopeMapImportsProtected guards HOW `protected` is derived, not WHETHER every
40
+ // module is listed.
41
+
42
+ // SCOPE: modules/ ONLY — the two named orphans, decided rather than left open.
43
+ // Task 1002900 asked for a ruling on two files said to be covered by no glob. Both
44
+ // answers changed when checked against origin/main on 2026-09-22:
45
+ // - scripts/gds/fitness.js — NOT uncovered. The `devsecops` key matches it. The row's
46
+ // claim was stale.
47
+ // - src/server.js — does not exist any more.
48
+ // - src/platform-server.js — genuinely uncovered, and DELIBERATELY still is.
49
+ // This check asserts what its name says: every carved MODULE has a roster key. Widening
50
+ // it to arbitrary src/ entrypoints is a different question about a different registry —
51
+ // `kernel` lists individual files by hand, so "should platform-server join it" is a call
52
+ // for whoever owns that scope, not a side effect of adding a module-coverage lint. Left
53
+ // as a recorded decision rather than an exemption entry, because there is nothing to
54
+ // exempt: modules/ is the stated scope and platform-server.js was never in it.
55
+
56
+ const SCOPE_ROSTER_REL = 'src/bongos/module-scope-map.js';
57
+
58
+ function scanScopeRosterCoverage({ discovered, rostered, globs, exists }) {
59
+ const missing = discovered.filter((k) => !rostered.includes(k)).sort();
60
+ const violations = missing.map((k) =>
61
+ `module '${k}' (modules/${k}/module.json) has no key in ${SCOPE_ROSTER_REL} MODULE_GLOBS — `
62
+ + 'any goal scoped to it will deny with empty_scope/unknown_scope');
63
+
64
+ // The mirror-image rot, reported as a WARNING rather than a failure: a roster glob that
65
+ // names an exact FILE which no longer exists. It cannot wall anything off, so it is a
66
+ // silent hole in a scope rather than a build breakage — and unlike a missing key it has
67
+ // no runtime symptom at all. Only exact file paths are checked; a directory glob may
68
+ // legitimately point at an absent optional module (see SUBSET above).
69
+ const warnings = [];
70
+ for (const [key, list] of Object.entries(globs || {})) {
71
+ for (const g of list || []) {
72
+ if (g.endsWith('/') || g.includes('*')) continue;
73
+ if (!exists(g)) warnings.push(`${SCOPE_ROSTER_REL}: scope '${key}' lists '${g}', which does not exist — dead entry, walls nothing`);
74
+ }
75
+ }
76
+ return { violations, warnings };
77
+ }
78
+
79
+ // REPO_ROOT and discoveredModuleKeys come from fitness.js, which owns module discovery
80
+ // (it unions across MODULE_ROOTS per ADR 0108). Passed in rather than re-derived so the
81
+ // two can never disagree about what a module is.
82
+ function checkScopeRosterCoversModules({ repoRoot, discoveredModuleKeys }) {
83
+ let res;
84
+ try {
85
+ const scopeMap = require(path.join(repoRoot, SCOPE_ROSTER_REL));
86
+ res = scanScopeRosterCoverage({
87
+ discovered: discoveredModuleKeys(),
88
+ rostered: scopeMap.moduleKeys(),
89
+ globs: scopeMap.MODULE_GLOBS,
90
+ exists: (g) => fs.existsSync(path.join(repoRoot, g)),
91
+ });
92
+ } catch (e) {
93
+ // FAILS OPEN, deliberately and narrowly. This catch fires only if requiring the scope
94
+ // map throws — i.e. the file is syntactically broken — and that is not this check's
95
+ // job to report: src/bongos/module-scope-map.js has its own unit coverage, and every
96
+ // sibling scope check (checkScopeMapImportsProtected, the ownership checks) would red
97
+ // on the same input. A coverage lint that hard-fails on someone else's syntax error
98
+ // adds a second confusing message to a build that is already failing for the real
99
+ // reason. The skip is surfaced as a warning rather than swallowed.
100
+ return {
101
+ name: 'every discovered module has a scope-roster key (task 1002900)',
102
+ ok: true, hardFail: false, violations: [], warnings: [`skipped: ${e.message}`],
103
+ note: `could not load ${SCOPE_ROSTER_REL} — check skipped.`,
104
+ };
105
+ }
106
+ return {
107
+ name: 'every discovered module has a scope-roster key (task 1002900)',
108
+ ok: res.violations.length === 0,
109
+ hardFail: res.violations.length > 0,
110
+ violations: res.violations,
111
+ warnings: res.warnings,
112
+ note: `every modules/<key>/ carrying module.json has a MODULE_GLOBS entry, so a goal can be `
113
+ + `scoped to it. Subset, not equality: a rostered key with no module is legitimate `
114
+ + `(non-module scopes like kernel/methodology/devsecops, and modules absent from this instance).`,
115
+ };
116
+ }
117
+
118
+ module.exports = { checkScopeRosterCoversModules, scanScopeRosterCoverage, SCOPE_ROSTER_REL };
@@ -451,6 +451,7 @@ const MODULE_GLOBS = {
451
451
  'scripts/gds/fitness-checks-packaging.js', 'scripts/gds/fitness-checks-route-pins.js',
452
452
  'scripts/gds/fitness-checks-vocabulary.js', 'scripts/gds/fitness-checks-write-validation.js',
453
453
  'scripts/gds/fitness-migration-contraction.js', 'scripts/gds/role-pack-guard.js',
454
+ 'scripts/gds/scope-roster-guard.js',
454
455
  'scripts/gds/route-shadow-guard.js', 'scripts/gds/routine-schedule.js',
455
456
  'scripts/gds/seam-wiring-guard.js', 'scripts/gds/skill-preflight.js',
456
457
  'scripts/gds/test-path-guard.js', 'scripts/gds/version-literal-guard.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.971'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.972'; // 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,117 @@
1
+ // tests/fitness_scope_roster.mjs
2
+ //
3
+ // Task 1002900 — the scope roster in src/bongos/module-scope-map.js (MODULE_GLOBS) is
4
+ // maintained by hand, and nothing asserted that every carved module has a covering key.
5
+ // A module lands in modules/<key>/, nobody adds the entry, and any goal scoped to that
6
+ // work hits an authorize-NOTHING wall: goal-scope-check.check() denies with
7
+ // empty_scope / unknown_scope. It surfaces only when someone tries to set a goal's wall.
8
+ //
9
+ // Three hand-fixed instances before this check existed: task 1002690 (provisioning +
10
+ // public-landing), task 1002322 (platform-identity), and `agents` — the one this row
11
+ // was filed for, since added at module-scope-map.js:322.
12
+ //
13
+ // SUBSET, NOT EQUALITY. The roster legitimately carries keys with no modules/<key>/ dir,
14
+ // for two distinct reasons, and equality would fail on all five in this tree:
15
+ // - non-module SCOPES: kernel, methodology, devsecops
16
+ // - modules ABSENT from this instance: art-pipeline, game (ADR 0108)
17
+ // So the invariant is one-directional. These tests pin that direction explicitly,
18
+ // because getting it wrong turns a useful check into one nobody can keep green.
19
+ //
20
+ // Run: node --test tests/fitness_scope_roster.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { test } from 'node:test';
24
+ import { createRequire } from 'node:module';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const { discoveredModuleKeys } = require(path.join(REPO_ROOT, 'scripts/gds/fitness.js'));
31
+ // The check lives in its own file (the role-pack-guard.js precedent) so fitness.js stays
32
+ // under the size ratchet; module discovery stays in fitness.js, which owns it.
33
+ const { scanScopeRosterCoverage } = require(path.join(REPO_ROOT, 'scripts/gds/scope-roster-guard.js'));
34
+ const scopeMap = require(path.join(REPO_ROOT, 'src/bongos/module-scope-map.js'));
35
+
36
+ const nothingExists = () => false;
37
+ const allExist = () => true;
38
+
39
+ test('THE BUG: a discovered module with no roster key is a violation', () => {
40
+ const { violations } = scanScopeRosterCoverage({
41
+ discovered: ['lifecycle', 'agents'],
42
+ rostered: ['lifecycle'],
43
+ globs: {},
44
+ exists: allExist,
45
+ });
46
+ assert.equal(violations.length, 1);
47
+ assert.match(violations[0], /'agents'/, 'must name the uncovered module');
48
+ assert.match(violations[0], /empty_scope|unknown_scope/,
49
+ 'and must name the symptom, since that is how this is actually discovered');
50
+ });
51
+
52
+ test('SUBSET NOT EQUALITY: a rostered key with no module is NOT a fault', () => {
53
+ // kernel/methodology/devsecops are surfaces, not modules; art-pipeline/game may be
54
+ // absent from a given instance. Failing on these would make the check unkeepable.
55
+ const { violations } = scanScopeRosterCoverage({
56
+ discovered: ['lifecycle'],
57
+ rostered: ['lifecycle', 'kernel', 'methodology', 'devsecops', 'art-pipeline', 'game'],
58
+ globs: {},
59
+ exists: allExist,
60
+ });
61
+ assert.deepEqual(violations, []);
62
+ });
63
+
64
+ test('several uncovered modules are all reported, sorted', () => {
65
+ const { violations } = scanScopeRosterCoverage({
66
+ discovered: ['zeta', 'alpha', 'lifecycle'],
67
+ rostered: ['lifecycle'],
68
+ globs: {},
69
+ exists: allExist,
70
+ });
71
+ assert.equal(violations.length, 2, 'one line per uncovered module — not just the first');
72
+ assert.match(violations[0], /'alpha'/);
73
+ assert.match(violations[1], /'zeta'/);
74
+ });
75
+
76
+ test('a dead exact-file glob is a WARNING, never a failure', () => {
77
+ // The mirror-image rot: a roster entry naming a file that no longer exists cannot wall
78
+ // anything off, but it breaks no build either — so it is reported, not enforced.
79
+ const { violations, warnings } = scanScopeRosterCoverage({
80
+ discovered: [], rostered: [],
81
+ globs: { kernel: ['server.js'] },
82
+ exists: nothingExists,
83
+ });
84
+ assert.deepEqual(violations, [], 'a dead glob must not fail the build');
85
+ assert.equal(warnings.length, 1);
86
+ assert.match(warnings[0], /server\.js/);
87
+ assert.match(warnings[0], /walls nothing/);
88
+ });
89
+
90
+ test('directory and wildcard globs are exempt from the existence warning', () => {
91
+ // A directory glob may legitimately point at an optional module absent from this
92
+ // instance — warning on those would bury the real signal in noise.
93
+ const { warnings } = scanScopeRosterCoverage({
94
+ discovered: [], rostered: [],
95
+ globs: { game: ['modules/game/', 'src/**/game-*.js'] },
96
+ exists: nothingExists,
97
+ });
98
+ assert.deepEqual(warnings, []);
99
+ });
100
+
101
+ test('THE REAL TREE: every discovered module is rostered (the check must land green)', () => {
102
+ // Last, and paired with the fixtures above: alone this would pass against a scanner
103
+ // that always returns [].
104
+ const { violations } = scanScopeRosterCoverage({
105
+ discovered: discoveredModuleKeys(),
106
+ rostered: scopeMap.moduleKeys(),
107
+ globs: {},
108
+ exists: allExist,
109
+ });
110
+ assert.deepEqual(violations, [],
111
+ 'a module was carved without a MODULE_GLOBS key — any goal scoped to it denies everything');
112
+ });
113
+
114
+ test('agents specifically is rostered (the instance this row was filed for)', () => {
115
+ assert.ok(scopeMap.moduleKeys().includes('agents'));
116
+ assert.ok(discoveredModuleKeys().includes('agents'), 'and it is a real discovered module, not just a roster entry');
117
+ });
@@ -0,0 +1,233 @@
1
+ // tests/goal_mine_lens.mjs — the goal board's half of the hall's "mine" lens:
2
+ // the goals you are added on, and inside one goal, the tasks you are holding.
3
+ //
4
+ // Two things are pinned here, and both are the kind that render fine while being
5
+ // wrong:
6
+ //
7
+ // * "My goals" NOW READS THE ROW. The lens used to answer membership out of the
8
+ // per-goal DETAIL prefetch — a best-effort fan-out. When that fan-out had not
9
+ // landed, or had failed, every goal answered "not a member" and the lens
10
+ // returned an empty board: an assertion about the READER, manufactured out of
11
+ // a failed fetch. GET /goals now carries `viewer_is_member`, so the lens has
12
+ // evidence that arrives with the list it is filtering.
13
+ // * A GOAL'S TO-DO LIST CAN BE NARROWED TO YOUR OWN CLAIMS, which is the board's
14
+ // "Only mine" question scoped to one goal.
15
+ //
16
+ // Executes the REAL goals-page.js + goals-render.js in a vm against the stub DOM
17
+ // this repo uses in place of jsdom — the tests/goal_criterion_confirm_ui.mjs
18
+ // precedent, and for its stated reason: it is the only way a renderer bug is
19
+ // caught rather than a source string.
20
+ //
21
+ // Run: node --test tests/goal_mine_lens.mjs
22
+
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import vm from 'node:vm';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const hall = (f) => fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8');
32
+ const TASK_LIB_SRC = hall('task-lib.js');
33
+ const GOALS_LIB_SRC = hall('goals-lib.js');
34
+ // goal-forest-lib.js is loaded so the detail view's dependency forest does not
35
+ // throw on the #/goal/N tests; nothing here asserts on it. It is a UMD module
36
+ // that attaches to the sandbox global rather than to `window`, so it is bridged
37
+ // across by hand after it runs.
38
+ const GOAL_FOREST_LIB_SRC = hall('goal-forest-lib.js');
39
+ const GOAL_FOREST_RENDER_SRC = hall('goal-forest-render.js');
40
+ const GOALS_RENDER_SRC = hall('goals-render.js');
41
+ const GOALS_PAGE_SRC = hall('goals-page.js');
42
+
43
+ const ME_ID = 1;
44
+
45
+ // Boot goals-page.js.
46
+ // `filters` — control values seeded BEFORE boot, so the first render already
47
+ // runs under the lens (the stub filter bar fires no onChange).
48
+ // `detailsFail` — the per-goal detail prefetch rejects, which is the exact
49
+ // state that used to empty the "My goals" board.
50
+ async function boot({
51
+ hash = '',
52
+ goals = [],
53
+ tasks = [],
54
+ activeClaims = [],
55
+ filters = {},
56
+ detailsFail = false,
57
+ } = {}) {
58
+ const requests = [];
59
+ const els = new Map();
60
+ const makeEl = (key) => ({
61
+ _key: key, dataset: {}, style: {}, title: '', textContent: '', hidden: false,
62
+ disabled: false, value: '', innerHTML: '',
63
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
64
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
65
+ appendChild() {}, focus() {}, click() {}, querySelectorAll() { return []; },
66
+ addEventListener() {},
67
+ });
68
+ const $ = (sel) => {
69
+ if (!sel) return null;
70
+ if (!els.has(sel)) els.set(sel, makeEl(sel));
71
+ return els.get(sel);
72
+ };
73
+ // Seed the lens controls before anything reads them.
74
+ for (const [id, value] of Object.entries(filters)) $(`#${id}`).value = value;
75
+
76
+ const api = {
77
+ request(method, p) {
78
+ requests.push([method, p]);
79
+ const reply = (data, status = 200) => Promise.resolve({ status, ok: true, data });
80
+ if (method === 'GET' && /\/me$/.test(p)) return reply({ builder: { id: ME_ID, rank: 'metic' }, active_claims: [] });
81
+ if (method === 'GET' && /\/goals$/.test(p)) return reply({ goals });
82
+ if (method === 'GET' && /\/goals\/\d+$/.test(p)) {
83
+ if (detailsFail) return Promise.resolve({ status: 500, ok: false, data: null });
84
+ const id = Number(p.match(/\/goals\/(\d+)$/)[1]);
85
+ const goal = goals.find((g) => Number(g.id) === id) || goals[0];
86
+ return reply({ goal, members: [], criteria: [], dependencies: [] });
87
+ }
88
+ if (method === 'GET' && /\/tasks\/claimable/.test(p)) return reply({ claimable: [], active_claims: activeClaims });
89
+ if (method === 'GET' && /\/tasks\?/.test(p)) return reply({ tasks });
90
+ if (method === 'GET' && /\/conflicts$/.test(p)) return reply({ pairs: [] });
91
+ if (method === 'GET' && /\/requests$/.test(p)) return reply({ requests: [] });
92
+ return reply({});
93
+ },
94
+ };
95
+
96
+ const windowObj = {
97
+ OTB: {
98
+ roleAxisLabel: () => 'Role',
99
+ roleLabel: (d) => String(d || ''),
100
+ roleOptions: () => [{ value: '', label: '— any —' }],
101
+ roleIsUnset: () => true,
102
+ ROLE_UNSET: 'unclassified',
103
+ KIND_UNSET: 'unclassified',
104
+ kindIsUnset: () => true,
105
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])),
106
+ $, fmtNum: (n) => String(n), fmtDate: () => '', rankLabel: (r) => r,
107
+ toast() {}, categoryChip: () => '', fillKindFilter() {},
108
+ },
109
+ OTBKit: {
110
+ makePager: (prefix) => ({ prefix, page: 1, perPage: 50 }),
111
+ paginate: (rows) => rows,
112
+ wirePager() {}, pagerBarHtml: () => '',
113
+ makeTabs: () => ({ setCount() {}, select() {}, activate() {} }),
114
+ makeFilterBar() {},
115
+ emptyStateHtml: (m) => `<div class="kit-empty">${m}</div>`,
116
+ skeletonRows: () => '',
117
+ ageChipHtml: () => '',
118
+ },
119
+ OTBBranding: { currencyLabel: () => 'credits', taskSettled: () => false, taskCreditValue: () => 0 },
120
+ OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
121
+ BongosClient: { createClient: () => api },
122
+ location: { hash, pathname: '/builders/goals', search: '', assign() {} },
123
+ addEventListener() {},
124
+ scrollTo() {},
125
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
126
+ localStorage: { getItem() { return null; }, setItem() {} },
127
+ };
128
+ const documentObj = {
129
+ body: { dataset: {} },
130
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
131
+ getElementById: (id) => $(`#${id}`),
132
+ querySelector: (sel) => $(sel),
133
+ querySelectorAll: () => [],
134
+ createElement: () => makeEl('created'),
135
+ addEventListener() {},
136
+ };
137
+
138
+ const sandbox = {
139
+ window: windowObj, document: documentObj, location: windowObj.location,
140
+ console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object,
141
+ JSON, Math, encodeURIComponent, Promise, Date, URLSearchParams,
142
+ };
143
+ sandbox.globalThis = sandbox;
144
+ vm.createContext(sandbox);
145
+ vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
146
+ vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
147
+ vm.runInContext(GOAL_FOREST_LIB_SRC, sandbox, { filename: 'goal-forest-lib.js' });
148
+ windowObj.OTBGoalForestLib = sandbox.OTBGoalForestLib;
149
+ vm.runInContext(GOAL_FOREST_RENDER_SRC, sandbox, { filename: 'goal-forest-render.js' });
150
+ vm.runInContext(GOALS_RENDER_SRC, sandbox, { filename: 'goals-render.js' });
151
+ vm.runInContext(GOALS_PAGE_SRC, sandbox, { filename: 'goals-page.js' });
152
+ for (let i = 0; i < 30; i += 1) await new Promise((r) => setTimeout(r, 0));
153
+
154
+ return { requests, el: (sel) => $(sel) };
155
+ }
156
+
157
+ const GOALS = [
158
+ { id: 11, version_id: 'V1', title: 'Goal I am on', status: 'open', visibility: 'public', created_by: 999, scope_modules: [], viewer_is_member: true },
159
+ { id: 12, version_id: 'V1', title: 'Goal I am not on', status: 'open', visibility: 'public', created_by: 999, scope_modules: [], viewer_is_member: false },
160
+ ];
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 1. "My goals" — the goals you are added on
164
+ // ---------------------------------------------------------------------------
165
+
166
+ test('the goal board shows both goals with the lens off', async () => {
167
+ const { el } = await boot({ goals: GOALS });
168
+ const grid = el('#goal-grid').innerHTML;
169
+ assert.match(grid, /Goal I am on/);
170
+ assert.match(grid, /Goal I am not on/);
171
+ });
172
+
173
+ test('"My goals" keeps the goals the viewer is a member of and drops the rest', async () => {
174
+ const { el } = await boot({ goals: GOALS, filters: { 'f-goal-mine': 'mine' } });
175
+ const grid = el('#goal-grid').innerHTML;
176
+ assert.match(grid, /Goal I am on/);
177
+ assert.doesNotMatch(grid, /Goal I am not on/);
178
+ });
179
+
180
+ // The regression this change exists for. Before `viewer_is_member`, membership
181
+ // was read from the per-goal detail — so a failed prefetch answered "you are on
182
+ // none of them" and the lens rendered an empty board.
183
+ test('"My goals" still answers when the per-goal detail prefetch FAILS', async () => {
184
+ const { el } = await boot({ goals: GOALS, filters: { 'f-goal-mine': 'mine' }, detailsFail: true });
185
+ const grid = el('#goal-grid').innerHTML;
186
+ assert.match(grid, /Goal I am on/,
187
+ 'the row carries the membership, so a dead detail read cannot empty the lens');
188
+ assert.doesNotMatch(grid, /Goal I am not on/);
189
+ });
190
+
191
+ // A deploy whose server has not yet grown the flag must fall back rather than
192
+ // answer "no" — the boolean check is what makes the fallback reachable.
193
+ test('a goal row WITHOUT the flag falls back to the detail instead of reading as "not mine"', async () => {
194
+ const legacy = GOALS.map(({ viewer_is_member, ...g }) => g); // eslint-disable-line no-unused-vars
195
+ const { el } = await boot({ goals: legacy, filters: { 'f-goal-mine': 'mine' } });
196
+ // The stub's detail returns members: [], so nothing matches — the point is that
197
+ // the page consulted the detail at all rather than short-circuiting on a
198
+ // missing field, and that it did not crash.
199
+ assert.ok(el('#goal-grid').innerHTML.length > 0, 'the board still renders');
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // 2. Inside one goal — the tasks you have claimed
204
+ // ---------------------------------------------------------------------------
205
+
206
+ const GOAL_TASKS = [
207
+ { id: 301, title: 'held by me', status: 'active', goal_id: 11, touches: [] },
208
+ { id: 302, title: 'held by someone else', status: 'active', goal_id: 11, touches: [] },
209
+ { id: 303, title: 'nobody has claimed this', status: 'ready', goal_id: 11, touches: [] },
210
+ ];
211
+ const GOAL_CLAIMS = [
212
+ { claim_id: 1, task_id: 301, builder_id: ME_ID, github_login: 'me', display_name: 'Me' },
213
+ { claim_id: 2, task_id: 302, builder_id: 999, github_login: 'them', display_name: 'Them' },
214
+ ];
215
+
216
+ test("a goal's To-do list shows every open task with the lens off", async () => {
217
+ const { el } = await boot({ hash: '#/goal/11', goals: GOALS, tasks: GOAL_TASKS, activeClaims: GOAL_CLAIMS });
218
+ const list = el('#goal-todo-list').innerHTML;
219
+ for (const title of ['held by me', 'held by someone else', 'nobody has claimed this']) {
220
+ assert.match(list, new RegExp(title), `${title} is there unfiltered`);
221
+ }
222
+ });
223
+
224
+ test('"claimed by me" narrows a goal\'s To-do list to the viewer\'s own claims', async () => {
225
+ const { el } = await boot({
226
+ hash: '#/goal/11', goals: GOALS, tasks: GOAL_TASKS, activeClaims: GOAL_CLAIMS,
227
+ filters: { 'f-goal-claimed': 'mine' },
228
+ });
229
+ const list = el('#goal-todo-list').innerHTML;
230
+ assert.match(list, /held by me/);
231
+ assert.doesNotMatch(list, /held by someone else/, "another builder's claim is not mine");
232
+ assert.doesNotMatch(list, /nobody has claimed this/, 'an unclaimed task is nobody\'s');
233
+ });
@@ -116,14 +116,15 @@ await test('getGoal: unknown id returns null (not undefined)', async () => {
116
116
  // =========================================================================
117
117
  // listGoals
118
118
  // =========================================================================
119
- await test('listGoals: no args binds [null, null, null] (all three filters off)', async () => {
119
+ await test('listGoals: no args binds [null, null, null, null] (every filter off, no viewer)', async () => {
120
120
  const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [GOAL_ROW] } : null));
121
121
  const out = await listGoals(undefined, { pool });
122
122
  assert.deepEqual(out, [GOAL_ROW]);
123
123
  const sel = find(pool.queries, /FROM goals/);
124
124
  // task 1002539 added an OPTIONAL limit. Postgres treats `LIMIT NULL` as unbounded,
125
125
  // so the goal board's read is byte-for-byte unchanged in behaviour.
126
- assert.deepEqual(sel.params, [null, null, null]);
126
+ // $4 is the viewer whose membership `viewer_is_member` answers; null = nobody.
127
+ assert.deepEqual(sel.params, [null, null, null, null]);
127
128
  assert.match(sel.sql, /ORDER BY version_id, sort_order, created_at, id/, 'priority-first, then stable ordering');
128
129
  assert.match(sel.sql, /LIMIT \$3::int/, 'the limit is bound, never interpolated');
129
130
  });
@@ -131,13 +132,38 @@ await test('listGoals: no args binds [null, null, null] (all three filters off)'
131
132
  await test('listGoals: versionId + status flow through to the bound params', async () => {
132
133
  const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
133
134
  await listGoals({ versionId: 'BONGOS-V1', status: 'open' }, { pool });
134
- assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', null]);
135
+ assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', null, null]);
135
136
  });
136
137
 
137
138
  await test('listGoals: an explicit limit is bound so Postgres truncates, not JS', async () => {
138
139
  const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
139
140
  await listGoals({ versionId: 'BONGOS-V1', status: 'open', limit: 6 }, { pool });
140
- assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', 6]);
141
+ assert.deepEqual(find(pool.queries, /FROM goals/).params, ['BONGOS-V1', 'open', 6, null]);
142
+ });
143
+
144
+ // The "goals I am added on" half of the hall's Only-mine lens. The board asks this
145
+ // of a list it already fetches, so the answer has to ride the row rather than cost
146
+ // a membership read per goal.
147
+ await test('listGoals: a viewer binds as $4 and the row carries viewer_is_member', async () => {
148
+ const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
149
+ await listGoals({ viewerId: 77 }, { pool });
150
+ const sel = find(pool.queries, /FROM goals/);
151
+ assert.deepEqual(sel.params, [null, null, null, 77]);
152
+ assert.match(sel.sql, /AS viewer_is_member/, 'the flag is named on the wire');
153
+ assert.match(sel.sql, /EXISTS \(SELECT 1 FROM goal_members gm[\s\S]*?gm\.builder_id = \$4::bigint\)/,
154
+ 'and it is an EXISTS over goal_members for THAT builder');
155
+ });
156
+
157
+ // A NULL viewer must yield false, never NULL: the client branches on
158
+ // `typeof g.viewer_is_member === 'boolean'` to decide whether the server answered
159
+ // at all, so a three-valued column would make "nobody is reading" indistinguishable
160
+ // from "this deploy does not send the flag" and silently re-arm the empty-lens bug.
161
+ await test('listGoals: a viewer-less read still answers the flag with a BOOLEAN false', async () => {
162
+ const pool = fakePool((s) => (/FROM goals/.test(s) ? { rows: [] } : null));
163
+ await listGoals({}, { pool });
164
+ const sel = find(pool.queries, /FROM goals/);
165
+ assert.match(sel.sql, /\(\$4::bigint IS NOT NULL\s*\n?\s*AND EXISTS/,
166
+ 'the NULL case is short-circuited to false before the EXISTS is consulted');
141
167
  });
142
168
 
143
169
  // =========================================================================