@bongos/core 1.19.782 → 1.19.784

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,243 @@
1
+ // tests/session_accessibility.mjs
2
+ //
3
+ // Unit tests for the ACCESSIBILITY dimension of the session evaluator
4
+ // (task 1002516; clause 4 of criterion `accessibility-fluency-profile`) —
5
+ // modules/sessions/accessibility.js, which is PURE.
6
+ //
7
+ // These pin the four contracts the module's own header names, because those are
8
+ // what make the metric safe rather than harmful. In particular the task's "watch
9
+ // out" is that a metric which punishes an expert's terseness is ACTIVELY harmful,
10
+ // so the terseness and waiver tests below are the point of the file, not trim.
11
+ //
12
+ // Run: node tests/session_accessibility.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const acc = require('../modules/sessions/accessibility.js');
19
+ const interactionPrefs = require('../modules/builder-settings/interaction-prefs.js');
20
+
21
+ let passed = 0;
22
+ let failed = 0;
23
+ function test(name, fn) {
24
+ try {
25
+ fn();
26
+ passed++;
27
+ console.log(` ok ${name}`);
28
+ } catch (err) {
29
+ failed++;
30
+ console.log(` FAIL ${name}`);
31
+ console.log(` ${err.message}`);
32
+ }
33
+ }
34
+
35
+ // ---- fixtures -------------------------------------------------------------
36
+
37
+ // Plain prose carrying no jargon, no definition marker, no command handoff and
38
+ // no framing term — so a fixture's length can be tuned without changing what any
39
+ // detector sees.
40
+ const FILLER = 'the quick brown fox jumps over the lazy dog again and again ';
41
+ const padTo = (text, chars) => {
42
+ let s = text;
43
+ while (s.length < chars) s += FILLER;
44
+ return s.slice(0, Math.max(text.length, chars));
45
+ };
46
+ const say = (text, chars) => ({ role: 'assistant', snippet: chars ? padTo(text, chars) : text });
47
+
48
+ // ---- the interaction-prefs tripwire ---------------------------------------
49
+ //
50
+ // accessibility.js may not require builder-settings (ADR 0083 forbids a module
51
+ // importing a sibling), so it keys on literal profile values. This test is the
52
+ // coupling: a rename in interaction-prefs must fail HERE rather than silently
53
+ // stop waiving an engineer's session.
54
+
55
+ test('tripwire: every profile value accessibility.js keys on still exists in interaction-prefs', () => {
56
+ for (const [field, values] of Object.entries(acc.PROFILE_VALUES)) {
57
+ assert.ok(interactionPrefs.FIELD_KEYS.includes(field), `interaction-prefs still has field ${field}`);
58
+ for (const v of values) {
59
+ assert.ok(interactionPrefs.isValidValue(field, v), `interaction-prefs still has ${field}=${v}`);
60
+ }
61
+ }
62
+ });
63
+
64
+ test('tripwire: the defaults accessibility.js falls back to are interaction-prefs own defaults', () => {
65
+ for (const [field, value] of Object.entries(acc.PROFILE_DEFAULTS)) {
66
+ assert.equal(interactionPrefs.defaultValue(field), value, `${field} default`);
67
+ }
68
+ });
69
+
70
+ // ---- contract A: profile-relative, never absolute --------------------------
71
+
72
+ test('default profile scores the jargon signal (it is not waived)', () => {
73
+ const w = acc.waiversFor({});
74
+ assert.ok(!w.unexplained_jargon, 'jargon is scored for the default non-technical profile');
75
+ assert.ok(!w.terminal_where_ui_exists, 'terminal-first is scored for a toolchain-new profile');
76
+ });
77
+
78
+ test('engineer waives jargon + framing; comfortable waives terminal-first', () => {
79
+ const eng = acc.waiversFor({ technical_level: 'engineer' });
80
+ assert.ok(eng.unexplained_jargon, 'engineer waives jargon');
81
+ assert.ok(eng.implementation_over_outcome, 'engineer waives implementation framing');
82
+ assert.ok(!eng.terminal_where_ui_exists, 'engineer alone does not waive terminal-first');
83
+
84
+ const comf = acc.waiversFor({ toolchain_familiarity: 'comfortable' });
85
+ assert.ok(comf.terminal_where_ui_exists, 'comfortable waives terminal-first');
86
+ assert.ok(!comf.unexplained_jargon, 'comfortable alone does not waive jargon');
87
+ });
88
+
89
+ test('a waived signal carries the reason that waived it, not a silent absence', () => {
90
+ const r = acc.scoreAccessibility([say('the webhook is idempotent', 400)], {
91
+ profile: { technical_level: 'engineer' },
92
+ });
93
+ const jargon = r.signals.find((s) => s.type === 'unexplained_jargon');
94
+ assert.equal(jargon.waived, true);
95
+ assert.match(jargon.reason, /technical_level: engineer/);
96
+ });
97
+
98
+ test('every-signal-waived scores null, never a fabricated 10', () => {
99
+ const dense = say('the async closure memoize the webhook regex upsert', 300);
100
+ const r = acc.scoreAccessibility([dense], {
101
+ profile: { technical_level: 'engineer', toolchain_familiarity: 'comfortable' },
102
+ });
103
+ assert.equal(r.score, null, 'no scored signal → no score');
104
+ assert.equal(r.band, 'n/a');
105
+ assert.ok(r.signals.every((s) => s.waived), 'all three signals waived');
106
+ });
107
+
108
+ test('an expert profile can never score WORSE than the same transcript under the default', () => {
109
+ const detail = [say('the middleware closure will deserialize the jsonb schema', 500)];
110
+ const def = acc.scoreAccessibility(detail, { profile: {} });
111
+ const eng = acc.scoreAccessibility(detail, {
112
+ profile: { technical_level: 'engineer', toolchain_familiarity: 'comfortable' },
113
+ });
114
+ assert.ok(def.score != null && def.score < 10, 'the default profile does score this down');
115
+ assert.equal(eng.score, null, 'the expert profile is not scored down at all');
116
+ });
117
+
118
+ test('mixed technical level relaxes the jargon allowance rather than waiving it', () => {
119
+ // One jargon hit in ~500 sampled chars = a rate of 2.0 per 1000. The default
120
+ // free rate is 1.0 (a penalty); `mixed` doubles it to 2.0 (no penalty).
121
+ const detail = [say('the webhook fires on save', 500)];
122
+ assert.equal(acc.leniencyFor({ technical_level: 'mixed' }, 'unexplained_jargon'), 2);
123
+ const def = acc.scoreAccessibility(detail, { profile: {} });
124
+ const mixed = acc.scoreAccessibility(detail, { profile: { technical_level: 'mixed' } });
125
+ assert.ok(def.score < 10, 'default profile penalises the hit');
126
+ assert.equal(mixed.score, 10, 'mixed absorbs it inside the wider allowance');
127
+ });
128
+
129
+ // ---- contract B: terseness is never a penalty ------------------------------
130
+
131
+ test('the score is a DENSITY: the same rate scores the same at any reply length', () => {
132
+ const short = [say('the webhook the closure the regex', 1000)];
133
+ const long = [say('the webhook the closure the regex', 1000), say('the webhook the closure the regex', 1000)];
134
+ const a = acc.scoreAccessibility(short, { profile: {} });
135
+ const b = acc.scoreAccessibility(long, { profile: {} });
136
+ assert.equal(a.sampled_chars * 2, b.sampled_chars, 'the fixture really is twice as long');
137
+ assert.equal(a.score, b.score, 'twice the prose at the same density scores identically');
138
+ });
139
+
140
+ test('verbosity is not an input — a concise profile is scored exactly like a detailed one', () => {
141
+ const detail = [say('the webhook the closure the regex', 900)];
142
+ const concise = acc.scoreAccessibility(detail, { profile: { verbosity: 'concise' } });
143
+ const detailed = acc.scoreAccessibility(detail, { profile: { verbosity: 'detailed' } });
144
+ assert.equal(concise.score, detailed.score);
145
+ assert.ok(!('verbosity' in concise.profile), 'verbosity is not even carried onto the result');
146
+ });
147
+
148
+ // ---- the detectors ---------------------------------------------------------
149
+
150
+ test('jargon: a term with a nearby definition marker is explained, not counted', () => {
151
+ assert.equal(acc.countUnexplainedJargon(['the webhook fires on save']).count, 1);
152
+ assert.equal(acc.countUnexplainedJargon(['a webhook (a call another site makes to us) fires']).count, 0);
153
+ assert.equal(acc.countUnexplainedJargon(['a webhook — a call another site makes to us']).count, 0);
154
+ assert.equal(acc.countUnexplainedJargon(['a webhook, which means another site calls us']).count, 0);
155
+ });
156
+
157
+ test('jargon: matching is word-bounded, so ordinary English is never a hit', () => {
158
+ // "ORM" inside "format", "CORS" inside "scores", "seam" inside "seams" — the
159
+ // substring bug this guards is the difference between a metric and a nuisance.
160
+ assert.equal(acc.countUnexplainedJargon(['the format of the scores is fine']).count, 0);
161
+ assert.equal(acc.countUnexplainedJargon(['performance is normal and the form works']).count, 0);
162
+ });
163
+
164
+ test('jargon: multi-token and punctuated terms still anchor', () => {
165
+ assert.equal(acc.countUnexplainedJargon(['this is a race condition']).count, 1);
166
+ assert.equal(acc.countUnexplainedJargon(['a classic N+1 here']).count, 1);
167
+ });
168
+
169
+ test('terminal-first: only counted when the subject HAS a click-path', () => {
170
+ const withUi = acc.countTerminalWhereUiExists(['run `node scripts/gds/api-key.js` to set your api key']);
171
+ assert.equal(withUi.count, 1);
172
+ assert.match(withUi.evidence, /Settings/, 'the evidence names where the button is');
173
+
174
+ // A command for something with no UI at all is the only way to do the job.
175
+ assert.equal(acc.countTerminalWhereUiExists(['run `npm test` to check the suite']).count, 0);
176
+ // Naming a UI-backed subject without handing over a command is not a finding.
177
+ assert.equal(acc.countTerminalWhereUiExists(['your api key is missing']).count, 0);
178
+ });
179
+
180
+ test('framing: implementation taxonomy counts only when no outcome framing rides with it', () => {
181
+ assert.equal(acc.countImplementationOverOutcome(['this changes the module boundary and the data model']).count, 1);
182
+ assert.equal(
183
+ acc.countImplementationOverOutcome(['this changes the data model, so it costs about 20 minutes']).count,
184
+ 0,
185
+ 'naming the architecture AND what it costs is exactly right'
186
+ );
187
+ });
188
+
189
+ // ---- scoring shape ---------------------------------------------------------
190
+
191
+ test('score is monotonic in the finding rate and clamped to [0,10]', () => {
192
+ const clean = acc.scoreAccessibility([say('we changed the button label', 1000)], { profile: {} });
193
+ const some = acc.scoreAccessibility([say('the webhook the closure the regex', 1000)], { profile: {} });
194
+ const awful = acc.scoreAccessibility(
195
+ [say('the webhook closure regex upsert jsonb middleware singleton polyfill debounce mutex', 200)],
196
+ { profile: {} }
197
+ );
198
+ assert.equal(clean.score, 10);
199
+ assert.ok(some.score < clean.score, 'more findings score lower');
200
+ assert.ok(awful.score < some.score, 'many more findings score lower still');
201
+ assert.ok(awful.score >= 0, 'never below 0');
202
+ });
203
+
204
+ test('no assistant prose → null score and an honest confidence, not a default pass', () => {
205
+ const r = acc.scoreAccessibility([], { profile: {} });
206
+ assert.equal(r.score, null);
207
+ assert.equal(r.confidence, 'none');
208
+ assert.equal(r.sampled_turns, 0);
209
+ });
210
+
211
+ test('user turns are not scored — the metric judges the agent, not the builder', () => {
212
+ const detail = [
213
+ { role: 'user', snippet: 'the webhook closure regex upsert jsonb middleware' },
214
+ say('we changed the button label', 1000),
215
+ ];
216
+ const r = acc.scoreAccessibility(detail, { profile: {} });
217
+ assert.equal(r.sampled_turns, 1, 'only the assistant turn was sampled');
218
+ assert.equal(r.score, 10, 'the builder\'s own jargon does not count against the agent');
219
+ });
220
+
221
+ test('a thin sample says so rather than pretending to a normal reading', () => {
222
+ const r = acc.scoreAccessibility([say('we changed the button label')], { profile: {} });
223
+ assert.ok(r.sampled_chars < acc.LOW_CONFIDENCE_CHARS);
224
+ assert.equal(r.confidence, 'low');
225
+ });
226
+
227
+ test('an unknown or partial profile falls back to the accessible default, never unscored', () => {
228
+ const r = acc.scoreAccessibility([say('the webhook fires', 1000)], {
229
+ profile: { technical_level: 'wizard', toolchain_familiarity: null },
230
+ });
231
+ assert.equal(r.profile.technical_level, 'non_technical');
232
+ assert.equal(r.profile.toolchain_familiarity, 'new');
233
+ assert.ok(r.score != null, 'a junk profile is still scored, at the default posture');
234
+ });
235
+
236
+ test('the signal spec can never penalise more than a 10 in total', () => {
237
+ const total = Object.values(acc.SIGNAL_SPEC).reduce((n, s) => n + s.max, 0);
238
+ assert.equal(total, 10, 'the three signals sum to exactly the full scale');
239
+ });
240
+
241
+ console.log('');
242
+ console.log(`Session accessibility (clause 4) tests: ${passed} passed, ${failed} failed`);
243
+ if (failed > 0) process.exit(1);
@@ -331,6 +331,57 @@ test('evaluateSession: safe on empty/malformed', () => {
331
331
  assert.equal(ev.evaluateSession(null).verdict, 'efficient');
332
332
  });
