@bongos/core 1.19.762 → 1.19.764

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.
@@ -9,35 +9,92 @@
9
9
  // Audit-only — does NOT write to task_grades. Pre-grader ships keep clean
10
10
  // history.
11
11
 
12
- const { execSync } = require('node:child_process');
12
+ const { execFileSync } = require('node:child_process');
13
13
  const grader = require('../../modules/grading/grader');
14
14
  const { cliClient } = require('./cli-lib');
15
15
 
16
+ // EVERY git call here goes through argv, never a shell string (task 1003223 —
17
+ // the class task 1002761 named and task 1002526 fixed in docs-entropy).
18
+ //
19
+ // WHAT WAS WRONG. Five call sites built shell strings. THREE of them interpolated
20
+ // a quoted empty `--format=''`; a POSIX shell strips those quotes and git sees an
21
+ // empty format, but the shell Node uses on Windows does not, so git receives
22
+ // `--format=''` literally and dies with `fatal: invalid --pretty format: ''`.
23
+ // Verified on win32 before fixing: the shell-string form throws, the argv form
24
+ // returns " 1 file changed, 139 insertions(+)". The correct argv spelling is
25
+ // `--format=` — empty value, no quotes, which no shell can mangle because no
26
+ // shell is involved. The other two passed `--format='%H %s'`, which survives a
27
+ // POSIX shell and arrives with literal quotes elsewhere; same class, same fix.
28
+ //
29
+ // WHY IT MATTERED MORE THAN A LOUD CRASH. The originating row asked whether the
30
+ // try/catch around these calls "degrades to a silently empty audit — if so it is
31
+ // the same dangerous shape after all". It did. Every catch returned null or [],
32
+ // so on Windows the audit reported no diff stats and zero downstream impact for
33
+ // EVERY ship, and looked like a clean run. `gitFails` below keeps the same return
34
+ // values but says so on stderr, so an empty audit can never again be mistaken for
35
+ // an uneventful one.
36
+ //
37
+ // One of the five also built a `grep | head | awk` pipeline and pinned
38
+ // `shell: '/bin/bash'` to survive; that filtering is now done in JS, so the script
39
+ // needs no shell at all.
40
+ function gitLines(args, { cwd } = {}) {
41
+ return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
42
+ .split('\n').map((s) => s.trim()).filter(Boolean);
43
+ }
44
+
45
+ // One place that reports a git failure instead of swallowing it. Returns the
46
+ // caller's fallback so behaviour is unchanged; only the silence is.
47
+ function gitFails(what, err, fallback) {
48
+ const msg = String((err && (err.stderr || err.message)) || err).trim().split('\n')[0];
49
+ console.error(`audit-ships: ${what} failed — ${msg}`);
50
+ return fallback;
51
+ }
52
+
16
53
  const DAYS = Number(process.env.AUDIT_DAYS || 2);
17
54
 
