@bongos/core 1.19.1041 → 1.19.1043

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.
Files changed (87) hide show
  1. package/.bongos-core.json +127 -87
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
  8. package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
  9. package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +109 -7
  12. package/docs/api-reference.md +9 -2
  13. package/docs/copy-inventory.md +79 -59
  14. package/docs/copy-registry.json +248 -68
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +12 -0
  17. package/docs/modules-contract.md +4 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/copy-desk.html +1 -1
  23. package/modules/hall-ui/public/deploy.html +1 -1
  24. package/modules/hall-ui/public/diagrams.html +1 -1
  25. package/modules/hall-ui/public/drachmae.html +1 -1
  26. package/modules/hall-ui/public/fleet.html +1 -1
  27. package/modules/hall-ui/public/gate.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/modules.html +1 -1
  34. package/modules/hall-ui/public/palette.js +1 -0
  35. package/modules/hall-ui/public/people.html +1 -1
  36. package/modules/hall-ui/public/primer.html +1 -1
  37. package/modules/hall-ui/public/profile.html +1 -1
  38. package/modules/hall-ui/public/project-settings.html +1 -1
  39. package/modules/hall-ui/public/ranks.html +1 -1
  40. package/modules/hall-ui/public/roadmap.html +1 -1
  41. package/modules/hall-ui/public/roster.html +1 -1
  42. package/modules/hall-ui/public/sessions.html +1 -1
  43. package/modules/hall-ui/public/settings-software-update.js +194 -0
  44. package/modules/hall-ui/public/settings.css +39 -0
  45. package/modules/hall-ui/public/settings.html +24 -2
  46. package/modules/hall-ui/public/settings.js +5 -0
  47. package/modules/hall-ui/public/settings.states.json +2 -1
  48. package/modules/hall-ui/public/shell.js +25 -39
  49. package/modules/hall-ui/public/studio.html +1 -1
  50. package/modules/hall-ui/public/style.css +4 -0
  51. package/modules/hall-ui/public/task.css +6 -0
  52. package/modules/hall-ui/public/task.html +2 -2
  53. package/modules/hall-ui/public/task.js +17 -0
  54. package/modules/hall-ui/public/watch.html +1 -1
  55. package/modules/hall-ui/public/work.html +1 -1
  56. package/modules/hall-ui/records/panel-family.md +9 -0
  57. package/modules/hall-ui/records/record-family.md +2 -0
  58. package/modules/npm-release/module.json +8 -2
  59. package/modules/npm-release/public/task-widget.js +79 -0
  60. package/modules/npm-release/public/work.js +8 -3
  61. package/modules/npm-release/routes/task-where.js +48 -0
  62. package/modules/npm-release/work.js +78 -11
  63. package/package-lock.json +2 -2
  64. package/package.json +1 -1
  65. package/release-notes.json +16 -0
  66. package/scripts/gds/exec-path-guard.js +1 -1
  67. package/scripts/gds/fitness.js +7 -0
  68. package/scripts/gds/main-audit.js +204 -7
  69. package/scripts/gds/seam-wiring-guard.js +4 -2
  70. package/scripts/hall-preview/server.js +14 -1
  71. package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
  72. package/src/bongos/routes/core-update.js +65 -3
  73. package/src/bongos/serve-internal.js +35 -4
  74. package/src/bongos/software-update.js +127 -0
  75. package/src/module-api.js +10 -1
  76. package/src/module-loader/loader.js +13 -3
  77. package/tests/core_upgrade_owner_door.mjs +27 -18
  78. package/tests/hall_record_world.mjs +23 -1
  79. package/tests/helpers.mjs +92 -0
  80. package/tests/main_audit_attestation.mjs +238 -0
  81. package/tests/module_api.mjs +1 -0
  82. package/tests/module_loader.mjs +27 -0
  83. package/tests/nav_permission_atoms.mjs +1 -1
  84. package/tests/npm_release_page.mjs +11 -1
  85. package/tests/npm_release_where.mjs +323 -0
  86. package/tests/npm_release_work.mjs +9 -94
  87. package/tests/software_update.mjs +449 -0