333
333
 
334
+ // ---- accessibility dimension (task 1002516, clause 4) ----
335
+ //
336
+ // The dimension itself is tested in tests/session_accessibility.mjs; these pin
337
+ // the WIRING — that the evaluator prefers the score the upload route already
338
+ // computed, re-scores a row that predates it, and only raises a footgun when the
339
+ // finding is both real and profile-sanctioned-free.
340
+
341
+ test('accessibilityFor prefers the stored score over re-scoring', () => {
342
+ const stored = { score: 4.2, band: 'dense', signals: [], confidence: 'normal' };
343
+ const r = ev.accessibilityFor({ metrics: { accessibility: stored }, detail: [] });
344
+ assert.equal(r, stored, 'the route\'s score — it had the real profile — is used as-is');
345
+ assert.ok(!r.rescored);
346
+ });
347
+
348
+ test('accessibilityFor re-scores a row that predates the dimension, and says so', () => {
349
+ const r = ev.accessibilityFor({ metrics: {}, detail: [{ role: 'assistant', snippet: 'we changed the button label' }] });
350
+ assert.equal(r.rescored, true, 'a re-derived score is marked, not passed off as the original');
351
+ assert.ok('score' in r);
352
+ });
353
+
354
+ test('detectFootguns: unplain_speech fires below the 6.0 threshold', () => {
355
+ const acc = { score: 3.5, band: 'opaque', confidence: 'normal', signals: [{ type: 'unexplained_jargon', waived: false, penalty: 3, evidence: '"webhook" undefined' }] };
356
+ const out = ev.detectFootguns([], {}, { accessibility: acc });
357
+ const f = out.find((x) => x.type === 'unplain_speech');
358
+ assert.ok(f, 'the footgun fires');
359
+ assert.match(f.evidence, /unexplained_jargon/, 'the evidence names the worst signal');
360
+ });
361
+
362
+ test('detectFootguns: unplain_speech never fires on a waived (null) score or a thin sample', () => {
363
+ // An engineer's session waives every signal and scores null — it must be
364
+ // impossible for it to reach this finding at all.
365
+ const waived = { score: null, band: 'n/a', confidence: 'normal', signals: [] };
366
+ assert.equal(ev.detectFootguns([], {}, { accessibility: waived }).some((x) => x.type === 'unplain_speech'), false);
367
+ const thin = { score: 2, band: 'opaque', confidence: 'low', signals: [] };
368
+ assert.equal(ev.detectFootguns([], {}, { accessibility: thin }).some((x) => x.type === 'unplain_speech'), false);
369
+ const fine = { score: 8, band: 'clear', confidence: 'normal', signals: [] };
370
+ assert.equal(ev.detectFootguns([], {}, { accessibility: fine }).some((x) => x.type === 'unplain_speech'), false);
371
+ assert.equal(ev.detectFootguns([], {}, {}).some((x) => x.type === 'unplain_speech'), false);
372
+ });
373
+
374
+ test('evaluateSession surfaces the accessibility dimension per session', () => {
375
+ const r = ev.evaluateSession({
376
+ session_id: 's1',
377
+ github_login: 'alice',
378
+ metrics: { accessibility: { score: 7.5, band: 'workable', confidence: 'normal', signals: [] } },
379
+ detail: [],
380
+ });
381
+ assert.equal(r.accessibility.score, 7.5);
382
+ assert.equal(r.builder, 'alice', 'the score is attributable to a builder');
383
+ });
384
+
334
385
  test('detectFootguns: a null/non-object turn does not throw (regression)', () => {
335
386
  // computeOverhead guards each turn; detectFootguns must too, or one malformed
336
387
  // turn crashes the whole evaluation.
@@ -376,7 +427,7 @@ test('findingsByBuilder groups flagged sessions; excludes footgun-free builders'
376
427
  });
377
428
 
378
429
  test('FINDING_TEMPLATES cover every footgun class', () => {
379
- for (const t of ['guess_and_retry_api', 'heavyweight_pipeline_on_trivial_change', 'stale_test_red_herring', 'repeated_tool_failures', 'empty_diff_grade', 'claim_consumed_on_fail', 'wrong_path_worktree', 'off_task_wandering']) {
430
+ for (const t of ['guess_and_retry_api', 'heavyweight_pipeline_on_trivial_change', 'stale_test_red_herring', 'repeated_tool_failures', 'empty_diff_grade', 'claim_consumed_on_fail', 'wrong_path_worktree', 'off_task_wandering', 'unplain_speech']) {
380
431
  assert.ok(ev.FINDING_TEMPLATES[t] && ev.FINDING_TEMPLATES[t].title, `template for ${t}`);
381
432
  }
382
433
  });
@@ -0,0 +1,112 @@
1
+ // tests/start_inbox_count.mjs
2
+ //
3
+ // task 1003454 — /builder-start's "N ideas in inbox" figure must be the inbox's
4
+ // OPEN COUNT, not the length of the page it happened to be served.
5
+ //
6
+ // GET /inbox answers { ideas, open_count }: `ideas` is a page (default 50 rows)
7
+ // and `open_count` is the real total, counted server-side under the same rank
8
+ // gate as the rows. start.js read `ideas.length`, so the card reported the page
9
+ // size — measured live at "50 ideas in inbox" against an open_count of 99, and
10
+ // pinned at exactly 50 for any queue larger than one page.
11
+ //
12
+ // Exercises the pure helper start.js exports, DB-free — no live server, mirroring
13
+ // start_rebase_warning.mjs. The invariants under test:
14
+ // - open_count wins whenever it is present, INCLUDING when it disagrees with
15
+ // the page length (the regression's exact shape)
16
+ // - a queue larger than one page reports honestly, not the page size
17
+ // - 0 is a real answer, not a missing one (the falsy trap)
18
+ // - the page length survives as a fallback for a core predating open_count
19
+ // - an unreadable/failed response is null, which renders as "ideas: N/A"
20
+ //
21
+ // Run: node tests/start_inbox_count.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+ import fs from 'node:fs';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const START_PATH = require.resolve('../scripts/gds/start.js');
29
+ const { inboxOpenCount } = require(START_PATH);
30
+
31
+ let passed = 0;
32
+ let failed = 0;
33
+ function t(name, fn) {
34
+ try { fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ t('the helper is exported (the test cannot silently cover nothing)', () => {
39
+ assert.equal(typeof inboxOpenCount, 'function');
40
+ });
41
+
42
+ // --- the regression itself -------------------------------------------------
43
+
44
+ t('open_count wins over the page length — the measured 50-vs-99 case', () => {
45
+ const res = { ok: true, data: { ideas: new Array(50).fill({}), open_count: 99 } };
46
+ assert.equal(inboxOpenCount(res), 99, 'must report the real total, not the page');
47
+ });
48
+
49
+ t('a queue larger than one page does not pin at the page size', () => {
50
+ // The old code returned 50 for every one of these; the figure never moved.
51
+ for (const total of [51, 99, 250, 1000]) {
52
+ const res = { ok: true, data: { ideas: new Array(50).fill({}), open_count: total } };
53
+ assert.equal(inboxOpenCount(res), total, `expected ${total}`);
54
+ }
55
+ });
56
+
57
+ t('a queue smaller than a page still reads open_count (page length agrees)', () => {
58
+ const res = { ok: true, data: { ideas: new Array(7).fill({}), open_count: 7 } };
59
+ assert.equal(inboxOpenCount(res), 7);
60
+ });
61
+
62
+ t('zero open ideas is a real answer, not a missing one', () => {
63
+ // The falsy trap: `open_count || ideas.length` would fall through to the page
64
+ // here, so an empty inbox and an unreadable one would render identically.
65
+ const res = { ok: true, data: { ideas: [], open_count: 0 } };
66
+ assert.equal(inboxOpenCount(res), 0);
67
+ assert.notEqual(inboxOpenCount(res), null, 'an empty inbox must not render as N/A');
68
+ });
69
+
70
+ // --- the fallbacks ---------------------------------------------------------
71
+
72
+ t('page length is the fallback when open_count is absent (older core)', () => {
73
+ const res = { ok: true, data: { ideas: new Array(12).fill({}) } };
74
+ assert.equal(inboxOpenCount(res), 12);
75
+ });
76
+
77
+ t('a non-numeric open_count does not poison the figure', () => {
78
+ const res = { ok: true, data: { ideas: new Array(4).fill({}), open_count: '99' } };
79
+ assert.equal(inboxOpenCount(res), 4, 'a string count is not a count; fall back to the page');
80
+ });
81
+
82
+ t('the legacy `count` field is still honoured when there are no rows to count', () => {
83
+ const res = { ok: true, data: { count: 5 } };
84
+ assert.equal(inboxOpenCount(res), 5);
85
+ });
86
+
87
+ // --- the null paths (rendered as "ideas: N/A") -----------------------------
88
+
89
+ t('a failed request is null, never 0', () => {
90
+ const res = { ok: false, data: { ideas: new Array(50).fill({}), open_count: 99 } };
91
+ assert.equal(inboxOpenCount(res), null, 'a failed read must not report a figure');
92
+ });
93
+
94
+ t('missing / malformed responses are null, and never throw', () => {
95
+ for (const res of [null, undefined, {}, { ok: true }, { ok: true, data: null },
96
+ { ok: true, data: 'nope' }, { ok: true, data: { ideas: 'nope' } }]) {
97
+ assert.equal(inboxOpenCount(res), null, `expected null for ${JSON.stringify(res)}`);
98
+ }
99
+ });
100
+
101
+ // --- the guard: the bug must not be reintroduced in the caller -------------
102
+
103
+ t('the card derivation calls the helper — no inline ideas.length survives', () => {
104
+ const src = fs.readFileSync(START_PATH, 'utf8');
105
+ assert.match(src, /const inboxCount = inboxOpenCount\(inboxRes\);/,
106
+ 'the header derivation must go through the helper');
107
+ assert.doesNotMatch(src, /inboxRes\.data\?\.ideas\.length/,
108
+ 'no caller may read the page length directly again');
109
+ });
110
+
111
+ console.log(`\nstart_inbox_count: ${passed} passed, ${failed} failed`);
112
+ process.exit(failed ? 1 : 0);