@bongos/core 1.19.534 → 1.19.535
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 +28 -23
- package/docs/copy-inventory.md +34 -32
- package/docs/copy-registry.json +66 -38
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/hall_kit_charts.mjs +173 -0
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_reading_world.mjs +153 -0
|
@@ -2039,8 +2039,10 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2039
2039
|
reduced-motion kill switch (§the kill switch, at the end of this sheet)
|
|
2040
2040
|
already zeroes this with !important — do NOT add a second @media block
|
|
2041
2041
|
here, which is what `tests/hall_tokens.mjs` forbids and why the rule
|
|
2042
|
-
cannot rot in two places.
|
|
2043
|
-
|
|
2042
|
+
cannot rot in two places. `margin-left` rides along because a SPAN bar
|
|
2043
|
+
(barSeriesHtml's per-row `start`) moves as well as grows, and a bar that
|
|
2044
|
+
slides while its neighbour eases reads as two different animations. */
|
|
2045
|
+
transition: width 200ms ease, margin-left 200ms ease;
|
|
2044
2046
|
}
|
|
2045
2047
|
.kit-bars__row:hover .kit-bars__fill { opacity: .8; }
|
|
2046
2048
|
.kit-bars__val { text-align: right; color: var(--ink-faint); }
|
|
@@ -111,6 +111,10 @@
|
|
|
111
111
|
<h2 id="ledger-h" class="scroll__h">Repo health</h2>
|
|
112
112
|
<p class="scroll__lede">The shape and health of the codebase.</p>
|
|
113
113
|
<div id="ledger-stats" class="ov-figs"><span class="kit-skel"></span></div>
|
|
114
|
+
<!-- The trend the request has always carried and the page dropped
|
|
115
|
+
on the floor (task 1003447) — the same shape as the sessions
|
|
116
|
+
fix above it. -->
|
|
117
|
+
<div id="ledger-trend"></div>
|
|
114
118
|
<div id="ledger-loc"></div>
|
|
115
119
|
<div id="ledger-files"></div>
|
|
116
120
|
</section>
|
|
@@ -942,6 +942,120 @@
|
|
|
942
942
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
943
943
|
}
|
|
944
944
|
|
|
945
|
+
// The four series the snapshot table keeps, in the order a reader wants them:
|
|
946
|
+
// how big the codebase is, then the three kinds of rot it accumulates.
|
|
947
|
+
//
|
|
948
|
+
// `zeroBased` is the axis decision, made per metric because the metrics are
|
|
949
|
+
// not alike. A count of dead exports belongs on a zero floor: zero is a real,
|
|
950
|
+
// reachable state and the distance from it IS the reading. `total_loc` never
|
|
951
|
+
// goes near zero — on a zero floor a month of real movement is a flat band
|
|
952
|
+
// across the top of the box, which is a picture of nothing. That one fits its
|
|
953
|
+
// floor to the data, and `trendFigure` puts the range in the foot so the
|
|
954
|
+
// magnified wiggle is never silent about what it is.
|
|
955
|
+
const TREND_METRICS = [
|
|
956
|
+
{
|
|
957
|
+
key: 'total_loc', title: 'Total lines', fmt: fmtCompact, zeroBased: false,
|
|
958
|
+
foot: 'Every tracked line of the codebase, snapshot by snapshot.',
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
key: 'dead_exports', title: 'Dead exports', fmt: fmtInt, zeroBased: true,
|
|
962
|
+
foot: 'Exported symbols nothing imports — the knip scan, run daily.',
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
key: 'stale_branches', title: 'Stale branches', fmt: fmtInt, zeroBased: true,
|
|
966
|
+
foot: 'Branches with no movement inside the staleness window.',
|
|
967
|
+
},
|
|
968
|
+
{
|
|
969
|
+
key: 'confirmed_unshipped', title: 'Confirmed, unshipped', fmt: fmtInt, zeroBased: true,
|
|
970
|
+
foot: 'Work verified but not yet landed — the strand backlog.',
|
|
971
|
+
},
|
|
972
|
+
];
|
|
973
|
+
|
|
974
|
+
// One repo-health metric as a figure: the step plot, the value it ends on, and
|
|
975
|
+
// the table twin that keeps it out of picture-only territory.
|
|
976
|
+
//
|
|
977
|
+
// A STEP line, not a polyline, and that is the honest shape here: a snapshot's
|
|
978
|
+
// value is what the repo measured that morning and it holds until the next run
|
|
979
|
+
// measures again. The scan does not run every single day, so a straight line
|
|
980
|
+
// between two samples a week apart would draw movement nobody recorded.
|
|
981
|
+
//
|
|
982
|
+
// No readout is wired, so the plot passes `interactive: false` and takes
|
|
983
|
+
// neither a focus stop nor a crosshair — the /drachmae contract. The reading
|
|
984
|
+
// is the aria-label, the labelled endpoint and the twin.
|
|
985
|
+
function trendFigure({ key, title, fmt, foot: footText, zeroBased }, trend) {
|
|
986
|
+
const kit = window.OTBKit;
|
|
987
|
+
// Only rows that actually carry this metric. An older snapshot predates a
|
|
988
|
+
// column and sends null; counting that as 0 would draw a cliff to the floor
|
|
989
|
+
// that never happened.
|
|
990
|
+
//
|
|
991
|
+
// NOT `Number(v)` — that is the trap, and the trap is silent: Number(null),
|
|
992
|
+
// Number(undefined ?? '') and Number('') are 0, 0 and 0, every one of them
|
|
993
|
+
// finite, so a plain isFinite filter keeps a missing reading as a real zero.
|
|
994
|
+
// The page's own `num()` coerces the same way for the same reason. Mapping
|
|
995
|
+
// an absent value to NaN is what makes the filter below mean "carried".
|
|
996
|
+
const carried = (v) => (v === null || v === undefined || v === '' ? NaN : Number(v));
|
|
997
|
+
const points = trend
|
|
998
|
+
.map((r) => ({ x: Date.parse(r.snapshot_date), value: carried(r[key]) }))
|
|
999
|
+
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.value));
|
|
1000
|
+
// One sample is a reading, not a trend, and it draws a single dot on an
|
|
1001
|
+
// axis with nothing to compare it to.
|
|
1002
|
+
if (points.length < 2) return '';
|
|
1003
|
+
|
|
1004
|
+
const last = points[points.length - 1];
|
|
1005
|
+
// The axis window is the kit's arithmetic, not this page's.
|
|
1006
|
+
const { lo, hi, yMin, yMax } = kit.plotWindow(points.map((p) => p.value), { zeroBased });
|
|
1007
|
+
|
|
1008
|
+
const series = kit.timeStepSeriesHtml(points, {
|
|
1009
|
+
x0: points[0].x, x1: last.x, yMin, yMax,
|
|
1010
|
+
});
|
|
1011
|
+
const { endX, endY } = series.geometry;
|
|
1012
|
+
const day = (t) => new Date(t).toISOString().slice(0, 10);
|
|
1013
|
+
// At most ~21 rows however wide the window is (?days goes to 90), and the
|
|
1014
|
+
// last row is always present so the twin ends where the plot's label does.
|
|
1015
|
+
const step = Math.max(1, Math.ceil(points.length / 20));
|
|
1016
|
+
const twinRows = points.filter((p, i) => i % step === 0 || i === points.length - 1);
|
|
1017
|
+
const range = `${fmt(lo)} – ${fmt(hi)} across ${fmtInt(points.length)} snapshots.`;
|
|
1018
|
+
// A fitted floor multiplies how big every move looks, so the figure says so
|
|
1019
|
+
// in words rather than letting the picture imply a collapse to nothing.
|
|
1020
|
+
const axisNote = zeroBased ? '' : ` The axis starts at ${fmt(Math.round(yMin))}, not zero.`;
|
|
1021
|
+
|
|
1022
|
+
return `<figure class="pchart" id="ledger-trend-${escapeHtml(key)}">
|
|
1023
|
+
<figcaption class="pchart__title">${escapeHtml(title)}</figcaption>
|
|
1024
|
+
${kit.plotSvgHtml({
|
|
1025
|
+
series: series.body,
|
|
1026
|
+
interactive: false,
|
|
1027
|
+
ariaLabel: `${title}: ${fmt(last.value)} on ${day(last.x)}, ranging ${fmt(lo)} to ${fmt(hi)} over the window.`,
|
|
1028
|
+
label: kit.plotValueLabelHtml(endX, endY - 8, fmt(last.value), 'end'),
|
|
1029
|
+
leftTick: day(points[0].x),
|
|
1030
|
+
rightTick: day(last.x),
|
|
1031
|
+
})}
|
|
1032
|
+
<details class="pchart__twin">
|
|
1033
|
+
<summary>Read it as a table</summary>
|
|
1034
|
+
<table class="ledger pchart__table"><thead><tr><th>Snapshot</th><th>${escapeHtml(title)}</th></tr></thead><tbody>
|
|
1035
|
+
${twinRows.map((p) => `<tr><td>${escapeHtml(day(p.x))}</td><td>${escapeHtml(fmt(p.value))}</td></tr>`).join('')}
|
|
1036
|
+
</tbody></table>
|
|
1037
|
+
</details>
|
|
1038
|
+
<p class="pchart__foot">${escapeHtml(`${footText} ${range}${axisNote}`)}</p>
|
|
1039
|
+
</figure>`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// The trend the route has always sent and renderLedger discarded (task
|
|
1043
|
+
// 1003447). It is requested WITH ?days=30 — the page was paying for the window
|
|
1044
|
+
// and reading one snapshot off it.
|
|
1045
|
+
function renderTrend(data) {
|
|
1046
|
+
const el = $('#ledger-trend');
|
|
1047
|
+
if (!el) return;
|
|
1048
|
+
const trend = Array.isArray(data.trend) ? data.trend : [];
|
|
1049
|
+
const figures = TREND_METRICS.map((m) => trendFigure(m, trend)).filter(Boolean).join('');
|
|
1050
|
+
// Nothing plottable yet (a fresh instance, or one metrics run) renders
|
|
1051
|
+
// nothing at all — the figures above already say what today's numbers are,
|
|
1052
|
+
// and an empty panel under them would be worse than no panel.
|
|
1053
|
+
el.innerHTML = figures
|
|
1054
|
+
? `<section class="ov-group"><div class="ov-group__head"><span class="ov-group__name">Over the last ${escapeHtml(String(data.window_days || 30))} days</span></div>`
|
|
1055
|
+
+ `<div class="pcharts">${figures}</div></section>`
|
|
1056
|
+
: '';
|
|
1057
|
+
}
|
|
1058
|
+
|
|
945
1059
|
function renderLedger(data, stats) {
|
|
946
1060
|
const locEl = $('#ledger-loc');
|
|
947
1061
|
const filesEl = $('#ledger-files');
|
|
@@ -962,6 +1076,8 @@
|
|
|
962
1076
|
}
|
|
963
1077
|
stats.innerHTML = figs(cards);
|
|
964
1078
|
|
|
1079
|
+
renderTrend(data);
|
|
1080
|
+
|
|
965
1081
|
// loc_by_dir as a two-column ledger under its own head.
|
|
966
1082
|
const locByDir = latest.loc_by_dir && typeof latest.loc_by_dir === 'object' ? latest.loc_by_dir : {};
|
|
967
1083
|
const dirEntries = Object.entries(locByDir).sort((a, b) => num(b[1]) - num(a[1]));
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.535",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.535",
|
|
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.
|
|
3
|
+
"version": "1.19.535",
|
|
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",
|
|
@@ -63,6 +63,12 @@ const SNAPSHOTS = [
|
|
|
63
63
|
['public/progress', 'public__progress'],
|
|
64
64
|
['public/recent-shipped', 'public__recent-shipped'],
|
|
65
65
|
['public/repo-info', 'public__repo-info'],
|
|
66
|
+
// The roadmap's OTHER read (task 1003447). It was missing, so /roadmap has
|
|
67
|
+
// always 404'd here and fallen through its `.catch(() => [])` — which meant
|
|
68
|
+
// no track chip and no shipped date in preview, and (once the deck learned to
|
|
69
|
+
// draw them) no timeline. `versions` above is the AUTHENTICATED list; this is
|
|
70
|
+
// the public one the page actually calls, and the two are not interchangeable.
|
|
71
|
+
['public/versions', 'public__versions'],
|
|
66
72
|
];
|
|
67
73
|
|
|
68
74
|
(async () => {
|
package/src/module-api.js
CHANGED
|
@@ -46,7 +46,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
46
46
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
47
47
|
// the entry to that file. Look for a version's history there, not here.
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
|
-
const CORE_VERSION = '1.19.
|
|
49
|
+
const CORE_VERSION = '1.19.535'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
50
50
|
|
|
51
51
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
52
52
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -349,6 +349,10 @@ test('the kit is loaded before every page that draws through it', () => {
|
|
|
349
349
|
['index.html', 'hall-render.js'],
|
|
350
350
|
['watch.html', 'watch.js'],
|
|
351
351
|
['profile.html', 'profile-charts.js'],
|
|
352
|
+
// task 1003447: the roadmap's own series module reads the kit, and the page
|
|
353
|
+
// reads that. Both edges of the chain are ordered here.
|
|
354
|
+
['roadmap.html', 'roadmap-timeline.js'],
|
|
355
|
+
['roadmap.html', 'roadmap.js'],
|
|
352
356
|
]) {
|
|
353
357
|
// The loaded ORDER, read off the script tags — index.html's prose mentions
|
|
354
358
|
// hall-render.js ~90 lines above where it loads it, so a substring search
|
|
@@ -375,3 +379,172 @@ test('the migrated pages run for real against the kit and emit its classes', ()
|
|
|
375
379
|
/class="kit-bars"/,
|
|
376
380
|
);
|
|
377
381
|
});
|
|
382
|
+
|
|
383
|
+
// ---- 5. the SPAN bar and the fitted axis (task 1003447) --------------------
|
|
384
|
+
//
|
|
385
|
+
// Two options, both added so the goal's last two charts needed no new drawing
|
|
386
|
+
// code and no new class family: a bar that starts somewhere other than the
|
|
387
|
+
// track's left edge (which is what makes a bar series a TIMELINE), and a plot
|
|
388
|
+
// whose axis sits at something other than zero (which is what makes a step line
|
|
389
|
+
// readable for a LEVEL rather than a running total).
|
|
390
|
+
|
|
391
|
+
test('a row\'s `start` offsets the bar and leaves its length alone', () => {
|
|
392
|
+
// The roadmap's shape: version A ran for the first quarter of the axis,
|
|
393
|
+
// version B for the middle half. Same primitive, same classes.
|
|
394
|
+
const html = kit.barSeriesHtml([
|
|
395
|
+
{ label: 'A', value: 25 },
|
|
396
|
+
{ label: 'B', value: 50, start: 25 },
|
|
397
|
+
], { max: 100 });
|
|
398
|
+
const fills = [...html.matchAll(/class="kit-bars__fill" style="([^"]+)"/g)].map((m) => m[1]);
|
|
399
|
+
assert.deepEqual(fills, ['width:25%', 'margin-left:25%;width:50%']);
|
|
400
|
+
assert.equal((html.match(/class="kit-bars"/g) || []).length, 1, 'still one class family');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('a bar with no `start` writes no offset at all', () => {
|
|
404
|
+
// The compatibility contract: every caller that predates the span option gets
|
|
405
|
+
// byte-identical markup, so `margin-left:0%` never appears in a diff or in a
|
|
406
|
+
// snapshot of an existing chart.
|
|
407
|
+
const html = kit.barSeriesHtml([{ label: 'a', value: 3 }, { label: 'b', value: 1, start: 0 }]);
|
|
408
|
+
assert.doesNotMatch(html, /margin-left/);
|
|
409
|
+
// A nonsense start is ignored rather than written through into the style.
|
|
410
|
+
const junk = kit.barSeriesHtml([{ label: 'a', value: 3, start: 'nope' }], { max: 10 });
|
|
411
|
+
assert.doesNotMatch(junk, /margin-left/);
|
|
412
|
+
assert.doesNotMatch(junk, /NaN/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('a tiny non-zero `start` still offsets, so "first" stays a real claim', () => {
|
|
416
|
+
// The offset takes the same floor the width does. Without it a span beginning
|
|
417
|
+
// just after the axis origin rounds to 0% and draws flush left — which on a
|
|
418
|
+
// timeline reads as "this one started first", a claim about ordering rather
|
|
419
|
+
// than a rounding artefact.
|
|
420
|
+
const html = kit.barSeriesHtml([
|
|
421
|
+
{ label: 'first', value: 500 },
|
|
422
|
+
{ label: 'barely later', value: 500, start: 1 },
|
|
423
|
+
], { max: 1000 });
|
|
424
|
+
const fills = [...html.matchAll(/class="kit-bars__fill" style="([^"]+)"/g)].map((m) => m[1]);
|
|
425
|
+
assert.equal(fills[0], 'width:50%', 'the one at the origin carries no offset');
|
|
426
|
+
assert.match(fills[1], /^margin-left:1%/, 'and the one after it is visibly after it');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a positive value never draws nothing, however small its share', () => {
|
|
430
|
+
// Rounding alone sends anything under half a percent to `width:0%`, which
|
|
431
|
+
// reads as "none" — the one thing it is not. Load-bearing for the roadmap: a
|
|
432
|
+
// version that started and shipped inside one day is a real span.
|
|
433
|
+
const html = kit.barSeriesHtml([{ label: 'tiny', value: 1 }, { label: 'huge', value: 100000 }]);
|
|
434
|
+
assert.match(html, /width:1%/, 'the sliver survives');
|
|
435
|
+
// and zero is still zero: an empty track is the honest picture of nothing.
|
|
436
|
+
assert.match(kit.barSeriesHtml([{ label: 'a', value: 0 }, { label: 'b', value: 9 }]), /width:0%/);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('plotWindow puts a reachable-zero series on a zero floor', () => {
|
|
440
|
+
const w = kit.plotWindow([0, 3, 1, 4]);
|
|
441
|
+
assert.equal(w.yMin, 0, 'a count sits on zero — the distance from it IS the reading');
|
|
442
|
+
assert.ok(w.yMax > 4, 'with air above, so the peak does not ride the top edge');
|
|
443
|
+
assert.equal(w.lo, 0);
|
|
444
|
+
assert.equal(w.hi, 4);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('plotWindow fits the floor for a level, which is why the caller must say so', () => {
|
|
448
|
+
const w = kit.plotWindow([24880, 27195], { zeroBased: false });
|
|
449
|
+
assert.ok(w.yMin > 24000 && w.yMin < 24880, 'the axis rises to just under the minimum');
|
|
450
|
+
assert.ok(w.yMax > 27195);
|
|
451
|
+
// The whole point: on a zero floor this month of change would occupy the top
|
|
452
|
+
// 9% of the box. Fitted, it occupies the whole of it.
|
|
453
|
+
const zero = kit.plotWindow([24880, 27195]);
|
|
454
|
+
assert.ok((w.yMax - w.yMin) < (zero.yMax - zero.yMin) / 5, 'the fitted window is far tighter');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('plotWindow survives a flat series and an empty one', () => {
|
|
458
|
+
// A flat series has no range to take a fraction of, and dividing by it is how
|
|
459
|
+
// a chart renders as an invisible NaN.
|
|
460
|
+
const flat = kit.plotWindow([5, 5, 5]);
|
|
461
|
+
assert.ok(flat.yMax > 5, 'flat still gets air, so the line is not welded to the top');
|
|
462
|
+
const flatFit = kit.plotWindow([5, 5, 5], { zeroBased: false });
|
|
463
|
+
assert.ok(flatFit.yMin < 5 && flatFit.yMax > 5, 'and a fitted flat line lands mid-box');
|
|
464
|
+
const zeros = kit.plotWindow([0, 0, 0]);
|
|
465
|
+
assert.equal(zeros.yMin, 0);
|
|
466
|
+
assert.ok(zeros.yMax > 0, 'a month of honest zeros still divides by something');
|
|
467
|
+
assert.deepEqual(kit.plotWindow([]), { lo: 0, hi: 0, yMin: 0, yMax: 1, n: 0 });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('plotWindow DROPS a non-finite reading rather than counting it as zero', () => {
|
|
471
|
+
// The trap this exists to close: Number(null) is 0 and finite, so a snapshot
|
|
472
|
+
// that predates a column would drag `lo` to zero and draw a cliff.
|
|
473
|
+
const w = kit.plotWindow([100, null, 120, undefined, 110]);
|
|
474
|
+
assert.equal(w.lo, 100);
|
|
475
|
+
assert.equal(w.n, 3, 'three readings, not five');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('yMin lifts the axis, and its default leaves every existing series where it was', () => {
|
|
479
|
+
const box = { width: 100, height: 100, pad: { l: 0, r: 0, t: 0, b: 0 } };
|
|
480
|
+
const pts = [{ x: 0, value: 10 }, { x: 10, value: 20 }];
|
|
481
|
+
// Default (0): 20 of a 20-high window is the top, 10 is halfway.
|
|
482
|
+
const zero = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMax: 20, ...box });
|
|
483
|
+
assert.equal(zero.py(20).toFixed(1), '0.0');
|
|
484
|
+
assert.equal(zero.py(10).toFixed(1), '50.0');
|
|
485
|
+
// Fitted to 10..20: the same two points now span the whole box.
|
|
486
|
+
const fitted = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMin: 10, yMax: 20, ...box });
|
|
487
|
+
assert.equal(fitted.py(20).toFixed(1), '0.0');
|
|
488
|
+
assert.equal(fitted.py(10).toFixed(1), '100.0');
|
|
489
|
+
// A degenerate window (yMin === yMax) must not divide by zero.
|
|
490
|
+
const flat = kit.timeStepGeometry(pts, { x0: 0, x1: 10, yMin: 5, yMax: 5, ...box });
|
|
491
|
+
assert.doesNotMatch(flat.d, /NaN|Infinity/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('RUN: the roadmap timeline draws the real versions as spans on one axis', async () => {
|
|
495
|
+
// The page's own series module, run against the checked-in /public/versions
|
|
496
|
+
// fixture — the arithmetic a source assertion cannot reach. It is imported
|
|
497
|
+
// rather than stubbed for the same reason the kit is: a copy of the maths
|
|
498
|
+
// agreeing with itself proves nothing about what a reader gets.
|
|
499
|
+
const modPath = path.join(HALL, 'roadmap-timeline.js');
|
|
500
|
+
const tl = await import(`file://${modPath.replace(/\\/g, '/')}`).then((m) => m.default);
|
|
501
|
+
// public__versions.json, NOT versions.json — the deck calls /public/versions,
|
|
502
|
+
// and the authenticated list is a different payload. Reading the wrong one
|
|
503
|
+
// here is precisely the trap this task's own CLAUDE.md note warns about.
|
|
504
|
+
const fixture = JSON.parse(fs.readFileSync(
|
|
505
|
+
path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'public__versions.json'), 'utf8'));
|
|
506
|
+
const all = fixture.versions.map((v) => ({ id: v.id, start: v.started_at, ship: v.shipped_at }));
|
|
507
|
+
const now = Date.parse('2026-09-03T12:00:00Z');
|
|
508
|
+
|
|
509
|
+
const rows = tl.timelineRows(all, now);
|
|
510
|
+
assert.equal(rows.length, all.length, 'every fixture version has a start date');
|
|
511
|
+
const starts = rows.map((r) => r.from);
|
|
512
|
+
assert.deepEqual(starts, [...starts].sort((a, b) => a - b), 'oldest first');
|
|
513
|
+
assert.ok(rows.some((r) => r.open), 'the in-flight version runs to today');
|
|
514
|
+
assert.ok(rows.every((r) => r.to >= r.from), 'no span runs backwards');
|
|
515
|
+
|
|
516
|
+
const card = tl.timelineCardHtml(all, now, kit);
|
|
517
|
+
assert.equal(card.count, rows.length);
|
|
518
|
+
assert.doesNotMatch(card.html, /NaN|Infinity|undefined/);
|
|
519
|
+
assert.match(card.html, /class="kit-bars"/, 'drawn by the kit, not by hand');
|
|
520
|
+
const offsets = [...card.html.matchAll(/margin-left:(\d+)%/g)].map((m) => Number(m[1]));
|
|
521
|
+
assert.ok(offsets.length >= 2, 'later versions start further along the axis');
|
|
522
|
+
assert.deepEqual(offsets, [...offsets].sort((a, b) => a - b), 'and their offsets grow with their start dates');
|
|
523
|
+
// PMS-V1 started and shipped inside ONE day of a four-month axis: the span
|
|
524
|
+
// that rounds to nothing without the 1% floor. It is also the earliest
|
|
525
|
+
// version, so it correctly carries no offset — assert the floor, not a
|
|
526
|
+
// margin-left the axis origin has no reason to write.
|
|
527
|
+
const pms = card.html.match(/PMS-V1<\/span><span class="kit-bars__track"[^>]*><span class="kit-bars__fill" style="([^"]+)"/);
|
|
528
|
+
assert.ok(pms, 'PMS-V1 has a bar');
|
|
529
|
+
assert.match(pms[1], /width:[1-9]/, 'a one-day span still draws, rather than rounding to nothing');
|
|
530
|
+
assert.doesNotMatch(pms[1], /margin-left/, 'and the earliest version sits at the axis origin');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('RUN: the timeline refuses to draw rather than drawing an empty axis', async () => {
|
|
534
|
+
const modPath = path.join(HALL, 'roadmap-timeline.js');
|
|
535
|
+
const tl = await import(`file://${modPath.replace(/\\/g, '/')}`).then((m) => m.default);
|
|
536
|
+
const now = Date.parse('2026-09-03T12:00:00Z');
|
|
537
|
+
assert.equal(tl.timelineCardHtml([], now, kit), null, 'nothing at all');
|
|
538
|
+
assert.equal(tl.timelineCardHtml([{ id: 'A', start: '2026-01-01' }], now, kit), null,
|
|
539
|
+
'one version is not a timeline');
|
|
540
|
+
// A version with no start date is left OUT and counted in the foot, never
|
|
541
|
+
// drawn at the axis origin — which would read as "began with the project".
|
|
542
|
+
const card = tl.timelineCardHtml([
|
|
543
|
+
{ id: 'A', start: '2026-01-01', ship: '2026-02-01' },
|
|
544
|
+
{ id: 'B', start: '2026-03-01', ship: null },
|
|
545
|
+
{ id: 'C', start: null, ship: null },
|
|
546
|
+
], now, kit);
|
|
547
|
+
assert.equal(card.count, 2);
|
|
548
|
+
assert.doesNotMatch(card.html, />C</, 'the unstarted version is not a bar');
|
|
549
|
+
assert.match(card.html, /1 version with no start date is not shown/, 'the foot says so');
|
|
550
|
+
});
|
|
@@ -11,6 +11,7 @@ import test from 'node:test';
|
|
|
11
11
|
import assert from 'node:assert/strict';
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
+
import vm from 'node:vm';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
|
|
16
17
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -116,3 +117,161 @@ test('the vocabulary wall holds: the family is named oversight on the government
|
|
|
116
117
|
assert.ok(!/governance/i.test(read(f)), `${f} does not say the pre-ADR-0174 word`);
|
|
117
118
|
}
|
|
118
119
|
});
|
|
120
|
+
|
|
121
|
+
// ---- the repo-health trend (task 1003447, `legibility-series-are-drawn`) ----
|
|
122
|
+
//
|
|
123
|
+
// /public/repo-health has always answered with `latest` PLUS a trend[], and the
|
|
124
|
+
// route's own comment says the trend exists "so the status page can draw LOC /
|
|
125
|
+
// dead-exports / stale-branch lines over time ... oldest->newest so the
|
|
126
|
+
// dashboard can plot left->right". Watch requested it WITH ?days=30 — paying for
|
|
127
|
+
// the whole window — and renderLedger read one snapshot off it. The series
|
|
128
|
+
// arrived over the wire and went on the floor.
|
|
129
|
+
//
|
|
130
|
+
// The AXIS arithmetic is the kit's (plotWindow / timeStepSeriesHtml) and
|
|
131
|
+
// tests/hall_kit_charts.mjs runs it for real. What lives here is Watch's half:
|
|
132
|
+
// that the section composes the kit rather than drawing by hand, and — the part
|
|
133
|
+
// no source assertion can reach — that booting the REAL page against the REAL
|
|
134
|
+
// checked-in fixture puts four finite plots on the panel.
|
|
135
|
+
|
|
136
|
+
const FIXTURES = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
|
|
137
|
+
const repoHealthFixture = () => JSON.parse(fs.readFileSync(path.join(FIXTURES, 'public__repo-health.json'), 'utf8'));
|
|
138
|
+
|
|
139
|
+
// Boot watch.js in a stub DOM with the real dom-utils and the real kit, answer
|
|
140
|
+
// /me as an archon and /public/repo-health with the fixture, and hand back what
|
|
141
|
+
// landed in #ledger-trend. Every OTHER endpoint answers 500, which is the
|
|
142
|
+
// page's own design (each section loads independently) and keeps this harness
|
|
143
|
+
// to the one panel it is about.
|
|
144
|
+
async function bootWatchLedger(overrides = {}) {
|
|
145
|
+
const payload = { ...repoHealthFixture(), ...overrides };
|
|
146
|
+
const els = new Map();
|
|
147
|
+
const makeEl = () => ({
|
|
148
|
+
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
149
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
150
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
151
|
+
addEventListener() {}, appendChild() {}, insertAdjacentHTML(_, h) { this._html += h; },
|
|
152
|
+
closest() { return null; },
|
|
153
|
+
querySelector() { return null; }, querySelectorAll() { return []; },
|
|
154
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
155
|
+
});
|
|
156
|
+
const documentObj = {
|
|
157
|
+
getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
|
|
158
|
+
querySelector(sel) {
|
|
159
|
+
const s = String(sel);
|
|
160
|
+
return s.startsWith('#') ? this.getElementById(s.slice(1)) : makeEl();
|
|
161
|
+
},
|
|
162
|
+
querySelectorAll() { return []; },
|
|
163
|
+
createElement() { return makeEl(); },
|
|
164
|
+
addEventListener() {},
|
|
165
|
+
};
|
|
166
|
+
const client = {
|
|
167
|
+
request: async (method, urlPath) => {
|
|
168
|
+
if (urlPath.endsWith('/me')) {
|
|
169
|
+
return { ok: true, status: 200, data: { builder: { id: '1', rank: 'archon', github_login: 'x' } } };
|
|
170
|
+
}
|
|
171
|
+
if (urlPath.includes('/public/repo-health')) return { ok: true, status: 200, data: payload };
|
|
172
|
+
return { ok: false, status: 500, data: {} };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const windowObj = {
|
|
176
|
+
BongosClient: { createClient: () => client },
|
|
177
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
178
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
179
|
+
location: { hash: '', pathname: '/watch', search: '', assign() {} },
|
|
180
|
+
addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
181
|
+
};
|
|
182
|
+
const sandbox = {
|
|
183
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
184
|
+
localStorage: windowObj.localStorage,
|
|
185
|
+
// The two browser globals the kit reaches for outside `window`: CSS.escape
|
|
186
|
+
// (tab selectors) and history.replaceState (the tab's deep link).
|
|
187
|
+
CSS: { escape: (s) => String(s) },
|
|
188
|
+
history: { replaceState() {} },
|
|
189
|
+
console: { ...console, error() {}, warn() {} },
|
|
190
|
+
setTimeout, clearTimeout, URLSearchParams, Intl,
|
|
191
|
+
};
|
|
192
|
+
sandbox.globalThis = sandbox;
|
|
193
|
+
vm.createContext(sandbox);
|
|
194
|
+
for (const f of ['dom-utils.js', 'hall-kit.js', 'watch.js']) {
|
|
195
|
+
vm.runInContext(read(f), sandbox, { filename: f });
|
|
196
|
+
}
|
|
197
|
+
// load() is async: let every section's promise settle before reading the DOM.
|
|
198
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
|
|
199
|
+
return { html: documentObj.getElementById('ledger-trend').innerHTML, latest: payload.latest, windowObj };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('the repo-health trend has a mount, and renderLedger fills it', () => {
|
|
203
|
+
const js = read('watch.js');
|
|
204
|
+
assert.match(read('watch.html'), /id="ledger-trend"/, 'the mount exists in the markup');
|
|
205
|
+
assert.match(js, /renderTrend\(data\);/, 'renderLedger hands the whole payload on, not just latest');
|
|
206
|
+
assert.match(js, /'\/public\/repo-health\?days=30'/, 'the request still asks for the window');
|
|
207
|
+
assert.match(js, /data\.window_days/, 'and the heading says which window it drew');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('every mark in the trend is the kit\'s — no hand-drawn plot on the oversight page', () => {
|
|
211
|
+
const js = read('watch.js');
|
|
212
|
+
for (const fn of ['plotWindow', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml']) {
|
|
213
|
+
assert.match(js, new RegExp(`kit\\.${fn}\\(`), `the trend draws through ${fn}`);
|
|
214
|
+
}
|
|
215
|
+
// §6b holds this globally; pinned here too because this is the page that
|
|
216
|
+
// hand-rolled a bar chart once already (task 1003445).
|
|
217
|
+
assert.doesNotMatch(js, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
|
|
218
|
+
'no hand-built SVG geometry on Watch');
|
|
219
|
+
// No readout is wired, so the plot must drop the focus stop AND the crosshair
|
|
220
|
+
// together — the task 1003446 pairing. Half of it is worse than neither.
|
|
221
|
+
assert.match(js, /interactive: false/, 'the trend plots are not fake-interactive');
|
|
222
|
+
assert.match(js, /class="pchart__twin"/, 'and each carries its table twin instead');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a fitted axis says so in words, because it magnifies every move', () => {
|
|
226
|
+
// total_loc is the one metric drawn off a non-zero floor: on a zero floor a
|
|
227
|
+
// month of real change is a flat band across the top of the box. That choice
|
|
228
|
+
// makes small movement look large, so the figure states its baseline rather
|
|
229
|
+
// than letting the picture imply a collapse to nothing.
|
|
230
|
+
const js = read('watch.js');
|
|
231
|
+
assert.match(js, /key: 'total_loc',[\s\S]{0,120}zeroBased: false/, 'total_loc fits its floor');
|
|
232
|
+
for (const k of ['dead_exports', 'stale_branches', 'confirmed_unshipped']) {
|
|
233
|
+
assert.match(js, new RegExp(`key: '${k}',[\\s\\S]{0,120}zeroBased: true`), `${k} keeps the zero floor`);
|
|
234
|
+
}
|
|
235
|
+
assert.match(js, /not zero/, 'and a fitted figure names its baseline in the foot');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('RUN: the real page against the real fixture puts four finite plots on the panel', async () => {
|
|
239
|
+
// The half a source assertion cannot see. A NaN in a path `d` renders as an
|
|
240
|
+
// invisible chart with no error anywhere — a grep never finds that, and a
|
|
241
|
+
// stub agreeing with a stub never would either.
|
|
242
|
+
const { html, latest } = await bootWatchLedger();
|
|
243
|
+
assert.ok(html.length > 0, 'the panel drew something');
|
|
244
|
+
assert.doesNotMatch(html, /NaN|Infinity|undefined/, 'no broken geometry reaches the DOM');
|
|
245
|
+
const figures = [...html.matchAll(/id="ledger-trend-([a-z_]+)"/g)].map((m) => m[1]);
|
|
246
|
+
assert.deepEqual(figures, ['total_loc', 'dead_exports', 'stale_branches', 'confirmed_unshipped'],
|
|
247
|
+
'all four metrics draw, in the reading order');
|
|
248
|
+
assert.equal((html.match(/class="kit-plot"/g) || []).length, 4, 'four plots');
|
|
249
|
+
assert.ok(!/tabindex="0"/.test(html), 'none of them is a focus stop with nothing to do');
|
|
250
|
+
assert.equal((html.match(/class="kit-plot__hair"/g) || []).length, 0, 'and none carries a crosshair nothing moves');
|
|
251
|
+
// The plot and the figure band above it derive from ONE payload, so they
|
|
252
|
+
// cannot disagree: the labelled endpoint is the number the snapshot states.
|
|
253
|
+
const compact = `${(latest.total_loc / 1e3).toFixed(1)}k`;
|
|
254
|
+
assert.ok(html.includes(`>${compact}<`), `the line ends on ${compact}, the value the snapshot figure shows`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('RUN: a payload with no trend renders nothing rather than an empty panel', async () => {
|
|
258
|
+
// The honest exit the module doc requires. The figures above already say what
|
|
259
|
+
// today's numbers are; an empty card under them would be worse than no card.
|
|
260
|
+
const { html } = await bootWatchLedger({ trend: [] });
|
|
261
|
+
assert.equal(html.trim(), '', 'no heading, no card, no "no data" box');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('RUN: one snapshot is a reading, not a trend', async () => {
|
|
265
|
+
// A single point draws a dot on an axis with nothing to compare it to.
|
|
266
|
+
const { html } = await bootWatchLedger({ trend: repoHealthFixture().trend.slice(-1) });
|
|
267
|
+
assert.equal(html.trim(), '');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('RUN: a metric its snapshots never carried is skipped, not drawn as a cliff', async () => {
|
|
271
|
+
// An older snapshot predates a column and sends null. Reading that as 0 would
|
|
272
|
+
// draw a fall to the floor that never happened, so the figure is dropped.
|
|
273
|
+
const trend = repoHealthFixture().trend.map((r) => ({ ...r, dead_exports: null }));
|
|
274
|
+
const { html } = await bootWatchLedger({ trend });
|
|
275
|
+
const figures = [...html.matchAll(/id="ledger-trend-([a-z_]+)"/g)].map((m) => m[1]);
|
|
276
|
+
assert.deepEqual(figures, ['total_loc', 'stale_branches', 'confirmed_unshipped']);
|
|
277
|
+
});
|