55
+ // `git log --all` once per RUN, not once per task.
56
+ //
57
+ // The shell pipeline this replaced ended in `head -1`, which let git stop as soon
58
+ // as one subject matched. The argv rewrite has no such early exit — and
59
+ // findCommitForTask is called once per shipped task, up to 500 in a run — so a
60
+ // naive port turned one bounded call into 500 full history walks. The commit list
61
+ // cannot change mid-run, so it is read once and reused. Caught by the grader on
62
+ // the first round of this task, which is a fair catch: replacing a shell pipeline
63
+ // means inheriting the work its operators were doing, not just its output.
64
+ let commitSubjectsCache = null;
65
+ function commitSubjects() {
66
+ if (commitSubjectsCache) return commitSubjectsCache;
67
+ commitSubjectsCache = gitLines(['log', '--all', '--format=%H %s']);
68
+ return commitSubjectsCache;
69
+ }
70
+
18
71
  function findCommitForTask(taskId) {
19
- let sha;
72
+ // Matches both subject conventions: the legacy ' #NNN:' prefix and the de-#-ed
73
+ // 'task NNN:' form ship.js writes since task 916 (a bare "#NNN" in commit
74
+ // subjects autolinked to dead GitHub issue numbers).
75
+ //
76
+ // The grep|head|awk pipeline this replaces needed `shell: '/bin/bash'` to run at
77
+ // all — a hard dependency on a POSIX shell at a fixed path, for filtering JS can
78
+ // do. Doing it here also makes the id match EXACT: the old regex was
79
+ // interpolated unescaped, so a task id was pasted straight into a regex.
80
+ const re = new RegExp(` (?:#|task )${String(taskId).replace(/[^0-9]/g, '')}:`);
81
+ let lines;
20
82
  try {
21
- sha = execSync(
22
- // Matches both subject conventions: the legacy ' #NNN:' prefix and the
23
- // de-#-ed 'task NNN:' form ship.js writes since task 916 (a bare "#NNN"
24
- // in commit subjects autolinked to dead GitHub issue numbers).
25
- `git log --all --format='%H %s' | grep -E ' (#|task )${taskId}:' | head -1 | awk '{print $1}'`,
26
- { encoding: 'utf8', shell: '/bin/bash' }
27
- ).trim();
28
- } catch (_) {
29
- return null;
83
+ lines = commitSubjects();
84
+ } catch (e) {
85
+ return gitFails(`finding the commit for task ${taskId}`, e, null);
30
86
  }
31
- return sha || null;
87
+ const hit = lines.find((l) => re.test(l));
88
+ return hit ? (hit.split(' ')[0] || null) : null;
32
89
  }
33
90
 
