@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.
@@ -855,11 +855,22 @@ function taskTouchesProtectedPath(task) {
855
855
  // and rank them.
856
856
  //
857
857
  // Ranking key (most relevant first):
858
- // 1. priority DESC a P5 in another lane still beats a P1
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 = Number(a.priority) || 0;
884
- const pb = Number(b.priority) || 0;
885
- if (pb !== pa) return pb - pa; // higher priority first
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.621",
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.621",
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.621",
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 = [
@@ -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 db.rankCrossDisciplineRecommendations (the canonical server-side
49
- // home for this logic — kept in sync deliberately; start.js is a pure HTTP
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
- // db.rankCrossDisciplineRecommendations (src/bongos/db.js). Given the full
474
- // claimable list and the builder's preferred_disciplines, return up to `limit`
475
- // tasks whose discipline is OUTSIDE the builder's preferences, ranked:
476
- // priority DESC, credits/min DESC, est_minutes ASC, id ASC.
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 = Number(a.priority) || 0;
494
- const pb = Number(b.priority) || 0;
495
- if (pb !== pa) return pb - pa;
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,
@@ -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.621'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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 > all five delegate to runPageGate, guarded by BV1.R107.
98
- // requireNonXenosPage / (the R113 roles-tab gate; the /people curate gate)
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
+ });