cyborg-hunter 0.7.4 → 0.8.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -1,125 +1,316 @@
1
1
  // src/replay/viewer-model.js
2
- // Wire SessionRecording → viewer model. Pure — no Node APIs — so a browser
3
- // demo can bundle it directly (0.7.2 extraction from
4
- // cli/renderers/replay-assets.js, which re-exports this for existing
5
- // callers).
2
+ // SessionRecording v2 (spec r2) → viewer model. Pure — no Node APIs — so a
3
+ // browser demo can bundle it directly (0.7.2 extraction from
4
+ // cli/renderers/replay-assets.js, which re-exports this for existing callers).
6
5
  //
7
6
  // This is one of exactly two allowed wire→viewer time-conversion points (the
8
- // other lives in the CLI ingest path): SessionRecording carries ms since
9
- // session start; the viewer speaks trial-relative ms.
7
+ // other lives in the CLI ingest path): a SessionRecording carries ms since
8
+ // `recording_started_at_perf`; the viewer speaks segment-relative ms.
9
+ //
10
+ // Input is a parsed v2 recording from ANY producer, not just CH's recorder:
11
+ // the shared format's whole point is that a conforming file plays in either
12
+ // project's player. Everything CH-specific is read from
13
+ // `extensions['cyborg-hunter']` and guarded on its own key.
14
+ //
15
+ // The model is a frozen wire format between two files that ship together
16
+ // (renderReplayAssets writes it as JSONP; the report inlines its own copy of
17
+ // the viewer client), so there is no back-compat problem here — and no
18
+ // back-compat either. There is no CH-v1 playback path: v1 recordings are
19
+ // rejected by the §11 tolerant profile below and are played with the v0.7.1
20
+ // tag (design §12).
21
+
22
+ // Spec §11, tolerant loader profile. Reject ONLY these four categories;
23
+ // everything else loads with a warning-free documented default. Rationale:
24
+ // recordings are unrepeatable participant data, so rejection at runtime is
25
+ // data loss — strictness lives in CI (src/shared/schema-v2-validator.js),
26
+ // where it hits a developer instead of an analyst. The reason strings mirror
27
+ // that validator's tolerant profile verbatim so the repo holds ONE reading of
28
+ // the profile rather than two that can drift.
29
+ function tolerantReasons(rec) {
30
+ const reasons = [];
31
+ if (typeof rec !== 'object' || rec === null || Array.isArray(rec)) {
32
+ return ['recording must be a JSON object'];
33
+ }
34
+ if (rec.schema_version !== 2) reasons.push('schema_version must be the integer 2');
35
+ if (!rec.recorder || typeof rec.recorder.name !== 'string' || typeof rec.recorder.version !== 'string') {
36
+ reasons.push('recorder {name, version} strings are required');
37
+ }
38
+ if (!Array.isArray(rec.segments)) {
39
+ reasons.push('segments array is required');
40
+ } else {
41
+ rec.segments.forEach((s, i) => {
42
+ if (!s || typeof s !== 'object' || !Array.isArray(s.events)) {
43
+ reasons.push('segments[' + i + '] must carry an events array');
44
+ }
45
+ });
46
+ }
47
+ return reasons;
48
+ }
49
+
50
+ const asArray = (v) => (Array.isArray(v) ? v : []);
51
+ const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
52
+ // The wire rounds to 0.1 ms (spec §7); rebasing two rounded floats
53
+ // reintroduces binary noise (2100 − 2000 = 99.99999999999999), so every
54
+ // derived time is re-rounded to the same precision.
55
+ const round1 = (v) => Math.round(v * 10) / 10;
56
+
57
+ // A keyframe is a DomNode OBJECT. A primitive (a v1 HTML string, say) or an
58
+ // array carries none of a DomNode's fields, so treating it as a keyframe
59
+ // would hand `mountTree` something it cannot instantiate and would license
60
+ // later `dom.*` patches against a snapshot that does not exist. Same test as
61
+ // the strict profile's `isKeyframe` (validator.js:203-204).
62
+ const isKeyframe = (s) => s.initial_dom != null && typeof s.initial_dom === 'object'
63
+ && !Array.isArray(s.initial_dom);
10
64
 
