cyborg-hunter 0.3.0

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,200 @@
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
+
6
+ import { writeFileSync } from 'fs';
7
+ 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
+
16
+ export async function renderTypingProfiles(participants, config) {
17
+ const { createCanvas } = await import('canvas');
18
+ const threshold = config.typingSpeedThreshold_cps || 10;
19
+
20
+ for (const p of participants) {
21
+ const trials = p.trials;
22
+ if (trials.length === 0) continue;
23
+
24
+ // Per-trial state. We distinguish three cases:
25
+ // "typed" — charsPerSec is a real number, draw a normal bar
26
+ // "paste-only" — charsPerSec is null but there's a paste event; draw a
27
+ // grey hatched marker so the absence is legible. Single
28
+ // paste fires one input event → editTimestamps.length < 2
29
+ // → computeTypingSpeed returns null. Without this, a
30
+ // pasted response looks identical to a very slow one.
31
+ // "no-data" — neither, leave blank
32
+ const kinds = trials.map(t => {
33
+ if (typeof t.charsPerSec === 'number') return 'typed';
34
+ if ((t.pasteEvents || []).length > 0) return 'paste-only';
35
+ return 'no-data';
36
+ });
37
+ const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
38
+ if (kinds.every(k => k === 'no-data')) continue;
39
+
40
+ const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
41
+ const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
42
+ const canvas = createCanvas(canvasW, canvasH);
43
+ const ctx = canvas.getContext('2d');
44
+
45
+ // Background
46
+ ctx.fillStyle = '#ffffff';
47
+ ctx.fillRect(0, 0, canvasW, canvasH);
48
+
49
+ // Title
50
+ ctx.fillStyle = '#333';
51
+ ctx.font = 'bold 14px sans-serif';
52
+ ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
53
+ ctx.font = '10px sans-serif';
54
+ ctx.fillStyle = '#666';
55
+ ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
56
+
57
+ // Legend — three states documented in the `kinds` comment above.
58
+ const legendY = 35;
59
+ const legendX0 = LEFT_PAD + 180;
60
+ ctx.fillStyle = '#2196F3';
61
+ ctx.fillRect(legendX0, legendY - 8, 12, 10);
62
+ ctx.fillStyle = '#666';
63
+ ctx.fillText('typed', legendX0 + 16, legendY);
64
+ ctx.fillStyle = '#f44336';
65
+ ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
66
+ ctx.fillStyle = '#666';
67
+ ctx.fillText('> threshold', legendX0 + 76, legendY);
68
+ // Hatched swatch for paste-only
69
+ drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
70
+ ctx.fillStyle = '#666';
71
+ ctx.fillText('paste (P)', legendX0 + 166, legendY);
72
+
73
+ // Y-axis scaling
74
+ const maxSpeed = Math.max(...speeds, threshold * 1.5);
75
+ const yScale = CHART_HEIGHT / maxSpeed;
76
+
77
+ // Y-axis labels
78
+ ctx.fillStyle = '#666';
79
+ ctx.font = '9px sans-serif';
80
+ const yTicks = 5;
81
+ for (let t = 0; t <= yTicks; t++) {
82
+ const val = (maxSpeed / yTicks) * t;
83
+ const y = TOP_PAD + CHART_HEIGHT - val * yScale;
84
+ ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
85
+ // Grid line
86
+ ctx.strokeStyle = '#eee';
87
+ ctx.lineWidth = 0.5;
88
+ ctx.beginPath();
89
+ ctx.moveTo(LEFT_PAD, y);
90
+ ctx.lineTo(canvasW - 10, y);
91
+ ctx.stroke();
92
+ }
93
+
94
+ // Threshold line
95
+ const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
96
+ ctx.strokeStyle = '#ff0000';
97
+ ctx.lineWidth = 1.5;
98
+ ctx.setLineDash([5, 3]);
99
+ ctx.beginPath();
100
+ ctx.moveTo(LEFT_PAD, threshY);
101
+ ctx.lineTo(canvasW - 10, threshY);
102
+ ctx.stroke();
103
+ ctx.setLineDash([]);
104
+
105
+ // Bars
106
+ const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
107
+ for (let i = 0; i < trials.length; i++) {
108
+ const kind = kinds[i];
109
+ const speed = speeds[i];
110
+ const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
111
+
112
+ if (kind === 'paste-only') {
113
+ // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
114
+ // — the point is visible presence, not magnitude (we have no CPS to
115
+ // show). Sits at the bottom like a normal bar.
116
+ const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
117
+ drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
118
+ ctx.strokeStyle = '#888';
119
+ ctx.lineWidth = 0.5;
120
+ ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
121
+ // "P" marker above the bar
122
+ ctx.fillStyle = '#555';
123
+ ctx.font = 'bold 11px sans-serif';
124
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
125
+ } else if (kind === 'typed') {
126
+ const barH = speed * yScale;
127
+ const y = TOP_PAD + CHART_HEIGHT - barH;
128
+ ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
129
+ ctx.fillRect(x, y, BAR_WIDTH, barH);
130
+ ctx.strokeStyle = '#333';
131
+ ctx.lineWidth = 0.5;
132
+ ctx.strokeRect(x, y, BAR_WIDTH, barH);
133
+ if (speed > 0) {
134
+ ctx.fillStyle = '#fff';
135
+ ctx.font = 'bold 9px sans-serif';
136
+ ctx.fillText(speed.toFixed(1), x + 3, y + 12);
137
+ }
138
+ // Mixed: typed AND pasted in the same trial. The bar shows the typing
139
+ // rate (legitimate signal); the "P" glyph above it flags that *some*
140
+ // of the response came in via paste. Distinct from pure paste-only
141
+ // (which is a hatched bar, no colored fill).
142
+ if ((trials[i].pasteEvents || []).length > 0) {
143
+ ctx.fillStyle = '#555';
144
+ ctx.font = 'bold 11px sans-serif';
145
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
146
+ }
147
+ }
148
+ // 'no-data' → render nothing (blank column)
149
+
150
+ // Trial label (always drawn, even for blank columns)
151
+ const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
152
+ ctx.fillStyle = '#333';
153
+ ctx.font = '8px sans-serif';
154
+ ctx.save();
155
+ ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
156
+ ctx.rotate(Math.PI / 4);
157
+ ctx.fillText(trialId.substring(0, 12), 0, 0);
158
+ ctx.restore();
159
+ }
160
+
161
+ // Baseline
162
+ ctx.strokeStyle = '#333';
163
+ ctx.lineWidth = 1;
164
+ ctx.beginPath();
165
+ ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
166
+ ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
167
+ ctx.stroke();
168
+
169
+ const buf = canvas.toBuffer('image/png');
170
+ const filename = `typing_profile_${sanitize(p.participantId)}.png`;
171
+ writeFileSync(join(config.outputDir, 'images', filename), buf);
172
+ }
173
+
174
+ console.log(` typing profiles — rendered`);
175
+ }
176
+
177
+ function sanitize(name) {
178
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
179
+ }
180
+
181
+ // Cross-hatched fill for paste-only markers. Uses a light grey background
182
+ // with diagonal strokes so the bar reads as "distinct category, not zero".
183
+ function drawHatchedRect(ctx, x, y, w, h) {
184
+ ctx.save();
185
+ ctx.fillStyle = '#e8e8e8';
186
+ ctx.fillRect(x, y, w, h);
187
+ ctx.beginPath();
188
+ ctx.rect(x, y, w, h);
189
+ ctx.clip();
190
+ ctx.strokeStyle = '#888';
191
+ ctx.lineWidth = 0.8;
192
+ const step = 5;
193
+ for (let d = -h; d < w + h; d += step) {
194
+ ctx.beginPath();
195
+ ctx.moveTo(x + d, y);
196
+ ctx.lineTo(x + d + h, y + h);
197
+ ctx.stroke();
198
+ }
199
+ ctx.restore();
200
+ }
@@ -0,0 +1,100 @@
1
+ // src/cli/report.js
2
+ // CLI report pipeline orchestrator.
3
+ // Runs: config → ingest → analyze → render → output
4
+ //
5
+ // This is the main pipeline that ties all CLI modules together.
6
+ // Each stage is imported lazily where possible so partial runs
7
+ // (e.g., missing canvas) degrade gracefully.
8
+
9
+ import { join } from 'path';
10
+ import { loadConfig } from './config.js';
11
+ import { ingest } from './ingest.js';
12
+ import { computeSummary } from './analyzers/summary.js';
13
+ import { detectEdgeExits } from './analyzers/edge-exit.js';
14
+ import { rankTriage } from './analyzers/triage.js';
15
+ import { VERSION } from '../shared/constants.js';
16
+
17
+ export async function run(args) {
18
+ console.log(`cyborg-hunter report v${VERSION}\n`);
19
+
20
+ // 1. Load and merge config (defaults ← file ← CLI flags)
21
+ const config = loadConfig(args);
22
+
23
+ // 2. Ingest participant data files
24
+ const { participants, warnings } = await ingest(config);
25
+ console.log(`Found ${participants.length} participants` +
26
+ (warnings.length ? ` (${warnings.length} files had warnings)` : ''));
27
+
28
+ if (participants.length === 0) {
29
+ console.error('No valid participant data found. Check dataDir and filePattern.');
30
+ process.exit(1);
31
+ }
32
+
33
+ // 3. Analyze — compute summaries, detect edge exits, rank by triage priority
34
+ const summaries = computeSummary(participants, config);
35
+ const edgeExits = detectEdgeExits(participants, config);
36
+ const triage = rankTriage(summaries, edgeExits, config);
37
+
38
+ const flaggedHard = triage.filter(t => t.hardTriggered).length;
39
+ const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
40
+ const clean = triage.length - flaggedHard - flaggedSoft;
41
+
42
+ console.log(`\nAnalyzing...`);
43
+ console.log(` Participants flagged (hard signals): ${flaggedHard}`);
44
+ console.log(` Participants flagged (soft score): ${flaggedSoft}`);
45
+ console.log(` Participants clean: ${clean}`);
46
+
47
+ // 4. Render outputs
48
+ console.log(`\nRendering...`);
49
+ const { mkdirSync } = await import('fs');
50
+ mkdirSync(config.outputDir, { recursive: true });
51
+
52
+ // Text-based renderers (always available, no native dependencies)
53
+ const { renderSummaryCSV } = await import('./renderers/summary-csv.js');
54
+ const { renderTriage } = await import('./renderers/triage-md.js');
55
+ const { renderEventLog } = await import('./renderers/event-log.js');
56
+ const { renderExtensions } = await import('./renderers/extensions.js');
57
+
58
+ await renderSummaryCSV(summaries, triage, config);
59
+ await renderTriage(triage, config);
60
+ await renderEventLog(participants, config);
61
+ await renderExtensions(participants, config);
62
+
63
+ // Visual renderers require node-canvas (optional dependency).
64
+ // If canvas is unavailable, skip with a helpful install message.
65
+ let visualsRendered = false;
66
+ if (!config.noVisuals) {
67
+ let canvas;
68
+ try {
69
+ canvas = await import('canvas');
70
+ } catch {
71
+ console.log(`\n Trajectory images require the 'canvas' package, which needs Cairo.\n`);
72
+ console.log(` macOS: brew install pkg-config cairo pango libpng jpeg giflib librsvg`);
73
+ console.log(` Ubuntu: sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev`);
74
+ console.log(` Windows: https://github.com/nicktacik/node-canvas#compiling\n`);
75
+ console.log(` Then run: npm install canvas`);
76
+ console.log(` Continuing without visuals.\n`);
77
+ }
78
+
79
+ if (canvas) {
80
+ const { renderTrajectories } = await import('./renderers/trajectories.js');
81
+ const { renderTabTimelines } = await import('./renderers/tab-timeline.js');
82
+ const { renderTypingProfiles } = await import('./renderers/typing-profile.js');
83
+
84
+ // Create images/ subdirectory for visual outputs
85
+ mkdirSync(join(config.outputDir, 'images'), { recursive: true });
86
+
87
+ await renderTrajectories(participants, triage, config);
88
+ await renderTabTimelines(participants, config);
89
+ await renderTypingProfiles(participants, config);
90
+ visualsRendered = true;
91
+ }
92
+ }
93
+
94
+ // HTML index page — references images/ folder (not base64-embedded)
95
+ const { renderHtmlIndex } = await import('./renderers/html-index.js');
96
+ await renderHtmlIndex(summaries, triage, participants, config, visualsRendered);
97
+
98
+ console.log(`\nReport written to ${config.outputDir}/`);
99
+ console.log(` Open ${config.outputDir}/index.html to review`);
100
+ }
@@ -0,0 +1,13 @@
1
+ // src/core/index.js
2
+ // Package entry point. Exports init() and static utilities.
3
+ //
4
+ // IMPORTANT: Do NOT manually assign window.CyborgHunter here.
5
+ // esbuild's globalName: 'CyborgHunter' handles the IIFE global assignment,
6
+ // and the build footer adds window.IntegrityMonitor = CyborgHunter.
7
+ // Manual assignment would create a second object, causing state divergence.
8
+
9
+ import { init } from './monitor.js';
10
+ import { VERSION } from '../shared/constants.js';
11
+ import { preventTextSelection, addHoneypot, setAltText } from './signals/dom-protection.js';
12
+
13
+ export { init, VERSION, preventTextSelection, addHoneypot, setAltText };