@bongos/core 1.19.879 → 1.19.880

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.
@@ -2232,5 +2232,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2232
2232
  landed since 1.19.877 with no explicit bump. run 35439004814. (task 1002620)
2233
2233
  1.19.879 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2234
2234
  landed since 1.19.878 with no explicit bump. run 35439529685. (task 1002620)
2235
+ 1.19.880 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2236
+ landed since 1.19.879 with no explicit bump. run 35453118533. (task 1002620)
2235
2237
  ---------------------------------------------------------------------------
2236
2238
  ```
@@ -146,7 +146,10 @@
146
146
  for (const k of list) {
147
147
  const opt = document.createElement('option');
148
148
  opt.value = k;
149
- opt.textContent = label(k);
149
+ // The unset bucket is named for a reader on EVERY surface, filters included
150
+ // — `label` is the caller's choice for the real kinds, never for this one
151
+ // (task 1004038). roleOptions() names the role twin the same way.
152
+ opt.textContent = kindIsUnset(k) ? kindLabel(k) : label(k);
150
153
  sel.appendChild(opt);
151
154
  }
152
155
  // A refill must not drop the reader's pick; `selected` is the filing default.
@@ -154,10 +157,23 @@
154
157
  if (want) sel.value = want;
155
158
  }
156
159
 
160
+ // A task with no kind carries the DB default 'unclassified' — the twin of
161
+ // ROLE_UNSET one axis over (task 1004038, filed by task 1003936 which fixed the
162
+ // role axis and left this one). A word a column chose is not a word a person
163
+ // should read (docs/voice.md V2), so a picker shows "No kind yet" and a row tag
164
+ // renders NOTHING. kindIsUnset() is how a caller asks. The STORED value and the
165
+ // filter VALUE stay 'unclassified'; only what is read changes.
166
+ const KIND_UNSET = 'unclassified';
167
+ function kindIsUnset(k) { return !k || String(k) === KIND_UNSET; }
168
+
169
+ // NOT ESCAPE-SAFE: like roleLabel() below, this returns a transformed copy of
170
+ // its input, so a caller rendering it into markup must escapeHtml() the result.
171
+ // Every caller today assigns it to textContent, which escapes for them.
157
172
  // How a kind is named in a picker — 'blocker-resolution' → 'Blocker resolution'.
158
173
  // The same shape roleLabel takes below, and it reproduces every label the
159
174
  // hand-written <option> list in ideas.html carried, so no copy changes.
160
175
  function kindLabel(k) {
176
+ if (kindIsUnset(k)) return 'No kind yet';
161
177
  const s = String(k || '').replace(/-/g, ' ');
162
178
  return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
163
179
  }
@@ -407,7 +423,7 @@
407
423
  escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
408
424
  // task 1003936 — ONE word for the role axis, all six surfaces
409
425
  roleAxisLabel, roleLabel, roleOptions, roleIsUnset, ROLE_UNSET,
410
- fillKindFilter, kindLabel, // task 1003453 — one Kind filler, all three surfaces
426
+ fillKindFilter, kindLabel, kindIsUnset, KIND_UNSET, // task 1003453 — one Kind filler, all three surfaces
411
427
  errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
412
428
  contributeSettingsPanel, settingsPanels,
413
429
  contributeHallWidget, hallWidgets, hallWidgetIds,
@@ -321,6 +321,11 @@
321
321
  const disciplineTag = window.OTB.roleIsUnset(t.discipline)
322
322
  ? ''
323
323
  : `<span class="task__tag">${escapeHtml(window.OTB.roleLabel(t.discipline))}</span>`;
324
+ // A kind nobody set is no tag at all, not the column's default word — the
325
+ // role tag above is the precedent this follows (task 1004038).
326
+ const kindTag = window.OTB.kindIsUnset(t.kind)
327
+ ? ''
328
+ : `<span class="task__tag">${escapeHtml(t.kind)}</span>`;
324
329
  const newcomerTag = t.newcomer_friendly ? '<span class="task__tag task__tag--newcomer">newcomer</span>' : '';
325
330
  const rankTag = rankTagHtml(t.requires_rank);
326
331
  const touches = Array.isArray(t.touches) ? t.touches : [];
@@ -361,7 +366,7 @@
361
366
  <p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(id))}">${escapeHtml(t.title || '')}</a></p>
362
367
  <div class="task__meta">
363
368
  <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
364
- <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
369
+ ${kindTag}
365
370
  ${disciplineTag}
366
371
  ${newcomerTag}
367
372
  ${rankTag}
@@ -403,7 +408,7 @@
403
408
  <tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
404
409
  <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
405
410
  <td>${escapeHtml(t.title || '')}</td>
406
- <td>${escapeHtml(t.kind || '—')}</td>
411
+ <td>${window.OTB.kindIsUnset(t.kind) ? '—' : escapeHtml(t.kind)}</td>
407
412
  <td class="ledger__drachmae">${fmtNum(creditVal(t))}${taskSettled(t) ? '' : ' <span class="ledger__est" title="Estimate — credits settle when the task is confirmed">est.</span>'}</td>
408
413
  <td class="ledger__date">${fmtDate(t.shipped_at)}</td>
409
414
  </tr>`).join('');
