cyborg-hunter 0.7.5 → 0.9.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 (46) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +22 -7
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +5 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/package.json +10 -2
  9. package/src/cli/analyzers/score-weights.js +145 -0
  10. package/src/cli/analyzers/triage.js +55 -39
  11. package/src/cli/config.js +15 -0
  12. package/src/cli/ingest.js +311 -57
  13. package/src/cli/renderers/html-index-core.js +88 -22
  14. package/src/cli/renderers/html-index.js +7 -5
  15. package/src/cli/renderers/replay-assets.js +61 -7
  16. package/src/cli/renderers/replay-client-source.js +102 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  18. package/src/cli/renderers/score-weights.js +16 -0
  19. package/src/cli/renderers/summary-csv.js +2 -0
  20. package/src/cli/renderers/trajectories-core.js +2 -1
  21. package/src/cli/renderers/triage-md.js +9 -2
  22. package/src/cli/report.js +16 -3
  23. package/src/core/monitor.js +12 -13
  24. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  25. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  26. package/src/replay/capture-dom.js +470 -458
  27. package/src/replay/capture-trace.js +688 -271
  28. package/src/replay/delivery.js +82 -0
  29. package/src/replay/dom-instantiate.js +779 -0
  30. package/src/replay/index.js +88 -5
  31. package/src/replay/initial-state.js +295 -0
  32. package/src/replay/mutations.js +668 -0
  33. package/src/replay/node-registry.js +116 -0
  34. package/src/replay/persistence.js +19 -6
  35. package/src/replay/recorder.js +342 -73
  36. package/src/replay/redaction.js +165 -0
  37. package/src/replay/serializer.js +148 -43
  38. package/src/replay/snapshot.js +409 -0
  39. package/src/replay/span.js +55 -0
  40. package/src/replay/viewer-model.js +293 -102
  41. package/src/shared/constants.js +1 -1
  42. package/src/shared/inline-safe.js +80 -0
  43. package/src/shared/schema-v2-validator.js +595 -0
  44. package/src/shared/schema.js +1 -0
  45. package/src/shared/validation.js +1 -1
  46. package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
@@ -21,7 +21,10 @@
21
21
 
22
22
  import { VERSION, sanitizeId } from '../../shared/constants.js';
23
23
  import { decomposeScore } from '../analyzers/triage.js';
24
+ import { resolveScoreWeights, customWeightsText, formatScore } from '../analyzers/score-weights.js';
24
25
  import { getByPath } from '../../shared/paths.js';
26
+ import { inferTier } from '../../replay/viewer-model.js';
27
+ import { inlineSafeJson, inlineSafeSrc } from '../../shared/inline-safe.js';
25
28
 
26
29
  /**
27
30
  * Renders the report's index.html as a string. `opts.replayClientSrc` is the
@@ -33,7 +36,14 @@ import { getByPath } from '../../shared/paths.js';
33
36
  * pre-split renderHtmlIndex always rendered).
34
37
  */