@@ -0,0 +1,238 @@
1
+ // tests/main_audit_attestation.mjs — the main audit's attestation pass (task 1001433).
2
+ //
3
+ // The pass ties every permission-sensitive commit on main to the pull request that
4
+ // brought it in, and requires that PR's head to carry the server's `gate-author-trust`
5
+ // status, posted by the server's own GitHub identity. The author NAME the older pass
6
+ // reads is typed text; the status creator is not. FLAG-ONLY by the owner's ruling
7
+ // (2026-09-26): findings are 'review', never 'blocker'.
8
+ //
9
+ // Everything here runs on injected git + fetch seams — no repository, no network.
10
+ //
11
+ // Run: node --test tests/main_audit_attestation.mjs
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const audit = require('../scripts/gds/main-audit.js');
18
+ const {
19
+ ATTEST_CONTEXT, mapIntroducingHeads, isAttested, analyzeAttestation, attestationConfig,
20
+ fetchAttestations, runAttestationPass,
21
+ } = audit;
22
+
23
+ const sha = (c) => c.repeat(40);
24
+ const PROTECTED = 'src/bongos/auth.js'; // floored in the real registry
25
+ const PLAIN = 'docs/notes.md'; // not a protected surface
26
+ const status = (login, state = 'success', context = ATTEST_CONTEXT) => ({ context, state, creator: { login } });
27
+
28
+ // A tiny history. Mainline: R (release) ← M (merges PR head H over base B).
29
+ // The PR carried two commits: A (protected) then H (the head, plain).
30
+ const B = sha('b'); const A = sha('a'); const H = sha('h'); const M = sha('m'); const D = sha('d');
31
+ const commitA = { sha: A, authorName: 'somemetic', parents: [B], files: [PROTECTED] };
32
+ const commitH = { sha: H, authorName: 'somemetic', parents: [A], files: [PLAIN] };
33
+ const mergeM = { sha: M, authorName: 'Lars', parents: [B, H], isMerge: true, files: [] };
34
+ // D: a protected commit pushed straight onto main (no merge brought it in).
35
+ const directD = { sha: D, authorName: 'IAMMASTERQUA', parents: [M], files: [PROTECTED] };
36
+ const revRanges = { [`${B}..${H}`]: [H, A] };
37
+ const revList = (r) => revRanges[r] || [];
38
+
39
+ test('the real registry still floors the fixture paths the way these tests assume', () => {
40
+ const ppc = require('../src/bongos/permission-path-check.js');
41
+ assert.ok(ppc.matchProtected([PROTECTED]).length > 0, `${PROTECTED} must be protected`);
42
+ assert.equal(ppc.matchProtected([PLAIN]).length, 0, `${PLAIN} must not be protected`);
43
+ });
44
+
45
+ // ---- mapIntroducingHeads ----
46
+
47
+ test('a mainline merge vouches for every commit its PR brought in, via its SECOND parent', () => {
48
+ const headOf = mapIntroducingHeads([mergeM], revList);
49
+ assert.equal(headOf.get(A), H);
50
+ assert.equal(headOf.get(H), H, 'a PR head vouches for itself');
51
+ assert.equal(headOf.has(D), false, 'a direct push has no introducing merge');
52
+ });
53
+
54
+ test('a commit keeps the FIRST merge that claims it; single-parent commits are ignored', () => {
55
+ const other = { sha: sha('n'), parents: [sha('x'), sha('y')] };
56
+ const headOf = mapIntroducingHeads([mergeM, other, commitA], (r) => (r === `${sha('x')}..${sha('y')}` ? [A] : revList(r)));
57
+ assert.equal(headOf.get(A), H);
58
+ });
59
+
60
+ // ---- isAttested: the creator is the part that cannot be faked ----
61
+
62
+ test('a success from the trusted account attests; the login match ignores case', () => {
63
+ assert.equal(isAttested([status('owner-acct')], 'Owner-Acct'), true);
64
+ });
65
+
66
+ test('FORGERY: the same context posted by any other account attests nothing', () => {
67
+ assert.equal(isAttested([status('IAMMASTERQUA')], 'owner-acct'), false);
68
+ assert.equal(isAttested([status('github-actions[bot]')], 'owner-acct'), false);
69
+ });
70
+
71
+ test("only the trusted account's NEWEST status for the context decides", () => {
72
+ // newest first, as GitHub returns them
73
+ assert.equal(isAttested([status('owner-acct', 'failure'), status('owner-acct', 'success')], 'owner-acct'), false, 'a later failure withdraws');
74
+ assert.equal(isAttested([status('owner-acct', 'success'), status('owner-acct', 'failure')], 'owner-acct'), true, 'a later success restores');
75
+ // another account can neither attest nor revoke
76
+ assert.equal(isAttested([status('mallory', 'failure'), status('owner-acct', 'success')], 'owner-acct'), true);
77
+ });
78
+
79
+ test('another context, a pending state, or no trusted login attests nothing', () => {
80
+ assert.equal(isAttested([status('owner-acct', 'success', 'unit-report')], 'owner-acct'), false);
81
+ assert.equal(isAttested([status('owner-acct', 'pending')], 'owner-acct'), false);
82
+ assert.equal(isAttested([status('owner-acct')], ''), false);
83
+ assert.equal(isAttested(null, 'owner-acct'), false);
84
+ });
85
+
86
+ // ---- analyzeAttestation ----
87
+
88
+ test('an attested PR head clears every protected commit it brought in', () => {
89
+ const headOf = mapIntroducingHeads([mergeM], revList);
90
+ const r = analyzeAttestation([commitA, commitH], { headOf, attested: new Set([H]), trustedLogin: 'owner-acct' });
91
+ assert.equal(r.checked, 1, 'only the protected commit is checked');
92
+ assert.deepEqual(r.findings, []);
93
+ });
94
+
95
+ test('an unattested PR head is a REVIEW finding naming the head — never a blocker', () => {
96
+ const headOf = mapIntroducingHeads([mergeM], revList);
97
+ const r = analyzeAttestation([commitA], { headOf, attested: new Set(), trustedLogin: 'owner-acct' });
98
+ assert.equal(r.findings.length, 1);
99
+ const f = r.findings[0];
100
+ assert.equal(f.severity, 'review', 'flag-only: the owner ruled this pass must not gate');
101
+ assert.equal(f.kind, 'unattested');
102
+ assert.equal(f.head, H);
103
+ assert.match(f.summary, /pull request that brought it in \(head hhhhhhhhh\)/);
104
+ assert.match(f.summary, /gate-author-trust' status from 'owner-acct'/);
105
+ });
106
+
107
+ test('a direct push to main is flagged as having no pull request at all', () => {
108
+ const r = analyzeAttestation([directD], { headOf: new Map(), attested: new Set(), trustedLogin: 'owner-acct' });
109
+ assert.equal(r.findings.length, 1);
110
+ assert.equal(r.findings[0].severity, 'review');
111
+ assert.equal(r.findings[0].head, D);
112
+ assert.match(r.findings[0].summary, /without passing through a pull request/);
113
+ });
114
+
115
+ test('a ONE-commit pull request (head === the commit) still reads as a pull request', () => {
116
+ const lone = { sha: H, authorName: 'somemetic', parents: [B], files: [PROTECTED] };
117
+ const r = analyzeAttestation([lone], { headOf: new Map([[H, H]]), attested: new Set(), trustedLogin: 'owner-acct' });
118
+ assert.match(r.findings[0].summary, /pull request that brought it in/);
119
+ assert.doesNotMatch(r.findings[0].summary, /without passing through a pull request/);
120
+ });
121
+
122
+ test('a direct push that carries the attestation ITSELF is cleared', () => {
123
+ const r = analyzeAttestation([directD], { headOf: new Map(), attested: new Set([D]), trustedLogin: 'owner-acct' });
124
+ assert.deepEqual(r.findings, []);
125
+ });
126
+
127
+ // ---- attestationConfig ----
128
+
129
+ test('config: Actions env first, GH_TOKEN as the laptop fallback, owner as the default login', () => {
130
+ assert.deepEqual(attestationConfig({ env: { GITHUB_TOKEN: 't1', GITHUB_REPOSITORY: 'owner-acct/core' } }),
131
+ { token: 't1', repo: 'owner-acct/core', trustedLogin: 'owner-acct' });
132
+ assert.equal(attestationConfig({ env: { GH_TOKEN: 't2' } }).token, 't2');
133
+ assert.equal(attestationConfig({ env: {} }).token, null);
134
+ });
135
+
136
+ test('config: the repo is read from the origin remote without ever capturing a credential in it', () => {
137
+ const https = attestationConfig({ env: {}, remoteUrl: 'https://x-access-token:SECRET@github.com/owner/repo.git' });
138
+ assert.equal(https.repo, 'owner/repo');
139
+ assert.ok(!JSON.stringify(https).includes('SECRET'));
140
+ assert.equal(attestationConfig({ env: {}, remoteUrl: 'git@github.com:owner/repo.git' }).repo, 'owner/repo');
141
+ assert.equal(attestationConfig({ env: {}, remoteUrl: 'https://example.com/x/y.git' }).repo, null);
142
+ });
143
+
144
+ test('config: --attest-login overrides the owner default (an App-posting instance)', () => {
145
+ const c = attestationConfig({ env: { GITHUB_REPOSITORY: 'o/r' }, loginArg: 'bongos-app[bot]' });
146
+ assert.equal(c.trustedLogin, 'bongos-app[bot]');
147
+ });
148
+
149
+ // ---- fetchAttestations ----
150
+
151
+ function stubFetch(bySha) {
152
+ const calls = [];
153
+ const impl = async (url, init) => {
154
+ calls.push({ url, init });
155
+ const s = Object.keys(bySha).find((k) => url.includes(`/commits/${k}/`));
156
+ const r = bySha[s];
157
+ if (r instanceof Error) throw r;
158
+ if (typeof r === 'number') return { ok: false, status: r, json: async () => ({}) };
159
+ return { ok: true, status: 200, json: async () => r };
160
+ };
161
+ return { impl, calls };
162
+ }
163
+
164
+ test('fetch: reads each head once, keeps only the trusted attestations, sends the token', async () => {
165
+ const { impl, calls } = stubFetch({ [H]: [status('owner-acct')], [D]: [status('mallory')] });
166
+ const r = await fetchAttestations([H, D, H], { repo: 'o/r', token: 'tok', trustedLogin: 'owner-acct', fetchImpl: impl });
167
+ assert.deepEqual([...r.attested], [H]);
168
+ assert.deepEqual(r.unreadable, []);
169
+ assert.equal(calls.length, 2, 'a head shared by several commits is read once');
170
+ assert.equal(calls[0].url, `https://api.github.com/repos/o/r/commits/${H}/statuses?per_page=100`);
171
+ assert.equal(calls[0].init.headers.Authorization, 'Bearer tok');
172
+ });
173
+
174
+ test('fetch: an unreadable head is reported, never counted as attested or unattested', async () => {
175
+ const { impl } = stubFetch({ [H]: 404, [D]: new Error('network down') });
176
+ const r = await fetchAttestations([H, D], { repo: 'o/r', token: 'tok', trustedLogin: 'owner-acct', fetchImpl: impl });
177
+ assert.equal(r.attested.size, 0);
178
+ assert.deepEqual(r.unreadable.map((u) => u.reason), ['HTTP 404', 'network down']);
179
+ });
180
+
181
+ // ---- runAttestationPass: the whole pass on fake git + fetch ----
182
+
183
+ function fakeGit({ mainline, ranges, remote = 'https://github.com/o/r.git' }) {
184
+ return (args) => {
185
+ if (args === 'remote get-url origin') return `${remote}\n`;
186
+ if (args.startsWith('rev-list --first-parent ')) return mainline.join('\n');
187
+ if (args.startsWith('rev-list ')) return (ranges[args.slice('rev-list '.length)] || []).join('\n');
188
+ throw new Error(`unexpected git ${args}`);
189
+ };
190
+ }
191
+ const history = [directD, mergeM, commitH, commitA];
192
+ const gitImpl = fakeGit({ mainline: [D, M], ranges: revRanges });
193
+
194
+ test('pass: nothing protected in range → it ran, checked nothing, needed no token', async () => {
195
+ const r = await runAttestationPass([commitH], 'x..y', { env: {}, gitImpl: () => { throw new Error('git must not be called'); } });
196
+ assert.deepEqual(r, { ran: true, checked: 0, findings: [] });
197
+ });
198
+
199
+ test('pass: no token → DID NOT RUN with the reason, never a clean pass', async () => {
200
+ const r = await runAttestationPass(history, 'x..y', { env: {}, gitImpl });
201
+ assert.equal(r.ran, false);
202
+ assert.match(r.reason, /no GitHub token/);
203
+ });
204
+
205
+ test('pass: end to end — the attested PR clears, the direct push is flagged', async () => {
206
+ const { impl } = stubFetch({ [H]: [status('o')], [D]: [] });
207
+ const r = await runAttestationPass(history, 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl, fetchImpl: impl });
208
+ assert.equal(r.ran, true);
209
+ assert.equal(r.checked, 2);
210
+ assert.deepEqual(r.findings.map((f) => f.sha), [D]);
211
+ assert.equal(r.trustedLogin, 'o', 'the owner of the origin remote');
212
+ });
213
+
214
+ test('pass: a FORGED mark on the PR head (right name, wrong account) is flagged', async () => {
215
+ const { impl } = stubFetch({ [H]: [status('IAMMASTERQUA')], [D]: [status('o')] });
216
+ const r = await runAttestationPass(history, 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl, fetchImpl: impl });
217
+ assert.deepEqual(r.findings.map((f) => f.sha), [A]);
218
+ assert.ok(r.findings.every((f) => f.severity === 'review'));
219
+ });
220
+
221
+ test('pass: a head it could not read makes the whole pass DID NOT RUN', async () => {
222
+ const { impl } = stubFetch({ [H]: 502, [D]: [status('o')] });
223
+ const r = await runAttestationPass(history, 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl, fetchImpl: impl });
224
+ assert.equal(r.ran, false);
225
+ assert.match(r.reason, /could not read the commit statuses of 1 pull-request head/);
226
+ });
227
+
228
+ test('pass: a merge INSIDE a pull request never vouches — only mainline merges do', async () => {
229
+ // I: a branch's "merge main into me" whose second parent is a main commit P.
230
+ const P = sha('p'); const I = sha('i');
231
+ const inner = { sha: I, authorName: 'x', parents: [A, P], isMerge: true, files: [] };
232
+ const mainP = { sha: P, authorName: 'x', parents: [B], files: [PROTECTED] };
233
+ const g = fakeGit({ mainline: [P], ranges: { [`${A}..${P}`]: [P] } });
234
+ const { impl } = stubFetch({ [P]: [] });
235
+ const r = await runAttestationPass([inner, mainP], 'x..y', { env: { GH_TOKEN: 'tok' }, gitImpl: g, fetchImpl: impl });
236
+ assert.equal(r.findings.length, 1);
237
+ assert.equal(r.findings[0].head, P, 'P answers for itself; the inner merge did not claim it');
238
+ });
@@ -56,6 +56,7 @@ const PUBLISHED_SURFACE = [
56
56
  'buildInfo', 'validateOrRespond', 'LIMITS', 'parseId', 'asyncHandler', 'corsPublicGet', // parseId added 1.8.0 (BV1.R78); asyncHandler + corsPublicGet added 1.12.0 (BV1.R86; lifecycle routes)
57
57
  'parsePagination', 'pageMeta', 'PAGINATION', // R14 (#2001, 1.17.0): shared pagination contract for module list routes (ADR 0119)
58
58
  'responsibilityFor', 'ROLE_RESPONSIBILITIES', // added by task 1003732 (ADR 0275; criterion wa6-written-responsibilities): the ONE source for the sentence each craft is answerable for. It rides the doorway because two modules need the same text — grading judges role-shaped work against it, the hall shows it to the person being judged — and modules never import each other. Declared here deliberately: this list is what makes adding to the doorway a decision rather than a side effect
59
+ 'readPackageRegistry', // added by task 1004296 (ADR 0340 D5): the npm registry reader moved into the core, because Settings → Software update needs it in every hall; the module that publishes a package reads the same answer and cache through here rather than keeping a second copy
59
60
  'logger',
60
61
  ];
