@bongos/core 1.19.262 → 1.19.263

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.
@@ -23,6 +23,14 @@
23
23
  (function () {
24
24
  'use strict';
25
25
  const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
26
+ // Shared task-domain helpers (task 1002463). These lived in two copies here and
27
+ // in the other board and had already drifted — goals-page.js had silently lost
28
+ // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
29
+ // below reads exactly as before; the single copy lives in task-lib.js.
30
+ const {
31
+ priorityRank, touchesConflict, rankTagHtml, deHashRefs,
32
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
33
+ } = window.OTBTaskLib;
26
34
  // Shared component kit (hall-kit.js, task 2166): pager, filter bar, tabs,
27
35
  // empty/skeleton states — adopted here by task 2171.
28
36
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar,
@@ -497,40 +505,10 @@
497
505
  // ===================================================================
498
506
 
499
507
  // Board-identical numeric helpers (work.js:214-240).
500
- function creditsPerMinute(t) {
501
- const c = Number(t.credits_reward); const m = Number(t.est_minutes);
502
- if (!c || !m || m <= 0) return -Infinity;
503
- return c / m;
504
- }
505
- function priorityRank(p) {
506
- if (!p) return 99;
507
- const m = /^P(\d+)/i.exec(String(p));
508
- return m ? Number(m[1]) : 99;
509
- }
510
508
  // Two tasks can't run in parallel if their touches[] overlap (exact path or a
511
509
  // directory-prefix either way); empty touches never conflict. Powers the
512
510
  // filter bar's "Run mode" control (work.js:230-239).
513
- function touchesConflict(aList, bList) {
514
- const norm = (p) => String(p).replace(/\/+$/, '');
515
- for (const a of (Array.isArray(aList) ? aList : [])) {
516
- const na = norm(a);
517
- for (const b of (Array.isArray(bList) ? bList : [])) {
518
- const nb = norm(b);
519
- if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
520
- }
521
- }
522
- return false;
523
- }
524
511
  // Per-task rank floor tag (work.js:247-254).
525
- function rankTagHtml(requiresRank) {
526
- const rr = (requiresRank || 'xenos').toLowerCase();
527
- if (rr === 'xenos') {
528
- return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
529
- }
530
- const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
531
- return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${escapeHtml(label)}</span>`;
532
- }
533
-
534
512
  // Free-text search over the goal's todo list (work.js:261-274, minus the
535
513
  // goal-title match — a single goal makes that redundant).
536
514
  function todoSearchQuery() {
@@ -548,21 +526,6 @@
548
526
 
549
527
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
550
528
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
551
- function deHashRefs(text) {
552
- return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
553
- }
554
- function buildPromptForTask(t) {
555
- const desc = deHashRefs((t.description || '').trim());
556
- const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
557
- const hallUrl = `${hallBase}#/task/${t.id}`;
558
- return [
559
- `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
560
- `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
561
- ``,
562
- `Task description:`,
563
- desc || '(no description)',
564
- ].join('\n');
565
- }
566
529
  async function copyPromptForTask(taskId, btn) {
567
530
  const t = state.work.todo.find((x) => String(x.id) === String(taskId));
568
531
  if (!t) return;
@@ -697,21 +660,6 @@
697
660
  }
698
661
  }
699
662
  // A single batch-item failure reason → a readable fragment (work.js:545-562).
700
- function friendlyBatchReason(item) {
701
- switch (item && item.reason) {
702
- case 'TASK_NOT_FOUND': return 'no longer exists';
703
- case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
704
- case 'ALREADY_CLAIMED': return 'already claimed';
705
- case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
706
- case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
707
- case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
708
- case 'rank_too_low_for_task':
709
- case 'XENOS_RESTRICTED':
710
- case 'INSUFFICIENT_RANK': return 'rank too low';
711
- case 'BUILDER_INACTIVE': return 'account inactive';
712
- default: return (item && item.reason) || 'not claimable';
713
- }
714
- }
715
663
  // Bulk claim the selected ready cards (work.js doBulkClaim, claims/batch is
716
664
  // all-or-nothing: a 409 batch_failed means nothing was claimed). Reloads the
717
665
  // goal's tasks either way so the cards reflect fresh truth.
@@ -194,6 +194,8 @@
194
194
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
195
195
  <!-- shared component kit (window.OTBKit) — pager, filter bar, tabs, empty/skeleton (task 2166). -->
196
196
  <script src="/builders/hall-kit.js"></script>
197
+ <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
198
+ <script src="/builders/task-lib.js"></script>
197
199
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
198
200
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
199
201
  <script src="/builders/goals-page.js?v=2026-07-10-goals5"></script>
@@ -0,0 +1,136 @@
1
+ // modules/hall-ui/public/task-lib.js — the hall's shared TASK-DOMAIN helpers
2
+ // (task 1002463, from idea 1000567).
3
+ //
4
+ // WHY A SIBLING OF hall-kit.js RATHER THAN PART OF IT. hall-kit.js is explicitly
5
+ // "the SIX primitives every page redesign adopts" — pager, ledger, filter bar,
6
+ // tabs, section rail, empty/skeleton/collapsible. Those are UI *markup* factories,
7
+ // and modules/hall-ui/CLAUDE.md's "no bespoke pager / filter / tab / rail markup"
8
+ // rule is scoped to exactly that. The functions below are task-DOMAIN logic
9
+ // (how a task is ranked, whether two touches[] collide, what a refusal means in
10
+ // English). Folding them into the kit would blur a contract that is currently
11
+ // crisp, so they get their own namespace instead.
12
+ //
13
+ // Contract, mirrored from hall-kit.js so load order only needs "before the page
14
+ // script": dependency-free (own esc(), no window.OTB use), no eval-time DOM, and
15
+ // everything hangs off one global. Node-require-safe so it can be unit-tested.
16
+ //
17
+ // WHAT THIS FIXES. Before extraction these lived in TWO copies (work.js and
18
+ // goals-page.js) and had already drifted: goals-page.js's friendlyBatchReason had
19
+ // silently lost the MODULE_CONFLICT case that work.js handles, so a batch-claim
20
+ // refused for a module collision showed the raw code "MODULE_CONFLICT" to the
21
+ // builder on the Goals page and a sentence on the Tasks board. One copy, one
22
+ // behaviour, and the drift cannot re-open.
23
+ (() => {
24
+ 'use strict';
25
+
26
+ // Local HTML-escape — deliberately NOT window.OTB.escapeHtml, so this file
27
+ // keeps hall-kit's dependency-free property (same reasoning, same duplication
28
+ // of ~4 lines being cheaper than a load-order constraint).
29
+ function esc(s) {
30
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
31
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
32
+ }[c]));
33
+ }
34
+
35
+ // Sort key for a "P3"-style priority string. Unparseable/absent sorts last.
36
+ function priorityRank(p) {
37
+ if (!p) return 99;
38
+ const m = /^P(\d+)/i.exec(String(p));
39
+ return m ? Number(m[1]) : 99;
40
+ }
41
+
42
+ // Do two touches[] lists collide? A path collides with itself and with anything
43
+ // under it, so "src/" conflicts with "src/gds/db.js". Trailing slashes are
44
+ // normalised first so "src" and "src/" are the same path.
45
+ function touchesConflict(aList, bList) {
46
+ const norm = (p) => String(p).replace(/\/+$/, '');
47
+ for (const a of (Array.isArray(aList) ? aList : [])) {
48
+ const na = norm(a);
49
+ for (const b of (Array.isArray(bList) ? bList : [])) {
50
+ const nb = norm(b);
51
+ if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+
57
+ // The rank-floor chip. 'xenos' means no floor at all, which reads as an
58
+ // invitation ("Any rank") rather than a restriction.
59
+ function rankTagHtml(requiresRank) {
60
+ const rr = (requiresRank || 'xenos').toLowerCase();
61
+ if (rr === 'xenos') {
62
+ return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
63
+ }
64
+ const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
65
+ return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${esc(label)}</span>`;
66
+ }
67
+
68
+ // Strip bare "#NNN" GDS refs from prose we are about to paste elsewhere: on
69
+ // GitHub a bare #NNN autolinks to an ISSUE, and our ids are not GitHub's, so
70
+ // the link 404s. (Same rule the repo enforces in committed markdown.)
71
+ // VERBATIM from the two originals — do not "tidy" either detail:
72
+ // • `\d{1,5}` (not `\d+`) is load-bearing by omission: a 6+ digit run never
73
+ // matches at all, so long numerics are left untouched rather than rewritten.
74
+ // • `text == null ? '' : text` (not `text || ''`) keeps falsy-but-real inputs:
75
+ // deHashRefs(0) must render "0", not "". `|| ''` would swallow it.
76
+ // Both were silently changed in an earlier draft of this extraction and the
77
+ // regression escaped an equivalence pass whose inputs were all strings/null.
78
+ function deHashRefs(text) {
79
+ return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
80
+ }
81
+
82
+ // Reward density, for the "best value" sort. -Infinity (not 0) for anything
83
+ // unpriced or unestimated so those sink below every real ratio instead of
84
+ // tying with a genuinely zero-value task.
85
+ function creditsPerMinute(t) {
86
+ const c = Number(t.credits_reward);
87
+ const m = Number(t.est_minutes);
88
+ if (!c || !m || m <= 0) return -Infinity;
89
+ return c / m;
90
+ }
91
+
92
+ // The paste-into-your-agent prompt for one task.
93
+ function buildPromptForTask(t) {
94
+ const desc = deHashRefs((t.description || '').trim());
95
+ const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
96
+ const hallUrl = `${hallBase}#/task/${t.id}`;
97
+ return [
98
+ `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
99
+ `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
100
+ ``,
101
+ `Task description:`,
102
+ desc || '(no description)',
103
+ ].join('\n');
104
+ }
105
+
106
+ // Turn a batch-claim refusal into a sentence. THE DEFAULT MATTERS: an unmapped
107
+ // code falls through to the raw string, which is how the missing MODULE_CONFLICT
108
+ // case leaked a machine code into the Goals page UI. Adding a server-side reason
109
+ // without adding it here degrades quietly rather than loudly, so keep this in
110
+ // step with the claim route's refusal codes.
111
+ function friendlyBatchReason(item) {
112
+ switch (item && item.reason) {
113
+ case 'TASK_NOT_FOUND': return 'no longer exists';
114
+ case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
115
+ case 'ALREADY_CLAIMED': return 'already claimed';
116
+ case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
117
+ case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
118
+ case 'MODULE_CONFLICT': return item.conflictsWithTaskId
119
+ ? `shares module "${item.module_key}" with task #${item.conflictsWithTaskId} in this selection`
120
+ : `shares module "${item.module_key}" with an active claim`;
121
+ case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
122
+ case 'rank_too_low_for_task':
123
+ case 'XENOS_RESTRICTED':
124
+ case 'INSUFFICIENT_RANK': return 'rank too low';
125
+ case 'BUILDER_INACTIVE': return 'account inactive';
126
+ default: return (item && item.reason) || 'not claimable';
127
+ }
128
+ }
129
+
130
+ const api = {
131
+ priorityRank, touchesConflict, rankTagHtml, deHashRefs,
132
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
133
+ };
134
+ if (typeof window !== 'undefined') window.OTBTaskLib = api;
135
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
136
+ })();
@@ -135,6 +135,8 @@
135
135
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils" defer></script>
136
136
  <!-- shared component kit (window.OTBKit) — tabs, pager, filter bar (task 2166). -->
137
137
  <script src="/builders/hall-kit.js" defer></script>
138
+ <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
139
+ <script src="/builders/task-lib.js" defer></script>
138
140
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
139
141
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
140
142
  <script src="/builders/work.js?v=2026-07-03-workboard3" defer></script>
@@ -36,6 +36,14 @@
36
36
  // file's exact variants (date-only fmtDate; a 2200ms clearing toast).
37
37
  // fmtAgo / toAuth / getJSON / postJSON stay local below.
38
38
  const { escapeHtml, $, fmtNum } = window.OTB;
39
+ // Shared task-domain helpers (task 1002463). These lived in two copies here and
40
+ // in the other board and had already drifted — goals-page.js had silently lost
41
+ // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
42
+ // below reads exactly as before; the single copy lives in task-lib.js.
43
+ const {
44
+ priorityRank, touchesConflict, rankTagHtml,
45
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
46
+ } = window.OTBTaskLib; // deHashRefs is not called here — it is used inside buildPromptForTask
39
47
  // Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
40
48
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar } = window.OTBKit;
41
49
  const fmtDate = (iso) => window.OTB.fmtDate(iso);
