@bongos/core 1.19.621 → 1.19.623
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 +60 -30
- package/docs/copy-inventory.md +90 -82
- package/docs/copy-registry.json +205 -111
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/board-room.css +83 -0
- package/modules/hall-ui/public/board-room.html +70 -0
- package/modules/hall-ui/public/board-room.js +517 -0
- package/modules/hall-ui/public/board-room.states.json +104 -0
- package/modules/hall-ui/public/government.css +10 -57
- package/modules/hall-ui/public/government.html +10 -12
- package/modules/hall-ui/public/government.js +119 -367
- package/modules/hall-ui/public/palette.js +5 -2
- package/modules/hall-ui/public/shell.js +16 -0
- package/modules/lifecycle/db-tasks.js +24 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-vocabulary.js +7 -0
- package/scripts/gds/start.js +29 -11
- package/src/bongos/auth.js +27 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +6 -5
- package/tests/cross_discipline_priority_direction.mjs +196 -0
- package/tests/government_board_hash_redirect.mjs +129 -0
- package/tests/government_board_room_copy.mjs +41 -15
- package/tests/government_board_vote_ui.mjs +5 -1
- package/tests/hall_nav.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +11 -0
- package/tests/hall_palette.mjs +9 -3
- package/tests/rank_tier_single_source.mjs +8 -0
|
@@ -855,11 +855,22 @@ function taskTouchesProtectedPath(task) {
|
|
|
855
855
|
// and rank them.
|
|
856
856
|
//
|
|
857
857
|
// Ranking key (most relevant first):
|
|
858
|
-
// 1. priority
|
|
858
|
+
// 1. priority ASC, unset last — 1 is the MOST urgent. This is the DB's own
|
|
859
|
+
// convention: CHECK (priority BETWEEN 1 AND 5), and every version-progress
|
|
860
|
+
// view weights work as 6 - COALESCE(priority, 5), so a lower number earns
|
|
861
|
+
// more weight. It is the same order the SQL feeds use — `priority ASC
|
|
862
|
+
// NULLS LAST` in the optimizer, `priority NULLS LAST` in listTasks and the
|
|
863
|
+
// goal rollup. A task with no priority set ranks after an explicit P5,
|
|
864
|
+
// never ahead of a P1.
|
|
859
865
|
// 2. credits-per-minute DESC — best bang-for-effort among equal priority
|
|
860
866
|
// 3. est_minutes ASC — shorter wins the tiebreak (easier to try)
|
|
861
867
|
// 4. id ASC — stable final tiebreak
|
|
862
868
|
//
|
|
869
|
+
// scripts/gds/start.js keeps a hand-copied mirror of this function (it is a
|
|
870
|
+
// pure HTTP client and cannot import this module). The two are a documented
|
|
871
|
+
// mirror pair: tests/cross_discipline_priority_direction.mjs executes BOTH and
|
|
872
|
+
// fails if either drifts. Change one, change the other.
|
|
873
|
+
//
|
|
863
874
|
// 'unclassified' tasks are excluded: they're un-triaged, not a real lane to
|
|
864
875
|
// recommend exploring. If preferred_disciplines is empty (no preference), the
|
|
865
876
|
// builder already sees everything in the main list, so there is nothing
|
|
@@ -876,13 +887,22 @@ function rankCrossDisciplineRecommendations(claimable, preferredDisciplines, lim
|
|
|
876
887
|
const mins = Number(t.est_minutes_calibrated ?? t.est_minutes) || 0;
|
|
877
888
|
return mins > 0 ? credits / mins : 0;
|
|
878
889
|
};
|
|
890
|
+
// `ASC NULLS LAST` expressed in JS: an absent, blank or non-numeric priority
|
|
891
|
+
// sorts AFTER every explicit 1..5. Deliberately not `Number(t.priority) || 0`
|
|
892
|
+
// — that maps NULL to 0, which under an ascending sort is more urgent than a
|
|
893
|
+
// P1. Infinity is the only value that keeps unset work at the back.
|
|
894
|
+
const prio = (t) => {
|
|
895
|
+
if (t.priority === null || t.priority === undefined || t.priority === '') return Infinity;
|
|
896
|
+
const n = Number(t.priority);
|
|
897
|
+
return Number.isFinite(n) ? n : Infinity;
|
|
898
|
+
};
|
|
879
899
|
const outside = rows.filter(
|
|
880
900
|
(t) => t.discipline && t.discipline !== 'unclassified' && !prefSet.has(t.discipline)
|
|
881
901
|
);
|
|
882
902
|
outside.sort((a, b) => {
|
|
883
|
-
const pa =
|
|
884
|
-
const pb =
|
|
885
|
-
if (
|
|
903
|
+
const pa = prio(a);
|
|
904
|
+
const pb = prio(b);
|
|
905
|
+
if (pa !== pb) return pa - pb; // lower number = more urgent; unset last
|
|
886
906
|
const ca = cpm(a);
|
|
887
907
|
const cb = cpm(b);
|
|
888
908
|
if (cb !== ca) return cb - ca; // higher credits/min first
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.623",
|
|
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.623",
|
|
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.623",
|
|
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",
|
|
@@ -59,6 +59,13 @@ const VOCAB_SURFACE = [
|
|
|
59
59
|
'modules/hall-ui/public/government.js',
|
|
60
60
|
'modules/hall-ui/public/government.html',
|
|
61
61
|
'modules/hall-ui/public/government.css',
|
|
62
|
+
// ADR 0266 carved the Board Room out of government.js onto its own page. The
|
|
63
|
+
// wall follows the SURFACE, not the filename — without these two rows the
|
|
64
|
+
// room's own files would have left the wall's scope, which is how a renamed
|
|
65
|
+
// file quietly drops an enforcement it used to be held to.
|
|
66
|
+
'modules/hall-ui/public/board-room.js',
|
|
67
|
+
'modules/hall-ui/public/board-room.html',
|
|
68
|
+
'modules/hall-ui/public/board-room.css',
|
|
62
69
|
];
|
|
63
70
|
const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css']);
|
|
64
71
|
const VOCAB_BANNED = [
|
package/scripts/gds/start.js
CHANGED
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
//
|
|
46
46
|
// Below the main list, a "You might enjoy" section surfaces up to 3 claimable
|
|
47
47
|
// tasks OUTSIDE the builder's disciplines, ranked by relevance. The ranking
|
|
48
|
-
// mirrors
|
|
49
|
-
// home for this logic — kept in sync deliberately;
|
|
50
|
-
// client and cannot import the server db module).
|
|
48
|
+
// mirrors rankCrossDisciplineRecommendations in modules/lifecycle/db-tasks.js
|
|
49
|
+
// (the canonical server-side home for this logic — kept in sync deliberately;
|
|
50
|
+
// start.js is a pure HTTP client and cannot import the server db module).
|
|
51
51
|
//
|
|
52
52
|
//
|
|
53
53
|
// Returns JSON if --json is passed (for the slash command to parse cleanly).
|
|
@@ -470,12 +470,19 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
|
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
// Cross-discipline recommendations — mirror of
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
473
|
+
// rankCrossDisciplineRecommendations in modules/lifecycle/db-tasks.js (the
|
|
474
|
+
// canonical copy; it moved there from src/bongos/db.js in the twelve-unit
|
|
475
|
+
// carve). Given the full claimable list and the builder's
|
|
476
|
+
// preferred_disciplines, return up to `limit` tasks whose discipline is OUTSIDE
|
|
477
|
+
// the builder's preferences, ranked:
|
|
478
|
+
// priority ASC (unset last), credits/min DESC, est_minutes ASC, id ASC.
|
|
479
|
+
// Priority 1 is the MOST urgent — the DB's own convention (CHECK (priority
|
|
480
|
+
// BETWEEN 1 AND 5), weighted 6 - COALESCE(priority, 5)); an unset priority
|
|
481
|
+
// sorts after an explicit P5, never ahead of a P1.
|
|
477
482
|
// 'unclassified' tasks are excluded (un-triaged, not a real lane). Empty
|
|
478
483
|
// preferences → [] (everything is already in the main list; nothing outside).
|
|
484
|
+
// This copy and the canonical one are pinned together by
|
|
485
|
+
// tests/cross_discipline_priority_direction.mjs — change one, change the other.
|
|
479
486
|
function rankCrossDisciplineRecommendations(claimable, preferredDisciplines, limit = 3) {
|
|
480
487
|
const rows = Array.isArray(claimable) ? claimable : [];
|
|
481
488
|
const prefs = Array.isArray(preferredDisciplines) ? preferredDisciplines : [];
|
|
@@ -486,13 +493,21 @@ function rankCrossDisciplineRecommendations(claimable, preferredDisciplines, lim
|
|
|
486
493
|
const mins = Number(t.est_minutes_calibrated ?? t.est_minutes) || 0;
|
|
487
494
|
return mins > 0 ? credits / mins : 0;
|
|
488
495
|
};
|
|
496
|
+
// `ASC NULLS LAST` in JS — an absent/blank/non-numeric priority sorts after
|
|
497
|
+
// every explicit 1..5. Not `Number(t.priority) || 0`: that maps NULL to 0,
|
|
498
|
+
// which under an ascending sort outranks a P1.
|
|
499
|
+
const prio = (t) => {
|
|
500
|
+
if (t.priority === null || t.priority === undefined || t.priority === '') return Infinity;
|
|
501
|
+
const n = Number(t.priority);
|
|
502
|
+
return Number.isFinite(n) ? n : Infinity;
|
|
503
|
+
};
|
|
489
504
|
const outside = rows.filter(
|
|
490
505
|
(t) => t.discipline && t.discipline !== 'unclassified' && !prefSet.has(t.discipline)
|
|
491
506
|
);
|
|
492
507
|
outside.sort((a, b) => {
|
|
493
|
-
const pa =
|
|
494
|
-
const pb =
|
|
495
|
-
if (
|
|
508
|
+
const pa = prio(a);
|
|
509
|
+
const pb = prio(b);
|
|
510
|
+
if (pa !== pb) return pa - pb;
|
|
496
511
|
const ca = cpm(a);
|
|
497
512
|
const cb = cpm(b);
|
|
498
513
|
if (cb !== ca) return cb - ca;
|
|
@@ -973,7 +988,9 @@ async function main() {
|
|
|
973
988
|
// Only auto-run when invoked directly (`node start.js …`); a `require()` (the
|
|
974
989
|
// unit test) imports the pure render helpers without executing the fetch flow —
|
|
975
990
|
// the claim.js precedent. The owed-rebase warning helpers (ADR 0120 part 5) are
|
|
976
|
-
// exported so they can be unit-tested without a live server
|
|
991
|
+
// exported so they can be unit-tested without a live server, and so is
|
|
992
|
+
// rankCrossDisciplineRecommendations — the mirror test executes THIS copy
|
|
993
|
+
// alongside the canonical one rather than pattern-matching the source.
|
|
977
994
|
if (require.main === module) {
|
|
978
995
|
main().catch((err) => {
|
|
979
996
|
console.error('fatal:', err);
|
|
@@ -982,6 +999,7 @@ if (require.main === module) {
|
|
|
982
999
|
}
|
|
983
1000
|
|
|
984
1001
|
module.exports = {
|
|
1002
|
+
rankCrossDisciplineRecommendations,
|
|
985
1003
|
rebaseWarningModel,
|
|
986
1004
|
rebaseWarningMarkdown,
|
|
987
1005
|
rebaseWarningWidget,
|
package/src/bongos/auth.js
CHANGED
|
@@ -401,6 +401,21 @@ const PAGE_GATES = Object.freeze({
|
|
|
401
401
|
requireProjectCuratePage: Object.freeze({
|
|
402
402
|
permission: 'project.curate', deniedTo: '/builders', alsoAdmits: 'isProjectOwner',
|
|
403
403
|
}),
|
|
404
|
+
// The /board-room page (ADR 0266) — gated on `board.vote.cast`, the SAME atom
|
|
405
|
+
// the vote route checks, so the surface's reach equals the right to vote.
|
|
406
|
+
//
|
|
407
|
+
// That atom floors at XENOS on purpose (modules/government/board.js — low so
|
|
408
|
+
// that WIDENING the board works), which makes this the widest page gate in the
|
|
409
|
+
// table. It is deliberate and it is not a hole: the floor is the coarse gate,
|
|
410
|
+
// and MEMBERSHIP is still checked in-handler per item against that item's own
|
|
411
|
+
// snapshotted constitution. A holder who sits on no board sees the room and no
|
|
412
|
+
// ballot — the honest state, since ADR 0175 §9 made the ballot open on purpose.
|
|
413
|
+
//
|
|
414
|
+
// Reaching for `page.view.government` (metic+) instead would look tighter and
|
|
415
|
+
// be wrong: an instance that widens membership below Metic would then have
|
|
416
|
+
// members who can cast a vote through the API and cannot load the page they
|
|
417
|
+
// would cast it on. That is the code-path change ADR 0175 §6 exists to remove.
|
|
418
|
+
requireBoardVotePage: Object.freeze({ permission: 'board.vote.cast', deniedTo: '/builders' }),
|
|
404
419
|
});
|
|
405
420
|
|
|
406
421
|
// The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
|
|
@@ -550,6 +565,17 @@ async function requireProjectCuratePage(req, res, next) {
|
|
|
550
565
|
return runPageGate('requireProjectCuratePage', req, res, next);
|
|
551
566
|
}
|
|
552
567
|
|
|
568
|
+
// The /board-room page (ADR 0266) — gated on the `board.vote.cast` ATOM, the
|
|
569
|
+
// same key the vote route checks, so the page layer can never drift NARROWER
|
|
570
|
+
// than the API it fronts. That is the direction that matters here: the Board
|
|
571
|
+
// Room used to live inside /government behind `page.view.government` (metic+)
|
|
572
|
+
// while the vote atom floors at xenos, so a widened board produced members who
|
|
573
|
+
// could vote by API and not load the page. See PAGE_GATES above for why the
|
|
574
|
+
// wide floor is correct rather than a hole (membership is the in-handler check).
|
|
575
|
+
async function requireBoardVotePage(req, res, next) {
|
|
576
|
+
return runPageGate('requireBoardVotePage', req, res, next);
|
|
577
|
+
}
|
|
578
|
+
|
|
553
579
|
// The orientation reading rooms (the citizen's primer, the system-maps/diagrams)
|
|
554
580
|
// that #766 closed to newcomers and #770 enforces at the URL. A Xenos's path is
|
|
555
581
|
// action-only — find work, claim, ship three — so these pages are reserved for
|
|
@@ -821,6 +847,7 @@ module.exports = {
|
|
|
821
847
|
requireGovernmentManagePage,
|
|
822
848
|
requireNonXenosPage,
|
|
823
849
|
requireProjectCuratePage,
|
|
850
|
+
requireBoardVotePage,
|
|
824
851
|
requireRank,
|
|
825
852
|
rankMeetsThreshold,
|
|
826
853
|
requirePermission,
|
|
@@ -844,6 +844,21 @@ function mountInternalSurfaces(app) {
|
|
|
844
844
|
// the `government.manage` atom itself (archon), the same key its data routes
|
|
845
845
|
// check — requireGovernmentManagePage, not the metic-floored gate above.
|
|
846
846
|
const GOVERNMENT_PAGE_RE = /^\/builders\/government(?:\.html)?\/?$/;
|
|
847
|
+
// /board-room — the Board Room, carved out of the /government tab strip onto
|
|
848
|
+
// its own page (ADR 0266). It does NOT take the gate above. Its atom is
|
|
849
|
+
// `board.vote.cast`, the same key the vote route checks, so the page's reach
|
|
850
|
+
// equals the right to vote: that atom floors at xenos on purpose, so that a
|
|
851
|
+
// constitution widening membership below Metic gets members who can actually
|
|
852
|
+
// reach the room rather than members who can only vote by curl. Membership is
|
|
853
|
+
// still the in-handler check, per item, against that item's own snapshotted
|
|
854
|
+
// constitution.
|
|
855
|
+
//
|
|
856
|
+
// NB as at PROJECT_SETTINGS_PAGE_RE: this assignment must stay
|
|
857
|
+
// `const NAME_RE = /…/;` with nothing between the `=` and the literal —
|
|
858
|
+
// tests/hall_page_gate_map.mjs rebuilds the URL→gate map by parsing this file
|
|
859
|
+
// statically, and a comment in that gap drops the regex out of the map, which
|
|
860
|
+
// makes the page test as UNGATED while looking gated here. Prose goes above.
|
|
861
|
+
const BOARD_ROOM_PAGE_RE = /^\/builders\/board-room(?:\.html)?\/?$/;
|
|
847
862
|
// The /people builder directory (task 1002577): gated on `project.curate`, the
|
|
848
863
|
// exact permission its data route (GET /scouting) checks — same posture as the
|
|
849
864
|
// /government tab above (the page can never drift wider than the API it fronts).
|
|
@@ -885,6 +900,12 @@ function mountInternalSurfaces(app) {
|
|
|
885
900
|
if (GOVERNMENT_PAGE_RE.test(norm)) {
|
|
886
901
|
return gdsAuth.requireGovernmentPage(req, res, next);
|
|
887
902
|
}
|
|
903
|
+
// ADR 0266: the Board Room's own page, on the vote atom rather than the
|
|
904
|
+
// government page floor. Ordered after the branch above only for reading
|
|
905
|
+
// order — the regexes are disjoint, so the sequence carries no meaning.
|
|
906
|
+
if (BOARD_ROOM_PAGE_RE.test(norm)) {
|
|
907
|
+
return gdsAuth.requireBoardVotePage(req, res, next);
|
|
908
|
+
}
|
|
888
909
|
if (PEOPLE_PAGE_RE.test(norm)) {
|
|
889
910
|
return gdsAuth.requireProjectCuratePage(req, res, next);
|
|
890
911
|
}
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.623'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -94,16 +94,17 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
|
|
|
94
94
|
// requireBuilder — the API gate. Guarded here (BV1.R109-follow-up).
|
|
95
95
|
// requireBuilderPage \
|
|
96
96
|
// requireGovernmentPage \
|
|
97
|
-
// requireGovernmentManagePage
|
|
98
|
-
// requireNonXenosPage
|
|
99
|
-
// requireProjectCuratePage
|
|
97
|
+
// requireGovernmentManagePage \ all SIX delegate to runPageGate, guarded by BV1.R107.
|
|
98
|
+
// requireNonXenosPage / (the R113 roles-tab gate; the /people curate
|
|
99
|
+
// requireProjectCuratePage / gate; the ADR 0266 /board-room vote gate)
|
|
100
|
+
// requireBoardVotePage /
|
|
100
101
|
// Anything else async and router-mounted must be added with its own try/catch.
|
|
101
102
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
|
|
102
103
|
const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
|
|
103
|
-
assert.deepEqual(asyncMw.sort(), ['requireBuilder', 'requireBuilderPage', 'requireGovernmentManagePage', 'requireGovernmentPage', 'requireNonXenosPage', 'requireProjectCuratePage'],
|
|
104
|
+
assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireGovernmentManagePage', 'requireGovernmentPage', 'requireNonXenosPage', 'requireProjectCuratePage'],
|
|
104
105
|
'a new async router middleware appeared — give it a try/catch and add it here');
|
|
105
106
|
// The page gates must actually delegate (that is WHY they are safe).
|
|
106
|
-
for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireGovernmentManagePage', 'requireNonXenosPage', 'requireProjectCuratePage']) {
|
|
107
|
+
for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireGovernmentManagePage', 'requireNonXenosPage', 'requireProjectCuratePage', 'requireBoardVotePage']) {
|
|
107
108
|
const fn = src.slice(src.indexOf(`async function ${name}`));
|
|
108
109
|
assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
|
|
109
110
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tests/cross_discipline_priority_direction.mjs — priority sorts ONE way
|
|
2
|
+
// (task 1003756).
|
|
3
|
+
//
|
|
4
|
+
// tasks.priority is 1..5 with 1 the MOST urgent. The DB says so in two places
|
|
5
|
+
// that cannot drift (the CHECK constraint and the 6 - COALESCE(priority, 5)
|
|
6
|
+
// weighting every version-progress view uses), and every SQL feed orders
|
|
7
|
+
// `priority ASC NULLS LAST`. rankCrossDisciplineRecommendations used to sort
|
|
8
|
+
// the other way and its header comment asserted the inverse as the global rule,
|
|
9
|
+
// so re-ranking a task moved it the wrong direction in /builder-start's
|
|
10
|
+
// "You might enjoy" section.
|
|
11
|
+
//
|
|
12
|
+
// The function has TWO hand-copied implementations — the canonical one in
|
|
13
|
+
// modules/lifecycle/db-tasks.js and a mirror in scripts/gds/start.js, which is
|
|
14
|
+
// a pure HTTP client and cannot import the server module. This test EXECUTES
|
|
15
|
+
// both against the same fixtures and asserts they agree, so the pair cannot
|
|
16
|
+
// drift apart again.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/cross_discipline_priority_direction.mjs
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { makeRunner } from './helpers.mjs';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
29
|
+
|
|
30
|
+
const CANONICAL = require(path.join(ROOT, 'modules/lifecycle/db-tasks.js'))
|
|
31
|
+
.rankCrossDisciplineRecommendations;
|
|
32
|
+
const MIRROR = require(path.join(ROOT, 'scripts/gds/start.js'))
|
|
33
|
+
.rankCrossDisciplineRecommendations;
|
|
34
|
+
|
|
35
|
+
// Both copies, run over the same input. Every behavioural assertion below goes
|
|
36
|
+
// through this so a fix applied to only one file fails the suite.
|
|
37
|
+
const IMPLS = [['db-tasks.js (canonical)', CANONICAL], ['start.js (mirror)', MIRROR]];
|
|
38
|
+
const bothRank = (rows, prefs, limit) => IMPLS.map(([name, fn]) => [name, fn(rows, prefs, limit)]);
|
|
39
|
+
const eachImpl = (rows, prefs, limit, check) => {
|
|
40
|
+
for (const [name, out] of bothRank(rows, prefs, limit)) check(out, name);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// A claimable row. discipline defaults OUTSIDE the caller's prefs (['engineer'])
|
|
44
|
+
// so the partition step never eats the fixture; credits/minutes are equal by
|
|
45
|
+
// default so priority is the only live sort key unless a test says otherwise.
|
|
46
|
+
const row = (id, priority, extra = {}) => ({
|
|
47
|
+
id, priority, discipline: 'design', credits_reward: 10, est_minutes: 10, ...extra,
|
|
48
|
+
});
|
|
49
|
+
const PREFS = ['engineer'];
|
|
50
|
+
const ids = (out) => out.map((t) => t.id);
|
|
51
|
+
|
|
52
|
+
const { test, summary } = makeRunner();
|
|
53
|
+
|
|
54
|
+
// ---- the direction itself ---------------------------------------------------
|
|
55
|
+
|
|
56
|
+
await test('P1 outranks P5 — 1 is the most urgent, in BOTH copies', () => {
|
|
57
|
+
// Input deliberately in the wrong order so a no-op sort cannot pass.
|
|
58
|
+
const rows = [row('p5', 5), row('p3', 3), row('p1', 1)];
|
|
59
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
60
|
+
assert.deepEqual(ids(out), ['p1', 'p3', 'p5'],
|
|
61
|
+
`${name}: ascending — the DB weights work 6 - COALESCE(priority, 5), so P1 earns the most`);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('the limit keeps the MOST urgent, not the least', () => {
|
|
66
|
+
const rows = [row('p5', 5), row('p4', 4), row('p1', 1), row('p2', 2)];
|
|
67
|
+
eachImpl(rows, PREFS, 2, (out, name) => {
|
|
68
|
+
assert.deepEqual(ids(out), ['p1', 'p2'],
|
|
69
|
+
`${name}: slicing after a backwards sort would surface the two LEAST urgent tasks`);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- the NULL mapping -------------------------------------------------------
|
|
74
|
+
// The old code read priority as `Number(t.priority) || 0`. Under the old DESC
|
|
75
|
+
// sort that put unset work last by accident; under the corrected ASC sort the
|
|
76
|
+
// same expression would rank it 0 — ahead of every P1. These pin the explicit
|
|
77
|
+
// handling that replaced it.
|
|
78
|
+
|
|
79
|
+
await test('an unset priority sorts LAST, not first (the || 0 trap)', () => {
|
|
80
|
+
for (const blank of [null, undefined, '']) {
|
|
81
|
+
const rows = [row('blank', blank), row('p1', 1), row('p5', 5)];
|
|
82
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
83
|
+
assert.deepEqual(ids(out), ['p1', 'p5', 'blank'],
|
|
84
|
+
`${name}: priority=${JSON.stringify(blank)} must rank after an explicit P5`);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('a non-numeric priority is treated as unset, not as 0', () => {
|
|
90
|
+
const rows = [row('junk', 'urgent'), row('p5', 5)];
|
|
91
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
92
|
+
assert.deepEqual(ids(out), ['p5', 'junk'], `${name}: NaN must not outrank a real priority`);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('a numeric string priority still sorts as its number', () => {
|
|
97
|
+
const rows = [row('five', '5'), row('one', '1')];
|
|
98
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
99
|
+
assert.deepEqual(ids(out), ['one', 'five'], `${name}: the API can hand back stringified integers`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ---- the rest of the ranking key, so fixing direction did not disturb it ----
|
|
104
|
+
|
|
105
|
+
await test('credits-per-minute DESC breaks a priority tie', () => {
|
|
106
|
+
const rows = [
|
|
107
|
+
row('cheap', 2, { credits_reward: 10, est_minutes: 100 }),
|
|
108
|
+
row('rich', 2, { credits_reward: 100, est_minutes: 10 }),
|
|
109
|
+
];
|
|
110
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
111
|
+
assert.deepEqual(ids(out), ['rich', 'cheap'], `${name}: best bang-for-effort first`);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('shorter est_minutes, then id, break the remaining ties', () => {
|
|
116
|
+
const rows = [
|
|
117
|
+
row('long', 2, { credits_reward: 10, est_minutes: 10 }),
|
|
118
|
+
row('short', 2, { credits_reward: 5, est_minutes: 5 }),
|
|
119
|
+
];
|
|
120
|
+
// Equal credits/min (1.0), so est_minutes decides.
|
|
121
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
122
|
+
assert.deepEqual(ids(out), ['short', 'long'], `${name}: shorter is easier to try`);
|
|
123
|
+
});
|
|
124
|
+
const tied = [row(9, 2), row(3, 2)];
|
|
125
|
+
eachImpl(tied, PREFS, 10, (out, name) => {
|
|
126
|
+
assert.deepEqual(ids(out), [3, 9], `${name}: id ASC is the stable final tiebreak`);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await test('partitioning is unchanged: outside-prefs only, no unclassified, empty prefs → []', () => {
|
|
131
|
+
const rows = [
|
|
132
|
+
row('mine', 1, { discipline: 'engineer' }),
|
|
133
|
+
row('untriaged', 1, { discipline: 'unclassified' }),
|
|
134
|
+
row('theirs', 4, { discipline: 'design' }),
|
|
135
|
+
];
|
|
136
|
+
eachImpl(rows, PREFS, 10, (out, name) => {
|
|
137
|
+
assert.deepEqual(ids(out), ['theirs'], `${name}: only lanes outside the builder's prefs`);
|
|
138
|
+
});
|
|
139
|
+
eachImpl(rows, [], 10, (out, name) => {
|
|
140
|
+
assert.deepEqual(out, [], `${name}: no stated preference → nothing is "outside"`);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('the two copies agree row-for-row on a mixed corpus', () => {
|
|
145
|
+
const disciplines = ['design', 'ops', 'research', 'engineer', 'unclassified'];
|
|
146
|
+
const priorities = [1, 2, 3, 4, 5, null, undefined, '', '2', 'junk'];
|
|
147
|
+
const rows = [];
|
|
148
|
+
for (let i = 0; i < 60; i++) {
|
|
149
|
+
rows.push(row(i, priorities[i % priorities.length], {
|
|
150
|
+
discipline: disciplines[i % disciplines.length],
|
|
151
|
+
credits_reward: (i % 7) * 5,
|
|
152
|
+
est_minutes: (i % 4) * 15,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
const [[, fromCanonical], [, fromMirror]] = bothRank(rows, ['engineer'], 25);
|
|
156
|
+
assert.ok(fromCanonical.length > 5, 'fixture must actually exercise the ranking');
|
|
157
|
+
assert.deepEqual(ids(fromMirror), ids(fromCanonical),
|
|
158
|
+
'start.js is a hand-copied mirror — it must produce the identical order');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---- the convention this direction is anchored to ---------------------------
|
|
162
|
+
// If someone ever inverts the DB's meaning of priority, these fail and force the
|
|
163
|
+
// behavioural assertions above to be reconsidered rather than silently re-flipped.
|
|
164
|
+
|
|
165
|
+
await test('the DB still says 1 is most urgent (CHECK + the 6 - COALESCE weighting)', () => {
|
|
166
|
+
const schema = read('migrations/003_pms.sql');
|
|
167
|
+
assert.match(schema, /priority\s+integer\s+CHECK \(priority BETWEEN 1 AND 5\)/,
|
|
168
|
+
'priority is a 1..5 integer');
|
|
169
|
+
assert.match(schema, /6 - COALESCE\(t\.priority, 5\)/,
|
|
170
|
+
'progress weighting subtracts from 6, so a LOWER priority number earns MORE weight');
|
|
171
|
+
assert.match(read('src/bongos/optimizer.js'), /priority ASC NULLS LAST/,
|
|
172
|
+
'the optimizer — the closest analogue to this recommender — orders ascending');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('neither copy claims priority DESC, and the mirror pointer resolves', () => {
|
|
176
|
+
const canonicalSrc = read('modules/lifecycle/db-tasks.js');
|
|
177
|
+
const mirrorSrc = read('scripts/gds/start.js');
|
|
178
|
+
for (const [name, src] of [['db-tasks.js', canonicalSrc], ['start.js', mirrorSrc]]) {
|
|
179
|
+
assert.ok(!/priority DESC/.test(src), `${name}: no comment may assert priority DESC`);
|
|
180
|
+
}
|
|
181
|
+
// The canonical home moved out of src/bongos/db.js in the twelve-unit carve;
|
|
182
|
+
// start.js pointed at the old address long after the function had left it.
|
|
183
|
+
assert.ok(
|
|
184
|
+
!/rankCrossDisciplineRecommendations \(src\/bongos\/db\.js\)/.test(mirrorSrc)
|
|
185
|
+
&& !/db\.rankCrossDisciplineRecommendations/.test(mirrorSrc),
|
|
186
|
+
'start.js must name modules/lifecycle/db-tasks.js as the canonical home'
|
|
187
|
+
);
|
|
188
|
+
assert.match(mirrorSrc, /modules\/lifecycle\/db-tasks\.js/,
|
|
189
|
+
'the mirror names where the canonical copy actually lives');
|
|
190
|
+
assert.ok(
|
|
191
|
+
!/rankCrossDisciplineRecommendations/.test(read('src/bongos/db.js')),
|
|
192
|
+
'src/bongos/db.js really does not define it — the old pointer was dangling'
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
summary();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// tests/government_board_hash_redirect.mjs — the `#board-room` compatibility
|
|
2
|
+
// redirect (ADR 0266, task 1003735).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. ADR 0266 carved the Board Room out of the /government
|
|
5
|
+
// tab strip onto its own page, and named this redirect load-bearing for a reason
|
|
6
|
+
// that does not expire: Discord's window-open announcement deep-links
|
|
7
|
+
// `?item=<id>#board-room` (ADR 0175 §8 makes that mention the "vote within X"
|
|
8
|
+
// notice), and every one of those messages already sitting in channel history
|
|
9
|
+
// carries the old form forever. If the redirect breaks, a member clicking the
|
|
10
|
+
// notification lands on the Permissions page with no way to the sitting.
|
|
11
|
+
//
|
|
12
|
+
// AND WHY IT CANNOT BE A SERVER TEST. A URL fragment is never sent to the
|
|
13
|
+
// server, so `#board-room` cannot be caught by a route in serve-internal.js —
|
|
14
|
+
// adding a regex there looks like the obvious fix and silently never fires. That
|
|
15
|
+
// makes this browser function the ONLY thing standing between an old link and a
|
|
16
|
+
// dead end, which is exactly the shape that deserves a pin.
|
|
17
|
+
//
|
|
18
|
+
// It is extracted and RUN, not grepped: the interesting behaviour is the
|
|
19
|
+
// branch (which hashes trigger) and the query-preservation (encodeURIComponent
|
|
20
|
+
// on a value that came off the URL), and a source match would pass on a
|
|
21
|
+
// function that returns the wrong string.
|
|
22
|
+
//
|
|
23
|
+
// Pure file-read + vm (no DB/network) — runs in the DB-free unit gate.
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import vm from 'node:vm';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
33
|
+
const GOV_SRC = fs.readFileSync(path.join(HALL, 'government.js'), 'utf8');
|
|
34
|
+
|
|
35
|
+
// Lift the function out of the page's IIFE and run it against a fake location.
|
|
36
|
+
// `sliceFn` counts braces rather than matching a closing line, so a nested block
|
|
37
|
+
// inside the body cannot end the slice early.
|
|
38
|
+
function sliceFn(src, name) {
|
|
39
|
+
const start = src.indexOf(`function ${name}`);
|
|
40
|
+
assert.notEqual(start, -1, `${name} not found in government.js`);
|
|
41
|
+
const open = src.indexOf('{', start);
|
|
42
|
+
let depth = 0;
|
|
43
|
+
for (let i = open; i < src.length; i += 1) {
|
|
44
|
+
if (src[i] === '{') depth += 1;
|
|
45
|
+
else if (src[i] === '}') {
|
|
46
|
+
depth -= 1;
|
|
47
|
+
if (depth === 0) return src.slice(start, i + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`${name} is unbalanced`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Returns { result, replaced } — `replaced` is the URL location.replace was
|
|
54
|
+
// called with, or null if it was never called.
|
|
55
|
+
function runRedirect({ hash, search = '' }) {
|
|
56
|
+
let replaced = null;
|
|
57
|
+
const sandbox = {
|
|
58
|
+
URLSearchParams,
|
|
59
|
+
window: {
|
|
60
|
+
location: {
|
|
61
|
+
hash,
|
|
62
|
+
search,
|
|
63
|
+
replace(url) { replaced = url; },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
vm.createContext(sandbox);
|
|
68
|
+
vm.runInContext(`${sliceFn(GOV_SRC, 'redirectLegacyBoardHash')}
|
|
69
|
+
globalThis.__out = redirectLegacyBoardHash();`, sandbox, { filename: 'government.js' });
|
|
70
|
+
return { result: sandbox.__out, replaced };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('the old hash lands on the room, and says it navigated', () => {
|
|
74
|
+
const { result, replaced } = runRedirect({ hash: '#board-room' });
|
|
75
|
+
assert.equal(replaced, '/board-room');
|
|
76
|
+
assert.equal(result, true,
|
|
77
|
+
'init() returns early on true — a falsy answer would let the government page paint over the navigation');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('?item=N is carried across — that is the whole point of the Discord link', () => {
|
|
81
|
+
const { replaced } = runRedirect({ hash: '#board-room', search: '?item=12' });
|
|
82
|
+
assert.equal(replaced, '/board-room?item=12');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the item is re-encoded, never pasted through raw', () => {
|
|
86
|
+
// The value arrives off a URL a stranger can author, and goes straight back
|
|
87
|
+
// into one. Board item ids are numeric, so this is defence rather than a live
|
|
88
|
+
// case — which is why it is pinned instead of assumed.
|
|
89
|
+
const { replaced } = runRedirect({ hash: '#board-room', search: '?item=1%202%263' });
|
|
90
|
+
assert.equal(replaced, '/board-room?item=1%202%263',
|
|
91
|
+
'a space and an ampersand survive as escapes rather than splitting the query');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an item that is absent or empty produces a bare URL, not a dangling ?item=', () => {
|
|
95
|
+
assert.equal(runRedirect({ hash: '#board-room', search: '?other=1' }).replaced, '/board-room');
|
|
96
|
+
assert.equal(runRedirect({ hash: '#board-room', search: '?item=' }).replaced, '/board-room',
|
|
97
|
+
'an empty value is falsy, so no query is appended');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('every OTHER hash is left alone — the two surviving tabs still work', () => {
|
|
101
|
+
// The government page kept #constitution and #ranks. A redirect that fired on
|
|
102
|
+
// those would make the page unreachable, which is a worse failure than the one
|
|
103
|
+
// this function exists to prevent.
|
|
104
|
+
for (const hash of ['#constitution', '#ranks', '', '#board-room-extra', '#Board-Room']) {
|
|
105
|
+
const { result, replaced } = runRedirect({ hash, search: '?item=12' });
|
|
106
|
+
assert.equal(replaced, null, `${hash || '(no hash)'} must not navigate`);
|
|
107
|
+
assert.equal(result, false, `${hash || '(no hash)'} must let init() continue`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('init() consults it FIRST and returns on true', () => {
|
|
112
|
+
// The order is the behaviour: called after the /me fetch, the reader would see
|
|
113
|
+
// the government page paint and then jump. Asserted on the source because it is
|
|
114
|
+
// a statement about sequence, not a value.
|
|
115
|
+
const init = sliceFn(GOV_SRC, 'init');
|
|
116
|
+
const guard = init.indexOf('redirectLegacyBoardHash()');
|
|
117
|
+
assert.notEqual(guard, -1, 'init() must consult the redirect');
|
|
118
|
+
assert.match(init.slice(guard - 30, guard + 60), /if \(redirectLegacyBoardHash\(\)\) return;/,
|
|
119
|
+
'it must be an early return, not a fire-and-continue');
|
|
120
|
+
const meFetch = init.indexOf("fetchJson('/me')");
|
|
121
|
+
assert.ok(guard < meFetch, 'the redirect must run BEFORE the /me read and any paint');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the government page no longer declares a board-room tab to redirect INTO', () => {
|
|
125
|
+
// If #board-room came back as a tab id, this redirect would fight makeTabs for
|
|
126
|
+
// the same hash and the room would exist in two places.
|
|
127
|
+
const ids = [...GOV_SRC.matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
|
|
128
|
+
assert.ok(!ids.includes('board-room'), `board-room is a page, not a tab (found ${ids})`);
|
|
129
|
+
});
|