61
62
 
@@ -330,6 +330,33 @@ test('hallWidgetScripts: a module with no uiSections is never listed', () => {
330
330
  loader._reset();
331
331
  });
332
332
 
333
+ // task 1004301 — the same seam, one file per page: task-widget.js for a task's own page.
334
+ test('hallWidgetScripts: file task-widget.js lists the modules that ship one, and only those', () => {
335
+ loader._reset();
336
+ const root = fixtureRoot({
337
+ alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/task-widget.js': '// task line' } },
338
+ beta: { manifest: validManifest('beta', { contributes: { uiSections: ['b'] } }), files: { 'public/hall-widget.js': '// index only' } },
339
+ });
340
+ const opts = { roots: [root], core: '1.0.0' };
341
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file: 'task-widget.js' }, opts),
342
+ [{ key: 'alpha', src: '/modules/alpha/public/task-widget.js' }]);
343
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true }, opts),
344
+ [{ key: 'beta', src: '/modules/beta/public/hall-widget.js' }], 'the default is still the index widget');
345
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: (k) => k !== 'alpha', file: 'task-widget.js' }, opts), [], 'disabled: never listed');
346
+ loader._reset();
347
+ });
348
+
349
+ test('hallWidgetScripts: a file name outside the page allowlist lists nothing — it is not a file server', () => {
350
+ loader._reset();
351
+ const root = fixtureRoot({
352
+ alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/secrets.js': '//', 'public/task-widget.js': '//' } },
353
+ });
354
+ for (const file of ['secrets.js', '../module.json', 'public/task-widget.js', 'TASK-WIDGET.JS']) {
355
+ assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file }, { roots: [root], core: '1.0.0' }), [], file);
356
+ }
357
+ loader._reset();
358
+ });
359
+
333
360
  // --- moduleWebSurfaces: an enabled module's host-routed static surface resolves,
