cyborg-hunter 0.7.0 → 0.7.3

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,211 @@
1
+ // src/cli/renderers/typing-profile-core.js
2
+ //
3
+ // Pure drawing core for per-participant typing-speed bar-chart images — no
4
+ // Node APIs, so a browser demo can bundle it directly (0.7.2-style
5
+ // extraction from cli/renderers/typing-profile.js, which is now a thin fs
6
+ // wrapper around this module: it acquires node-canvas, calls
7
+ // drawTypingProfile per participant, and writes the returned canvas to a
8
+ // PNG). Mirrors the session-timeline-core.js / trajectories-core.js split.
9
+ //
10
+ // drawTypingProfile(p, config, createCanvas) draws one participant's typing
11
+ // speed bar chart — one bar per trial's chars/sec, with a horizontal
12
+ // threshold line and bars above threshold colored red — and returns the
13
+ // drawn canvas (or null if there's nothing to draw: no trials, or no trial
14
+ // has either a typed speed or a paste event). createCanvas is injected so
15
+ // this module never imports the `canvas` package directly — a browser
16
+ // caller can pass a document-canvas-backed factory instead.
17
+
18
+ const BAR_WIDTH = 30;
19
+ const BAR_GAP = 8;
20
+ const CHART_HEIGHT = 200;
21
+ const TOP_PAD = 50;
22
+ const BOTTOM_PAD = 50;
23
+ const LEFT_PAD = 60;
24
+
25
+ // ── Per-participant renderer ─────────────────────────────────────────────
26
+ // Returns the drawn canvas, or null if there's no data to draw (caller
27
+ // decides what "no data" means — the fs wrapper skips the PNG write).
28
+ export function drawTypingProfile(p, config, createCanvas) {
29
+ const trials = p.trials;
30
+ if (trials.length === 0) return null;
31
+
32
+ // Per-participant threshold line: prefer the typing-speed cutoff the library
33
+ // actually screened this participant with (saved in session.config.thresholds)
34
+ // so the red "fast typing" line matches summary.csv's count; fall back to the
35
+ // CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
36
+ const threshold =
37
+ p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
38
+
39
+ // Per-trial state. We distinguish three cases:
40
+ // "typed" — charsPerSec is a real number, draw a normal bar
41
+ // "paste-only" — charsPerSec is null but there's a paste event; draw a
42
+ // grey hatched marker so the absence is legible. Single
43
+ // paste fires one input event → editTimestamps.length < 2
44
+ // → computeTypingSpeed returns null. Without this, a
45
+ // pasted response looks identical to a very slow one.
46
+ // "no-data" — neither, leave blank
47
+ const kinds = trials.map(t => {
48
+ if (typeof t.charsPerSec === 'number') return 'typed';
49
+ if ((t.pasteEvents || []).length > 0) return 'paste-only';
50
+ return 'no-data';
51
+ });
52
+ const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
53
+ if (kinds.every(k => k === 'no-data')) return null;
54
+
55
+ const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
56
+ const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
57
+ const canvas = createCanvas(canvasW, canvasH);
58
+ const ctx = canvas.getContext('2d');
59
+
60
+ // Background
61
+ ctx.fillStyle = '#ffffff';
62
+ ctx.fillRect(0, 0, canvasW, canvasH);
63
+
64
+ // Title
65
+ ctx.fillStyle = '#333';
66
+ ctx.font = 'bold 14px sans-serif';
67
+ ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
68
+ ctx.font = '10px sans-serif';
69
+ ctx.fillStyle = '#666';
70
+ ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
71
+
72
+ // Legend — three states documented in the `kinds` comment above.
73
+ const legendY = 35;
74
+ const legendX0 = LEFT_PAD + 180;
75
+ ctx.fillStyle = '#2196F3';
76
+ ctx.fillRect(legendX0, legendY - 8, 12, 10);
77
+ ctx.fillStyle = '#666';
78
+ ctx.fillText('typed', legendX0 + 16, legendY);
79
+ ctx.fillStyle = '#f44336';
80
+ ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
81
+ ctx.fillStyle = '#666';
82
+ ctx.fillText('> threshold', legendX0 + 76, legendY);
83
+ // Hatched swatch for paste-only
84
+ drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
85
+ ctx.fillStyle = '#666';
86
+ ctx.fillText('paste (P)', legendX0 + 166, legendY);
87
+
88
+ // Y-axis scaling
89
+ const maxSpeed = Math.max(...speeds, threshold * 1.5);
90
+ const yScale = CHART_HEIGHT / maxSpeed;
91
+
92
+ // Y-axis labels
93
+ ctx.fillStyle = '#666';
94
+ ctx.font = '9px sans-serif';
95
+ const yTicks = 5;
96
+ for (let t = 0; t <= yTicks; t++) {
97
+ const val = (maxSpeed / yTicks) * t;
98
+ const y = TOP_PAD + CHART_HEIGHT - val * yScale;
99
+ ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
100
+ // Grid line
101
+ ctx.strokeStyle = '#eee';
102
+ ctx.lineWidth = 0.5;
103
+ ctx.beginPath();
104
+ ctx.moveTo(LEFT_PAD, y);
105
+ ctx.lineTo(canvasW - 10, y);
106
+ ctx.stroke();
107
+ }
108
+
109
+ // Threshold line
110
+ const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
111
+ ctx.strokeStyle = '#ff0000';
112
+ ctx.lineWidth = 1.5;
113
+ ctx.setLineDash([5, 3]);
114
+ ctx.beginPath();
115
+ ctx.moveTo(LEFT_PAD, threshY);
116
+ ctx.lineTo(canvasW - 10, threshY);
117
+ ctx.stroke();
118
+ ctx.setLineDash([]);
119
+
120
+ // Bars
121
+ const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
122
+ for (let i = 0; i < trials.length; i++) {
123
+ const kind = kinds[i];
124
+ const speed = speeds[i];
125
+ const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
126
+
127
+ if (kind === 'paste-only') {
128
+ // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
129
+ // — the point is visible presence, not magnitude (we have no CPS to
130
+ // show). Sits at the bottom like a normal bar.
131
+ const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
132
+ drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
133
+ ctx.strokeStyle = '#888';
134
+ ctx.lineWidth = 0.5;
135
+ ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
136
+ // "P" marker above the bar
137
+ ctx.fillStyle = '#555';
138
+ ctx.font = 'bold 11px sans-serif';
139
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
140
+ } else if (kind === 'typed') {
141
+ const barH = speed * yScale;
142
+ const y = TOP_PAD + CHART_HEIGHT - barH;
143
+ ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
144
+ ctx.fillRect(x, y, BAR_WIDTH, barH);
145
+ ctx.strokeStyle = '#333';
146
+ ctx.lineWidth = 0.5;
147
+ ctx.strokeRect(x, y, BAR_WIDTH, barH);
148
+ if (speed > 0) {
149
+ ctx.fillStyle = '#fff';
150
+ ctx.font = 'bold 9px sans-serif';
151
+ ctx.fillText(speed.toFixed(1), x + 3, y + 12);
152
+ }
153
+ // Mixed: typed AND pasted in the same trial. The bar shows the typing
154
+ // rate (legitimate signal); the "P" glyph above it flags that *some*
155
+ // of the response came in via paste. Distinct from pure paste-only
156
+ // (which is a hatched bar, no colored fill).
157
+ if ((trials[i].pasteEvents || []).length > 0) {
158
+ ctx.fillStyle = '#555';
159
+ ctx.font = 'bold 11px sans-serif';
160
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
161
+ }
162
+ }
163
+ // 'no-data' → render nothing (blank column)
164
+
165
+ // Trial label (always drawn, even for blank columns)
166
+ const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
167
+ ctx.fillStyle = '#333';
168
+ ctx.font = '8px sans-serif';
169
+ ctx.save();
170
+ ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
171
+ ctx.rotate(Math.PI / 4);
172
+ ctx.fillText(trialId.substring(0, 12), 0, 0);
173
+ ctx.restore();
174
+ }
175
+
176
+ // Baseline
177
+ ctx.strokeStyle = '#333';
178
+ ctx.lineWidth = 1;
179
+ ctx.beginPath();
180
+ ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
181
+ ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
182
+ ctx.stroke();
183
+
184
+ return canvas;
185
+ }
186
+
187
+ // Cross-hatched fill for paste-only markers. Uses a light grey background
188
+ // with diagonal strokes so the bar reads as "distinct category, not zero".
189
+ function drawHatchedRect(ctx, x, y, w, h) {
190
+ ctx.save();
191
+ ctx.fillStyle = '#e8e8e8';
192
+ ctx.fillRect(x, y, w, h);
193
+ ctx.beginPath();
194
+ ctx.rect(x, y, w, h);
195
+ ctx.clip();
196
+ ctx.strokeStyle = '#888';
197
+ ctx.lineWidth = 0.8;
198
+ const step = 5;
199
+ for (let d = -h; d < w + h; d += step) {
200
+ ctx.beginPath();
201
+ ctx.moveTo(x + d, y);
202
+ ctx.lineTo(x + d + h, y + h);
203
+ ctx.stroke();
204
+ }
205
+ ctx.restore();
206
+ }
207
+
208
+ // Used by the fs wrapper to build the per-participant PNG filename.
209
+ export function sanitize(name) {
210
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
211
+ }
@@ -1,177 +1,26 @@
1
1
  // src/cli/renderers/typing-profile.js
