@bongos/core 1.19.790 → 1.19.791

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.
@@ -0,0 +1,85 @@
1
+ // modules/hall-ui/public/hall-visual.js — the ship-time task visual, in one place
2
+ // (task 1003835; the renderers themselves are tasks 1003109 and 1003829).
3
+ //
4
+ // A satellite of hall-kit.js rather than a part of it, and that is the decision
5
+ // this file records. These three functions lived in the kit, which is 61KB of
6
+ // pager/ledger/filter/chart machinery. The task record page (task.html) renders a
7
+ // visual and needs NOTHING else the kit holds, so converging it onto the kit's
8
+ // renderer would have meant loading 61KB on the hall's most-visited record page
9
+ // for one function — a real cost, paid on the wrong page. The alternative was to
10
+ // let task.js keep its own copy, which is how the hall ended up with two.
11
+ //
12
+ // So the helpers moved OUT. Any page that renders a visual loads this file; the
13
+ // kit no longer carries them and the pages that only use the kit no longer pay
14
+ // for them. The owner's call (2026-09-16) was that the kit may grow satellites
15
+ // like this one.
16
+ //
17
+ // Contract, identical to the kit's own: dependency-free (its own esc(), no
18
+ // window.OTB use) so load order only needs "before the page script";
19
+ // node-require-safe, so tests/task_visuals.mjs can require it with no DOM;
20
+ // string-template builders returning HTML, no state, no render loop.
21
+ //
22
+ // CSS: each caller supplies its own BEM base, because SIZE is the surface's
23
+ // decision and not this file's — the task record caps at 52vh in a record column
24
+ // (.tk-visual, style.css/task.css), the Studio gives the plate the card's whole
25
+ // width (.studio-plate). The markup, the URL guard and the escaping are shared.
26
+ (() => {
27
+ 'use strict';
28
+
29
+ // HTML-escape for text interpolated into innerHTML.
30
+ function esc(s) {
31
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
32
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
33
+ }[c]));
34
+ }
35
+
36
+ // Same-origin, SERVER-MINTED paths only. The URL arrives from our own API, but
37
+ // it lands in an href and a src, so it is matched literally against the shape
38
+ // modules/lifecycle/task-visuals.js mints rather than trusted. Anything else
39
+ // renders nothing at all.
40
+ const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
41
+ function isTaskVisualUrl(url) {
42
+ return VISUAL_URL_RE.test(String(url ?? ''));
43
+ }
44
+
45
+ // The compact thumbnail, for a FEED — where the summary is one line in a list,
46
+ // so the image appears small and links to the full-size file. Three feeds draw
47
+ // it (the hall's ship ledger, a profile's recent ships, the status dashboard).
48
+ // Returns '' when the row carries no visual: a task without one must never look
49
+ // incomplete, so nothing is drawn and there is no placeholder.
50
+ function shipVisualThumbHtml(row) {
51
+ const url = String((row && row.visual_url) ?? '');
52
+ if (!isTaskVisualUrl(url)) return '';
53
+ const alt = String((row && row.visual_alt) || `Visual for task ${(row && row.id) ?? ''}`).trim();
54
+ return `<a href="${esc(url)}" target="_blank" rel="noopener" title="${esc(alt)}">` +
55
+ `<img class="ship-visual-thumb" src="${esc(url)}" alt="${esc(alt)}" loading="lazy"></a>`;
56
+ }
57
+
58
+ // The FULL-SIZE form: the picture as the thing being looked at, with visual_alt
59
+ // as its caption. For a surface whose subject IS the image — the task record
60
+ // (.tk-visual) and the Studio's review card (.studio-plate).
61
+ //
62
+ // `className` is the caller's BEM base; it is sanitised because it reaches a
63
+ // class attribute. Returns '' with no visual, like the thumbnail.
64
+ function taskVisualFigureHtml(row, { className = 'task-visual' } = {}) {
65
+ const url = String((row && row.visual_url) ?? '');
66
+ if (!isTaskVisualUrl(url)) return '';
67
+ const written = String((row && row.visual_alt) || '').trim();
68
+ const alt = written || `Visual for task ${(row && row.id) ?? ''}`;
69
+ const base = String(className).replace(/[^A-Za-z0-9_-]/g, '');
70
+ // The caption is only drawn when the shipper WROTE one — the fallback alt is
71
+ // a machine sentence for a screen reader, not a caption to show a reader.
72
+ const caption = written ? `<figcaption class="${base}__caption">${esc(written)}</figcaption>` : '';
73
+ return `<figure class="${base}">` +
74
+ `<a href="${esc(url)}" target="_blank" rel="noopener">` +
75
+ `<img class="${base}__img" src="${esc(url)}" alt="${esc(alt)}" loading="lazy">` +
76
+ `</a>${caption}</figure>`;
77
+ }
78
+
79
+ const api = { isTaskVisualUrl, shipVisualThumbHtml, taskVisualFigureHtml };
80
+
81
+ // Node-require-safe (the dom-utils.js / hall-kit.js pattern): assignment is the
82
+ // only eval-time side effect, and only when a window exists.
83
+ if (typeof window !== 'undefined') window.OTBVisual = api;
84
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
85
+ })();
@@ -110,6 +110,7 @@
110
110
  <script src="/builders/box-panel.js?v=2026-06-14-domutils" defer></script>