334
361
  // a disabled one vanishes (BV1.R84 / task 1635, ADR 0093 §1 / 02-module-map §6) ---
335
362
 
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '983282a83fd5aa71';
90
+ const SHELL_SHA = 'ff2017aa9bbbc507';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -22,6 +22,8 @@ const DEPLOY_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deplo
22
22
  const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/npm-release__work.json'), 'utf8'));
23
23
 
24
24
  const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
25
+ // Voice rule V4: quoted data renders with its dashes as commas (docs/voice.md).
26
+ const unDash = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
25
27
 
26
28
  function makeEl() {
27
29
  return { hidden: true, _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
@@ -67,7 +69,7 @@ test('every recent task is named, by title, linked to its page', async () => {
67
69
  // Stages longer than the fold show their first rows; the rest sit behind "Show all".
68
70
  for (const t of rows.slice(0, 8)) {
69
71
  assert.ok(html.includes(`href="/task/${t.id}"`), `${stage}: task ${t.id} is linked`);
70
- assert.ok(html.includes(escapeHtml(t.title)), `${stage}: task ${t.id} is named`);
72
+ assert.ok(html.includes(escapeHtml(unDash(t.title))), `${stage}: task ${t.id} is named`);
71
73
  }
72
74
  }
73
75
  assert.match(html, /Show all 15/);
@@ -171,3 +173,11 @@ test('the deploy page loads the module script only for its own view, and only wi
171
173
  assert.deepEqual(bootDeploy({ modules: undefined }), [], 'no module record at all');
172
174
  assert.deepEqual(bootDeploy({ modules: on, search: '?project=mercury' }), [], 'another project\'s view is not this package\'s');
173
175
  });
176
+
177
+ test('a task title and a note line are quoted ledger data: their dashes render as commas (V4)', async () => {
178
+ const stages = { ...FIXTURE.stages, not_merged: [{ ...FIXTURE.stages.not_merged[0], title: 'Deploy page — where the work is – phase 1' }] };
179
+ const b = bootWork(() => ok({ ...FIXTURE, stages }));
180
+ await b.settle();
181
+ assert.ok(b.work().innerHTML.includes('>Deploy page, where the work is, phase 1</a>'));
182
+ assert.doesNotMatch(b.work().innerHTML, /Deploy page [—–]/);
183
+ });
@@ -0,0 +1,323 @@
1
+ // tests/npm_release_where.mjs — a task's own page says where its code is (task 1004301):
2
+ // the npm-release module's one-task reading (readTaskWhere), its route, the browser widget
3
+ // that draws the line, and the core seam that puts the widget on the task page without the
4
+ // page naming the module.
5
+ //
6
+ // WHAT IS AT RISK. The line is read by the person who shipped the task, and the case the
7
+ // task exists for is the ship that never landed: "done, but not merged" must be said for a
8
+ // completed/confirmed task, never a version it is not in. Every other stage must be the
9
+ // deploy page's own answer, the widget must write only into the slot the page gives it and
10
+ // only for the task still on screen, and a failure must draw nothing rather than something
11
+ // wrong.
12
+ //
13
+ // Run: node tests/npm_release_where.mjs
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
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
+ import { createRequire } from 'node:module';
21
+ import { npmTarball, npmPackument, fakeHttpsGet } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const { readTaskWhere } = require('../modules/npm-release/work.js');
26
+ const buildRouter = require('../modules/npm-release/routes/task-where.js');
27
+
28
+ const ORIGIN = 'https://registry.npmjs.org';
29
+ const PKG = '@bongos/core';
30
+ const TIMES = {
31
+ '1.0.1': '2026-09-22T10:00:00.000Z',
32
+ '1.0.2': '2026-09-23T10:00:00.000Z',
33
+ '1.0.3': '2026-09-24T10:00:00.000Z',
34
+ '1.0.4': '2026-09-25T10:00:00.000Z',
35
+ };
36
+ const NOTES = {
37
+ '1.0.1': [{ id: '11', text: 'Released long ago.' }],
38
+ '1.0.2': [{ id: '12', text: 'Running here, not released.' }],
39
+ '1.0.3': [{ id: '13', text: 'Published, not running.' }, { text: 'A hand-landed change nobody numbered.' }],
40
+ '1.0.4': [],
41
+ };
42
+ const TASKS = {
43
+ 11: { status: 'shipped', value_summary: 'x', at: '2026-09-21T12:00:00Z' },
44
+ 12: { status: 'shipped', value_summary: 'x', at: '2026-09-22T12:00:00Z' },
45
+ 13: { status: 'shipped', value_summary: 'x', at: '2026-09-23T12:00:00Z' },
46
+ 14: { status: 'shipped', value_summary: 'A hand-landed change nobody numbered.', at: '2026-09-23T13:00:00Z' },
47
+ 15: { status: 'shipped', value_summary: 'Merged after the last version.', at: '2026-09-25T12:00:00Z' },
48
+ 16: { status: 'shipped', value_summary: 'A scope edit.', at: '2026-09-20T12:00:00Z' },
49
+ 20: { status: 'confirmed', value_summary: 'Stranded.', at: '2026-09-25T12:00:00Z' },
50
+ 21: { status: 'completed', value_summary: null, at: '2026-09-25T13:00:00Z' },
51
+ 30: { status: 'active', value_summary: null, at: '2026-09-25T13:00:00Z' },
52
+ };
53
+
54
+ function fakePool() {
55
+ const queries = [];
56
+ return {
57
+ queries,
58
+ async query(sql, params) {
59
+ queries.push({ sql, params });
60
+ if (/FROM tasks WHERE id = \$1::bigint/.test(sql)) {
61
+ const t = TASKS[params[0]];
62
+ return { rows: t ? [{ id: params[0], ...t }] : [] };
63
+ }
64
+ throw new Error(`unexpected query: ${sql}`);
65
+ },
66
+ };
67
+ }
68
+ const freshStore = () => ({ packument: { at: 0, value: null, inflight: null }, notes: { version: null, at: 0, value: null, inflight: null } });
69
+
70
+ function setup({ latest = '1.0.1', pkStatus = 200 } = {}) {
71
+ const versions = Object.keys(TIMES);
72
+ const newest = versions[versions.length - 1];
73
+ const fg = fakeHttpsGet({
74
+ [`${ORIGIN}/@bongos%2fcore`]: { status: pkStatus, body: pkStatus === 200 ? npmPackument({ versions, latest, times: TIMES }) : 'nope' },
75
+ [`${ORIGIN}/@bongos/core/-/core-${newest}.tgz`]: { body: npmTarball({ notes: NOTES }) },
76
+ });
77
+ const pool = fakePool();
78
+ const store = freshStore();
79
+ const read = (id, over = {}) => readTaskWhere({ pool, id, pkg: PKG, live: '1.0.2', get: fg.get, store, installedNotes: () => null, now: () => Date.parse('2026-09-26T13:00:00Z'), ...over });
80
+ return { read, pool, asked: fg.asked };
81
+ }
82
+
83
+ // ---- the reading ---------------------------------------------------------------------
84
+
85
+ test('each shipped task gets the deploy page\'s own stage and the version that carries it', async () => {
86
+ const { read } = setup();
87
+ const pick = async (id) => { const w = await read(id); return [w.stage, w.version]; };
88
+ assert.deepEqual(await pick(11), ['released', '1.0.1']);
89
+ assert.deepEqual(await pick(12), ['live_unreleased', '1.0.2']);
90
+ assert.deepEqual(await pick(13), ['not_live', '1.0.3']);
91
+ assert.deepEqual(await pick(14), ['not_live', '1.0.3'], 'a hand-landed merge is matched on the ship summary');
92
+ assert.deepEqual(await pick(15), ['unversioned', null], 'merged after the newest version went out');
93
+ assert.deepEqual(await pick(16), ['no_version', null]);
94
+ const w = await read(12);
95
+ assert.equal(w.live, '1.0.2');
96
+ assert.equal(w.released, '1.0.1');
97
+ assert.equal(w.partial, false);
98
+ assert.equal(w.task_id, '12');
99
+ });
100
+
101
+ test('a task marked done whose code never merged says so — the case this line exists for', async () => {
102
+ const { read } = setup();
103
+ for (const id of [20, 21]) {
104
+ const w = await read(id);
105
+ assert.equal(w.stage, 'not_merged', String(id));
106
+ assert.equal(w.version, null);
107
+ }
108
+ });
109
+
110
+ test('a task not yet done has nothing to place, and costs no registry read', async () => {
111
+ const { read, asked } = setup();
112
+ const w = await read(30);
113
+ assert.deepEqual(w, { task_id: '30', package: PKG, status: 'active', stage: null });
114
+ assert.deepEqual(asked, [], 'no packument, no tarball');
115
+ });
116
+
117
+ test('an id that is not a task answers null; one that is not a number is never queried', async () => {
118
+ const { read, pool } = setup();
119
+ assert.equal(await read(999), null);
120
+ for (const bad of ['1; DROP', '', null, '12a', '1234567890123']) assert.equal(await read(bad), null, String(bad));
121
+ assert.equal(pool.queries.length, 1, 'only the real id reached the database');
122
+ });
123
+
124
+ test('a registry that does not answer is a partial reading, said so', async () => {
125
+ const { read } = setup({ pkStatus: 500 });
126
+ const w = await read(12);
127
+ assert.equal(w.partial, true);
128
+ assert.equal(w.stage, 'no_version', 'with no notes nothing can be placed in a version');
129
+ });
130
+
131
+ test('the offered flag follows the deploy page: published before this hall ran it, and already released', async () => {
132
+ const { read } = setup({ latest: '1.0.4' });
133
+ const w = await read(13);
134
+ assert.equal(w.stage, 'not_live');
135
+ assert.equal(w.offered, true);
136
+ });
137
+
138
+ // ---- the route -----------------------------------------------------------------------
139
+
140
+ function layers(read) {
141
+ const router = buildRouter({ read });
142
+ const layer = router.stack.find((l) => l.route && l.route.path === '/npm-release/task/:id');
143
+ return layer.route.stack.map((s) => s.handle);
144
+ }
145
+ function call(handler, id) {
146
+ return new Promise((resolve) => {
147
+ const res = { code: 200, status(c) { this.code = c; return this; }, json(v) { resolve({ status: this.code, body: v }); } };
148
+ Promise.resolve(handler({ params: { id } }, res)).catch((e) => resolve({ status: 'threw', body: e }));
149
+ });
150
+ }
151
+
152
+ test('the route asks only for a signed-in builder — nothing on the line is not already public', () => {
153
+ const handles = layers(async () => ({}));
154
+ assert.equal(handles.length, 2, 'requireBuilder, then the handler');
155
+ const src = fs.readFileSync(path.join(ROOT, 'modules/npm-release/routes/task-where.js'), 'utf8');
156
+ assert.match(src, /'\/npm-release\/task\/:id',\s*\n\s*api\.requireBuilder,\s*\n\s*async/);
157
+ assert.doesNotMatch(src, /requirePermission|api\.requireRank\(/);
158
+ });
159
+
160
+ test('the route: 400 for a non-number, 404 for no task, 500 plainly on a failed read', async () => {
161
+ const h = (read) => layers(read)[1];
162
+ assert.deepEqual((await call(h(async () => ({ stage: 'released' })), '12')).body, { stage: 'released' });
163
+ assert.equal((await call(h(async () => { throw new Error('no'); }), 'x')).status, 400);
164
+ assert.equal((await call(h(async () => null), '12')).status, 404);
165
+ const failed = await call(h(async () => { throw new Error('pool gone'); }), '12');
166
+ assert.equal(failed.status, 500);
167
+ assert.equal(failed.body.error.code, 'task_where_failed');
168
+ assert.doesNotMatch(JSON.stringify(failed.body), /pool gone/, 'the internal message stays in the log');
169
+ });
170
+
171
+ // ---- the widget ----------------------------------------------------------------------
172
+
173
+ const WIDGET_SRC = fs.readFileSync(path.join(ROOT, 'modules/npm-release/public/task-widget.js'), 'utf8');
174
+
175
+ function bootWidget(answer, { shown = null } = {}) {
176
+ const slot = { _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; } };
177
+ const listeners = {};
178
+ const asked = [];
179
+ const windowObj = {
180
+ OTBTaskShown: shown,
181
+ BongosClient: { createClient: () => ({ request: async (m, url) => { asked.push(url); return answer(url); } }) },
182
+ };
183
+ const documentObj = {
184
+ getElementById: (id) => (id === 'task-contrib' ? slot : null),
185
+ addEventListener: (ev, fn) => { (listeners[ev] ||= []).push(fn); },
186
+ };
187
+ const sandbox = { window: windowObj, document: documentObj, console, Map, Promise, encodeURIComponent, String };
188
+ vm.createContext(sandbox);
189
+ vm.runInContext(WIDGET_SRC, sandbox, { filename: 'task-widget.js' });
190
+ const announce = (detail) => { windowObj.OTBTaskShown = detail; for (const fn of listeners['otb:task-shown'] || []) fn({ detail }); };
191
+ const settle = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); };
192
+ return { slot, asked, announce, settle, win: windowObj };
193
+ }
194
+ const ok = (data) => ({ status: 200, ok: true, data });
195
+
196
+ test('each stage reads as one plain sentence under the rail', async () => {
197
+ const cases = [
198
+ [{ stage: 'released', version: '1.19.1036', live: '1.19.1040' }, /In <b>1\.19\.1036<\/b> · running here · released to other projects\./],
199
+ [{ stage: 'live_unreleased', version: '1.19.1040', live: '1.19.1040' }, /In <b>1\.19\.1040<\/b> · running here · not released to other projects yet\./],
200
+ [{ stage: 'not_live', version: '1.19.1041' }, /In <b>1\.19\.1041<\/b> · published, not running here yet\./],
201
+ [{ stage: 'not_live', version: '1.19.1041', offered: true }, /not running here yet · already offered to other projects\./],
202
+ [{ stage: 'unversioned' }, /Merged, waiting for the next published version\./],
203
+ [{ stage: 'no_version' }, /Shipped, though no published version lists it — usually a change that needed no code/],
204
+ [{ stage: 'not_merged' }, /<b>Done, but not merged\.<\/b> The ledger marks this task done, and its code is not on main yet/],
205
+ ];
206
+ for (const [w, re] of cases) {
207
+ const b = bootWidget(() => ok(w));
208
+ b.announce({ id: '12', status: w.stage === 'not_merged' ? 'confirmed' : 'shipped' });
209
+ await b.settle();
210
+ assert.match(b.slot.innerHTML, re, w.stage);
211
+ }
212
+ });
213
+
214
+ test('a partial reading is said; a version is escaped; an unknown stage draws nothing', async () => {
215
+ const partial = bootWidget(() => ok({ stage: 'no_version', partial: true }));
216
+ partial.announce({ id: '12', status: 'shipped' });
217
+ await partial.settle();
218
+ assert.match(partial.slot.innerHTML, /\(a partial reading: the registry or the release notes could not be read\)/);
219
+
220
+ const evil = bootWidget(() => ok({ stage: 'released', version: '<img src=x>' }));
221
+ evil.announce({ id: '12', status: 'shipped' });
222
+ await evil.settle();
223
+ assert.match(evil.slot.innerHTML, /In <b>&lt;img src=x&gt;<\/b>/);
224
+
225
+ const odd = bootWidget(() => ok({ stage: 'somewhere-else' }));
226
+ odd.announce({ id: '12', status: 'shipped' });
227
+ await odd.settle();
228
+ assert.equal(odd.slot.innerHTML, '');
229
+ });
230
+
231
+ test('work not yet done asks nothing; a failure draws nothing rather than something wrong', async () => {
232
+ const idle = bootWidget(() => ok({ stage: 'released' }));
233
+ idle.announce({ id: '12', status: 'active' });
234
+ await idle.settle();
235
+ assert.deepEqual(idle.asked, []);
236
+ assert.equal(idle.slot.innerHTML, '');
237
+
238
+ for (const answer of [() => ({ status: 403, ok: false, data: null }), () => ({ status: 404, ok: false, data: null }), () => { throw new Error('offline'); }]) {
239
+ const b = bootWidget(answer);
240
+ b.announce({ id: '12', status: 'shipped' });
241
+ await b.settle();
242
+ assert.equal(b.slot.innerHTML, '');
243
+ }
244
+ });
245
+
246
+ test('one read per task, however often the page re-renders — and never for a task no longer shown', async () => {
247
+ const b = bootWidget(() => ok({ stage: 'released', version: '1.0.1' }));
248
+ b.announce({ id: '12', status: 'shipped' });
249
+ b.announce({ id: '12', status: 'shipped' });
250
+ await b.settle();
251
+ assert.deepEqual(b.asked, ['/api/bongos/npm-release/task/12']);
252
+
253
+ // The page moved on to another task while the first read was in flight.
254
+ let release;
255
+ const slow = bootWidget(() => new Promise((r) => { release = () => r(ok({ stage: 'released', version: '9.9.9' })); }));
256
+ slow.announce({ id: '12', status: 'shipped' });
257
+ slow.win.OTBTaskShown = { id: '13', status: 'active' };
258
+ release();
259
+ await slow.settle();
260
+ assert.equal(slow.slot.innerHTML, '', 'task 12\'s line is not written onto task 13\'s page');
261
+ });
262
+
263
+ test('a widget that loads after the first announcement still draws the line', async () => {
264
+ const b = bootWidget(() => ok({ stage: 'released', version: '1.0.1' }), { shown: { id: '12', status: 'shipped' } });
265
+ await b.settle();
266
+ assert.match(b.slot.innerHTML, /In <b>1\.0\.1<\/b>/);
267
+ });
268
+
269
+ // ---- the seam that puts it on the page -----------------------------------------------
270
+
271
+ test('the module declares the task page section and ships its widget; the seam guard counts it wired', () => {
272
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/npm-release/module.json'), 'utf8'));
273
+ assert.deepEqual(manifest.contributes.uiSections, ['task-where']);
274
+ assert.ok(manifest.contributes.routes.includes('task-where'));
275
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules/npm-release/public/task-widget.js')));
276
+ const guard = fs.readFileSync(path.join(ROOT, 'scripts/gds/seam-wiring-guard.js'), 'utf8');
277
+ assert.match(guard, /alternates: \(dir\) => \[path\.join\(dir, 'public', 'task-widget\.js'\)\]/);
278
+ });
279
+
280
+ test('the server injects task widgets just before task.js, and names no module doing it', () => {
281
+ const { injectModuleHallWidgets } = require('../src/bongos/serve-internal.js');
282
+ const page = '<body><script src="/builders/md-reader.js" defer></script><script src="/builders/task.js?v=abc" defer></script></body>';
283
+ const out = injectModuleHallWidgets(page, [{ key: 'npm-release', src: '/modules/npm-release/public/task-widget.js' }], 'task');
284
+ assert.ok(out.indexOf('/modules/npm-release/public/task-widget.js') < out.indexOf('/builders/task.js'), 'before the consumer, so its listener exists first');
285
+ assert.ok(out.indexOf('/builders/md-reader.js') < out.indexOf('task-widget.js'));
286
+ assert.match(out, /<script src="\/modules\/npm-release\/public\/task-widget\.js" defer><\/script>/);
287
+ assert.equal(injectModuleHallWidgets(page, [], 'task'), page, 'no module, no change');
288
+ const index = '<script src="/builders/builders.js?v=1"></script>';
289
+ assert.match(injectModuleHallWidgets(index, [{ key: 'x', src: '/m.js' }]), /^<script src="\/m\.js" defer><\/script><script src="\/builders\/builders\.js/, 'the index injection is unchanged');
290
+ const si = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
291
+ assert.match(si, /hallWidgetScripts\(\{ isEnabled: isModuleEnabled, file: 'task-widget\.js' \}\),\s*\n\s*'task',/);
292
+ });
293
+
294
+ // ---- the fixes the first grade asked for ---------------------------------------------
295
+
296
+ test('the installed release notes are read from disk once per process, not once per view', async () => {
297
+ const realRead = fs.readFileSync;
298
+ let reads = 0;
299
+ fs.readFileSync = function (p, ...rest) {
300
+ if (String(p).endsWith('release-notes.json')) reads++;
301
+ return realRead.call(this, p, ...rest);
302
+ };
303
+ try {
304
+ const { pool } = setup();
305
+ const fg = fakeHttpsGet({ [`${ORIGIN}/@bongos%2fcore`]: { status: 500, body: 'down' } });
306
+ for (let i = 0; i < 3; i++) {
307
+ // No installedNotes: the module's own default, which is the path every task view takes.
308
+ await readTaskWhere({ pool, id: 12, pkg: PKG, live: '1.0.2', get: fg.get, store: freshStore() });
309
+ }
310
+ } finally { fs.readFileSync = realRead; }
311
+ assert.equal(reads, 1, 'three task views, one disk read');
312
+ });
313
+
314
+ test('the widget keeps only the last few readings', async () => {
315
+ const b = bootWidget((url) => ok({ stage: 'released', version: url.split('/').pop() }));
316
+ for (let i = 1; i <= 12; i++) b.announce({ id: String(i), status: 'shipped' });
317
+ await b.settle();
318
+ b.announce({ id: '12', status: 'shipped' });
319
+ b.announce({ id: '1', status: 'shipped' });
320
+ await b.settle();
321
+ assert.equal(b.asked.filter((u) => u.endsWith('/12')).length, 1, 'a recent task is served from the cache');
322
+ assert.equal(b.asked.filter((u) => u.endsWith('/1')).length, 2, 'an old one was evicted and is read again');
323
+ });