35
38
  export async function renderIndexHtml(summaries, triage, participants, config, visualsRendered, opts = {}) {
36
- const replayClientSrc = opts.replayClientSrc ?? '';
39
+ // Inlining JS into an HTML <script> means owning the one sequence the HTML
40
+ // parser reacts to (see src/shared/inline-safe.js for both rules and the
41
+ // precondition the source rule carries). Without it, one comment discussing
42
+ // script-end-tag breakouts — two of the v2 replay modules have one —
43
+ // truncates the whole viewer and the report boots with a SyntaxError. This
44
+ // was hand-rolled in nine places and missing from exactly this one, which is
45
+ // why the rule now lives in one module (T5 Task 10 + its fix round 3).
46
+ const replayClientSrc = inlineSafeSrc(opts.replayClientSrc);
37
47
  const visualsUnavailableNote = opts.visualsUnavailableNote
38
48
  ?? 'Visual renderers not available (install the canvas package).';
39
49
  const imageSources = opts.imageSources ?? null; // pid → {typingProfile, sessionTimeline, trajectories} data URIs (null entry = omit that img)
@@ -52,11 +62,11 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
52
62
 
53
63
  // Preloaded replay models (demo mode only) — embedded ahead of the replay
54
64
  // client script so window.__chReplay exists before any viewer code runs.
55
- // '<' chars are escaped to a six-character JS unicode escape sequence (not
56
- // the literal char) so a "</script>" inside the JSON can't prematurely
57
- // close this tag.
65
+ // This is DATA, so it takes the every-`<` rule (inline-safe.js rule 1),
66
+ // which is both lossless inside a JSON string and complete: no `<` survives
67
+ // for the HTML tokenizer to react to, in any of its states.
58
68
  const preloadedReplayScript = inlineReplayModels
59
- ? `<script>/* preloaded replay models (demo mode) */window.__chReplay = ${JSON.stringify(inlineReplayModels).replace(/</g, '\\u003c')};</script>\n `
69
+ ? `<script>/* preloaded replay models (demo mode) */window.__chReplay = ${inlineSafeJson(inlineReplayModels)};</script>\n `
60
70
  : '';
61
71
 
62
72
  // Hash-sync emission for the rail click handler (selectById, below): demo
@@ -450,7 +460,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
450
460
  <body data-filter="all">
451
461
  <header class="topbar">
452
462
  <h1>Cyborg Hunter Report</h1>
453
- <span class="meta">${triage.length} participants &middot; v${VERSION}</span>
463
+ <span class="meta">${triage.length} participants &middot; v${VERSION}${scoreWeightsNote(config)}</span>
454
464
  <button class="legend-btn" type="button" aria-haspopup="dialog" aria-controls="legend-modal">Legend &#9432;</button>
455
465
  </header>
456
466
  <div class="layout">
@@ -740,11 +750,13 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
740
750
  .replay-note { font-size: 12px; color: var(--dim); }
741
751
  .replay-warn { color: var(--hard); }
742
752
  /* Controls share the report's flat, line-bordered button style */
743
- .replay-play, .replay-load-btn, .replay-css-btn, .replay-trial-select, .replay-speed {
753
+ .replay-play, .replay-load-btn, .replay-css-btn, .replay-segment-select, .replay-speed {
744
754
  padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
745
755
  color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
746
756
  .replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
747
757
  .replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
758
+ .replay-fetch-css-label { margin-left: 10px; font-size: 12px; color: #555; }
759
+ .replay-unstyled { position: absolute; left: 0; right: 0; top: 0; z-index: 3; background: #fff4dd; color: #7a4b00; border-bottom: 1px solid #e8b24a; padding: 6px 10px; font-size: 12px; line-height: 1.4; }
748
760
  .replay-css-btn { font-size: 12px; padding: 2px 8px; }
749
761
  .replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
750
762
  font-variant-numeric: tabular-nums; }
@@ -754,6 +766,13 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
754
766
  background: rgba(0,0,0,0.72); color: #fff; padding: 2px 7px;
755
767
  border-radius: 3px; white-space: nowrap; }
756
768
  .replay-key-chip--redacted { background: rgba(0,0,0,0.5); font-style: italic; }
769
+ /* Media is reported as state, never played (design §7): the badges say what
770
+ the recorded element was doing, in the stage's top-left. */
771
+ .replay-media { position: absolute; left: 0; top: 0; display: flex; gap: 4px;
772
+ padding: 5px 6px; pointer-events: none; flex-wrap: wrap; }
773
+ .replay-media-badge { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
774
+ background: rgba(0,137,123,0.85); color: #fff; padding: 2px 7px;
775
+ border-radius: 3px; white-space: nowrap; }
757
776
  </style>
758
777
  ${preloadedReplayScript}<script>
759
778
  ${replayClientSrc}
@@ -770,6 +789,9 @@ ${replayClientSrc}
770
789
  const src = block.dataset.replaySrc;
771
790
  const pid = block.dataset.pid;
772
791
  const mount = block.querySelector('.replay-mount');
792
+ // Read the fetch decision before the mount is cleared.
793
+ const fetchBox = block.querySelector('.replay-fetch-css');
794
+ const viewerOpts = { externalCss: !!(fetchBox && fetchBox.checked) };
773
795
  btn.disabled = true;
774
796
  btn.textContent = 'Loading…';
775
797
  mount.setAttribute('aria-busy', 'true');
@@ -788,7 +810,7 @@ ${replayClientSrc}
788
810
  if (preloaded) {
789
811
  mount.removeAttribute('aria-busy');
790
812
  mount.textContent = '';
791
- window.initChReplayViewer(mount, preloaded);
813
+ window.initChReplayViewer(mount, preloaded, viewerOpts);
792
814
  return;
793
815
  }
794
816
  ` : ''}const s = document.createElement('script');
@@ -798,7 +820,7 @@ ${replayClientSrc}
798
820
  const model = (window.__chReplay || {})[pid];
799
821
  if (!model) { fail('Replay data failed to load (' + src + ').'); return; }
800
822
  mount.textContent = '';
801
- window.initChReplayViewer(mount, model);
823
+ window.initChReplayViewer(mount, model, viewerOpts);
802
824
  };
803
825
  s.onerror = function () {
804
826
  fail('Replay asset missing (' + src + ') — was the report generated with the replay artifacts present?');
@@ -825,7 +847,8 @@ function tierOf(t) {
825
847
  // drop, long tab-aways, AI extensions detected).
826
848
  // warn — soft-score-contributing signals that compound with others.
827
849
  // muted — diagnostic signals that are noise alone but corroborate.
828
- // Tone classes track scoring weights; if computeTriageScore changes, audit this.
850
+ // Tone classes track the DEFAULT score weights (score-weights.js); custom
851
+ // config.scoreWeights do not retone the tiles. If the defaults change, audit this.
829
852
  // `aiExtensionsCount` and `edgeExitCount` are derived (not on summary directly);
830
853
  // renderSignalGrid handles that mapping.
831
854
  const SIGNALS = [
@@ -939,7 +962,7 @@ function renderCohortRow(t) {
939
962
  <div class="cohort-row-top">
940
963
  <span class="tier-dot" data-tier="${tier}"></span>
941
964
  <span class="mono pid" title="${esc(pid)}">${esc(pid)}</span>
942
- <span class="mono score">${score}</span>
965
+ <span class="mono score">${formatScore(score)}</span>
943
966
  </div>
944
967
  <div class="cohort-row-bot">
945
968
  <span class="reason-excerpt">${esc(reason)}</span>
@@ -1013,7 +1036,7 @@ function renderDetail(t, participant, config, visualsRendered, visualsUnavailabl
1013
1036
  return `<section class="participant" id="p-${sanitized}"${defaultVisible ? '' : ' hidden'}>
1014
1037
  ${renderDetailHeader(t, tier, participant, config)}
1015
1038
  ${renderSignalGrid(s, t)}
1016
- ${renderScoreBreakdown(t)}
1039
+ ${renderScoreBreakdown(t, config)}
1017
1040
  ${renderReasonQuote(t)}
1018
1041
  ${renderSessionBlock(s, participant)}
1019
1042
  ${renderPasteEvidence(participant)}
@@ -1041,9 +1064,20 @@ function renderReplaySection(participant, sanitized, demoModel = null, replaySho
1041
1064
  if (!participant) return '';
1042
1065
  const replay = participant?.replay;
1043
1066
  if ((replay && replay.recording) || demoModel) {
1067
+ // Tier badge. The recording branch reads the v2 site
1068
+ // (extensions['cyborg-hunter'].tier) and falls back to the structural
1069
+ // inference, through the SAME helper the viewer model uses — reading the
1070
+ // tier off a v1 `metadata` block badged every v2 recording "trace",
1071
+ // because v2 has no such block (the tier moved in serializer.js:145).
1072
+ // The demo branch takes `demoModel.tier`: `inlineReplayModels` holds
1073
+ // VIEWER MODELS (see the opts docblock above), and a viewer model has
1074
+ // never had a `metadata` block in any version — that read resolved to
1075
+ // "trace" for every model ever passed. Suspected-dead branch
1076
+ // (demo/tests/tour.spec.js:420 records that the demo stopped passing
1077
+ // inlineReplayModels), fixed rather than deleted.
1044
1078
  const tier = demoModel
1045
- ? (demoModel.metadata?.tier || 'trace')
1046
- : (replay.recording.metadata?.tier || 'trace');
1079
+ ? (demoModel.tier || 'trace')
1080
+ : inferTier(replay.recording);
1047
1081
  // assetPath is stamped by replay-assets.js (collision-deduped filename)
1048
1082
  // and must be preferred — recomputing from the sanitized pid here would
1049
1083
  // resurrect the lossy-name collision the assets renderer just resolved.
@@ -1055,18 +1089,40 @@ function renderReplaySection(participant, sanitized, demoModel = null, replaySho
1055
1089
  // preloadedReplayScript in renderIndexHtml), so the block is marked
1056
1090
  // data-replay-preloaded instead of pointing the lazy loader at a file
1057
1091
  // that doesn't exist in the sandboxed iframe.
1092
+ // Sheets the capture could not inline (cross-origin, fetch refused) are
1093
+ // href-only. Offer the fetch decision HERE, beside the one button, ticked
1094
+ // by default: an unstyled replay is misaligned by construction, and the
1095
+ // in-viewer opt-in went unread through a whole session (2026-09-03).
1096
+ // Rendered only when it applies, so recordings with inlined CSS keep the
1097
+ // exact markup the snapshot tests pin.
1098
+ const externalSheets = replay && replay.recording
1099
+ ? (replay.recording.stylesheets || []).filter((sh) => sh && sh.kind === 'link' && sh.css == null).length
1100
+ : 0;
1101
+ const fetchCssLabel = externalSheets > 0
1102
+ ? `
1103
+ <label class="replay-fetch-css-label"><input type="checkbox" class="replay-fetch-css" checked> also fetch ${externalSheets} external stylesheet${externalSheets === 1 ? '' : 's'} from ${externalSheets === 1 ? 'its origin' : 'their origins'} (needed for a styled, aligned replay)</label>`
1104
+ : '';
1058
1105
  return `<div class="image-block replay-block" data-pid="${esc(participant.participantId)}"
1059
1106
  ${demoModel ? 'data-replay-preloaded="true"' : `data-replay-src="${esc(assetPath)}"`}>
1060
1107
  <h4 class="section-heading">Session replay <span class="replay-note">(${esc(tier)} tier)</span></h4>
1061
1108
  <div class="replay-mount">
1062
- <button class="replay-load-btn" type="button">Load replay</button>
1109
+ <button class="replay-load-btn" type="button">Load replay</button>${fetchCssLabel}
1063
1110
  </div>
1064
1111
  </div>`;
1065
1112
  }
1066
1113
  if (replay && replay.error) {
1114
+ // Two ways an attached artifact fails to reach the viewer, and the
1115
+ // analyst needs to tell them apart: 'parse_failed' (ingest could not read
1116
+ // the file at all) and 'unloadable' (the file parsed but the §11 tolerant
1117
+ // profile rejected it — a CH-v1 artifact is the common case, and there is
1118
+ // no v1 playback path). Calling a readable v1 file "corrupted" would send
1119
+ // the analyst looking for a truncated upload.
1120
+ const lead = replay.error === 'unloadable'
1121
+ ? 'Replay artifact could not be loaded'
1122
+ : 'Replay artifact corrupted';
1067
1123
  return `<div class="image-block replay-block">
1068
1124
  <h4 class="section-heading">Session replay</h4>
1069
- <p class="replay-note replay-warn">Replay artifact corrupted (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
1125
+ <p class="replay-note replay-warn">${lead} (${esc(replay.reason || replay.error)}) — file: ${esc(replay.file || 'unknown')}</p>
1070
1126
  </div>`;
1071
1127
  }
1072
1128
  const meta = participant?.trials?.[0]?.integrityReplayMeta;
@@ -1184,7 +1240,7 @@ function renderDetailHeader(t, tier, participant, config) {
1184
1240
  <div class="detail-header-top">
1185
1241
  <span class="mono pid-full">${esc(t.participantId)}</span>
1186
1242
  <span class="tier-pill" data-tier="${tier}">${tierLabel}</span>
1187
- <span class="mono score-big">${t.score}</span>
1243
+ <span class="mono score-big">${formatScore(t.score)}</span>
1188
1244
  </div>
1189
1245
  <div class="detail-header-sub">
1190
1246
  ${s.trialCount ?? 0} trials · ${cps} cps · ${offTask} off-task
@@ -1193,9 +1249,12 @@ function renderDetailHeader(t, tier, participant, config) {
1193
1249
  }
1194
1250
 
1195
1251
  // Horizontal score breakdown — only non-zero contributions, ending in Total:N.
1196
- // Sourced from decomposeScore so the displayed terms always sum to t.score.
1197
- function renderScoreBreakdown(t) {
1198
- const terms = decomposeScore(t.summary || {}, t.edgeExitCount, t.hardTriggered)
1252
+ // Draws the terms rankTriage actually summed (t.terms). Rows built elsewhere
1253
+ // without terms (hand-built callers) are decomposed here with the CONFIGURED
1254
+ // weights, so the bars never disagree with a custom-weights header.
1255
+ function renderScoreBreakdown(t, config) {
1256
+ const terms = (t.terms ?? decomposeScore(t.summary || {}, t.edgeExitCount, t.hardTriggered,
1257
+ resolveScoreWeights(config?.scoreWeights).weights))
1199
1258
  .filter(([, n]) => n > 0);
1200
1259
 
1201
1260
  const total = t.score;
@@ -1205,17 +1264,24 @@ function renderScoreBreakdown(t) {
1205
1264
  const termHtml = terms.map(([label, n]) =>
1206
1265
  `<span class="score-term">
1207
1266
  <span class="label">${label}</span>
1208
- <span class="mono contrib">+${n}</span>
1267
+ <span class="mono contrib">+${formatScore(n)}</span>
1209
1268
  <span class="bar" style="width:${barFor(n)}px"></span>
1210
1269
  </span>`
1211
1270
  ).join('');
1212
1271
 
1213
1272
  return `<div class="score-breakdown">
1214
1273
  ${termHtml}
1215
- <span class="score-total mono">Total: ${total}</span>
1274
+ <span class="score-total mono">Total: ${formatScore(total)}</span>
1216
1275
  </div>`;
1217
1276
  }
1218
1277
 
1278
+ // Top-bar note, present only when config.scoreWeights changes the score from
1279
+ // the defaults: " · custom score weights: copy 5 max 3, synthetic 1".
1280
+ function scoreWeightsNote(config) {
1281
+ const { weights, isDefault } = resolveScoreWeights(config?.scoreWeights);
1282
+ return isDefault ? '' : ` &middot; custom score weights: ${esc(customWeightsText(weights))}`;
1283
+ }
1284
+
1219
1285
  // Format milliseconds as "Xs" / "Xm Ys".
1220
1286
  function formatDuration(ms) {
1221
1287
  if (!ms || ms < 1000) return '0s';
@@ -2,15 +2,17 @@
2
2
  // Thin Node wrapper around the pure render core (html-index-core.js):
3
3
  // reads the replay viewer client from disk, writes index.html to outputDir.
4
4
  // Public API unchanged — report.js and adopters keep calling renderHtmlIndex.
5
- import { writeFileSync, readFileSync } from 'fs';
5
+ import { writeFileSync } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { renderIndexHtml } from './html-index-core.js';
8
+ import { readReplayClientSrc } from './replay-client-source.js';
8
9
 
9
- // The replay viewer client is developed as a real JS file (linted, syntax-
10
+ // The replay viewer client is developed as real JS files (linted, syntax-
10
11
  // highlighted) and embedded verbatim at render time — same file://-safe
11
- // output as the inline IIFE, without string-blob development pain.
12
- const REPLAY_CLIENT_SRC = readFileSync(
13
- new URL('./replay-viewer.client.js', import.meta.url), 'utf8');
12
+ // output as an inline IIFE, without string-blob development pain. It is a
13
+ // CONCATENATION: the client calls into src/replay/dom-instantiate.js, which
14
+ // ships ahead of it in the same script (see replay-client-source.js).
15
+ const REPLAY_CLIENT_SRC = readReplayClientSrc();
14
16
 
15
17
  export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
16
18
  const html = await renderIndexHtml(summaries, triage, participants, config,
@@ -16,6 +16,7 @@ import { join } from 'path';
16
16
 
17
17
  import { sanitizeId as sanitize } from '../../shared/constants.js';
18
18
  import { buildViewerModel } from '../../replay/viewer-model.js';
19
+ import { inlineSafeJson } from '../../shared/inline-safe.js';
19
20
 
20
21
  // Load-bearing re-export: renderReplayAssets (below) calls buildViewerModel
21
22
  // in-file, and tests/cli/replay-render.test.js + tests/replay/alignment-viewer-model.test.js
@@ -24,17 +25,55 @@ export { buildViewerModel };
24
25
 
25
26
  /**
26
27
  * Writes replay/<sanitizedPid>.replay.js for every participant with an
27
- * attached recording. Returns { count, totalBytes } so report.js can print
28
- * an honest size line (replay assets dominate report size at dom tier).
28
+ * attached recording. Returns { count, totalBytes, skipped } so report.js can
29
+ * print an honest size line (replay assets dominate report size at dom tier)
30
+ * and an honest line about what did not make it.
31
+ *
32
+ * `skipped` is [{participantId, file, reason}] — one entry per artifact that
33
+ * exists, reads fine, and still never reaches the viewer. Two things produce
34
+ * one, and they are the same event seen at different layers:
35
+ *
36
+ * - the §11 tolerant profile rejecting a recording here. buildViewerModel
37
+ * THROWS on that set (it is v2-only; a CH-v1 artifact is the common case),
38
+ * and the alternative to catching is that one unloadable file in a cohort
39
+ * aborts the whole report — precisely the data-loss trade §11 exists to
40
+ * refuse. v1 degraded and rendered the rest; v2 skips the participant and
41
+ * says so.
42
+ * - a refusal already stamped by INGEST (A3): a jsPsych v1 recording the
43
+ * converter would not migrate, which never gets as far as a model. Task
44
+ * 10(b) settled that a replay which does not make it is visible on three
45
+ * surfaces, and the CLI line report.js prints from this list is one of
46
+ * them, so both origins have to be accounted for here or the console is a
47
+ * partial account.
48
+ *
49
+ * A corrupted file (ingest's `parse_failed`) deliberately stays out: it keeps
50
+ * its own report lead text and its own ingest warning.
51
+ *
52
+ * The say-so is a stamp on `p.replay`, read by renderReplaySection's error
53
+ * branch — the report's existing state for "attached but not viewable".
54
+ * Repeated calls now return the same list (both classes are recognised from
55
+ * the stamp), which retires the non-idempotence noted at T5 Task 10 review
56
+ * M-5. One caller today (`report.js:149`), which renders the index from the
57
+ * same array.
29
58
  */
30
59
  export function renderReplayAssets(participants, outputDir) {
31
60
  let count = 0;
32
61
  let totalBytes = 0;
62
+ const skipped = [];
63
+ for (const p of participants) {
64
+ if (p.replay && !p.replay.recording && p.replay.error === 'unloadable') {
65
+ skipped.push({ participantId: p.participantId, file: p.replay.file || null,
66
+ reason: p.replay.reason || p.replay.error });
67
+ }
68
+ }
33
69
  const withReplay = participants.filter((p) => p.replay && p.replay.recording);
34
- if (withReplay.length === 0) return { count, totalBytes };
70
+ if (withReplay.length === 0) return { count, totalBytes, skipped };
35
71
 
72
+ // Created on the first successful write, not up front: a cohort whose
73
+ // every artifact is unloadable would otherwise ship an empty replay/ dir
74
+ // beside a report that says there is nothing to load.
36
75
  const replayDir = join(outputDir, 'replay');
37
- mkdirSync(replayDir, { recursive: true });
76
+ let dirMade = false;
38
77
 
39
78
  // Sanitization is lossy ('a/b' and 'a_b' both map to a_b) — dedupe with a
40
79
  // stable numeric suffix so a later write can never overwrite an earlier
@@ -42,7 +81,21 @@ export function renderReplayAssets(participants, outputDir) {
42
81
  // (replay.assetPath) and consumed by html-index, which must not recompute.
43
82
  const usedNames = new Set();
44
83
  for (const p of withReplay) {
45
- const model = buildViewerModel(p.replay.recording);
84
+ let model;
85
+ try {
86
+ model = buildViewerModel(p.replay.recording);
87
+ } catch (e) {
88
+ // Skip this participant, keep the cohort. The recording is dropped from
89
+ // the participant so the index cannot render a Load button for an asset
90
+ // that was never written; `error: 'unloadable'` distinguishes it from
91
+ // ingest's 'parse_failed' (a file that could not even be read).
92
+ // `e.reasons` is the §11 profile's own list; the message wrapper adds
93
+ // a function name the analyst has no use for.
94
+ const reason = Array.isArray(e.reasons) ? e.reasons.join('; ') : e.message;
95
+ skipped.push({ participantId: p.participantId, file: p.replay.file || null, reason });
96
+ p.replay = { error: 'unloadable', reason, file: p.replay.file || null };
97
+ continue;
98
+ }
46
99
  // The store is keyed by the RAW participant id (what the report's
47
100
  // loader passes); the filename uses the sanitized form.
48
101
  // Null-prototype store: participant ids are untrusted, and a pid like
@@ -51,17 +104,18 @@ export function renderReplayAssets(participants, outputDir) {
51
104
  const src =
52
105
  'window.__chReplay = window.__chReplay || Object.create(null);\n' +
53
106
  'window.__chReplay[' + JSON.stringify(String(p.participantId)) + '] = ' +
54
- JSON.stringify(model).replace(/</g, '\\u003c') + ';\n';
107
+ inlineSafeJson(model) + ';\n';
55
108
  let base = sanitize(p.participantId);
56
109
  let name = base + '.replay.js';
57
110
  for (let n = 2; usedNames.has(name.toLowerCase()); n++) {
58
111
  name = base + '~' + n + '.replay.js';
59
112
  }
60
113
  usedNames.add(name.toLowerCase());
114
+ if (!dirMade) { mkdirSync(replayDir, { recursive: true }); dirMade = true; }
61
115
  writeFileSync(join(replayDir, name), src);
62
116
  p.replay.assetPath = 'replay/' + name;
63
117
  count++;
64
118
  totalBytes += Buffer.byteLength(src);
65
119
  }
66
- return { count, totalBytes };
120
+ return { count, totalBytes, skipped };
67
121
  }
@@ -0,0 +1,102 @@
1
+ // src/cli/renderers/replay-client-source.js
2
+ // The report's viewer script, ASSEMBLED — the one place that knows the viewer
3
+ // client is a concatenation rather than a file.
4
+ //
5
+ // T5 Task 2 recorded the decision this module executes: `replay-viewer.client.js`
6
+ // calls `mountTree`/`applyPatch`/`applyPatches` out of `src/replay/dom-instantiate.js`,
7
+ // and the client is a plain IIFE inlined verbatim into an HTML report — it
8
+ // cannot `import`. The two ways to give it those functions are (a) the client
9
+ // carries its own copy, which would put two readings of spec §4 in the repo,
10
+ // which is the failure this migration exists to remove, or (b) the build
11
+ // concatenates the module ahead of the client with its single `export` line
12
+ // stripped. (b) is the decision, and `tests/replay/dom-instantiate.test.js`
13
+ // machine-checks that the module stays concatenable — no imports, exactly one
14
+ // strippable ESM statement, strict-safe.
15
+ //
16
+ // The concatenation is wrapped in ONE `'use strict'` IIFE so the module's ~35
17
+ // top-level `var`s and helper functions (`bind`, `resolve`, `result`, …) never
18
+ // reach the report page's global scope, where they would collide with the
19
+ // report's own scripts. The client's own IIFE nests inside it and closes over
20
+ // them; `window.initChReplayViewer` is still assigned, which is the whole
21
+ // public surface.
22
+ //
23
+ // FIVE consumers were surveyed at Task 2 and are routed as follows:
24
+ // html-index.js → this module (the shipped report)
25
+ // tools/assemble-demo-site.mjs → this module (writes the ASSEMBLED file,
26
+ // which demo/results.js fetches by name)
27
+ // cursor-alignment.battery.mjs → this module (Task 8's harness)
28
+ // tools/investigate/probe-support.mjs → NOT a consumer of the assembly: it
29
+ // extracts the literal `srcdocCsp()` out
30
+ // of the client source and needs the raw
31
+ // file, not the bundle.
32
+ // tools/investigate/cursor-alignment-probe.mjs → superseded (Task 1).
33
+
34
+ import { readFileSync } from 'fs';
35
+
36
+ import { inlineSrcHazards } from '../../shared/inline-safe.js';
37
+
38
+ // The one ESM statement `dom-instantiate.js` is allowed to carry. The same
39
+ // literal appears in that module's own concatenability test; if it drifts, this
40
+ // throws rather than shipping a report whose viewer has a stray `export`.
41
+ const EXPORT_BLOCK = /^export \{[^}]*\};$/m;
42
+
43
+ const MODULE_URL = new URL('../../replay/dom-instantiate.js', import.meta.url);
44
+ const CLIENT_URL = new URL('./replay-viewer.client.js', import.meta.url);
45
+
46
+ /**
47
+ * The assembly itself, over sources rather than paths, so every contract the
48
+ * assembled script has to satisfy can be exercised with a constructed
49
+ * violation instead of by editing shipped files.
50
+ *
51
+ * @param {string} mod src/replay/dom-instantiate.js
52
+ * @param {string} client src/cli/renderers/replay-viewer.client.js
53
+ * @returns {string} the two inside one strict IIFE
54
+ * @throws {Error} on any of the three contract violations below
55
+ */
56
+ export function assembleReplayClientSrc(mod, client) {
57
+ if (!EXPORT_BLOCK.test(mod)) {
58
+ throw new Error(
59
+ 'replay-client-source: dom-instantiate.js no longer ends in a single strippable ' +
60
+ '`export { … };` line — the concatenation contract (T5 Task 2) is broken. ' +
61
+ 'Fix the module, not this assembler.');
62
+ }
63
+ if (/^\s*import\b/m.test(mod)) {
64
+ throw new Error(
65
+ 'replay-client-source: dom-instantiate.js grew an import; a plain script ' +
66
+ 'concatenation cannot resolve it.');
67
+ }
68
+ const out = "(function () {\n'use strict';\n" +
69
+ mod.replace(EXPORT_BLOCK, '') + '\n' +
70
+ client + '\n})();\n';
71
+ // Third contract, and the reason it is HERE (T5 Task 10 fix round 3, review
72
+ // I-1): every consumer of this function inlines the result into an HTML
73
+ // `<script>`, and `inlineSafeSrc` — the rule they all apply — neutralises
74
+ // `</script` and cannot neutralise the script-data-ESCAPED breakout. An
75
+ // unpaired `<!--` followed by a `<script`, in a comment or a string, makes
76
+ // the parser swallow the page's own closing tag: the viewer never boots and
77
+ // NOTHING is logged. That is strictly worse than the truncation Task 10
78
+ // fixed, which at least raised a SyntaxError. The escape cannot be widened
79
+ // safely (see inline-safe.js), so the precondition is asserted instead —
80
+ // loudly, at build time, on the day a comment mentioning HTML lands.
81
+ const hazards = inlineSrcHazards(out);
82
+ if (hazards.length) {
83
+ throw new Error(
84
+ 'replay-client-source: the assembled viewer cannot be safely inlined into a ' +
85
+ '<script> element — it ' + hazards.join('; and it ') + '. Such a sequence puts ' +
86
+ 'the HTML parser into script-data-escaped state, where the report\'s own ' +
87
+ '</script> no longer closes the element and the viewer silently never boots. ' +
88
+ 'Reword the source (a comment mentioning HTML is the usual cause); do NOT ' +
89
+ 'widen the escape, which would change the meaning of regex literals.');
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * @returns {string} the viewer script as the report inlines it: the §4
96
+ * instantiation/patch module and the viewer client inside one strict IIFE.
97
+ */
98
+ export function readReplayClientSrc() {
99
+ return assembleReplayClientSrc(
100
+ readFileSync(MODULE_URL, 'utf8'),
101
+ readFileSync(CLIENT_URL, 'utf8'));
102
+ }