111
111
  <!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
112
112
  Loaded BEFORE hall-widgets.js, whose skeletons call OTBKit.pagerBarHtml(). -->
113
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1" defer></script>
113
114
  <script src="/builders/hall-kit.js" defer></script>
114
115
  <!-- BV1.R75: the CORE hall widgets register into window.OTB's hall-widget registry.
115
116
  Loaded BEFORE builders.js so the registry is populated when builders.js stamps
@@ -196,6 +196,7 @@
196
196
  <!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
197
197
  its shared thumbnail helper (task 1003109) and builds the section rail with
198
198
  it (task 1003489), so it must load first. -->
199
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
199
200
  <script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
200
201
  <!-- Connections (section 6). Loaded BEFORE profile.js so window.OTBConnections
201
202
  exists by the time renderProfile runs; profile.js guards on it anyway, so a
@@ -176,7 +176,7 @@
176
176
  const summary = s.value_summary
177
177
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(s.value_summary)}</span>`
178
178
  : '';
179
- const visual = window.OTBKit && window.OTBKit.shipVisualThumbHtml ? window.OTBKit.shipVisualThumbHtml(s) : '';
179
+ const visual = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml(s) : '';
180
180
  return `<tr>
181
181
  <td class="ledger__num">#${escapeHtml(String(s.id))}</td>