@@ -502,7 +507,7 @@
502
507
  const sortBy = $('#f-goal-sort')?.value || 'cpm';
503
508
  let list = state.work.todo.filter((t) => {
504
509
  if (!matchesTodoSearch(t, sQuery)) return false;
505
- if (kFilter && (t.kind || 'unclassified') !== kFilter) return false;
510
+ if (kFilter && (t.kind || window.OTB.KIND_UNSET) !== kFilter) return false;
506
511
  if (dFilter && (t.discipline || window.OTB.ROLE_UNSET) !== dFilter) return false;
507
512
  if (nFilter === 'yes' && !t.newcomer_friendly) return false;
508
513
  return true;
@@ -536,8 +536,12 @@
536
536
  // word is the thing the round's stat-tile reference exists to fix.
537
537
  const meta = [];
538
538
  if (t.version_id) meta.push(`<b>${escapeHtml(t.version_id)}</b>`);
539
- if (t.kind) meta.push(escapeHtml(t.kind));
540
- if (t.discipline && t.discipline !== 'unclassified') meta.push(escapeHtml(t.discipline));
539
+ // Neither axis says the word its column defaults to (task 1004038). `if
540
+ // (t.kind)` was never a guard for it: 'unclassified' is a truthy string, so
541
+ // the run read it out loud. The role line below asked the same question with
542
+ // a literal — it is right, but a copy of roleIsUnset() is free to drift.
543
+ if (!window.OTB.kindIsUnset(t.kind)) meta.push(escapeHtml(t.kind));
544
+ if (!window.OTB.roleIsUnset(t.discipline)) meta.push(escapeHtml(t.discipline));
541
545
  // rankText() answers "any rank" for an open task, which read as
542
546
  // "min rank any rank" once the label was prefixed. An open floor is not a
543
547
  // minimum, so it is said as the absence of one.
@@ -429,6 +429,8 @@
429
429
  const disciplineTag = window.OTB.roleIsUnset(t.discipline)
430
430
  ? ''
431
431
  : `<span class="task__tag">${escapeHtml(window.OTB.roleLabel(t.discipline))}</span>`;
432
+ // A kind nobody set is no tag at all, like the role above (task 1004038).
433
+ const kindTag = window.OTB.kindIsUnset(t.kind) ? '' : `<span class="task__tag">${escapeHtml(t.kind)}</span>`;
432
434
  const newcomerTag = t.newcomer_friendly
433
435
  ? '<span class="task__tag task__tag--newcomer">newcomer</span>'
434
436
  : '';
@@ -459,7 +461,7 @@
459
461
  <p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(t.id))}">${escapeHtml(t.title)}</a></p>
460
462
  <div class="task__meta">
461
463
  <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
462
- <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
464
+ ${kindTag}
463
465
  ${disciplineTag}
464
466
  ${newcomerTag}
465
467
  ${rankTag}
@@ -1142,7 +1144,7 @@
1142
1144
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1143
1145
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1144
1146
  <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1145