11
65
  /**
12
- * Wire SessionRecording → viewer model. Times become trial-relative
13
- * (t − t_load); null anchors (standalone implicit trials) degrade to the
14
- * first event's time so durations are always finite.
66
+ * The badge a recording earns: the stated tier wins, otherwise infer it
67
+ * structurally so a foreign file gets an honest badge instead of "trace"
68
+ * (design §10). Exported because the report's index renders the same badge
69
+ * from the RECORDING (html-index-core.js) while the viewer renders it from
70
+ * the MODEL — one reading of the rule, in one place, or the two drift.
71
+ *
72
+ * Safe on anything: the index calls it on an artifact that has not been
73
+ * through the §11 profile yet.
74
+ *
75
+ * @param {object} recording a parsed SessionRecording v2, any producer
76
+ * @returns {'dom'|'trace'|string} the stated tier, or the inferred one
77
+ */
78
+ export function inferTier(recording) {
79
+ const ext = (recording && recording.extensions && recording.extensions['cyborg-hunter']) || {};
80
+ if (ext.tier) return ext.tier;
81
+ const segs = (recording && Array.isArray(recording.segments)) ? recording.segments : [];
82
+ return segs.some((s) => s && isKeyframe(s)) ? 'dom' : 'trace';
83
+ }
84
+
85
+ /**
86
+ * @param {object} recording a parsed SessionRecording v2, any producer
87
+ * @returns {object} the viewer model (design §9)
88
+ * @throws {Error} with `.reasons` when the §11 tolerant profile rejects it
15
89
  */