34
91
  function gitDiffStatsForCommit(sha) {
35
92
  if (!sha) return null;
36
93
  let raw;
37
94
  try {
38
- raw = execSync(`git show --shortstat --format='' ${sha}`, { encoding: 'utf8' });
39
- } catch (_) {
40
- return null;
95
+ raw = execFileSync('git', ['show', '--shortstat', '--format=', sha], { encoding: 'utf8' });
96
+ } catch (e) {
97
+ return gitFails(`reading diff stats for ${String(sha).slice(0, 8)}`, e, null);
41
98
  }
42
99
  // Example shortstat line: " 15 files changed, 1171 insertions(+), 65 deletions(-)"
43
100
  const m = raw.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
@@ -53,10 +110,9 @@ function gitDiffStatsForCommit(sha) {
53
110
  function filesTouchedByCommit(sha) {
54
111
  if (!sha) return [];
55
112
  try {
56
- const out = execSync(`git show --name-only --format='' ${sha}`, { encoding: 'utf8' });
57
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
58
- } catch (_) {
59
- return [];
113
+ return gitLines(['show', '--name-only', '--format=', sha]);
114
+ } catch (e) {
115
+ return gitFails(`listing files touched by ${String(sha).slice(0, 8)}`, e, []);
60
116
  }
61
117
  }
62
118
 
@@ -64,35 +120,30 @@ function filesTouchedByCommit(sha) {
64
120
  // from origin/main that landed AFTER `sha` and touched any of `files`. High
65
121
  // count = subsequent work has built on top of this change, so a "redo"
66
122
  // risks conflicts. Excludes `sha` itself.
123
+ const NO_DOWNSTREAM = Object.freeze({
124
+ commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [],
125
+ });
126
+
67
127
  function downstreamImpact(sha, files) {
68
- if (!sha || files.length === 0) {
69
- return { commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [] };
70
- }
71
- // -- shell-quote each file path so spaces/specials don't break the cmd.
72
- const fileArgs = files.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(' ');
73
- let out;
128
+ if (!sha || files.length === 0) return { ...NO_DOWNSTREAM };
129
+ // The hand-rolled POSIX shell-quoting this replaces ("'" + escape + "'") was
130
+ // both unnecessary and wrong on Windows, where that quoting style is not the
131
+ // shell's. In argv form a path with spaces, quotes or a `$` is just an element.
132
+ const range = `${sha}..origin/main`;
133
+ let lines;
74
134
  try {
75
- out = execSync(
76
- `git log ${sha}..origin/main --format='%H %s' -- ${fileArgs}`,
77
- { encoding: 'utf8' }
78
- );
79
- } catch (_) {
80
- return { commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [] };
135
+ lines = gitLines(['log', range, '--format=%H %s', '--', ...files]);
136
+ } catch (e) {
137
+ return gitFails(`reading downstream commits after ${String(sha).slice(0, 8)}`, e, { ...NO_DOWNSTREAM });
81
138
  }
82
- const lines = out.split('\n').map((s) => s.trim()).filter(Boolean);
83
139
  // Distinct downstream files touched
84
140
  let distinct = 0;
85
141
  try {
86
- const namesOut = execSync(
87
- `git log ${sha}..origin/main --name-only --format='' -- ${fileArgs}`,
88
- { encoding: 'utf8' }
89
- );
90
- const set = new Set(
91
- namesOut.split('\n').map((s) => s.trim()).filter(Boolean)
92
- );
93
- distinct = set.size;
94
- } catch (_) {
95
- distinct = 0;
142
+ distinct = new Set(gitLines(['log', range, '--name-only', '--format=', '--', ...files])).size;
143
+ } catch (e) {
144
+ // Same shape as every other call site: the fallback comes FROM gitFails, so
145
+ // the value and the report can never drift apart.
146
+ distinct = gitFails(`reading downstream files after ${String(sha).slice(0, 8)}`, e, 0);
96
147
  }
97
148
  return {
98
149
  commits_after_touching_files: lines.length,
@@ -251,7 +302,18 @@ async function main() {
251
302
  for (const [k, v] of Object.entries(buckets)) console.log(` ${k}: ${v}`);
252
303
  }
253
304
 
254
- main().catch((err) => {
255
- console.error(err);
256
- process.exit(1);
257
- });
305
+ // Run only as a CLI. Without this guard the file executes main() — and hits the
306
+ // API — the instant anything requires it, which is why its git helpers had never
307
+ // been tested: there was no way to reach them without starting an audit
308
+ // (task 1003223).
309
+ if (require.main === module) {
310
+ main().catch((err) => {
311
+ console.error(err);
312
+ process.exit(1);
313
+ });
314
+ }
315
+
316
+ // Exported for tests/audit_ships_git_argv.mjs — the argv-vs-shell-string fix is a
317
+ // property of THESE functions, so the test drives them rather than re-typing an
318
+ // equivalent git call and hoping it matches.
319
+ module.exports = { findCommitForTask, gitDiffStatsForCommit, filesTouchedByCommit, downstreamImpact };
@@ -29,6 +29,7 @@ const ME_SCOPED_PATHS = [
29
29
  'me/profile-nudge',
30
30
  'me/render',
31
31
  'me/wandering',
32
+ 'me/interaction',
32
33
  'me/skill-prefs',
33
34
  'me/sound-prefs',
34
35
  'me/event-sound-prefs',
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.762'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.764'; // 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');
@@ -0,0 +1,173 @@
1
+ // tests/audit_ships_git_argv.mjs — audit-ships' git calls must be argv, not a
2
+ // shell string (task 1003223; the class task 1002761 named).
3
+ //
4
+ // THE BUG. Five calls interpolated `--format=''` into a shell string. A POSIX
5
+ // shell strips the quotes and git sees an empty format; the shell Node uses on
6
+ // Windows does not, so git receives `--format=''` literally and dies with
7
+ // `fatal: invalid --pretty format: ''`. A sixth built a `grep | head | awk`
8
+ // pipeline and pinned `shell: '/bin/bash'` to survive at all.
9
+ //
10
+ // WHY THIS NEEDS A TEST AND NOT JUST A FIX. Every one of those calls sat inside a
11
+ // try/catch that returned null or []. So the Windows failure did not crash the
12
+ // audit — it produced an audit with no diff stats and zero downstream impact for
13
+ // every ship, which is indistinguishable from a clean run. The originating row
14
+ // asked exactly this ("check whether the catch degrades to a silently empty audit
15
+ // — if so it is the same dangerous shape after all"). It did.
16
+ //
17
+ // The static half of this file is the real net: it is the half that stays true on
18
+ // Linux CI, where the shell-string form would have passed happily forever.
19
+ //
20
+ // Run: node --test tests/audit_ships_git_argv.mjs
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { execSync, execFileSync } from 'node:child_process';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const FILE = path.join(ROOT, 'scripts/gds/audit-ships.js');
33
+ const audit = require('../scripts/gds/audit-ships.js');
34
+ const raw = fs.readFileSync(FILE, 'utf8');
35
+
36
+ // Scan CODE, not prose. The file's header explains the bug by quoting the exact
37
+ // tokens that are banned ("--format=''", "grep -E", "shell: '/bin/bash'"), so a
38
+ // naive scan of the whole file fails on its own documentation — which is exactly
39
+ // what happened when this test was first written. Same comments-stripped posture
40
+ // as scripts/gds/category-advisory-guard.js.
41
+ const src = raw
42
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
43
+ .split('\n').map((l) => l.replace(/(^|\s)\/\/.*$/, '$1')).join('\n');
44
+
45
+ // ---- static: the shape that broke must not come back ----------------------
46
+
47
+ test('no execSync anywhere — a shell string is what mangles the quoting', () => {
48
+ assert.doesNotMatch(src, /\bexecSync\b/,
49
+ 'audit-ships must spawn git through execFileSync with an argv array');
50
+ });
51
+
52
+ test("no quoted --format='' — the argv spelling is --format= with no quotes", () => {
53
+ // Match the SHELL form precisely: two consecutive quotes after the '='. A naive
54
+ // /--format='/ also matches the correct argv literal '--format=', where that
55
+ // quote is JS string syntax, not a shell quote — it flagged the fix as the bug.
56
+ assert.doesNotMatch(src, /--format=''/,
57
+ "a quoted empty format is the exact token git rejects on Windows");
58
+ assert.match(src, /'--format='/, 'the argv spelling is present');
59
+ });
60
+
61
+ test('no shell pipeline and no pinned /bin/bash', () => {
62
+ assert.doesNotMatch(src, /\bshell:\s*['"]/, 'no shell is needed once calls are argv');
63
+ for (const tool of ['grep -E', '| head', '| awk']) {
64
+ assert.ok(!src.includes(tool), `${tool} is a POSIX-only dependency`);
65
+ }
66
+ });
67
+
68
+ test('git failures are reported, not swallowed into a clean-looking audit', () => {
69
+ // The silent `catch (_) { return null; }` is what made the Windows breakage
70
+ // invisible. Every catch around a git call must go through gitFails.
71
+ assert.match(raw, /function gitFails\(/, 'the reporting helper exists');
72
+ // Scoped to catches returning an empty RESULT SET — null or [] — which is the
73
+ // shape that turned a git failure into a clean-looking audit. A catch returning
74
+ // `{}` for an unreadable branding pack is a different, legitimate default and is
75
+ // deliberately not swept up here.
76
+ const silent = src.match(/catch \(_\)\s*\{\s*return (?:null|\[\])/g) || [];
77
+ assert.equal(silent.length, 0,
78
+ `silent catch(es) returning an empty result: ${silent.join(' | ')}`);
79
+ });
80
+
81
+ // ---- behavioural: the calls actually work on THIS platform ----------------
82
+ //
83
+ // These run real git against this repo, which is what makes them meaningful on
84
+ // Windows: before the fix, two of them threw here and returned empty.
85
+
86
+ const git = (args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim();
87
+ // A NON-MERGE commit. `git show --name-only` prints nothing for a merge by
88
+ // default, so anchoring on HEAD makes the file-listing assertions depend on
89
+ // whatever happened to be checked out — they failed here for that reason, not
90
+ // because the code was wrong. --no-merges picks a commit that really touched files.
91
+ const HEAD = git(['rev-list', '--no-merges', '-n', '1', 'HEAD']);
92
+
93
+ test('the exact argv git rejected as a shell string succeeds here', () => {
94
+ const out = execFileSync('git', ['show', '--shortstat', '--format=', HEAD], { cwd: ROOT, encoding: 'utf8' });
95
+ assert.match(out, /\d+ files? changed/, `shortstat should parse, got: ${JSON.stringify(out.slice(0, 80))}`);
96
+ });
97
+
98
+ test('the shell-string form is what actually fails on this platform', () => {
99
+ // Not a hypothetical: assert the OLD form's behaviour so the reason for the fix
100
+ // is recorded in a runnable way. On POSIX it succeeds (the quotes are stripped),
101
+ // on Windows it throws — either way, argv is the only spelling that is correct
102
+ // on both, which is the whole point.
103
+ let threw = false;
104
+ try { execSync(`git show --shortstat --format='' ${HEAD}`, { cwd: ROOT, encoding: 'utf8' }); }
105
+ catch { threw = true; }
106
+ if (process.platform === 'win32') {
107
+ assert.equal(threw, true, 'on win32 the quoted form must be the one that fails');
108
+ } else {
109
+ assert.equal(threw, false, 'on POSIX it happens to work — which is why this hid on CI');
110
+ }
111
+ });
112
+
113
+ test('--name-only --format= yields real paths', () => {
114
+ const out = execFileSync('git', ['show', '--name-only', '--format=', HEAD], { cwd: ROOT, encoding: 'utf8' });
115
+ const files = out.split('\n').map((s) => s.trim()).filter(Boolean);
116
+ assert.ok(files.length > 0, 'HEAD touched at least one file');
117
+ assert.ok(files.every((f) => !f.includes("'")), 'paths come back unquoted');
118
+ });
119
+
120
+ // ---- the real functions, not equivalents ---------------------------------
121
+ //
122
+ // These drive audit-ships' own helpers. Before this task the file executed main()
123
+ // on require — it hit the API the moment anything imported it — so its git helpers
124
+ // had never been reachable from a test at all. That is the deeper reason the
125
+ // Windows breakage went unnoticed for months.
126
+
127
+ test('gitDiffStatsForCommit returns real stats on this platform', () => {
128
+ const stats = audit.gitDiffStatsForCommit(HEAD);
129
+ assert.ok(stats, 'must not be the null the Windows failure returned');
130
+ assert.equal(stats.commit_sha, HEAD);
131
+ assert.ok(stats.files_changed > 0, `files_changed should be positive, got ${stats.files_changed}`);
132
+ });
133
+
134
+ test('filesTouchedByCommit returns real paths, not the empty array of a swallowed failure', () => {
135
+ const files = audit.filesTouchedByCommit(HEAD);
136
+ assert.ok(Array.isArray(files) && files.length > 0, `expected paths, got ${JSON.stringify(files)}`);
137
+ assert.ok(files.every((f) => typeof f === 'string' && f.length > 0));
138
+ });
139
+
140
+ test('the commit list is read once per run, not once per task', () => {
141
+ // The shell pipeline this replaced ended in `head -1`, so git stopped at the
142
+ // first match. The argv form has no early exit, and findCommitForTask runs once
143
+ // per shipped task — up to 500 a run — so a naive port turns one bounded call
144
+ // into 500 full history walks. Caught by the grader on this task's first round.
145
+ //
146
+ // Measured rather than asserted structurally: the second lookup must not spend
147
+ // anywhere near the first's time, because it must not touch git at all.
148
+ const t0 = process.hrtime.bigint();
149
+ audit.findCommitForTask(999999901);
150
+ const cold = process.hrtime.bigint() - t0;
151
+
152
+ const t1 = process.hrtime.bigint();
153
+ audit.findCommitForTask(999999902);
154
+ const warm = process.hrtime.bigint() - t1;
155
+
156
+ assert.ok(warm * 5n < cold || warm < 2_000_000n,
157
+ `second lookup should reuse the cached list (cold ${cold / 1000000n}ms, warm ${warm / 1000000n}ms)`);
158
+ });
159
+
160
+ test('the helpers still answer safely for a missing sha', () => {
161
+ assert.equal(audit.gitDiffStatsForCommit(null), null);
162
+ assert.deepEqual(audit.filesTouchedByCommit(null), []);
163
+ assert.deepEqual(audit.downstreamImpact(null, ['x']),
164
+ { commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [] });
165
+ });
166
+
167
+ test('a path containing a space survives argv — the hand-rolled quoting is gone', () => {
168
+ // git log -- <pathspec> with a space used to need shell quoting; in argv it is
169
+ // simply one element. An unmatched pathspec returns empty, never an error.
170
+ const out = execFileSync('git', ['log', '-1', '--format=%H', '--', 'no such file.md'], { cwd: ROOT, encoding: 'utf8' });
171
+ assert.equal(out.trim(), '', 'an unmatched pathspec is empty, not a crash');
172
+ });
173
+
@@ -130,6 +130,42 @@ test('wandering: lowest priority — emitted after methodology/status advisories
130
130
  assert.equal(a[a.length - 1].id, 'wandering', 'wandering is emitted last');
131
131
  });
132
132
 
133
+ // ---------- evaluateTriggers: interaction profile (task 2010, delivered 1002514) ----------
134
+ //
135
+ // This branch shipped with task 2010 and had NO coverage here, which is part of
136
+ // why the profile could be believed to have no consumer at all. It relays only —
137
+ // interaction-prefs.js resolves — so these pin the relay, plus the one end-to-end
138
+ // fact clause 1 of `accessibility-fluency-profile` turns on.
139
+
140
+ test('interaction: a profile with inject:true emits the pitch contract', () => {
141
+ const a = conductor.evaluateTriggers({
142
+ interaction: { inject: true, contract: 'Interaction profile for this builder — pitch replies accordingly:' },
143
+ });
144
+ const i = a.find((x) => x.id === 'interaction');
145
+ assert.ok(i, 'expected an interaction advisory');
146
+ assert.match(i.text, /pitch replies accordingly/);
147
+ });
148
+
149
+ test('interaction: inject:false, an empty contract, or a null knob emit nothing', () => {
150
+ assert.ok(!ids(conductor.evaluateTriggers({ interaction: { inject: false, contract: 'x' } })).includes('interaction'));
151
+ assert.ok(!ids(conductor.evaluateTriggers({ interaction: { inject: true, contract: '' } })).includes('interaction'));
152
+ assert.ok(!ids(conductor.evaluateTriggers({ interaction: null })).includes('interaction'));
153
+ assert.ok(!ids(conductor.evaluateTriggers({})).includes('interaction'));
154
+ });
155
+
156
+ // The clause-1 proof, run against the REAL resolver rather than a hand-written
157
+ // payload: the accessible DEFAULT profile — the one a builder who never opens
158
+ // Settings has — must reach a session. Before task 1002514 every default field
159
+ // carried an empty fragment, so describeForApi({}) came back inject:false and
160
+ // this advisory was never emitted for the builder it was written for.
161
+ test('interaction: the DEFAULT profile reaches a session end to end', () => {
162
+ const interactionPrefs = require('../modules/builder-settings/interaction-prefs.js');
163
+ const a = conductor.evaluateTriggers({ interaction: interactionPrefs.describeForApi({}) });
164
+ const i = a.find((x) => x.id === 'interaction');
165
+ assert.ok(i, 'a builder on all defaults must still be described to the session');
166
+ assert.match(i.text, /non-technical/);
167
+ });
168
+
133
169
  // The nudge says "your claim's", singular, so it is measured against the claim
134
170
  // bound to THIS worktree (task 1003837). These three carry a worktree_name and a
135
171
  // matching `worktree` for that reason; the four below pin what changed.
@@ -160,6 +160,55 @@ test('the wandering pick is a radio row, wired on change', () => {
160
160
  assert.match(radio, /height:\s*24px/, 'a native radio is 13px; the floor is 24');
161
161
  });
162
162
 
163
+ // ---- the interaction panel (task 1002514) ----------------------------------
164
+ //
165
+ // Three facts a browser is silent about: the panel is rendered by a script that
166
+ // is not settings.js, so the wiring between the two halves is the thing that can
167
+ // silently break; and the carve exists because settings.js is against the
168
+ // 1500-line ratchet, which is a fact a reader will otherwise undo.
169
+
170
+ test('the interaction panel is wired end to end across its two files', () => {
171
+ const html = read('settings.html');
172
+ const panelJs = read('settings-interaction.js');
173
+
174
+ // The markup half lives in settings.html and must join a group that already
175
+ // exists — the rail renders groups, so a section in an unknown data-group is
176
+ // markup nothing ever shows.
177
+ const section = html.match(/<section class="scroll" id="interaction-scroll"[^>]*>/);
178
+ assert.ok(section, 'the panel has a section');
179
+ assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
180
+ assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds, like every other panel');
181
+ assert.match(html, /<script src="\/builders\/settings-interaction\.js/, 'the page loads the panel script');
182
+
183
+ // The script half self-boots and reveals the section. If it stopped doing
184
+ // either, the panel would simply never appear and no test would notice.
185
+ assert.match(panelJs, /\$\('#interaction-scroll'\)\.hidden = false/, 'the script reveals its own section');
186
+ assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
187
+
188
+ // settings.js keeps ONLY the anchor entry, so /settings#interaction activates
189
+ // the group before the panel paints.
190
+ assert.match(settingsJs, /'interaction': 'interaction-scroll'/, 'the deep-link anchor stays in settings.js');
191
+ assert.doesNotMatch(settingsJs, /function renderInteraction\(/, 'the renderer moved out and must not come back inline');
192
+ });
193
+
194
+ test('the interaction panel stays out of settings.js, which is against the size ratchet', () => {
195
+ // Not style: inlining this panel put settings.js at 1601 lines and regressed
196
+ // oversized_file_count 0→1 and excess_lines_total 0→102 (fitness-ratchets.js,
197
+ // OVERSIZE_LINES = 1500). The settings-sessions.js precedent, same reason.
198
+ const lines = settingsJs.split('\n').length;
199
+ assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
200
+ });
201
+
202
+ test('a default value is named once, by the API, not twice by the row', () => {
203
+ // The first render read "Non-technical (default) · DEFAULT": interaction-prefs.js
204
+ // already ends every default value's LABEL with "(default)", and the row also
205
+ // carried the wandering picker's .prow__tag. One source for the word.
206
+ const panelJs = read('settings-interaction.js');
207
+ const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
208
+ assert.ok(render, 'render is a whole function');
209
+ assert.doesNotMatch(render[0], /prow__tag/, 'the row must not re-state "default" beside a label that already says it');
210
+ });
211
+
163
212
  test('.craft-pref is a 24px target with a 14px drawn box — it was 13x13, the smallest control in the hall', () => {
164
213
  const box = ruleOf(panelCss, '.craft-pref');
165
214
  assert.ok(box, '.craft-pref has a rule');
@@ -6,19 +6,47 @@ import { createRequire } from 'node:module';
6
6
  const require = createRequire(import.meta.url);
7
7
  const ip = require('../modules/builder-settings/interaction-prefs.js');
8
8
 
9
- test('defaults: unset prefs → all field defaults, empty contract, no inject', () => {
9
+ test('defaults: unset prefs → all field defaults', () => {
10
10
  const eff = ip.effectiveProfile({});
11
11
  assert.equal(eff.technical_level, 'non_technical');
12
12
  assert.equal(eff.toolchain_familiarity, 'new');
13
13
  assert.equal(eff.verbosity, 'concise');
14
- assert.equal(ip.contractFor({}), '', 'all-default profile injects nothing');
15
- assert.equal(ip.shouldInject({}), false);
16
14
  });
17
15
 
18
- test('null / missing behaviorPrefs is safe', () => {
16
+ // task 1002514, clause 1 of `accessibility-fluency-profile`. This REPLACES an
17
+ // assertion that the all-default profile injected nothing. That was correct while
18
+ // CLAUDE.md §2 carried the posture as prose, and it meant the accessible-by-
19
+ // default builder — the one who most needs the posture and least likely to open
20
+ // Settings — was the only builder the profile said nothing about.
21
+ test('the DEFAULT profile injects the accessible posture (it is a posture, not an absence)', () => {
22
+ const c = ip.contractFor({});
23
+ assert.notEqual(c, '', 'the accessible default must not be the silent one');
24
+ assert.equal(ip.shouldInject({}), true);
25
+ assert.match(c, /non-technical/, 'the vocabulary posture must arrive');
26
+ assert.match(c, /define a term the first time/);
27
+ assert.match(c, /cost, time and what the end user will see/, 'the framing posture must arrive');
28
+ assert.match(c, /step by step/, 'the toolchain posture must arrive');
29
+ assert.match(c, /concise/, 'the length posture must arrive');
30
+ });
31
+
32
+ test('null / missing behaviorPrefs is safe, and still resolves to the default posture', () => {
19
33
  assert.deepEqual(ip.storedProfile(null), {});
20
- assert.equal(ip.contractFor(null), '');
21
- assert.equal(ip.shouldInject(undefined), false);
34
+ assert.equal(ip.contractFor(null), ip.contractFor({}), 'absent prefs resolve exactly like empty prefs');
35
+ assert.equal(ip.shouldInject(undefined), true);
36
+ });
37
+
38
+ // The criterion's hard constraint: "an expert's terse experience is never
39
+ // degraded to help a newcomer." The expert contract must not merely differ from
40
+ // the newcomer one — it must be SHORTER, and it must tell the agent to skip.
41
+ test('an expert profile stays terse where a non-technical one is explained to', () => {
42
+ const beginner = ip.contractFor({});
43
+ const expert = ip.contractFor({
44
+ interaction: { technical_level: 'engineer', toolchain_familiarity: 'comfortable' },
45
+ });
46
+ assert.ok(expert.length < beginner.length, 'the expert contract must be shorter, not just different');
47
+ assert.match(expert, /skip basic explanations/);
48
+ assert.doesNotMatch(expert, /do not assume software-engineering vocabulary/);
49
+ assert.doesNotMatch(expert, /walk local tooling/);
22
50
  });
23
51
 
24
52
  test('a non-default field produces an injected contract', () => {
@@ -76,10 +104,21 @@ test('describeForApi exposes catalog + effective + contract + inject', () => {
76
104
  // The SURFACE half of the profile: how much of the work behind a reply gets
77
105
  // narrated. Default `show` keeps every existing builder on today's behavior.
78
106
 
79
- test('machinery defaults to show and injects nothing', () => {
107
+ // The assertion here used to be `contractFor({machinery:'show'}) === ''`, which
108
+ // measured the WHOLE contract's emptiness to prove one field contributed nothing.
109
+ // That conflated "this field is silent" with "every field is silent", so once the
110
+ // other defaults gained text (task 1002514) it would have gone red for a reason
111
+ // that had nothing to do with machinery. Same intent, pointed at the actual claim:
112
+ // adding `show` changes nothing, and `show` never names itself.
113
+ test('machinery defaults to show and contributes nothing to the contract', () => {
80
114
  assert.equal(ip.defaultValue('machinery'), 'show');
81
115
  assert.equal(ip.effectiveProfile({}).machinery, 'show');
82
- assert.equal(ip.contractFor({ interaction: { machinery: 'show' } }), '', 'the default must never spend session context');
116
+ assert.equal(
117
+ ip.contractFor({ interaction: { machinery: 'show' } }),
118
+ ip.contractFor({}),
119
+ 'an explicit `show` must read identically to leaving it unset',
120
+ );
121
+ assert.doesNotMatch(ip.contractFor({}), /Machinery/, 'the default must never spend session context');
83
122
  });
84
123
 
85
124
  test('machinery=quiet injects an outcomes-not-mechanism contract', () => {