182
182
  <td><a href="${taskHref(s.id)}">${escapeHtml(s.title || `Task ${s.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
@@ -473,7 +473,7 @@
473
473
  const summary = t.value_summary
474
474
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(t.value_summary)}</span>`
475
475
  : '';
476
- const visual = kit.shipVisualThumbHtml ? kit.shipVisualThumbHtml(t) : '';
476
+ const visual = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml(t) : '';
477
477
  return `<tr>
478
478
  <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
479
479
  <td><a href="${taskHref(t.id)}">${escapeHtml(t.title || `Task ${t.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
@@ -155,7 +155,7 @@
155
155
  IS the card: an artist is being asked to LOOK at something, so it is given the
156
156
  card's full width and as much height as a screen can spare, not the 44px
157
157
  thumbnail a ship feed uses. Markup comes from the kit's one renderer
158
- (OTBKit.taskVisualFigureHtml); only the size is this room's decision.
158
+ (OTBVisual.taskVisualFigureHtml); only the size is this room's decision.
159
159
 
160
160
  max-height in vh, and MEASURED at 1440x900 rather than chosen. The size an
161
161
  artist can judge it at wins over a zero-scroll card — that is the task's own
@@ -72,6 +72,7 @@
72
72
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
73
73
  (task 1003829). The copy desk — the artist's other room — already loads it,
74
74
  and a fourth hand-rolled <figure> is what the kit exists to stop. -->
75
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
75
76
  <script src="/builders/hall-kit.js"></script>
76
77
  <script src="/builders/studio.js?v=2026-09-10-studio2"></script>
77
78
  </body>
@@ -132,7 +132,7 @@
132
132
  // ---------------------------------------------------------------------------
133
133
 
134
134
  // THE PLATE — the reviewed work's ship-time visual, at the size the card can
135
- // give it. Through the kit's one renderer (OTBKit.taskVisualFigureHtml, which
135
+ // give it. Through the one renderer (OTBVisual.taskVisualFigureHtml, which
136
136
  // carries the server-minted-URL guard and the escaping) with the Studio's own
137
137
  // class, because the size is this surface's decision: here the picture is the
138
138
  // subject, not an illustration beside a record.
@@ -141,8 +141,9 @@
141
141
  // read, or when the kit is absent — a review with no picture must read as a
142
142
  // review, never as an empty frame (the degrade rule).
143
143
  function plateHtml(p) {
144
- if (!p.shipped || !kit || typeof kit.taskVisualFigureHtml !== 'function') return '';
145
- return kit.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
144
+ const vis = window.OTBVisual;
145
+ if (!p.shipped || !vis || typeof vis.taskVisualFigureHtml !== 'function') return '';
146
+ return vis.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
146
147
  }
147
148
 
148
149
  // The three weights, and the one field they share. Offered only to a viewer
@@ -2841,22 +2841,6 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2841
2841
  overflow-y: auto;
2842
2842
  }
2843
2843
  .td-touches { font-size: 12.5px; color: var(--ink-faint); font-family: var(--font-mono); word-break: break-word; margin: 0 0 12px; }
2844
- /* task 1003109: the ship-time VISUAL, rendered directly under the value summary
2845
- it restates. Sized to be READ (this exists so a non-engineer can see a change),
2846
- capped so a tall screenshot can't push the rest of the record off the page, and
2847
- never drawn at all when a task has no visual. */
2848
- .td-visual { margin: 0 0 12px; }
2849
- .td-visual__img {
2850
- display: block;
2851
- max-width: 100%;
2852
- max-height: 52vh;
2853
- width: auto;
2854
- height: auto;
2855
- border: var(--line) solid var(--rule-soft);
2856
- border-radius: var(--radius-sm);
2857
- background: var(--glass);
2858
- }
2859
- .td-visual__caption { font-size: 12.5px; color: var(--ink-faint); margin: 6px 0 0; }
2860
2844
  /* The compact form: a thumbnail beside a value summary in a ship FEED, where the
2861
2845
  summary is one line in a list and a full-width image would drown it. */
2862
2846
  .ship-visual-thumb {
@@ -68,6 +68,7 @@
68
68
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail2" defer></script>
69
69
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
70
70
  <script src="/builders/md-reader.js" defer></script>
71
+ <script src="/builders/hall-visual.js?v=2026-09-16-visual1" defer></script>
71
72
  <script src="/builders/task.js?v=2026-07-03-taskpage1" defer></script>
72
73
  </body>
73
74
  </html>
@@ -292,20 +292,17 @@
292
292
  // a detail can be opened full-size. The caption (visual_alt) doubles as the alt
293
293
  // text, which is why it is stored: an image with no alt tells a screen reader
294
294
  // nothing, and "a picture of the change" is exactly the audience that needs it.
295
+ // The shared renderer (hall-visual.js), not a copy of it — task 1003835. This
296
+ // page hand-rolled the same <figure> until then; the markup it produced is what
297
+ // OTBVisual emits, so .tk-visual's rules are unchanged. `className` keeps the
298
+ // sizing this page's own CSS owns (52vh inside a record column).
299
+ //
300
+ // Guarded rather than assumed: hall-visual.js is a separate <script>, so a page
301
+ // that forgot it should render no picture, never a broken one.
295
302
  function taskVisualHtml(t) {
296
- const url = String(t.visual_url || '');
297
- // Same-origin, server-generated paths only. The URL comes from our own API,
298
- // but rendering it into an href/src is a place to be literal rather than
299
- // trusting: anything that is not the shape our serve route mints is dropped.
300
- if (!/^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/.test(url)) return '';
301
- const alt = String(t.visual_alt || `Visual for task ${t.id}`);
302
- const caption = t.visual_alt
303
- ? `<figcaption class="tk-visual__caption">${escapeHtml(t.visual_alt)}</figcaption>`
304
- : '';
305
- return `<figure class="tk-visual">` +
306
- `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">` +
307
- `<img class="tk-visual__img" src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" loading="lazy">` +
308
- `</a>${caption}</figure>`;
303
+ const vis = window.OTBVisual;
304
+ if (!vis || typeof vis.taskVisualFigureHtml !== 'function') return '';
305
+ return vis.taskVisualFigureHtml(t, { className: 'tk-visual' });
309
306
  }
310
307
 
311
308
  async function copyText(text) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.790",
3
+ "version": "1.19.791",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.790",
9
+ "version": "1.19.791",
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.790",
3
+ "version": "1.19.791",
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",
@@ -0,0 +1,195 @@
1
+ // scripts/gds/accessibility-replay.js — the two-profile replay regression for the
2
+ // accessibility dimension (task 1002517; clauses 5 and 8 of criterion
3
+ // `accessibility-fluency-profile`, which named this run as its OWN proof).
4
+ //
5
+ // WHAT IT PROVES, and why it is not already covered.
6
+ // tests/session_accessibility.mjs pins the scorer's contracts against synthetic
7
+ // fixtures it builds itself — a test of the arithmetic. It cannot answer the
8
+ // criterion's actual question, which is about PROSE: does the dimension reward a
9
+ // reply a newcomer can use, and does it leave an expert's reply alone? So this
10
+ // harness takes a FIXED set of eight questions a newcomer really asks, each the
11
+ // documented trigger of a surface that exists, answered twice — once in each
12
+ // register — and scores both runs.
13
+ //
14
+ // Four legs. Each one fails a different way of being wrong:
15
+ //
16
+ // 1. FLOOR the non-technical run, under the non-technical profile,
17
+ // clears FLOOR. The dimension recognises accessible prose.
18
+ // 2. NO PENALTY the engineer run, under the engineer profile, is not
19
+ // scored at all — every signal waived, score null.
20
+ // 3. DISCRIMINATION the SAME engineer prose under the NON-TECHNICAL profile
21
+ // falls BELOW FLOOR, with every detector registering hits.
22
+ // Without this leg 1 proves nothing — a scorer that returned
23
+ // 10 for everything, or three detectors that had gone blind,
24
+ // would pass leg 1 and look identical to a working one.
25
+ // 4. LENGTH `doubleRun` says the same prose twice. Chars double, hits
26
+ // double, the rate is unchanged, so the score must be
27
+ // IDENTICAL. That is the metric reading density rather than
28
+ // volume, asserted against real prose.
29
+ //
30
+ // WHERE TERSENESS PROTECTION ACTUALLY COMES FROM, since the task's "watch out"
31
+ // is a metric that punishes an expert's brevity. It is leg 2 — the WAIVER — not
32
+ // the per-1000-character rates. The rates make the score independent of how long
33
+ // an answer is at a fixed density (leg 4); they do NOT make it independent of
34
+ // density, and they should not: three undefined terms in three hundred
35
+ // characters really is denser than three in three thousand. What protects the
36
+ // expert is that their profile removes those two signals from the scoring set
37
+ // entirely. `terseVariant` — every turn cut to its first sentence, a genuinely
38
+ // shorter and thinner answer — is checked too, asserting only that answering
39
+ // more briefly never COSTS points; it is a guard, not the mechanism.
40
+ //
41
+ // One honest asymmetry this surfaced and the corpus now records: under
42
+ // `technical_level: engineer` with `toolchain_familiarity: new`, the engineer
43
+ // run still scores, because terminal-where-a-click-path-exists is waived by the
44
+ // TOOLCHAIN setting, not the technical one. "I think in code" and "I live in a
45
+ // terminal" are two different answers, and the scorer is right to keep them
46
+ // apart. tests/accessibility_replay.mjs pins that pairing so a future widening
47
+ // of the engineer waiver has to be a deliberate edit.
48
+ //
49
+ // The corpus is data — tests/fixtures/accessibility-replay.json — so adding a
50
+ // prompt is an edit to JSON, not to code.
51
+ //
52
+ // Run: node scripts/gds/accessibility-replay.js
53
+ // node scripts/gds/accessibility-replay.js --json
54
+
55
+ 'use strict';
56
+
57
+ const fs = require('node:fs');
58
+ const path = require('node:path');
59
+
60
+ const { scoreAccessibility, bandFor } = require('../../modules/sessions/accessibility.js');
61
+
62
+ const CORPUS_PATH = path.join(__dirname, '..', '..', 'tests', 'fixtures', 'accessibility-replay.json');
63
+
64
+ // FLOOR — the score a non-technical run has to clear. It is bandFor()'s
65
+ // 'workable' boundary and deliberately not a second independent number: the
66
+ // scorer already draws the line between prose a newcomer can work with and prose
67
+ // that is merely dense, and inventing a different one here would let the two
68
+ // drift. tests/accessibility_replay.mjs asserts they stay tied.
69
+ const FLOOR = 6.0;
70
+
71
+ function loadCorpus(file = CORPUS_PATH) {
72
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
73
+ }
74
+
75
+ // terseVariant — each turn cut to its first sentence: a shorter, thinner answer.
76
+ // Mechanical on purpose. A hand-written "terse version" in the corpus would be a
77
+ // version tuned until it passed; taking the first sentence of prose written for
78
+ // another purpose is a variant nobody got to tune.
79
+ function terseVariant(turns) {
80
+ return turns.map((t) => {
81
+ const m = /^[^.!?]*[.!?]/.exec(t);
82
+ return m ? m[0] : t;
83
+ });
84
+ }
85
+
86
+ // doubleRun — the same prose said twice. Every count doubles and so does the
87
+ // character total, so every rate is unchanged and the score must come back
88
+ // EXACTLY equal. This is the length-independence property at prose altitude: a
89
+ // scorer that had drifted to absolute counts would double its penalties here.
90
+ function doubleRun(turns) {
91
+ return [...turns, ...turns];
92
+ }
93
+
94
+ // asDetail — a run's replies as the per-turn digest shape scoreAccessibility
95
+ // reads. The user turns are included because a real digest has them and
96
+ // assistantProse must keep ignoring them; if it ever stopped, the prompts would
97
+ // start scoring the builder's own words and every number here would move.
98
+ function asDetail(corpus, replies, { transform } = {}) {
99
+ const detail = [];
100
+ let i = 0;
101
+ for (const { id, prompt } of corpus.prompts) {
102
+ detail.push({ i: i++, role: 'user', snippet: prompt });
103
+ const turns = replies[id] || [];
104
+ for (const snippet of transform ? transform(turns) : turns) {
105
+ detail.push({ i: i++, role: 'assistant', snippet });
106
+ }
107
+ }
108
+ return detail;
109
+ }
110
+
111
+ const TRANSFORMS = { terse: terseVariant, doubled: doubleRun };
112
+
113
+ // replay — score one named run under one profile. `profile` defaults to the
114
+ // run's own declared profile, which is the pairing the criterion asks about;
115
+ // passing another is how leg 3 reads engineer prose as a newcomer would.
116
+ function replay(corpus, runName, { profile, variant } = {}) {
117
+ const run = corpus.runs[runName];
118
+ if (!run) throw new Error(`no such run in the corpus: ${runName}`);
119
+ const transform = variant ? TRANSFORMS[variant] : undefined;
120
+ if (variant && !transform) throw new Error(`no such variant: ${variant}`);
121
+ const effective = profile || run.profile;
122
+ const result = scoreAccessibility(asDetail(corpus, run.replies, { transform }), { profile: effective });
123
+ return { run: runName, variant: variant || 'full', profile: effective, result };
124
+ }
125
+
126
+ // The partial engineer profile — thinks in code, new to the toolchain. Named
127
+ // here because it is the pairing that shows the two waivers are independent.
128
+ const ENGINEER_NEW_TO_TOOLCHAIN = Object.freeze({ technical_level: 'engineer', toolchain_familiarity: 'new' });
129
+
130
+ function rows(corpus) {
131
+ const newcomer = corpus.runs.non_technical.profile;
132
+ return [
133
+ ['non-technical prose, non-technical profile', replay(corpus, 'non_technical')],
134
+ ['engineer prose, engineer profile', replay(corpus, 'engineer')],
135
+ ['engineer prose, NON-TECHNICAL profile', replay(corpus, 'engineer', { profile: newcomer })],
136
+ ['engineer prose, engineer but new to the terminal', replay(corpus, 'engineer', { profile: ENGINEER_NEW_TO_TOOLCHAIN })],
137
+ ['non-technical prose, said twice', replay(corpus, 'non_technical', { variant: 'doubled' })],
138
+ ['non-technical prose, first sentences only', replay(corpus, 'non_technical', { variant: 'terse' })],
139
+ ];
140
+ }
141
+
142
+ function fmt(n) {
143
+ return n == null ? '—' : n.toFixed(1);
144
+ }
145
+
146
+ function formatReport(corpus) {
147
+ const lines = [`accessibility replay — ${corpus.prompts.length} fixed beginner prompts, floor ${FLOOR.toFixed(1)}`, ''];
148
+ for (const [label, r] of rows(corpus)) {
149
+ const { score, band, sampled_chars: chars, sampled_turns: turns } = r.result;
150
+ const waived = r.result.signals.filter((s) => s.waived).length;
151
+ lines.push(
152
+ ` ${label.padEnd(48)} ${fmt(score).padStart(5)} ${band.padEnd(9)} ` +
153
+ `${String(turns).padStart(3)} turns ${String(chars).padStart(5)} chars ${waived}/3 waived`
154
+ );
155
+ for (const s of r.result.signals) {
156
+ if (s.waived) continue;
157
+ if (!s.count) continue;
158
+ lines.push(
159
+ ` ${s.type.padEnd(28)} ${String(s.count).padStart(2)} hits rate ${s.rate.toFixed(2)}/1k −${s.penalty.toFixed(2)}`
160
+ );
161
+ }
162
+ }
163
+ return lines.join('\n');
164
+ }
165
+
166
+ // main — the CLI. `write` is injectable so the test can exercise both output
167
+ // paths without printing a screenful of JSON into the test log.
168
+ function main(argv = process.argv.slice(2), write = (s) => process.stdout.write(s)) {
169
+ const corpus = loadCorpus();
170
+ if (argv.includes('--json')) {
171
+ const out = { floor: FLOOR };
172
+ for (const [label, r] of rows(corpus)) out[label] = r.result;
173
+ write(`${JSON.stringify(out, null, 2)}\n`);
174
+ return 0;
175
+ }
176
+ write(`${formatReport(corpus)}\n`);
177
+ return 0;
178
+ }
179
+
180
+ module.exports = {
181
+ FLOOR,
182
+ CORPUS_PATH,
183
+ ENGINEER_NEW_TO_TOOLCHAIN,
184
+ loadCorpus,
185
+ terseVariant,
186
+ doubleRun,
187
+ asDetail,
188
+ replay,
189
+ rows,
190
+ formatReport,
191
+ bandFor,
192
+ main,
193
+ };
194
+
195
+ if (require.main === module) process.exit(main());
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.790'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.791'; // 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');