@@ -211,48 +219,14 @@
211
219
  const sequentialCache = { key: '', order: null };
212
220
  let sequentialDebounce = null;
213
221
 
214
- function creditsPerMinute(t) {
215
- const c = Number(t.credits_reward);
216
- const m = Number(t.est_minutes);
217
- if (!c || !m || m <= 0) return -Infinity;
218
- return c / m;
219
- }
220
-
221
- function priorityRank(p) {
222
- if (!p) return 99;
223
- const m = /^P(\d+)/i.exec(String(p));
224
- return m ? Number(m[1]) : 99;
225
- }
226
-
227
222
  // task 1756: two tasks can't run in parallel if their touches[] overlap — exact
228
223
  // path match or a directory-prefix either way (mirrors the server's touchesOverlap
229
224
  // / the goals-page conflict estimate). Empty touches never conflict.
230
- function touchesConflict(aList, bList) {
231
- const norm = (p) => String(p).replace(/\/+$/, '');
232
- for (const a of (Array.isArray(aList) ? aList : [])) {
233
- const na = norm(a);
234
- for (const b of (Array.isArray(bList) ? bList : [])) {
235
- const nb = norm(b);
236
- if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
237
- }
238
- }
239
- return false;
240
- }
241
-
242
225
  // task 1752 (+ 1498 / ADR 0084): the minimum rank to claim a task, as a tag shown
