@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.
@@ -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
- transition: width 200ms ease;
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.534",
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.534",
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.534",
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.534'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
+ });