16
90
  export function buildViewerModel(recording) {
17
- const md = recording.metadata || {};
18
- const ext = recording.ch_extensions || {};
19
-
20
- // ── Camera seeding (central, per Sol round-1 finding 8) ──
21
- // New recordings carry a per-trial view_state seed. Legacy recordings
22
- // don't — but the full event stream is present, so each trial's starting
23
- // camera is reconstructed by folding all PRIOR trials' window-scroll and
24
- // resize events over the session-start viewport. Initial scroll is assumed
25
- // 0 (a recording that starts pre-scrolled with no scroll events is
26
- // unrecoverable — that's what the viewer's legacy banner covers).
27
- const vp = recording.viewport || {};
28
- const vv = vp.visual_viewport || {};
29
- // Scrollbar delta: legacy resize events carry only innerWidth/Height; the
30
- // layout (client) width is estimated as w minus the session-start delta
31
- // between innerWidth and the layout width (visual_viewport.width).
32
- const sbW = (vp.width && (vp.client_width || vv.width))
33
- ? vp.width - (vp.client_width || vv.width) : 0;
34
- const sbH = (vp.height && (vp.client_height || vv.height))
35
- ? vp.height - (vp.client_height || vv.height) : 0;
36
- let foldState = {
37
- x: 0, y: 0,
38
- w: vp.width || null, h: vp.height || null,
39
- cw: vp.client_width || vv.width || vp.width || null,
40
- ch: vp.client_height || vv.height || vp.height || null,
41
- dpr: vp.dpr || 1
42
- };
43
- const foldEvent = (state, e) => {
44
- if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
45
- state.x = Number(e.x) || 0;
46
- state.y = Number(e.y) || 0;
47
- } else if (e.kind === 'resize') {
48
- state.w = e.w != null ? e.w : state.w;
49
- state.h = e.h != null ? e.h : state.h;
50
- state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
51
- state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
52
- if (e.dpr != null) state.dpr = e.dpr;
53
- }
54
- return state;
91
+ const reasons = tolerantReasons(recording);
92
+ if (reasons.length) {
93
+ const err = new Error('buildViewerModel: not a loadable SessionRecording v2 — ' + reasons.join('; '));
94
+ err.reasons = reasons;
95
+ throw err;
96
+ }
97
+
98
+ const rec = recording;
99
+ // `extensions` is nullable at every level (spec §2/§9), and a foreign
100
+ // producer may carry the namespace for its own reasons — the T4 converter
101
+ // stamps provenance into it. Read individual keys; never treat presence of
102
+ // the namespace as evidence about the producer.
103
+ const ext = (rec.extensions && rec.extensions['cyborg-hunter']) || {};
104
+
105
+ // ── session viewport and the client (layout) box ────────────────────────
106
+ // Spec §2's ViewportState has no client-box room, so the layout box the page
107
+ // was formatted against travels as CH vendor data. The iframe must be sized
108
+ // to the LAYOUT width or every reconstruction is a scrollbar-width off.
109
+ // Chain (design §8): per-event `camera.client_w` (the viewer's business, at
110
+ // the moment of an anchored event) → `viewport_client` at session start →
111
+ // folded w/h minus the session scrollbar delta. The delta is computed from
112
+ // `viewport_client` ITSELF, so an absent one is a delta of zero rather than
113
+ // an arithmetic accident — which is exactly the foreign-file case.
114
+ const viewport = rec.viewport && typeof rec.viewport === 'object' ? rec.viewport : null;
115
+ const viewportClient = ext.viewport_client || null;
116
+ const scrollbar = {
117
+ w: viewport && viewportClient && num(viewport.w) != null && num(viewportClient.w) != null
118
+ ? viewport.w - viewportClient.w : 0,
119
+ h: viewport && viewportClient && num(viewport.h) != null && num(viewportClient.h) != null
120
+ ? viewport.h - viewportClient.h : 0,
55
121
  };
56
122
 
57
- // Defensive against malformed/truncated artifacts (a hand-edited or
58
- // partially-written recording): non-array trials/events and null entries must
59
- // degrade to empty rather than throw and abort the whole cohort report.
60
- const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
61
- const trials = rawTrials.map((trial) => {
62
- trial = trial || {};
63
- // Sort by absolute time before anchoring. RAF-coalesced input events flush
64
- // with an EARLIER timestamp than events pushed after they were enqueued, so
65
- // the recorded array is not strictly time-ordered; the viewer scrubs by
66
- // scanning until the first future event and would otherwise mis-apply an
67
- // out-of-order event on a seek. Stable sort keeps equal-time order.
68
- const events = (Array.isArray(trial.events) ? trial.events : [])
123
+ const viewportChanges = asArray(rec.viewport_changes)
124
+ .filter((c) => c && typeof c === 'object')
125
+ .slice()
126
+ .sort((a, b) => (num(a.t) || 0) - (num(b.t) || 0));
127
+
128
+ // ── segments ────────────────────────────────────────────────────────────
129
+ const rawSegments = rec.segments;
130
+
131
+ let spanStart = null; // index of the keyframe the current span opens at
132
+ const segments = rawSegments.map((s, i) => {
133
+ const keyframe = isKeyframe(s);
134
+ if (keyframe) spanStart = i;
135
+
136
+ const tStart = num(s.t_start);
137
+ const tDomReady = num(s.t_dom_ready);
138
+ const tLoad = num(s.t_load);
139
+ const tEnd = num(s.t_end);
140
+
141
+ // Events are sorted BEFORE the origin is taken, because the origin can
142
+ // fall back to the first event's time and the recorded array is not
143
+ // guaranteed ordered in practice: RAF-coalesced input flushes with an
144
+ // EARLIER timestamp than events pushed after it was enqueued. Spec §7
145
+ // requires producers to emit sorted arrays; the viewer scans forward and
146
+ // would mis-apply an out-of-order event on a seek, so a foreign producer's
147
+ // bug must not become CH's rendering bug. Stable sort keeps equal-t order,
148
+ // which §7 makes authoritative.
149
+ const events = asArray(s.events)
69
150
  .filter((e) => e && typeof e === 'object')
70
151
  .slice()
71
- .sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
72
- const anchor = trial.t_load != null ? trial.t_load
73
- : (events.length > 0 ? events[0].t : 0);
74
- const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
75
- const end = trial.t_end != null ? trial.t_end : lastT;
76
- // Camera seed for THIS trial: recorded view_state, else the folded state
77
- // as of the end of the previous trial. A recorded view_state also
78
- // RESYNCS the fold — in a mixed recording (some trials seeded, some
79
- // not: truncation, version mixes) a later unseeded trial must inherit
80
- // real observed state, not a fold that ignored every observation.
81
- if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
82
- const camera = trial.view_state
83
- ? Object.assign({}, trial.view_state, { source: 'view_state' })
84
- : Object.assign({}, foldState, { source: 'folded' });
85
- // Advance the fold across this trial's events for the NEXT trial's seed.
86
- events.forEach((e) => foldEvent(foldState, e));
152
+ .sort((a, b) => (num(a.t) || 0) - (num(b.t) || 0));
153
+
154
+ // Spec §3: "Segment time origin for per-segment clocks: first non-null of
155
+ // t_load, t_dom_ready, t_start; else the first event's t."
156
+ // Recorded because it was contested: the pre-design's carry paraphrased
157
+ // this as "t_dom_ready, then t_load, then t_start last", swapping the
158
+ // first two. The SPEC TEXT is the contract. The readings differ only for a
159
+ // segment stating both t_load and t_dom_ready with different values, where
160
+ // §3 says t_load wins — and that is also the no-change reading, since
161
+ // CH-v1's anchor was t_load. (The fork's PLAYER still rebases by
162
+ // `t_dom_ready ?? 0`; that divergence is a cross-repo rider, not T5 scope.)
163
+ const origin = tLoad != null ? tLoad
164
+ : tDomReady != null ? tDomReady
165
+ : tStart != null ? tStart
166
+ : (events.length > 0 && num(events[0].t) != null ? num(events[0].t) : 0);
167
+
168
+ const lastT = events.length > 0 ? (num(events[events.length - 1].t) || origin) : origin;
169
+ const end = tEnd != null ? tEnd : lastT;
170
+
87
171
  return {
88
- index: trial.trial_index,
89
- id: trial.trial_id,
90
- plugin: trial.plugin,
91
- durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
92
- initialDom: trial.initial_dom || '',
93
- camera,
172
+ // Spec §7: `index` MUST equal the array position, and tolerant loaders
173
+ // trust array order. Taking the position rather than the stated field
174
+ // means a producer's off-by-one cannot desync the model from the array
175
+ // every other part of the viewer indexes into.
176
+ index: i,
177
+ label: s.label != null ? s.label : null,
178
+ plugin: s.plugin != null ? s.plugin : null,
179
+ tStart, tDomReady, tLoad, tEnd,
180
+ origin,
181
+ durMs: Math.max(0, round1(end - origin)),
182
+ keyframe,
183
+ // The nearest earlier segment with a non-null initial_dom (itself if it
184
+ // is a keyframe): the segment a restore mounts before walking forward.
185
+ // Null until the first keyframe arrives.
186
+ spanStart,
187
+ // Spec §3's pre-keyframe rule, in the strict profile's reading
188
+ // (validator.js:209): a segment with no keyframe before it is illegal
189
+ // IFF it carries `dom.*` events, tracked with a RUNNING keyframe flag.
190
+ // Deliberately the same predicate as the machine check rather than a
191
+ // second reading of the same sentence — the reason the §11 rejection
192
+ // strings above are mirrored verbatim applies here too. Two shapes turn
193
+ // on it: a trace prologue before a later keyframe is LEGAL (it
194
+ // reconstructs nothing, so it loses nothing), and `dom.*` before any
195
+ // keyframe is a DEFECT even when no keyframe ever arrives (the viewer
196
+ // would otherwise show a clean grey trace stage while dropping patches
197
+ // §12 requires it to flag).
198
+ defect: spanStart === null && events.some(
199
+ (e) => typeof e.type === 'string' && e.type.indexOf('dom.') === 0)
200
+ ? 'continuation-before-keyframe' : null,
201
+ // v2's keyframe is a DomNode TREE, never an HTML string: the
202
+ // reconstruction is instantiated node by node and is never parsed from
203
+ // markup (design §4).
204
+ initialDom: keyframe ? s.initial_dom : null,
205
+ initialState: s.initial_state || null,
206
+ camera: null, // filled below, once every span start is known
207
+ // Segment-relative times, as v1's were trial-relative. A pure
208
+ // subtraction, NOT clamped at zero: an event recorded before its
209
+ // segment's origin keeps a negative relative time rather than being
210
+ // reported as happening at the segment start.
211
+ //
212
+ // THE CONVERSION CONTRACT (read this before writing Task 7's checkpoint
213
+ // executor). Rebasing rounds, so the two directions are not equally
214
+ // exact and only one of them is a rule:
215
+ // FORWARD (session → segment) is `round1(t − origin)`, and any
216
+ // consumer that needs a segment-relative time MUST compute it the
217
+ // same way. Do that and the comparison is exact by construction.
218
+ // REVERSE (`origin + tRel === t`) is NOT exact: `round1` re-rounds the
219
+ // difference and adding it back to a decimal origin does not
220
+ // reproduce the wire float (2000.1 + 14621.4 → 16621.500000000002).
221
+ // Measured over the corpus: 735 of jspsych-full's 909 events miss
222
+ // it, worst error 1.9e-7 ms. The stated bound is 5e-7 ms, pinned
223
+ // fixture-wide in tests/replay/viewer-model.test.js. Never compare
224
+ // reconstructed absolute times with ===.
94
225
  events: events.map((e) => {
95
226
  const out = Object.assign({}, e);
96
- out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
227
+ out.t = round1((num(e.t) || 0) - origin);
97
228
  return out;
98
- })
229
+ }),
99
230
  };
100
231
  });
101
232
 
233
+ // ── per-segment camera seed (design §8) ─────────────────────────────────
234
+ // v1 folded per-trial view-state seeds plus in-stream resize events. v2
235
+ // has neither: `resize` is not an event type and viewport history is
236
+ // session-level. So the seed is `viewport_changes` folded up to the
237
+ // segment's origin, over the top-level viewport, with window scroll taken
238
+ // from the SPAN KEYFRAME's `initial_state` — the state a restore actually
239
+ // opens with (design §5). Scroll is not folded across segments: the span
240
+ // walk replays `scroll.window` itself, and a segment with no initial_state
241
+ // opens at (0,0) because the frame survives segment changes.
242
+ for (const seg of segments) {
243
+ const folded = {
244
+ w: viewport ? num(viewport.w) : null,
245
+ h: viewport ? num(viewport.h) : null,
246
+ dpr: viewport && num(viewport.dpr) != null ? viewport.dpr : 1,
247
+ scale: viewport && num(viewport.scale) != null ? viewport.scale : 1,
248
+ offset_x: viewport && num(viewport.offset_x) != null ? viewport.offset_x : 0,
249
+ offset_y: viewport && num(viewport.offset_y) != null ? viewport.offset_y : 0,
250
+ };
251
+ for (const c of viewportChanges) {
252
+ if ((num(c.t) || 0) > seg.origin) break;
253
+ for (const k of Object.keys(folded)) if (num(c[k]) != null) folded[k] = c[k];
254
+ }
255
+ const keyframeSeg = seg.spanStart != null ? segments[seg.spanStart] : null;
256
+ const seedScroll = keyframeSeg && keyframeSeg.initialState && keyframeSeg.initialState.scroll;
257
+ seg.camera = Object.assign({}, folded, {
258
+ scroll_x: seedScroll && num(seedScroll.x) != null ? seedScroll.x : 0,
259
+ scroll_y: seedScroll && num(seedScroll.y) != null ? seedScroll.y : 0,
260
+ client_w: folded.w != null ? folded.w - scrollbar.w : null,
261
+ client_h: folded.h != null ? folded.h - scrollbar.h : null,
262
+ // Which of the two scroll sources produced this seed. Not spec — it is
263
+ // what makes a wrong seed legible in the viewer's own diagnostics.
264
+ source: seedScroll ? 'initial_state' : 'default',
265
+ });
266
+ }
267
+
102
268
  return {
103
- pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
104
- tier: md.tier || 'trace',
105
- keys: md.keys || null,
106
- startTime: md.start_time || null,
107
- endReason: md.end_reason || null,
108
- recorder: md.recorder || null,
109
- viewport: recording.viewport || null,
110
- // Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
111
- // mixed/truncated ones alike): those trials replay on folded camera
112
- // state, so the reduced-guarantees banner must show.
113
- legacy: !rawTrials.every((t) => t && t.view_state),
114
- markerAttr: ext.marker_attr || null,
115
- // Session scrollbar delta (innerWidth − layout width): the viewer uses
116
- // the same fallback chain as the folding above for legacy resize events.
117
- scrollbar: { w: sbW, h: sbH },
118
- stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
269
+ schemaVersion: 2,
270
+ pid: rec.participant_id != null ? String(rec.participant_id) : null,
271
+ recorder: rec.recorder,
272
+ host: rec.host || null,
273
+ startTime: rec.recording_started_at || null,
274
+ startPerf: num(rec.recording_started_at_perf),
275
+ endReason: rec.end_reason || null,
276
+ truncated: !!rec.truncated,
277
+ userAgent: rec.user_agent || null,
278
+
279
+ // `foreign` keys on PRODUCER IDENTITY, never on the presence of the
280
+ // `extensions['cyborg-hunter']` namespace: the T4 converter stamps its
281
+ // provenance into that namespace, so a namespace test calls the
282
+ // designated foreign-producer fixture CH-produced. A converted CH-v1 file
283
+ // is the mirror case — CH-produced and converter-stamped. `foreign` says
284
+ // "the report should not pretend this file has CH panels"; it never
285
+ // decides whether a given panel is drawn. Every panel below is guarded on
286
+ // its OWN field's presence.
287
+ foreign: rec.recorder.name !== 'cyborg-hunter-replay',
288
+
289
+ // Stated tier wins; otherwise infer structurally so a foreign file gets an
290
+ // honest badge instead of "trace" (design §10). Shared with the report's
291
+ // index, which badges the same recording before any model exists.
292
+ tier: inferTier(rec),
293
+ keys: ext.keys || null,
294
+ chVersion: ext.ch_version || null,
295
+ preset: ext.preset || null,
296
+ inheritedTaint: !!ext.inherited_redaction_taint,
297
+
298
+ viewport,
299
+ viewportClient,
300
+ scrollbar,
301
+ // Session-level streams keep ABSOLUTE wire times and are rebased at use:
302
+ // one array serves every segment.
303
+ viewportChanges,
304
+ stylesheets: asArray(rec.stylesheets),
305
+ stylesheetEvents: asArray(rec.stylesheet_events),
306
+
119
307
  scoring: ext.scoring || null,
120
- guardViolations: ext.guard_violations || [],
308
+ guardViolations: asArray(ext.guard_violations),
309
+ // Channel names only: the chip says which channel went dark, and the
310
+ // message/time detail has no surface in the report today.
311
+ captureFailures: asArray(ext.capture_failures).map((f) => f && f.channel).filter(Boolean),
121
312
  captureStopped: !!ext.capture_stopped,
122
- captureFailures: (ext.capture_failures || []).map((f) => f.channel),
123
- trials
313
+
314
+ segments,
124
315
  };
125
316
  }