243
226
  // on EVERY task row. 'xenos' is the open default → a muted "Any rank" tag (open to
244
227
  // all, not a warning); 'archon' shows bare; other ranks show 'Rank+'. Kept here for
245
228
  // now; the shared task-detail view (task 1753) will lift this into the common
246
229
  // toolkit so every task surface renders rank identically.
247
- function rankTagHtml(requiresRank) {
248
- const rr = (requiresRank || 'xenos').toLowerCase();
249
- if (rr === 'xenos') {
250
- return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
251
- }
252
- const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
253
- return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${escapeHtml(label)}</span>`;
254
- }
255
-
256
230
  // task 1829: free-text search over the claimable/backlog lists. Matches the
257
231
  // query (case-insensitive substring) against the task id (with or without a
258
232
  // leading '#'), the title, and the goal title (taskState.goalTitles — the
@@ -455,23 +429,6 @@
455
429
  // our records number tasks separately from GitHub. So the scroll we hand the
456
430
  // builder must never carry a bare "#NNN": write "task NNN" + the clickable hall
457
431
  // URL, and strip "#NNN" out of the (free-text) task description too.
458
- function deHashRefs(text) {
459
- return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
460
- }
461
-
462
- function buildPromptForTask(t) {
463
- const desc = deHashRefs((t.description || '').trim());
464
- const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
465
- const hallUrl = `${hallBase}#/task/${t.id}`;
466
- return [
467
- `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
468
- `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
469
- ``,
470
- `Task description:`,
471
- desc || '(no description)',
472
- ].join('\n');
473
- }
474
-
475
432
  async function copyPromptForTask(taskId, btn) {
476
433
  const t = taskState.all.find((x) => String(x.id) === String(taskId));
477
434
  if (!t) return;
@@ -546,25 +503,6 @@
546
503
  // /claims/batch/validate) to a builder-readable fragment. Distinct from
547
504
  // friendlyActionError above because a batch item's shape differs (reason,
548
505
  // not error; extra fields like conflictsWithTaskId/module_key).
549
- function friendlyBatchReason(item) {
550
- switch (item && item.reason) {
551
- case 'TASK_NOT_FOUND': return 'no longer exists';
552
- case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
553
- case 'ALREADY_CLAIMED': return 'already claimed';
554
- case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
555
- case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
556
- case 'MODULE_CONFLICT': return item.conflictsWithTaskId
557
- ? `shares module "${item.module_key}" with task #${item.conflictsWithTaskId} in this selection`
558
- : `shares module "${item.module_key}" with an active claim`;
559
- case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
560
- case 'rank_too_low_for_task':
561
- case 'XENOS_RESTRICTED':
562
- case 'INSUFFICIENT_RANK': return 'rank too low';
563
- case 'BUILDER_INACTIVE': return 'account inactive';
564
- default: return (item && item.reason) || 'not claimable';
565
- }
566
- }
567
-
568
506
  async function copyText(text) {
569
507
  try { await navigator.clipboard.writeText(text); return true; }
570
508
  catch (_) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.262",
3
+ "version": "1.19.263",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.262",
9
+ "version": "1.19.263",
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.262",
3
+ "version": "1.19.263",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -951,8 +951,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
951
951
  // landed since 1.19.260 with no explicit bump. run 32295699354. (task 1002620)
952
952
  // 1.19.262 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
953
953
  // landed since 1.19.261 with no explicit bump. run 32298063660. (task 1002620)
954
+ // 1.19.263 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
955
+ // landed since 1.19.262 with no explicit bump. run 32305228786. (task 1002620)
954
956
  // ---------------------------------------------------------------------------
955
- const CORE_VERSION = '1.19.262'; // CI auto-patch carrier (ADR 0161); see changelog above
957
+ const CORE_VERSION = '1.19.263'; // CI auto-patch carrier (ADR 0161); see changelog above
956
958
 
957
959
  // A namespaced logger so a module's log lines are attributable + consistent.
958
960
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -32,6 +32,11 @@ import { makeRunner } from './helpers.mjs';
32
32
 
33
33
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
34
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-page.js'), 'utf8');
35
+ // task 1002463: goals-page.js now pulls its task-domain helpers from the shared
36
+ // window.OTBTaskLib (task-lib.js) instead of holding its own drifted copies. Run
37
+ // the REAL file in the same context rather than stubbing it — stubbing would let
38
+ // the extraction rot without this, the only test that executes either board.
39
+ const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
35
40
 
36
41
  const { test, summary } = makeRunner();
37
42
 
@@ -126,6 +131,7 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
126
131
  const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise };
127
132
  sandbox.globalThis = sandbox;
128
133
  vm.createContext(sandbox);
134
+ vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
129
135
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
130
136
 
131
137
  // Let boot()'s promise chain settle (me → goals → details → route → render).
@@ -0,0 +1,115 @@
1
+ // tests/task_lib.mjs
2
+ //
3
+ // task 1002463 — the shared task-domain helpers extracted out of work.js and
4
+ // goals-page.js into modules/hall-ui/public/task-lib.js.
5
+ //
6
+ // WHY THIS FILE EXISTS. The extraction was verified by an equivalence pass against
7
+ // the pristine pre-refactor sources — and that pass MISSED two real regressions,
8
+ // because every input it fed deHashRefs was a string or null. A grader caught them:
9
+ // the digit bound had been "tidied" from \d{1,5} to \d+, and the null guard from
10
+ // (text == null ? '' : text) to (text || ''). Both are behaviour changes hiding in
11
+ // a refactor. The lesson is that equivalence proofs are only as good as their input
12
+ // domain, so the boundaries now have named, checked-in cases.
13
+ //
14
+ // Run: node tests/task_lib.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
23
+ const T = require(join(ROOT, 'modules/hall-ui/public/task-lib.js'));
24
+
25
+ let passed = 0, failed = 0;
26
+ function t(name, fn) {
27
+ try { fn(); passed++; console.log(` PASS ${name}`); }
28
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
29
+ }
30
+
31
+ // ---- deHashRefs: the two regressions, pinned ------------------------------
32
+ t('deHashRefs rewrites a 1-5 digit ref (GitHub would autolink it to an issue)', () => {
33
+ assert.equal(T.deHashRefs('see #123'), 'see task 123');
34
+ assert.equal(T.deHashRefs('#1'), 'task 1');
35
+ assert.equal(T.deHashRefs('#12345'), 'task 12345');
36
+ });
37
+
38
+ t('deHashRefs LEAVES a 6+ digit run alone — \\d{1,5} is load-bearing by omission', () => {
39
+ // \d{1,5} followed by \b cannot match inside a longer digit run: after 5 digits
40
+ // the next char is still a word char, so there is no boundary and no match at
41
+ // all. Widening to \d+ silently changed this. Pinned so it cannot drift back.
42
+ assert.equal(T.deHashRefs('#123456'), '#123456');
43
+ assert.equal(T.deHashRefs('#1002463'), '#1002463');
44
+ assert.equal(T.deHashRefs('#12345 and #123456'), 'task 12345 and #123456');
45
+ });
46
+
47
+ t('deHashRefs keeps falsy-but-real input — (text == null) not (!text)', () => {
48
+ // `text || ''` would swallow these into the empty string.
49
+ assert.equal(T.deHashRefs(0), '0');
50
+ assert.equal(T.deHashRefs(false), 'false');
51
+ assert.equal(T.deHashRefs(NaN), 'NaN');
52
+ // ...while genuinely absent input is still the empty string.
53
+ assert.equal(T.deHashRefs(null), '');
54
+ assert.equal(T.deHashRefs(undefined), '');
55
+ });
56
+
57
+ // ---- touchesConflict: the parallel-safety predicate -----------------------
58
+ t('touchesConflict: a path collides with itself, its parents and its children', () => {
59
+ assert.equal(T.touchesConflict(['src'], ['src']), true);
60
+ assert.equal(T.touchesConflict(['src'], ['src/gds/db.js']), true);
61
+ assert.equal(T.touchesConflict(['src/gds/db.js'], ['src']), true);
62
+ assert.equal(T.touchesConflict(['src'], ['srcery']), false, 'prefix must not count as a parent');
63
+ assert.equal(T.touchesConflict(['a'], ['b']), false);
64
+ });
65
+
66
+ t('touchesConflict: trailing slashes normalise, non-arrays are inert', () => {
67
+ assert.equal(T.touchesConflict(['src/'], ['src']), true);
68
+ assert.equal(T.touchesConflict(null, ['src']), false);
69
+ assert.equal(T.touchesConflict('src', ['src']), false, 'a bare string is not a list');
70
+ });
71
+
72
+ // ---- priorityRank / creditsPerMinute: sort keys ---------------------------
73
+ t('priorityRank: unparseable or absent sorts LAST, not first', () => {
74
+ assert.equal(T.priorityRank('P1'), 1);
75
+ assert.equal(T.priorityRank('P5'), 5);
76
+ assert.equal(T.priorityRank(null), 99);
77
+ assert.equal(T.priorityRank('nonsense'), 99);
78
+ });
79
+
80
+ t('creditsPerMinute: unpriced/unestimated sinks below a genuine zero ratio', () => {
81
+ assert.equal(T.creditsPerMinute({ credits_reward: 10, est_minutes: 5 }), 2);
82
+ assert.equal(T.creditsPerMinute({ credits_reward: 0, est_minutes: 5 }), -Infinity);
83
+ assert.equal(T.creditsPerMinute({ credits_reward: 10, est_minutes: 0 }), -Infinity);
84
+ assert.equal(T.creditsPerMinute({}), -Infinity);
85
+ });
86
+
87
+ // ---- friendlyBatchReason: the bug this extraction fixed -------------------
88
+ t('friendlyBatchReason explains MODULE_CONFLICT — the case goals-page.js had lost', () => {
89
+ assert.match(
90
+ T.friendlyBatchReason({ reason: 'MODULE_CONFLICT', module_key: 'ideas', conflictsWithTaskId: 7 }),
91
+ /shares module "ideas" with task #7/
92
+ );
93
+ assert.match(
94
+ T.friendlyBatchReason({ reason: 'MODULE_CONFLICT', module_key: 'ideas' }),
95
+ /shares module "ideas" with an active claim/
96
+ );
97
+ });
98
+
99
+ t('friendlyBatchReason falls through to the raw code for an unmapped reason', () => {
100
+ // The failure mode this guards: a new server-side refusal code degrades to the
101
+ // machine string in the UI rather than throwing. Loud enough to notice, safe.
102
+ assert.equal(T.friendlyBatchReason({ reason: 'SOME_NEW_CODE' }), 'SOME_NEW_CODE');
103
+ assert.equal(T.friendlyBatchReason(null), 'not claimable');
104
+ });
105
+
106
+ // ---- rankTagHtml: escaping ------------------------------------------------
107
+ t('rankTagHtml escapes its label and treats xenos as "no floor"', () => {
108
+ assert.match(T.rankTagHtml('xenos'), /Any rank/);
109
+ assert.match(T.rankTagHtml('metic'), /Metic\+/);
110
+ assert.match(T.rankTagHtml('archon'), /Archon</, 'archon takes no + suffix');
111
+ assert.doesNotMatch(T.rankTagHtml('<img src=x onerror=1>'), /<img/);
112
+ });
113
+
114
+ console.log(`\ntask-lib tests: ${passed} passed, ${failed} failed`);
115
+ if (failed) process.exit(1);