2
- // Renders per-participant typing speed bar chart using node-canvas.
3
- // Each bar = one trial's chars/sec. A horizontal threshold line shows
4
- // the configured CPS threshold. Bars above threshold are colored red.
5
-
2
+ // Thin fs wrapper around the pure drawing core (typing-profile-core.js).
3
+ // The core takes an injected createCanvas and returns the drawn canvas (or
4
+ // null when there's nothing to draw); this wrapper owns node-canvas
5
+ // acquisition and PNG serialization so the core can also run in a browser
6
+ // context (demo plot adapter) with document canvases.
6
7
  import { writeFileSync } from 'fs';
7
8
  import { join } from 'path';
8
-
9
- const BAR_WIDTH = 30;
10
- const BAR_GAP = 8;
11
- const CHART_HEIGHT = 200;
12
- const TOP_PAD = 50;
13
- const BOTTOM_PAD = 50;
14
- const LEFT_PAD = 60;
15
-
9
+ import { drawTypingProfile, sanitize } from './typing-profile-core.js';
10
+ // Load-bearing re-export: drawTypingProfile serves this wrapper's own loop
11
+ // below and the future browser demo plot adapter — mirrors
12
+ // session-timeline.js / trajectories.js. (renderTypingProfiles remains the
13
+ // only symbol external consumers import from this file — see
14
+ // tests/cli/renderers.test.js and src/cli/report.js.)
15
+ export { drawTypingProfile };
16
+
17
+ // ── Public API ───────────────────────────────────────────────────────────
16
18
  export async function renderTypingProfiles(participants, config) {
17
19
  const { createCanvas } = await import('canvas');
18
20
 
19
21
  for (const p of participants) {
20
- const trials = p.trials;
21
- if (trials.length === 0) continue;
22
-
23
- // Per-participant threshold line: prefer the typing-speed cutoff the library
24
- // actually screened this participant with (saved in session.config.thresholds)
25
- // so the red "fast typing" line matches summary.csv's count; fall back to the
26
- // CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
27
- const threshold =
28
- p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
29
-
30
- // Per-trial state. We distinguish three cases:
31
- // "typed" — charsPerSec is a real number, draw a normal bar
32
- // "paste-only" — charsPerSec is null but there's a paste event; draw a
33
- // grey hatched marker so the absence is legible. Single
34
- // paste fires one input event → editTimestamps.length < 2
35
- // → computeTypingSpeed returns null. Without this, a
36
- // pasted response looks identical to a very slow one.
37
- // "no-data" — neither, leave blank
38
- const kinds = trials.map(t => {
39
- if (typeof t.charsPerSec === 'number') return 'typed';
40
- if ((t.pasteEvents || []).length > 0) return 'paste-only';
41
- return 'no-data';
42
- });
43
- const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
44
- if (kinds.every(k => k === 'no-data')) continue;
45
-
46
- const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
47
- const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
48
- const canvas = createCanvas(canvasW, canvasH);
49
- const ctx = canvas.getContext('2d');
50
-
51
- // Background
52
- ctx.fillStyle = '#ffffff';
53
- ctx.fillRect(0, 0, canvasW, canvasH);
54
-
55
- // Title
56
- ctx.fillStyle = '#333';
57
- ctx.font = 'bold 14px sans-serif';
58
- ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
59
- ctx.font = '10px sans-serif';
60
- ctx.fillStyle = '#666';
61
- ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
62
-
63
- // Legend — three states documented in the `kinds` comment above.
64
- const legendY = 35;
65
- const legendX0 = LEFT_PAD + 180;
66
- ctx.fillStyle = '#2196F3';
67
- ctx.fillRect(legendX0, legendY - 8, 12, 10);
68
- ctx.fillStyle = '#666';
69
- ctx.fillText('typed', legendX0 + 16, legendY);
70
- ctx.fillStyle = '#f44336';
71
- ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
72
- ctx.fillStyle = '#666';
73
- ctx.fillText('> threshold', legendX0 + 76, legendY);
74
- // Hatched swatch for paste-only
75
- drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
76
- ctx.fillStyle = '#666';
77
- ctx.fillText('paste (P)', legendX0 + 166, legendY);
78
-
79
- // Y-axis scaling
80
- const maxSpeed = Math.max(...speeds, threshold * 1.5);
81
- const yScale = CHART_HEIGHT / maxSpeed;
82
-
83
- // Y-axis labels
84
- ctx.fillStyle = '#666';
85
- ctx.font = '9px sans-serif';
86
- const yTicks = 5;
87
- for (let t = 0; t <= yTicks; t++) {
88
- const val = (maxSpeed / yTicks) * t;
89
- const y = TOP_PAD + CHART_HEIGHT - val * yScale;
90
- ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
91
- // Grid line
92
- ctx.strokeStyle = '#eee';
93
- ctx.lineWidth = 0.5;
94
- ctx.beginPath();
95
- ctx.moveTo(LEFT_PAD, y);
96
- ctx.lineTo(canvasW - 10, y);
97
- ctx.stroke();
98
- }
99
-
100
- // Threshold line
101
- const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
102
- ctx.strokeStyle = '#ff0000';
103
- ctx.lineWidth = 1.5;
104
- ctx.setLineDash([5, 3]);
105
- ctx.beginPath();
106
- ctx.moveTo(LEFT_PAD, threshY);
107
- ctx.lineTo(canvasW - 10, threshY);
108
- ctx.stroke();
109
- ctx.setLineDash([]);
110
-
111
- // Bars
112
- const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
113
- for (let i = 0; i < trials.length; i++) {
114
- const kind = kinds[i];
115
- const speed = speeds[i];
116
- const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
117
-
118
- if (kind === 'paste-only') {
119
- // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
120
- // — the point is visible presence, not magnitude (we have no CPS to
121
- // show). Sits at the bottom like a normal bar.
122
- const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
123
- drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
124
- ctx.strokeStyle = '#888';
125
- ctx.lineWidth = 0.5;
126
- ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
127
- // "P" marker above the bar
128
- ctx.fillStyle = '#555';
129
- ctx.font = 'bold 11px sans-serif';
130
- ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
131
- } else if (kind === 'typed') {
132
- const barH = speed * yScale;
133
- const y = TOP_PAD + CHART_HEIGHT - barH;
134
- ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
135
- ctx.fillRect(x, y, BAR_WIDTH, barH);
136
- ctx.strokeStyle = '#333';
137
- ctx.lineWidth = 0.5;
138
- ctx.strokeRect(x, y, BAR_WIDTH, barH);
139
- if (speed > 0) {
140
- ctx.fillStyle = '#fff';
141
- ctx.font = 'bold 9px sans-serif';
142
- ctx.fillText(speed.toFixed(1), x + 3, y + 12);
143
- }
144
- // Mixed: typed AND pasted in the same trial. The bar shows the typing
145
- // rate (legitimate signal); the "P" glyph above it flags that *some*
146
- // of the response came in via paste. Distinct from pure paste-only
147
- // (which is a hatched bar, no colored fill).
148
- if ((trials[i].pasteEvents || []).length > 0) {
149
- ctx.fillStyle = '#555';
150
- ctx.font = 'bold 11px sans-serif';
151
- ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
152
- }
153
- }
154
- // 'no-data' → render nothing (blank column)
155
-
156
- // Trial label (always drawn, even for blank columns)
157
- const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
158
- ctx.fillStyle = '#333';
159
- ctx.font = '8px sans-serif';
160
- ctx.save();
161
- ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
162
- ctx.rotate(Math.PI / 4);
163
- ctx.fillText(trialId.substring(0, 12), 0, 0);
164
- ctx.restore();
165
- }
166
-
167
- // Baseline
168
- ctx.strokeStyle = '#333';
169
- ctx.lineWidth = 1;
170
- ctx.beginPath();
171
- ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
172
- ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
173
- ctx.stroke();
174
-
22
+ const canvas = drawTypingProfile(p, config, createCanvas);
23
+ if (!canvas) continue;
175
24
  const buf = canvas.toBuffer('image/png');
176
25
  const filename = `typing_profile_${sanitize(p.participantId)}.png`;
177
26
  writeFileSync(join(config.outputDir, 'images', filename), buf);
@@ -179,28 +28,3 @@ export async function renderTypingProfiles(participants, config) {
179
28
 
180
29
  console.log(` typing profiles — rendered`);
181
30
  }
182
-
183
- function sanitize(name) {
184
- return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
185
- }
186
-
187
- // Cross-hatched fill for paste-only markers. Uses a light grey background
188
- // with diagonal strokes so the bar reads as "distinct category, not zero".
189
- function drawHatchedRect(ctx, x, y, w, h) {
190
- ctx.save();
191
- ctx.fillStyle = '#e8e8e8';
192
- ctx.fillRect(x, y, w, h);
193
- ctx.beginPath();
194
- ctx.rect(x, y, w, h);
195
- ctx.clip();
196
- ctx.strokeStyle = '#888';
197
- ctx.lineWidth = 0.8;
198
- const step = 5;
199
- for (let d = -h; d < w + h; d += step) {
200
- ctx.beginPath();
201
- ctx.moveTo(x + d, y);
202
- ctx.lineTo(x + d + h, y + h);
203
- ctx.stroke();
204
- }
205
- ctx.restore();
206
- }
package/src/cli/report.js CHANGED
@@ -14,10 +14,15 @@ import { detectEdgeExits } from './analyzers/edge-exit.js';
14
14
  import { rankTriage } from './analyzers/triage.js';
15
15
  import { applyPhaseScope, describePhaseScope, findUnmatchedPhaseScopePhases } from './analyzers/phase-scope.js';
16
16
  import { VERSION } from '../shared/constants.js';
17
+ import { checkForUpdate, formatUpdateNotice, formatCollectedVersionNotice } from './update-check.js';
17
18
 
18
19
  export async function run(args) {
19
20
  console.log(`cyborg-hunter report v${VERSION}\n`);
20
21
 
22
+ // Kick the update check off first so the registry request overlaps the
23
+ // whole pipeline; awaited (bounded by its own short timeout) at the end.
24
+ const updateCheck = checkForUpdate({ currentVersion: VERSION }).catch(() => null);
25
+
21
26
  // 1. Load and merge config (defaults ← file ← CLI flags)
22
27
  const config = loadConfig(args);
23
28
 
@@ -45,6 +50,13 @@ export async function run(args) {
45
50
  process.exit(1);
46
51
  }
47
52
 
53
+ // Offline staleness note: payloads stamp the library version that collected
54
+ // them, so an experiment still serving an old bundle is visible without any
55
+ // network. (The registry check above covers the CLI side.)
56
+ const collectedNotice = formatCollectedVersionNotice(
57
+ participants.map(p => p.libraryVersion), VERSION);
58
+ if (collectedNotice) console.log(collectedNotice);
59
+
48
60
  // 3. Analyze — compute summaries, detect edge exits, rank by triage priority.
49
61
  // config.phaseScope (0.6.1) filters which trials feed the analyzers so
50
62
  // scores can honor pre-registered phase scoping; renderers below still get
@@ -145,4 +157,9 @@ export async function run(args) {
145
157
 
146
158
  console.log(`\nReport written to ${config.outputDir}/`);
147
159
  console.log(` Open ${config.outputDir}/index.html to review`);
160
+
161
+ const update = await updateCheck;
162
+ if (update && update.updateAvailable) {
163
+ console.log(`\n${formatUpdateNotice(VERSION, update.latest)}`);
164
+ }
148
165
  }
@@ -0,0 +1,110 @@
1
+ // src/cli/update-check.js
2
+ // Post-report update nudge, plus the offline collected-with version notice.
3
+ //
4
+ // The registry check is deliberately zero-dependency and fail-silent: one
5
+ // request to npm's registry for the `latest` dist-tag, throttled to once per
6
+ // day via a small cache file. A report run must never break, slow down, or
7
+ // complain because npm is unreachable. It runs only in the CLI on the
8
+ // researcher's machine — the browser library never makes network requests.
9
+ //
10
+ // Opt-outs (checked before any network or cache access):
11
+ // NO_UPDATE_NOTIFIER — the convention established by update-notifier
12
+ // CI — CI runs have no human to nudge
13
+
14
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+
18
+ const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/cyborg-hunter/latest';
19
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
20
+ const FETCH_TIMEOUT_MS = 1500;
21
+
22
+ function defaultCacheFile() {
23
+ return join(homedir(), '.cache', 'cyborg-hunter', 'update-check.json');
24
+ }
25
+
26
+ // Numeric dot-segment comparison ("0.10.0" vs "0.7.2"): returns 1 / 0 / -1 as
27
+ // a is newer / equal / older than b. Non-numeric segments count as 0, so a
28
+ // malformed registry answer degrades to "no update" rather than a false nudge.
29
+ export function compareVersions(a, b) {
30
+ const as = String(a).split('.').map(s => parseInt(s, 10) || 0);
31
+ const bs = String(b).split('.').map(s => parseInt(s, 10) || 0);
32
+ const len = Math.max(as.length, bs.length);
33
+ for (let i = 0; i < len; i++) {
34
+ const d = (as[i] || 0) - (bs[i] || 0);
35
+ if (d > 0) return 1;
36
+ if (d < 0) return -1;
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ // Resolves to { latest, updateAvailable } or null (opted out, offline, old
42
+ // Node without fetch, or any registry hiccup — all silent by design).
43
+ // Everything impure is injectable so tests never touch the network or $HOME.
44
+ export async function checkForUpdate({
45
+ currentVersion,
46
+ env = process.env,
47
+ fetchImpl = globalThis.fetch,
48
+ cacheFile = defaultCacheFile(),
49
+ timeoutMs = FETCH_TIMEOUT_MS,
50
+ now = Date.now(),
51
+ } = {}) {
52
+ if (env.NO_UPDATE_NOTIFIER || env.CI || typeof fetchImpl !== 'function') return null;
53
+
54
+ let cached = null;
55
+ try {
56
+ cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
57
+ } catch { /* no cache yet, or unreadable — fetch below */ }
58
+
59
+ let latest;
60
+ if (cached && typeof cached.latest === 'string' &&
61
+ typeof cached.checkedAt === 'number' && now - cached.checkedAt < CHECK_INTERVAL_MS) {
62
+ latest = cached.latest;
63
+ } else {
64
+ const controller = new AbortController();
65
+ // Deliberately NOT unref'd: on a hung fetch this timer can be the only
66
+ // live handle, and an unref'd one lets the event loop drain before it
67
+ // fires (Node 20 cancels pending awaits that way). It is cleared the
68
+ // moment fetch settles, so it never holds a normal run open.
69
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
70
+ try {
71
+ const res = await fetchImpl(REGISTRY_LATEST_URL, { signal: controller.signal });
72
+ if (!res.ok) return null;
73
+ const body = await res.json();
74
+ if (typeof body.version !== 'string') return null;
75
+ latest = body.version;
76
+ } catch {
77
+ return null;
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ try {
82
+ mkdirSync(dirname(cacheFile), { recursive: true });
83
+ writeFileSync(cacheFile, JSON.stringify({ checkedAt: now, latest }));
84
+ } catch { /* unwritable cache dir just means one fetch per run */ }
85
+ }
86
+
87
+ return { latest, updateAvailable: compareVersions(latest, currentVersion) > 0 };
88
+ }
89
+
90
+ export function formatUpdateNotice(current, latest) {
91
+ return `Update available: cyborg-hunter ${current} -> ${latest}\n` +
92
+ ` npx picks up new versions automatically; a global install updates with\n` +
93
+ ` npm install -g cyborg-hunter@latest`;
94
+ }
95
+
96
+ // Offline staleness notice: session payloads stamp the library version that
97
+ // collected them (monitor.js sets report.libraryVersion), so a mismatch with
98
+ // the CLI's own version is detectable with no network at all. This catches
99
+ // the case the registry check can't: an experiment still collecting data
100
+ // with an old bundle. Returns the notice string, or null when every session
101
+ // matches the CLI version (or carries no version, e.g. CSV-derived data).
102
+ export function formatCollectedVersionNotice(collectedVersions, cliVersion) {
103
+ const distinct = [...new Set(
104
+ collectedVersions.filter(v => typeof v === 'string' && v.length > 0)
105
+ )].filter(v => v !== cliVersion).sort(compareVersions);
106
+ if (distinct.length === 0) return null;
107
+ return ` Note: sessions in this dataset were collected with cyborg-hunter ` +
108
+ `${distinct.join(', ')}; this CLI is ${cliVersion}.\n` +
109
+ ` Signal definitions and scoring defaults can differ between versions.`;
110
+ }
@@ -378,6 +378,23 @@ export function init(userConfig) {
378
378
  report.mouseMetrics = mouseMetrics;
379
379
  }
380
380
 
381
+ // Privacy gate: the raw per-sample mouse coordinates are a much
382
+ // higher-resolution behavioral trace than the derived mouseMetrics
383
+ // above. Persist them ONLY when explicitly enabled
384
+ // (collectForPostHoc.rawMouseTrack) — under the `mouseTrack` name,
385
+ // which is what extract-core's field map already expects (mouseTrack →
386
+ // mouseEvents), so a saved report round-trips through
387
+ // extractIntegrityData() without new glue. Otherwise drop the raw
388
+ // report.mouseEvents entirely — the mouseMetrics signal above still
389
+ // works, but the {x,y,t,type} samples never leave the browser. Before
390
+ // this gate, deepCopy(trialData) put the full raw track in every
391
+ // report regardless of the documented off-by-default rawMouseTrack
392
+ // toggle.
393
+ if (config.collectForPostHoc.rawMouseTrack) {
394
+ report.mouseTrack = report.mouseEvents;
395
+ }
396
+ delete report.mouseEvents;
397
+
381
398
  // Accumulate into session
382
399
  sessionData.trialsCompleted++;
383
400
 
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
35
35
  class CyborgHunterReplayExtension {
36
36
  static info = {
37
37
  name: 'cyborg-hunter-replay',
38
- version: '0.7.0',
38
+ version: '0.7.3',
39
39
  data: {} // per-trial return is {}; session meta goes via addProperties
40
40
  };
41
41
 
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
10
10
  // and package.json). Hand-bumped on each release; if you forget, the
11
11
  // jsPsych developer console shows a stale number — the actual version
12
12
  // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
- version: '0.7.0',
13
+ version: '0.7.3',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -905,8 +905,11 @@ export function fullscreenElementOf(doc) {
905
905
  }
906
906
 
907
907
  // ----- Entry trial ---------------------------------------------
908
- function createEntryTrial(opts = {}) {
909
- const message = opts.message || `
908
+ // Hoisted out of createEntryTrial (verbatim string move) so the frozen
909
+ // public API can expose it as `defaultEntryMessage` below — demo/demo.js
910
+ // renders this SAME string verbatim on its guard-entry step, so it can
911
+ // never drift out of sync with what a real participant actually sees.
912
+ const DEFAULT_ENTRY_MESSAGE = `
910
913
  <h2>Fullscreen mode required</h2>
911
914
  <p>To keep the experiment fair for everyone, we ask that this study be completed in fullscreen mode,
912
915
  with no browser sidebars (Gemini, Copilot, Edge sidebar, etc.) open, and with this tab focused.</p>
@@ -914,6 +917,9 @@ export function fullscreenElementOf(doc) {
914
917
  <p>We care about collecting high-quality data, and these rules help ensure a fair experience for all participants.</p>
915
918
  <p>Please close any sidebars now, then click the button below to continue in fullscreen.</p>
916
919
  `;
920
+
921
+ function createEntryTrial(opts = {}) {
922
+ const message = opts.message || DEFAULT_ENTRY_MESSAGE;
917
923
  return {
918
924
  type: jsPsychHtmlButtonResponse,
919
925
  stimulus: message,
@@ -1048,6 +1054,9 @@ export function fullscreenElementOf(doc) {
1048
1054
  createEntryTrial: function (opts) { return createEntryTrial(opts); },
1049
1055
  onViolation: function (handler) { return onViolation(handler); },
1050
1056
  getCurrentState: function () { return getCurrentState(); },
1057
+ // Added on the object literal BEFORE Object.freeze() below — a
1058
+ // post-freeze assignment would silently no-op.
1059
+ defaultEntryMessage: DEFAULT_ENTRY_MESSAGE,
1051
1060
  };
1052
1061
 
1053
1062
  Object.freeze(api);