- <span class="history__when">${escapeHtml(t.kind || '')}</span>
1147
+ <span class="history__when">${window.OTB.kindIsUnset(t.kind) ? '' : escapeHtml(t.kind)}</span>
1146
1148
  </div>`;
1147
1149
  }).join('');
1148
1150
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.879",
3
+ "version": "1.19.880",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.879",
9
+ "version": "1.19.880",
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.879",
3
+ "version": "1.19.880",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.879'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.880'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -354,6 +354,11 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
354
354
  roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
355
355
  roleIsUnset: (d) => !d || d === 'unclassified',
356
356
  ROLE_UNSET: 'unclassified',
357
+ // task 1004038 — the kind axis asks the same question. Stubbed for the
358
+ // reason the comment above gives: an undefined helper throws mid-render,
359
+ // and the page then renders its "feed unavailable" card instead.
360
+ kindIsUnset: (k) => !k || k === 'unclassified',
361
+ KIND_UNSET: 'unclassified',
357
362
  escapeHtml,
358
363
  $: (sel) => documentObj.querySelector(sel),
359
364
  fmtDate: (iso) => String(iso).slice(0, 10),
@@ -0,0 +1,195 @@
1
+ // tests/kind_axis_unset.mjs — the KIND axis never reads its column default to a
2
+ // person (task 1004038, goal 1000095).
3
+ //
4
+ // The twin of the role axis task 1003936 fixed. `tasks.kind` defaults to
5
+ // 'unclassified'; docs/voice.md V2 says a value that exists because a column
6
+ // needed a default is not a word a person should read. Before this, the word
7
+ // reached a reader on four surfaces: the Goals page row tag, the Tasks board row
8
+ // tag, the backlog history row, and every Kind filter's option list.
9
+ //
10
+ // The line this holds — the same one 1003936 drew: the STORED value and the
11
+ // FILTER VALUE stay 'unclassified'. Only what is READ changes. A test that let
12
+ // the value change would be pinning the wrong half.
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
23
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
24
+
25
+ // The real dom-utils.js, booted the way the pages boot it, so these are the
26
+ // functions that ship and not a restatement of them.
27
+ function loadOTB() {
28
+ const mkEl = () => ({
29
+ value: '', textContent: '', length: 0, children: [],
30
+ appendChild(c) { this.children.push(c); this.length = this.children.length; },
31
+ setAttribute() {}, addEventListener() {},
32
+ });
33
+ const sandbox = {
34
+ window: { __BRANDING__: {}, __MODULES__: { enabled: {} }, addEventListener() {} },
35
+ document: { createElement: mkEl, getElementById: () => null, querySelector: () => null,
36
+ querySelectorAll: () => [], addEventListener() {} },
37
+ console,
38
+ };
39
+ sandbox.globalThis = sandbox;
40
+ vm.createContext(sandbox);
41
+ vm.runInContext(read('dom-utils.js'), sandbox, { filename: 'dom-utils.js' });
42
+ return { OTB: sandbox.window.OTB };
43
+ }
44
+
45
+ test('the unset kind is asked about, not spelled, and the stored value is untouched', () => {
46
+ const { OTB } = loadOTB();
47
+ assert.equal(OTB.KIND_UNSET, 'unclassified',
48
+ 'the STORED value is still the column default — this fix changes reading, not writing');
49
+ assert.equal(OTB.kindIsUnset('unclassified'), true);
50
+ assert.equal(OTB.kindIsUnset(''), true, 'a missing kind is unset too');
51
+ assert.equal(OTB.kindIsUnset(null), true);
52
+ assert.equal(OTB.kindIsUnset(undefined), true);
53
+ assert.equal(OTB.kindIsUnset('bug'), false, 'a real kind is never unset');
54
+ assert.equal(OTB.kindIsUnset('verify'), false);
55
+ });
56
+
57
+ test('a kind is named for a reader, and the unset one is named a phrase a person would say', () => {
58
+ const { OTB } = loadOTB();
59
+ assert.equal(OTB.kindLabel('unclassified'), 'No kind yet',
60
+ "the column's word never reaches a reader");
61
+ assert.doesNotMatch(OTB.kindLabel('unclassified'), /unclassified/i);
62
+ // The labels task 1003453 shipped are unchanged — this fix adds a case, it
63
+ // does not restyle the vocabulary.
64
+ assert.equal(OTB.kindLabel('bug'), 'Bug');
65
+ assert.equal(OTB.kindLabel('blocker-resolution'), 'Blocker resolution');
66
+ assert.equal(OTB.kindLabel('learning-capture'), 'Learning capture');
67
+ });
68
+
69
+ test('EVERY Kind picker names the unset bucket, filters included — and keeps its value', () => {
70
+ const { OTB } = loadOTB();
71
+ const sel = { value: '', length: 1, options: [],
72
+ appendChild(o) { this.options.push(o); this.length = this.options.length + 1; } };
73
+ // A FILTER: `label` is identity, as work.js and goals-page.js pass it. Before
74
+ // this fix that identity function is exactly what leaked the column's word.
75
+ OTB.fillKindFilter(sel, ['bug', 'verify', 'unclassified'], { keepFirst: 1 });
76
+ const unset = sel.options.find((o) => o.value === 'unclassified');
77
+ assert.ok(unset, 'the unset bucket is still SELECTABLE — a filter you cannot apply is a lost lens');
78
+ assert.equal(unset.value, 'unclassified', 'and it still filters on the stored value');
79
+ assert.equal(unset.textContent, 'No kind yet', 'but it READS as a phrase');
80
+ // The real kinds still honour the caller's label choice.
81
+ assert.equal(sel.options.find((o) => o.value === 'bug').textContent, 'bug',
82
+ "a filter's raw-value labelling for real kinds is unchanged");
83
+ });
84
+
85
+ // ---- the render sites ------------------------------------------------------
86
+ // Source pins, the idiom tests/hall_board_world.mjs uses for the role twin: a
87
+ // row tag is built in a template literal that no unit test can reach without
88
+ // booting the whole page.
89
+
90
+ const goalsRender = read('goals-render.js');
91
+ const workJs = read('work.js');
92
+ const taskJs = read('task.js');
93
+
94
+ // Every `t.kind` USE in the two files is accounted for — the structural form,
95
+ // not a pattern match. task 1004038's first round asserted the literal
96
+ // `t.kind || 'unclassified'` was gone, which was true and still missed
97
+ // taskRowsDone()'s `t.kind || '—'`: a different spelling of the same bug, in a
98
+ // file the diff was already editing. `||` cannot guard this axis AT ALL, because
99
+ // the default is the truthy string 'unclassified' — so the test has to enumerate
100
+ // every use and demand each one either guards or is a declared non-render.
101
+ // Each scanned file split ONCE at module load — the scan below reads these, so
102
+ // adding a case costs no re-read (the per-test split was the round-2 note).
103
+ const scanned = (src) => {
104
+ const all = src.split('\n');
105
+ return { raw: src, all, hits: all.map((l, i) => [l, i]).filter(([l]) => l.includes('t.kind')) };
106
+ };
107
+
108
+ const USES = {
109
+ 'goals-render.js': {
110
+ src: scanned(goalsRender),
111
+ // A use that never reaches a reader, each with the reason it is exempt.
112
+ nonRender: {
113
+ "case 'kind': return String(t.kind || '').toLowerCase();": 'a SORT key — compared, never displayed',
114
+ "if (kFilter && (t.kind || window.OTB.KIND_UNSET) !== kFilter) return false;": 'the filter PREDICATE — must use the stored value',
115
+ },
116
+ },
117
+ 'work.js': {
118
+ src: scanned(workJs),
119
+ nonRender: {
120
+ 'if (f.kind && t.kind !== f.kind) return false;': 'the board filter PREDICATE — stored value again',
121
+ },
122
+ },
123
+ // Round 2 found the RECORD page reading the word too, via `if (t.kind)` — which
124
+ // is not a guard, because the default is truthy. Enumerating the file here is
125
+ // what stops the next one being found by a grader instead of by this test.
126
+ 'task.js': {
127
+ src: scanned(taskJs),
128
+ nonRender: {
129
+ "if (t.kind === 'verify' && !window.confirm(": 'a BRANCH on the value — gates a confirm prompt, renders nothing',
130
+ "const body = t.kind === 'verify' ? { verified: true } : {};": 'a REQUEST body — sent to the route, never displayed',
131
+ },
132
+ },
133
+ };
134
+
135
+ test('every t.kind use either goes through kindIsUnset or is a declared non-render', () => {
136
+ for (const [name, { src, nonRender }] of Object.entries(USES)) {
137
+ const { all, hits } = src;
138
+ assert.ok(hits.length, `${name} has t.kind uses to check`);
139
+ for (const [raw, i] of hits) {
140
+ const line = raw.trim();
141
+ if (line.startsWith('//') || line.startsWith('*')) continue; // prose about the axis
142
+ if (Object.keys(nonRender).some((k) => line.includes(k.trim()))) continue;
143
+ // The guard may be a multi-line ternary whose `?`/`:` arms sit on their own
144
+ // lines (goals-render.js writes it that way), so the window is the
145
+ // statement, not the line — two lines back is enough for every form here.
146
+ const stmt = all.slice(Math.max(0, i - 2), i + 1).join('\n');
147
+ assert.match(stmt, /kindIsUnset/,
148
+ `${name}: this t.kind use neither guards nor is declared a non-render — `
149
+ + `if it renders, the column's word reaches a reader:\n ${line}`);
150
+ }
151
+ }
152
+ });
153
+
154
+ test('the column default is TRUTHY, so `||` can never be the guard', () => {
155
+ // The fact that makes the finding above a bug rather than a style note, and
156
+ // the reason no render site may fall back with `||`. Pinned so a future reader
157
+ // does not "simplify" a kindIsUnset() call back into a falsy check.
158
+ const { OTB } = loadOTB();
159
+ assert.ok(OTB.KIND_UNSET, 'the default is a non-empty string');
160
+ assert.equal(!!(OTB.KIND_UNSET || '—'), true, '`t.kind || fallback` keeps the default, never the fallback');
161
+ assert.equal(OTB.KIND_UNSET || '—', 'unclassified', 'which is exactly the word that must not be read');
162
+ for (const [name, { src }] of Object.entries(USES)) {
163
+ assert.doesNotMatch(src.raw, /escapeHtml\(t\.kind \|\|/,
164
+ `${name} never renders a \`||\` fallback for the kind axis — it cannot fire`);
165
+ }
166
+ });
167
+
168
+ test('a kind nobody set renders NO tag, exactly as an unset role does', () => {
169
+ for (const [name, src] of [['goals-render.js', goalsRender], ['work.js', workJs]]) {
170
+ assert.match(src, /window\.OTB\.kindIsUnset\(t\.kind\)/,
171
+ `${name} asks the vocabulary whether the kind is set`);
172
+ assert.match(src, /kindTag/, `${name} renders through the guarded tag`);
173
+ }
174
+ // The backlog history row is the third surface and the easiest to miss — it
175
+ // rendered `t.kind || ''`, which is empty-safe but says 'unclassified' out loud.
176
+ const history = workJs.slice(workJs.indexOf('class="history__row"'));
177
+ assert.match(history, /kindIsUnset\(t\.kind\) \? '' : escapeHtml\(t\.kind\)/,
178
+ 'the backlog history row is guarded too');
179
+ });
180
+
181
+ test("a TABLE cell keeps its placeholder — 'no tag' is not 'no cell'", () => {
182
+ // taskRowsDone()'s Completed ledger is a table: a vanished <td> would shift
183
+ // every following column. It guards like the rest but falls back to the
184
+ // em-dash the ledger already used for an empty cell.
185
+ const done = goalsRender.slice(goalsRender.indexOf('function taskRowsDone'));
186
+ assert.match(done.slice(0, done.indexOf('function ', 10)), /kindIsUnset\(t\.kind\) \? '—' : escapeHtml\(t\.kind\)/,
187
+ 'the ledger cell is guarded and still renders a placeholder');
188
+ });
189
+
190
+ test('the filter PREDICATE still compares against the stored value', () => {
191
+ // If this ever reads the label instead, every Kind filter silently matches
192
+ // nothing and the board looks empty rather than broken.
193
+ assert.match(goalsRender, /t\.kind \|\| window\.OTB\.KIND_UNSET/,
194
+ 'the goals-page predicate defaults to the stored value, through the constant');
195
+ });