@bongos/core 1.19.1041 → 1.19.1042
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +103 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
- package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
- package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +64 -7
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +41 -21
- package/docs/copy-registry.json +207 -27
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +10 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/palette.js +1 -0
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +194 -0
- package/modules/hall-ui/public/settings.css +39 -0
- package/modules/hall-ui/public/settings.html +24 -2
- package/modules/hall-ui/public/settings.js +5 -0
- package/modules/hall-ui/public/settings.states.json +2 -1
- package/modules/hall-ui/public/shell.js +25 -39
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/panel-family.md +9 -0
- package/modules/npm-release/public/work.js +8 -3
- package/modules/npm-release/work.js +8 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/main-audit.js +204 -7
- package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
- package/src/bongos/routes/core-update.js +65 -3
- package/src/bongos/serve-internal.js +17 -0
- package/src/bongos/software-update.js +127 -0
- package/src/module-api.js +10 -1
- package/tests/core_upgrade_owner_door.mjs +27 -18
- package/tests/helpers.mjs +92 -0
- package/tests/main_audit_attestation.mjs +238 -0
- package/tests/module_api.mjs +1 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/npm_release_page.mjs +11 -1
- package/tests/npm_release_work.mjs +9 -94
- package/tests/software_update.mjs +449 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/software-update.js — Settings → Software update (task 1004296): this project's
|
|
4
|
+
// running core, the newest release its update channel lets it take, and what is in the
|
|
5
|
+
// versions between the two.
|
|
6
|
+
//
|
|
7
|
+
// The owner, 2026-09-25: "each builders hall settings has a version description where they
|
|
8
|
+
// can pull the latest release. Just like iOS does." So this is the one place any project —
|
|
9
|
+
// the platform included — reads its version and what an update would bring. It is the CORE's,
|
|
10
|
+
// not a module's, because every project installs releases.
|
|
11
|
+
//
|
|
12
|
+
// WHAT'S NEW COMES FROM THE PACKAGE'S OWN RELEASE NOTES, never from the changelog. Every CI
|
|
13
|
+
// version's changelog line reads "carrier for merges landed since X", which names no work;
|
|
14
|
+
// release-notes.json (scripts/gds/release-notes.js, task 1004218) is the one per-version
|
|
15
|
+
// list of what landed, and the newest package carries the whole history. The running core's
|
|
16
|
+
// own copy stops at its own version, so an update's notes always come off the registry
|
|
17
|
+
// (package-registry.js: streamed, cached, fail-soft).
|
|
18
|
+
//
|
|
19
|
+
// THE CHANNEL RULE IS NOT RESTATED HERE, as in core-update.js: `resolveChannelTarget` is the
|
|
20
|
+
// upgrade runner's own function, so this never offers a version the runner would refuse.
|
|
21
|
+
// No channel is passed, exactly as the banner passes none: an instance does not store its
|
|
22
|
+
// own channel, and the runner's default is what it would get.
|
|
23
|
+
//
|
|
24
|
+
// A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
|
|
25
|
+
// is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
|
|
26
|
+
// and a version whose notes are missing is shown as missing rather than as empty.
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
CORE_PKG,
|
|
30
|
+
DEFAULT_CHANNEL,
|
|
31
|
+
normalizeChannel,
|
|
32
|
+
resolveChannelTarget,
|
|
33
|
+
compareSemver,
|
|
34
|
+
} = require('../../scripts/gds/update-channel.js');
|
|
35
|
+
const { readRegistry } = require('./package-registry');
|
|
36
|
+
|
|
37
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
38
|
+
// The same bound the deploy page puts on a line: a release note is a sentence, and one that
|
|
39
|
+
// came off the network is re-checked before it is drawn.
|
|
40
|
+
const TEXT_MAX = 400;
|
|
41
|
+
// A project months behind can be hundreds of versions short. The panel lists the newest
|
|
42
|
+
// ones and counts the rest; every one of them is still in the update.
|
|
43
|
+
const VERSIONS_MAX = 40;
|
|
44
|
+
|
|
45
|
+
function boundText(text) {
|
|
46
|
+
const t = String(text || '').trim();
|
|
47
|
+
return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One version's entries, re-checked; null when the notes do not list the version at all. */
|
|
51
|
+
function entriesFor(notes, version) {
|
|
52
|
+
const list = notes && Object.prototype.hasOwnProperty.call(notes, version) ? notes[version] : undefined;
|
|
53
|
+
if (!Array.isArray(list)) return null;
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const e of list) {
|
|
56
|
+
const text = boundText(e && e.text);
|
|
57
|
+
if (!text) continue;
|
|
58
|
+
const id = e && /^\d{1,12}$/.test(String(e.id == null ? '' : e.id)) ? String(e.id) : null;
|
|
59
|
+
out.push({ id, text });
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The panel's whole answer, from one registry reading. PURE — no clock, no I/O.
|
|
66
|
+
*
|
|
67
|
+
* `registry` and `notes` are package-registry.js's `readRegistry` result. Versions in the
|
|
68
|
+
* update are the published ones above `running` and at or below `offered`, newest first.
|
|
69
|
+
*/
|
|
70
|
+
function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt }) {
|
|
71
|
+
const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
|
|
72
|
+
const base = { checked_at: checkedAt, running: running || null, channel: ch };
|
|
73
|
+
if (!running || !VERSION_RE.test(String(running))) {
|
|
74
|
+
return { ...base, reading: 'no_answer', error: 'this project could not name the version it runs', offered: null, newest: null, whats_new: null };
|
|
75
|
+
}
|
|
76
|
+
if (!registry || !registry.ok) {
|
|
77
|
+
return { ...base, reading: 'no_answer', error: (registry && registry.error) || 'the registry could not be read', offered: null, newest: null, whats_new: null };
|
|
78
|
+
}
|
|
79
|
+
const offered = resolveChannelTarget({ installed: running, channel: ch, available: registry.versions }) || null;
|
|
80
|
+
const published = registry.published || {};
|
|
81
|
+
const answer = {
|
|
82
|
+
...base,
|
|
83
|
+
reading: 'ok',
|
|
84
|
+
error: null,
|
|
85
|
+
offered,
|
|
86
|
+
offered_published_at: offered ? published[offered] || null : null,
|
|
87
|
+
newest: registry.newest || null,
|
|
88
|
+
whats_new: null,
|
|
89
|
+
};
|
|
90
|
+
if (!offered) return answer;
|
|
91
|
+
|
|
92
|
+
const inUpdate = registry.versions
|
|
93
|
+
.filter((v) => compareSemver(v, running) > 0 && compareSemver(v, offered) <= 0)
|
|
94
|
+
.sort((a, b) => compareSemver(b, a));
|
|
95
|
+
const shown = inUpdate.slice(0, VERSIONS_MAX);
|
|
96
|
+
const ok = !!(notes && notes.ok);
|
|
97
|
+
answer.whats_new = {
|
|
98
|
+
ok,
|
|
99
|
+
error: ok ? null : ((notes && notes.error) || 'the release notes could not be read'),
|
|
100
|
+
count: inUpdate.length,
|
|
101
|
+
more: inUpdate.length - shown.length,
|
|
102
|
+
versions: shown.map((version) => ({
|
|
103
|
+
version,
|
|
104
|
+
published_at: published[version] || null,
|
|
105
|
+
entries: ok ? entriesFor(notes.notes, version) : null,
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What GET /api/bongos/software-update answers — and this file's one export.
|
|
113
|
+
*
|
|
114
|
+
* `get`/`now`/`store` pass through to the registry reader, so a test drives the real
|
|
115
|
+
* packument parsing, the real tarball stream and the real cache rather than a stub.
|
|
116
|
+
*/
|
|
117
|
+
async function readSoftwareUpdate({ running, channel, get, now = Date.now, store } = {}) {
|
|
118
|
+
const t = now();
|
|
119
|
+
const { registry, notes } = await readRegistry({
|
|
120
|
+
pkg: CORE_PKG, get, now, store,
|
|
121
|
+
// Nothing to describe when nothing newer is published: skip the download.
|
|
122
|
+
haveNotesFor: (newest) => !VERSION_RE.test(String(running || '')) || compareSemver(newest, running) <= 0,
|
|
123
|
+
});
|
|
124
|
+
return softwareUpdateVerdict({ running, channel, registry, notes, checkedAt: new Date(t).toISOString() });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { readSoftwareUpdate };
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.1042'; // 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');
|
|
@@ -494,6 +494,15 @@ module.exports = {
|
|
|
494
494
|
responsibilityFor,
|
|
495
495
|
ROLE_RESPONSIBILITIES,
|
|
496
496
|
|
|
497
|
+
// --- readPackageRegistry({ pkg, haveNotesFor, get, now, store }) — what the npm
|
|
498
|
+
// registry says about a package: its versions, their publish dates, the
|
|
499
|
+
// `latest` label, and the release notes the newest version was packed with
|
|
500
|
+
// (task 1004296). The core owns it because Settings → Software update needs
|
|
501
|
+
// it in every hall; a module that publishes a package reads the same answer
|
|
502
|
+
// and the same process-local cache through here. Fail-soft: it resolves
|
|
503
|
+
// `{ registry, notes }` with `ok:false` and a sentence, never a throw.
|
|
504
|
+
readPackageRegistry: (...a) => require('./bongos/package-registry').readRegistry(...a),
|
|
505
|
+
|
|
497
506
|
// --- logging
|
|
498
507
|
logger,
|
|
499
508
|
};
|
|
@@ -342,8 +342,9 @@ await t('deploy_url is the hub origin plus this project\'s slug, and null withou
|
|
|
342
342
|
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
343
343
|
|
|
344
344
|
// The real shell.js in a DOM stub (the tests/core_update_banner.mjs harness), with the
|
|
345
|
-
// /core-update answer carrying a deploy_url
|
|
346
|
-
|
|
345
|
+
// /core-update answer optionally carrying a deploy_url — an older server still sends one —
|
|
346
|
+
// and elements that remember their attributes.
|
|
347
|
+
function boot({ provisioning, deployUrl, pathname = '/settings', hash = '' }) {
|
|
347
348
|
const els = new Map();
|
|
348
349
|
const inserted = [];
|
|
349
350
|
const makeEl = () => ({
|
|
@@ -372,7 +373,7 @@ function boot({ provisioning, deployUrl }) {
|
|
|
372
373
|
addEventListener() {}, dispatchEvent() {},
|
|
373
374
|
get cookie() { return ''; }, set cookie(_v) {},
|
|
374
375
|
};
|
|
375
|
-
const location = { hostname: 'h', origin: 'https://h', pathname
|
|
376
|
+
const location = { hostname: 'h', origin: 'https://h', pathname, hash, assign() {}, replace() {} };
|
|
376
377
|
const update = { running: '1.19.871', latest: '1.19.1033', behind: true, reading: 'ok' };
|
|
377
378
|
const windowObj = {
|
|
378
379
|
__BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
|
|
@@ -401,40 +402,48 @@ function boot({ provisioning, deployUrl }) {
|
|
|
401
402
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
402
403
|
const HUB = 'https://cloudbongos.com/deploy?project=hoodify-finance';
|
|
403
404
|
|
|
404
|
-
|
|
405
|
+
// Task 1004296 moved the hand-off: a hall without a door of its own sends its owner to
|
|
406
|
+
// Settings → Software update, whose Update button carries the hub address (GET
|
|
407
|
+
// /software-update's `door`). The banner and the rail no longer link off-site.
|
|
408
|
+
await t('on a hosted project, the banner\'s Upgrade goes to Settings → Software update, not off-site', async () => {
|
|
405
409
|
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
406
410
|
await settle();
|
|
407
411
|
assert.ok(b.banner(), 'the banner renders');
|
|
408
|
-
assert.
|
|
409
|
-
assert.
|
|
412
|
+
assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, 'Upgrade opens the Software update panel');
|
|
413
|
+
assert.ok(!b.banner().innerHTML.includes(HUB), 'never the hub address directly, even when an older server sends one');
|
|
414
|
+
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the local /deploy, which this hall no longer has');
|
|
410
415
|
});
|
|
411
416
|
|
|
412
|
-
await t('on a hosted project, the rail\'s Deploy item
|
|
417
|
+
await t('on a hosted project, the rail\'s Deploy item stays hidden — it is the platform hall\'s page', async () => {
|
|
413
418
|
const b = boot({ provisioning: false, deployUrl: HUB });
|
|
414
419
|
await settle();
|
|
415
420
|
const item = b.deployItem();
|
|
416
|
-
assert.equal(item.getAttribute('href'),
|
|
417
|
-
assert.equal(
|
|
418
|
-
assert.equal(item.hidden, false, 'an Archon on their own project sees it');
|
|
421
|
+
assert.equal(item.getAttribute('href'), null, 'not re-pointed at the hub');
|
|
422
|
+
assert.equal(item.dataset.module, 'provisioning', 'its module gate stays, so a hall without the door hides it');
|
|
419
423
|
});
|
|
420
424
|
|
|
421
|
-
await t('a
|
|
422
|
-
for (const url of [null, 'javascript:alert(1)',
|
|
425
|
+
await t('a hall with no door of its own and no hub still links to Software update', async () => {
|
|
426
|
+
for (const url of [null, 'javascript:alert(1)', 42]) {
|
|
423
427
|
const b = boot({ provisioning: false, deployUrl: url });
|
|
424
428
|
await settle();
|
|
425
|
-
assert.
|
|
426
|
-
assert.doesNotMatch(b.banner().innerHTML, /
|
|
427
|
-
const item = b.deployItem();
|
|
428
|
-
assert.equal(item.getAttribute('href'), null, `the rail is not re-pointed for ${JSON.stringify(url)}`);
|
|
429
|
-
assert.equal(item.dataset.module, 'provisioning', 'and it keeps its module gate, so a hosted project still hides it');
|
|
429
|
+
assert.match(b.banner().innerHTML, /href="\/settings#software-update"/, `Software update for ${JSON.stringify(url)}`);
|
|
430
|
+
assert.doesNotMatch(b.banner().innerHTML, /javascript:/);
|
|
430
431
|
}
|
|
431
432
|
});
|
|
432
433
|
|
|
433
|
-
await t('on the
|
|
434
|
+
await t('on the Software update panel itself, the banner points rather than linking to the page it is on', async () => {
|
|
435
|
+
const b = boot({ provisioning: false, pathname: '/settings', hash: '#software-update' });
|
|
436
|
+
await settle();
|
|
437
|
+
assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, 'no link to where you already are');
|
|
438
|
+
assert.match(b.banner().innerHTML, /core-update-notice__hint/);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await t('on the platform, the local door wins: the banner links /deploy and the rail keeps it', async () => {
|
|
434
442
|
const b = boot({ provisioning: true, deployUrl: HUB });
|
|
435
443
|
await settle();
|
|
436
444
|
assert.match(b.banner().innerHTML, /href="\/deploy"/);
|
|
437
445
|
assert.ok(!b.banner().innerHTML.includes(HUB));
|
|
446
|
+
assert.ok(!b.banner().innerHTML.includes('software-update'), 'the platform takes its update at its own door');
|
|
438
447
|
const item = b.deployItem();
|
|
439
448
|
assert.notEqual(item.getAttribute('href'), HUB, 'the rail keeps the local /deploy');
|
|
440
449
|
assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
|
package/tests/helpers.mjs
CHANGED
|
@@ -73,6 +73,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
73
73
|
import { createRequire } from 'node:module';
|
|
74
74
|
import { execFileSync } from 'node:child_process';
|
|
75
75
|
import vm from 'node:vm';
|
|
76
|
+
import zlib from 'node:zlib';
|
|
77
|
+
import { EventEmitter } from 'node:events';
|
|
78
|
+
import { Readable } from 'node:stream';
|
|
76
79
|
|
|
77
80
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
78
81
|
|
|
@@ -1012,3 +1015,92 @@ export function macShapedTools(binDir) {
|
|
|
1012
1015
|
'',
|
|
1013
1016
|
].join('\n'), { mode: 0o755 });
|
|
1014
1017
|
}
|
|
1018
|
+
|
|
1019
|
+
// ---- a fake npm registry (task 1002622; shared since task 1004296) -------------------
|
|
1020
|
+
//
|
|
1021
|
+
// A real gzipped tar built by hand, a packument, and an `https.get` stand-in that answers
|
|
1022
|
+
// in 777-byte chunks — so a reader under test runs its real gunzip, tar walk and JSON parse,
|
|
1023
|
+
// with headers landing across chunk boundaries. Used by tests/npm_release_work.mjs and
|
|
1024
|
+
// tests/software_update.mjs.
|
|
1025
|
+
|
|
1026
|
+
function tarHeader(name, size, type = '0') {
|
|
1027
|
+
const h = Buffer.alloc(512, 0);
|
|
1028
|
+
h.write(name.slice(0, 100), 0, 'utf8');
|
|
1029
|
+
h.write('0000644\0', 100, 'ascii');
|
|
1030
|
+
h.write('0000000\0', 108, 'ascii');
|
|
1031
|
+
h.write('0000000\0', 116, 'ascii');
|
|
1032
|
+
h.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii');
|
|
1033
|
+
h.write('00000000000\0', 136, 'ascii');
|
|
1034
|
+
h.write(' ', 148, 'ascii');
|
|
1035
|
+
h.write(type, 156, 'ascii');
|
|
1036
|
+
h.write('ustar\0', 257, 'ascii');
|
|
1037
|
+
h.write('00', 263, 'ascii');
|
|
1038
|
+
let sum = 0;
|
|
1039
|
+
for (const b of h) sum += b;
|
|
1040
|
+
h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
|
|
1041
|
+
return h;
|
|
1042
|
+
}
|
|
1043
|
+
function tarEntry(name, data, type = '0') {
|
|
1044
|
+
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
1045
|
+
const pad = Buffer.alloc((512 - (body.length % 512)) % 512, 0);
|
|
1046
|
+
return Buffer.concat([tarHeader(name, body.length, type), body, pad]);
|
|
1047
|
+
}
|
|
1048
|
+
function paxEntry(path) {
|
|
1049
|
+
// A pax record is "<len> <key>=<value>\n", where <len> counts its own digits too.
|
|
1050
|
+
const rec = (k, v) => {
|
|
1051
|
+
const body = ` ${k}=${v}\n`;
|
|
1052
|
+
let n = body.length + 1;
|
|
1053
|
+
while (String(n).length + body.length !== n) n = String(n).length + body.length;
|
|
1054
|
+
return `${n}${body}`;
|
|
1055
|
+
};
|
|
1056
|
+
return tarEntry('PaxHeader/x', rec('path', path), 'x');
|
|
1057
|
+
}
|
|
1058
|
+
/** A gzipped npm-shaped tarball. `notes` null leaves release-notes.json out. */
|
|
1059
|
+
export function npmTarball({ notes, padBytes = 40000, pax = false, pkg = '@bongos/core' } = {}) {
|
|
1060
|
+
const parts = [
|
|
1061
|
+
tarEntry('package/package.json', JSON.stringify({ name: pkg })),
|
|
1062
|
+
// A large file BEFORE the notes, so the reader has to skip across many chunks.
|
|
1063
|
+
tarEntry('package/big.js', Buffer.alloc(padBytes, 97)),
|
|
1064
|
+
];
|
|
1065
|
+
if (notes) {
|
|
1066
|
+
const json = JSON.stringify(notes);
|
|
1067
|
+
if (pax) parts.push(paxEntry('package/release-notes.json'), tarEntry('package/SHORTNAME', json));
|
|
1068
|
+
else parts.push(tarEntry('package/release-notes.json', json));
|
|
1069
|
+
}
|
|
1070
|
+
parts.push(Buffer.alloc(1024, 0));
|
|
1071
|
+
return zlib.gzipSync(Buffer.concat(parts));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ---- a fake registry -----------------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
export function npmPackument({ versions, latest, times, pkg = '@bongos/core', origin = 'https://registry.npmjs.org' }) {
|
|
1077
|
+
const doc = { name: pkg, 'dist-tags': { latest }, versions: {}, time: {} };
|
|
1078
|
+
for (const v of versions) {
|
|
1079
|
+
doc.versions[v] = { version: v, dist: { tarball: `${origin}/${pkg}/-/${pkg.split('/').pop()}-${v}.tgz` } };
|
|
1080
|
+
doc.time[v] = times[v];
|
|
1081
|
+
}
|
|
1082
|
+
return doc;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** `get` answering from `routes` (url → { status, body }), recording every URL asked for. */
|
|
1086
|
+
export function fakeHttpsGet(routes) {
|
|
1087
|
+
const asked = [];
|
|
1088
|
+
const get = (url, opts, cb) => {
|
|
1089
|
+
asked.push(url);
|
|
1090
|
+
const req = new EventEmitter();
|
|
1091
|
+
req.destroy = () => {};
|
|
1092
|
+
setImmediate(() => {
|
|
1093
|
+
const r = routes[url];
|
|
1094
|
+
if (!r) { req.emit('error', new Error(`no route for ${url}`)); return; }
|
|
1095
|
+
const body = Buffer.isBuffer(r.body) ? r.body : Buffer.from(typeof r.body === 'string' ? r.body : JSON.stringify(r.body));
|
|
1096
|
+
// Small chunks, so a tar header lands across a chunk boundary.
|
|
1097
|
+
const chunks = [];
|
|
1098
|
+
for (let i = 0; i < body.length; i += 777) chunks.push(body.subarray(i, i + 777));
|
|
1099
|
+
const res = Readable.from(chunks);
|
|
1100
|
+
res.statusCode = r.status || 200;
|
|
1101
|
+
cb(res);
|
|
1102
|
+
});
|
|
1103
|
+
return req;
|
|
1104
|
+
};
|
|
1105
|
+
return { get, asked };
|
|
1106
|
+
}
|
|
@@ -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
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -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
|
|
|
@@ -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 = '
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
});
|