@@ -2,7 +2,7 @@
2
2
  // Default thresholds and preset configurations for Cyborg Hunter.
3
3
  // Single source of truth — used by both the browser library and CLI report tool.
4
4
 
5
- export const VERSION = "0.7.4";
5
+ export const VERSION = "0.8.0";
6
6
 
7
7
  // Default detection thresholds shared across presets.
8
8
  // Researchers can override any value at init() time.
@@ -0,0 +1,80 @@
1
+ // src/shared/inline-safe.js
2
+ // Putting text inside an HTML `<script>` element safely. TWO rules, because
3
+ // there are two kinds of text and one rule for both is wrong in each
4
+ // direction. Pure and dependency-free: the CLI renderers, the Playwright
5
+ // batteries and the investigation probes all import it.
6
+ //
7
+ // WHY THIS MODULE EXISTS. Before T5 Task 10 this duty was hand-rolled in nine
8
+ // places, and the copy that mattered — the shipped report's — had drifted to
9
+ // zero, so every report generated between Task 4 and Task 10 inlined a viewer
10
+ // the HTML parser truncated at the first `</script` inside a source COMMENT
11
+ // and booted with a SyntaxError and no player. Nine correct copies and one
12
+ // missing copy is the same failure the tier read had (fixed by exporting
13
+ // `inferTier`), so it gets the same answer.
14
+ //
15
+ // ── Rule 1, inlineSafeJson: for DATA (a model, a payload) ──────────────────
16
+ // Escape EVERY `<` to the six-character JS unicode escape `<`. Inside a
17
+ // JSON string literal that is the same character to the JS parser, so the
18
+ // value round-trips exactly, and no `<` survives for the HTML tokenizer to
19
+ // react to — which covers `</script`, `<!--` and `<script` in one move. This
20
+ // is the rule to use whenever the text is untrusted (participant-typed or
21
+ // visitor-pasted content reaches the viewer model as DomNode text).
22
+ //
23
+ // ── Rule 2, inlineSafeSrc: for JS SOURCE ───────────────────────────────────
24
+ // Rule 1 cannot be used here: source is not a string literal, so rewriting
25
+ // `a < b` to `a < b` is a syntax error. What is safe is neutralising the
26
+ // one sequence that can close a `<script>` element — `</script`,
27
+ // case-insensitively. `<\/script` is an identity escape inside a string, a
28
+ // `\/` inside a regex literal, and raw text inside a comment, so no code
29
+ // changes meaning. Nothing wider is safe: `<\script` alters a regex literal
30
+ // (`\s` is a character class) and `<\!--` is a SyntaxError under `/u`.
31
+ //
32
+ // ── The precondition Rule 2 carries, and why the guard below exists ────────
33
+ // Rule 2 is complete for the END-TAG class only. An unpaired `<!--` puts the
34
+ // tokenizer into script-data-escaped state, and a following `<script` takes it
35
+ // to script-data-DOUBLE-escaped state, where the page's own `</script>` no
36
+ // longer closes the element — the viewer never boots and, unlike the
37
+ // truncation above, NOTHING IS LOGGED. That failure needs no `</script` at
38
+ // all. So a consumer that inlines source must also assert the precondition,
39
+ // which is what `inlineSrcHazards` is for. Data has no such precondition:
40
+ // Rule 1 escapes those sequences too.
41
+
42
+ /**
43
+ * Inline a value as JSON inside a `<script>`. Use for all DATA.
44
+ * @param {*} value anything JSON-serialisable
45
+ * @returns {string} the JSON text, with every `<` escaped to `<`
46
+ */
47
+ export function inlineSafeJson(value) {
48
+ return JSON.stringify(value).replace(/</g, '\\u003c');
49
+ }
50
+
51
+ /**
52
+ * Inline JavaScript SOURCE inside a `<script>`. Use for source only; pair it
53
+ * with `inlineSrcHazards` (or a caller that has already asserted the
54
+ * precondition — see `readReplayClientSrc`).
55
+ * @param {string} src
56
+ * @returns {string} the source with every `</script` neutralised
57
+ */
58
+ export function inlineSafeSrc(src) {
59
+ return String(src ?? '').replace(/<\/(script)/gi, '<\\/$1');
60
+ }
61
+
62
+ /**
63
+ * The sequences `inlineSafeSrc` cannot neutralise. Non-empty means the source
64
+ * must not be inlined as-is: it can silently swallow its own closing tag.
65
+ * @param {string} src
66
+ * @returns {string[]} human-readable reasons, empty when the source is safe
67
+ */
68
+ export function inlineSrcHazards(src) {
69
+ const s = String(src ?? '');
70
+ const reasons = [];
71
+ // `<!--` opens script-data-escaped state. A matching `-->` would close it,
72
+ // but "matching" is a tokenizer state question, not a text question, so the
73
+ // safe rule is: no `<!--` in inlined source at all.
74
+ if (/<!--/.test(s)) reasons.push('contains `<!--`, which opens script-data-escaped state');
75
+ // `<script` + whitespace/`/`/`>` is the second half of the double-escape.
76
+ // Harmless on its own, refused anyway: the pair is what bites, and either
77
+ // half arriving alone is one edit away from the other.
78
+ if (/<script[\s/>]/i.test(s)) reasons.push('contains `<script`, the second half of the double-escape');
79
+ return reasons;
80
+ }