@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.
- package/.bongos-core.json +55 -25
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +151 -152
- package/docs/copy-registry.json +193 -212
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +16 -0
- package/modules/hall-ui/public/mine-lens.js +172 -0
- package/modules/hall-ui/public/work-summary.js +90 -0
- package/modules/hall-ui/public/work.css +50 -0
- package/modules/hall-ui/public/work.html +24 -0
- package/modules/hall-ui/public/work.js +38 -51
- package/modules/lifecycle/db-goals.js +20 -3
- package/modules/lifecycle/routes/goals.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +3 -0
- package/scripts/gds/scope-roster-guard.js +118 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/fitness_scope_roster.mjs +117 -0
- package/tests/goal_mine_lens.mjs +233 -0
- package/tests/goal_tier.mjs +30 -4
- package/tests/hall_mine_lens.mjs +381 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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.
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
});
|
package/tests/goal_tier.mjs
CHANGED
|
@@ -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] (
|
|
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
|
-
|
|
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
|
// =========================================================================
|