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.
- package/CHANGELOG.md +63 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/report.js +6 -0
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +59 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// tools/convert/jspsych-v1-to-v2.mjs
|
|
2
|
+
//
|
|
3
|
+
// jsPsych `schema_version: 1` SessionRecording → SessionRecording v2
|
|
4
|
+
// (docs/plans/2026-08-09-session-recording-v2-spec-draft.md, §14 migration).
|
|
5
|
+
// This tool IS the migration path: players stay v2-only, there is no dual-read,
|
|
6
|
+
// and there is no v2 → v1 direction.
|
|
7
|
+
//
|
|
8
|
+
// v1 source of truth: the fork's pre-flip `src/schema/types.ts`
|
|
9
|
+
// git -C <jspsych-replay-fork> show 06dfa08~1:src/schema/types.ts
|
|
10
|
+
// itself copied from jspsych/jsPsych packages/jspsych/src/modules/recording.ts.
|
|
11
|
+
// The key tables below are that interface, transcribed. They are the whole
|
|
12
|
+
// shape contract: a recording whose top-level or per-trial key set differs from
|
|
13
|
+
// them is REFUSED, in both directions (unknown keys and missing keys alike).
|
|
14
|
+
//
|
|
15
|
+
// Why refuse instead of coping. A converter that defaults a missing field is
|
|
16
|
+
// guessing about unrepeatable participant data, and a converter that renumbers
|
|
17
|
+
// a disagreeing `trial_index` silently rewrites the experiment's own record of
|
|
18
|
+
// what ran when. Both failures are invisible downstream — the output validates
|
|
19
|
+
// either way. So every deviation from the v1 shape stops the conversion and
|
|
20
|
+
// names itself, with a remedy attached. (Note the deliberate contrast with the
|
|
21
|
+
// v2 *loader*, which is tolerant by design, spec §11: tolerance protects an
|
|
22
|
+
// analyst opening a file; strictness protects a producer manufacturing one.
|
|
23
|
+
// This tool is BOTH — a producer when it cuts a fixture, and an archive's only
|
|
24
|
+
// door when a researcher points it at a 2025 recording, since §14 makes
|
|
25
|
+
// conversion the sole migration path. Hence the one concession below.)
|
|
26
|
+
//
|
|
27
|
+
// What the mapping does NOT touch: `initial_dom`, `events`, `trial_data`,
|
|
28
|
+
// stylesheets, viewport changes and RNG records are participant data and are
|
|
29
|
+
// copied through value-for-value. Their internal key order is theirs, not ours.
|
|
30
|
+
//
|
|
31
|
+
// Packaging: `convertRecording` has no dependency outside node: builtins and
|
|
32
|
+
// runs from a bare copy of this file. The CLI additionally validates its output
|
|
33
|
+
// against the in-repo schema-v2 validator, which it imports lazily, so a missing
|
|
34
|
+
// `tests/` directory costs the CLI its output gate and costs the pure function
|
|
35
|
+
// nothing. (`validator.js:2` says that file lifts into a shared package one day;
|
|
36
|
+
// when it moves, only the dynamic specifier below needs updating.)
|
|
37
|
+
//
|
|
38
|
+
// Usage:
|
|
39
|
+
// node tools/convert/jspsych-v1-to-v2.mjs <v1.json> --stdout
|
|
40
|
+
// node tools/convert/jspsych-v1-to-v2.mjs <v1.json> --out <v2.json>
|
|
41
|
+
// cat v1.json | node tools/convert/jspsych-v1-to-v2.mjs --stdout
|
|
42
|
+
import { createHash } from 'node:crypto';
|
|
43
|
+
import { readFileSync, writeFileSync, writeSync } from 'node:fs';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
const VALIDATOR_SPECIFIER = '../../src/shared/schema-v2-validator.js';
|
|
47
|
+
|
|
48
|
+
// Stamped into every converted file. Bump it deliberately: the goldens carry
|
|
49
|
+
// this string, so a bump fails the golden tests until they are regenerated,
|
|
50
|
+
// which is exactly the review moment a mapping change deserves.
|
|
51
|
+
// 1.1.0: stylesheets backfill + `backfilled` report, provenance moved under the
|
|
52
|
+
// `cyborg-hunter` vendor slug, `label` null instead of String(trial_index).
|
|
53
|
+
export const CONVERTER_VERSION = '1.1.0';
|
|
54
|
+
const CONVERTER_TOOL = 'jspsych-v1-to-v2';
|
|
55
|
+
// Spec §9 types `extensions` as { "<vendor>": JsonValue } with lowercase-slug
|
|
56
|
+
// vendor keys. "converter" is a role, not a vendor, so the stamp nests inside
|
|
57
|
+
// CH's existing namespace — the shape travels to the fork with the jspsych-full
|
|
58
|
+
// fixture, where a bare "converter" key would read as a second vendor.
|
|
59
|
+
const CH_VENDOR = 'cyborg-hunter';
|
|
60
|
+
|
|
61
|
+
// Transcribed from v1 `interface SessionRecording` / `interface TrialRecording`.
|
|
62
|
+
const V1_TOP_KEYS = [
|
|
63
|
+
'schema_version', 'jspsych_version', 'recording_started_at',
|
|
64
|
+
'recording_started_at_perf', 'user_agent', 'viewport', 'rng',
|
|
65
|
+
'display_element_id', 'stylesheets', 'stylesheet_events', 'trials',
|
|
66
|
+
'viewport_changes', 'rng_calls', 'ended_at_perf', 'end_reason',
|
|
67
|
+
];
|
|
68
|
+
const V1_TRIAL_KEYS = [
|
|
69
|
+
'trial_index', 't_start', 't_dom_ready', 't_end', 'plugin',
|
|
70
|
+
'initial_dom', 'events', 'trial_data',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// The ONLY concession to v1 history, mirroring the v1 reference validator
|
|
74
|
+
// (fork 06dfa08~1:src/schema/types.ts:219-224, "stylesheet fields were added
|
|
75
|
+
// later. Default to empty arrays so older recordings still load") — and those
|
|
76
|
+
// are its only two backfills, so this is a bounded concession, not the top of a
|
|
77
|
+
// slope. These two fields postdate the rest of the shape, so ABSENCE means the
|
|
78
|
+
// recorder had no stylesheet feature: `[]` records that fact rather than
|
|
79
|
+
// inventing one, which is why `viewport` or `rng_calls` cannot join the list.
|
|
80
|
+
// Absence only. A present-but-wrong-type value means something went wrong, and
|
|
81
|
+
// the converter has nothing true to say about it: it passes through and the
|
|
82
|
+
// strict profile stops it at the CLI boundary.
|
|
83
|
+
const V1_BACKFILL_KEYS = ['stylesheets', 'stylesheet_events'];
|
|
84
|
+
|
|
85
|
+
// ── conversion ──────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Convert a jsPsych-v1 SessionRecording object to v2. Pure: the input is never
|
|
89
|
+
* mutated and the output shares no structure with it (one clone up front), so
|
|
90
|
+
* a caller can keep using either independently.
|
|
91
|
+
*
|
|
92
|
+
* Throws on ANY deviation from the v1 shape. The Error carries `.reasons`
|
|
93
|
+
* (string[]) with every problem found, not just the first.
|
|
94
|
+
*/
|
|
95
|
+
export function convertRecording(input) {
|
|
96
|
+
const reasons = [];
|
|
97
|
+
|
|
98
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
99
|
+
throw refusal([
|
|
100
|
+
`input must be a JSON object (got ${describe(input)}). ` +
|
|
101
|
+
`Pass one jsPsych SessionRecording, not a list of them or a bare value.`,
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (input.schema_version !== 1) {
|
|
106
|
+
reasons.push(
|
|
107
|
+
`schema_version must be the integer 1 (got ${describe(input.schema_version)}). ` +
|
|
108
|
+
`Point this tool at a jsPsych v1 recording: a v2 file needs no conversion, and a ` +
|
|
109
|
+
`Cyborg Hunter v1 file takes the separate CH migration path (spec §14).`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const backfilled = V1_BACKFILL_KEYS.filter(k => !Object.keys(input).includes(k));
|
|
114
|
+
const top = keySetDiff(input, V1_TOP_KEYS, backfilled);
|
|
115
|
+
if (top.unknown.length) {
|
|
116
|
+
reasons.push(
|
|
117
|
+
`unknown top-level key(s): ${top.unknown.join(', ')}. ` +
|
|
118
|
+
`Remove them from the recording, or extend V1_TOP_KEYS in this tool if jsPsych's ` +
|
|
119
|
+
`v1 shape really grew a field.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (top.missing.length) {
|
|
123
|
+
reasons.push(
|
|
124
|
+
`missing top-level key(s): ${top.missing.join(', ')}. ` +
|
|
125
|
+
`Re-export the recording from its source; only ${V1_BACKFILL_KEYS.join('/')} have a ` +
|
|
126
|
+
`safe default ([], applied automatically), so filling anything else in would invent data.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof input.jspsych_version !== 'string') {
|
|
131
|
+
reasons.push(
|
|
132
|
+
`jspsych_version must be a string (got ${describe(input.jspsych_version)}). ` +
|
|
133
|
+
`Quote it ("8.2.1"): it becomes recorder.version and host.version.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (typeof input.display_element_id !== 'string' || input.display_element_id === '') {
|
|
137
|
+
reasons.push(
|
|
138
|
+
`display_element_id must be a non-empty string (got ${describe(input.display_element_id)}). ` +
|
|
139
|
+
`Name the element the session was recorded from; it becomes observed_root as "#"+id.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Trial checks only run when there is an array to walk; otherwise every
|
|
144
|
+
// per-trial message would be noise on top of the real problem.
|
|
145
|
+
if (!Array.isArray(input.trials)) {
|
|
146
|
+
reasons.push(
|
|
147
|
+
`trials must be an array (got ${describe(input.trials)}). ` +
|
|
148
|
+
`Pass the recording's own trials list, even when it is empty.`
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
input.trials.forEach((t, i) => checkTrial(t, i, reasons));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Nothing below runs for a refused recording, so a bad file costs neither the
|
|
155
|
+
// clone nor the hash.
|
|
156
|
+
if (reasons.length) throw refusal(reasons);
|
|
157
|
+
|
|
158
|
+
const v1 = structuredClone(input);
|
|
159
|
+
// Hashed canonically (see canonicalize) and BEFORE the backfill, so the stamp
|
|
160
|
+
// identifies the source recording as it arrived; `backfilled` below says what
|
|
161
|
+
// the converter added on top.
|
|
162
|
+
const sourceHash = sha256(JSON.stringify(canonicalize(input)));
|
|
163
|
+
for (const k of backfilled) v1[k] = [];
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
schema_version: 2,
|
|
167
|
+
// v1 states one version for the recorder and the runtime because in v1 they
|
|
168
|
+
// are the same program. v2 splits the roles, so both get the same identity
|
|
169
|
+
// here rather than one of them getting a guess.
|
|
170
|
+
recorder: { name: 'jspsych', version: v1.jspsych_version },
|
|
171
|
+
host: { name: 'jspsych', version: v1.jspsych_version },
|
|
172
|
+
participant_id: null, // v1 records none, so the converter invents none
|
|
173
|
+
recording_started_at: v1.recording_started_at,
|
|
174
|
+
recording_started_at_perf: v1.recording_started_at_perf,
|
|
175
|
+
user_agent: v1.user_agent,
|
|
176
|
+
viewport: v1.viewport,
|
|
177
|
+
// v2 wants a selector (§2). Not CSS-escaped: an id starting with a digit or
|
|
178
|
+
// holding a `.`/`:`/space yields a selector querySelector rejects. Left as
|
|
179
|
+
// is deliberately — CH's own recorder builds observed_root the same way
|
|
180
|
+
// (src/replay/capture-dom.js:251-253), so escaping is a repo-wide
|
|
181
|
+
// convention to change in both places or neither.
|
|
182
|
+
observed_root: '#' + v1.display_element_id,
|
|
183
|
+
stylesheets: v1.stylesheets,
|
|
184
|
+
stylesheet_events: v1.stylesheet_events,
|
|
185
|
+
viewport_changes: v1.viewport_changes,
|
|
186
|
+
rng: v1.rng,
|
|
187
|
+
rng_calls: v1.rng_calls,
|
|
188
|
+
ended_at_perf: v1.ended_at_perf,
|
|
189
|
+
end_reason: v1.end_reason,
|
|
190
|
+
truncated: false, // v1 has no early-stop channel to report
|
|
191
|
+
extensions: {
|
|
192
|
+
[CH_VENDOR]: {
|
|
193
|
+
converter: {
|
|
194
|
+
tool: CONVERTER_TOOL,
|
|
195
|
+
version: CONVERTER_VERSION,
|
|
196
|
+
source_sha256: sourceHash,
|
|
197
|
+
// Present only when something was filled in, so its presence alone is
|
|
198
|
+
// the signal that this file is not purely what the recorder wrote.
|
|
199
|
+
...(backfilled.length ? { backfilled } : {}),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
segments: v1.trials.map(convertTrial),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// jsPsych wipes the display between trials, so every v1 trial is a v2 keyframe:
|
|
208
|
+
// `initial_dom` is always a fresh snapshot and node numbering always restarts.
|
|
209
|
+
// That is why `initial_state` is null (spec §3 exempts wiping hosts) and why no
|
|
210
|
+
// continuation bookkeeping is needed here.
|
|
211
|
+
function convertTrial(t) {
|
|
212
|
+
return {
|
|
213
|
+
index: t.trial_index,
|
|
214
|
+
// null, not String(trial_index): spec §3 calls `label` host-assigned, and
|
|
215
|
+
// jsPsych assigns none. Stringifying the index would duplicate `index` while
|
|
216
|
+
// asserting a label the recording never carried.
|
|
217
|
+
label: null,
|
|
218
|
+
plugin: t.plugin,
|
|
219
|
+
t_start: t.t_start,
|
|
220
|
+
t_dom_ready: t.t_dom_ready,
|
|
221
|
+
t_load: null, // v1 never recorded a load milestone
|
|
222
|
+
t_end: t.t_end,
|
|
223
|
+
initial_dom: t.initial_dom, // v1's DomNode encoding IS v2's (§4)
|
|
224
|
+
initial_state: null,
|
|
225
|
+
events: t.events, // v1's dotted event vocabulary IS v2's (§5)
|
|
226
|
+
host_data: t.trial_data,
|
|
227
|
+
extensions: null,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── refusals ────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function checkTrial(t, i, reasons) {
|
|
234
|
+
const at = `trials[${i}]`;
|
|
235
|
+
if (typeof t !== 'object' || t === null || Array.isArray(t)) {
|
|
236
|
+
reasons.push(
|
|
237
|
+
`${at} must be a JSON object (got ${describe(t)}). ` +
|
|
238
|
+
`Drop the entry or restore the trial record; the converter will not invent one.`
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const keys = keySetDiff(t, V1_TRIAL_KEYS);
|
|
244
|
+
if (keys.unknown.length) {
|
|
245
|
+
reasons.push(
|
|
246
|
+
`${at}: unknown trial-level key(s): ${keys.unknown.join(', ')}. ` +
|
|
247
|
+
`Remove them, or extend V1_TRIAL_KEYS in this tool if the v1 trial shape really grew a field.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (keys.missing.length) {
|
|
251
|
+
reasons.push(
|
|
252
|
+
`${at}: missing trial-level key(s): ${keys.missing.join(', ')}. ` +
|
|
253
|
+
`Re-export the recording from its source; no trial-level field has a safe default.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!Number.isInteger(t.trial_index)) {
|
|
258
|
+
reasons.push(
|
|
259
|
+
`${at}.trial_index must be an integer (got ${describe(t.trial_index)}). ` +
|
|
260
|
+
`Fix it at the source: it becomes the segment index, which v2 §7 requires to equal ` +
|
|
261
|
+
`the array position.`
|
|
262
|
+
);
|
|
263
|
+
} else if (t.trial_index !== i) {
|
|
264
|
+
// Never renumbered. v2 §7 requires index === array position, and the only
|
|
265
|
+
// safe way to satisfy it is to make a human decide which one is wrong.
|
|
266
|
+
reasons.push(
|
|
267
|
+
`${at}.trial_index (${t.trial_index}) must equal its array position (${i}). ` +
|
|
268
|
+
`Reorder the trials to match their own indices, or fix the indices at the source; ` +
|
|
269
|
+
`this tool never renumbers, because that would rewrite the experiment's record of ` +
|
|
270
|
+
`what ran when.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// `exempt` names keys whose absence is being handled elsewhere (the stylesheets
|
|
276
|
+
// backfill), so they are not also reported as missing.
|
|
277
|
+
function keySetDiff(obj, known, exempt = []) {
|
|
278
|
+
const present = Object.keys(obj);
|
|
279
|
+
return {
|
|
280
|
+
unknown: present.filter(k => !known.includes(k)).sort(),
|
|
281
|
+
missing: known.filter(k => !present.includes(k) && !exempt.includes(k)).sort(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function refusal(reasons) {
|
|
286
|
+
const err = new Error(
|
|
287
|
+
`${CONVERTER_TOOL} refused this recording (${reasons.length} problem` +
|
|
288
|
+
`${reasons.length === 1 ? '' : 's'}):\n` +
|
|
289
|
+
reasons.map(r => ' - ' + r).join('\n')
|
|
290
|
+
);
|
|
291
|
+
err.reasons = reasons;
|
|
292
|
+
return err;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function describe(v) {
|
|
296
|
+
if (v === undefined) return 'undefined';
|
|
297
|
+
if (v === null) return 'null';
|
|
298
|
+
if (Array.isArray(v)) return 'an array';
|
|
299
|
+
if (typeof v === 'object') return 'an object';
|
|
300
|
+
return JSON.stringify(v);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── provenance hash ─────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
// Recursively key-sorted copy. Hashing THIS rather than the raw bytes makes the
|
|
306
|
+
// provenance stamp identify the recording's content, not its formatting: the
|
|
307
|
+
// same recording re-serialized with different key order or indentation gets the
|
|
308
|
+
// same hash, and any change to a value gets a different one.
|
|
309
|
+
function canonicalize(value) {
|
|
310
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
311
|
+
if (value && typeof value === 'object') {
|
|
312
|
+
const out = {};
|
|
313
|
+
for (const k of Object.keys(value).sort()) out[k] = canonicalize(value[k]);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function sha256(text) {
|
|
320
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
const USAGE = `Usage: node tools/convert/jspsych-v1-to-v2.mjs [<v1.json>] [--stdout | --out <v2.json>]
|
|
326
|
+
|
|
327
|
+
<v1.json> input path; "-" or omitted reads stdin
|
|
328
|
+
--stdout write the converted recording to stdout (the default)
|
|
329
|
+
--out, -o <path> write it to a file instead
|
|
330
|
+
--help, -h this message
|
|
331
|
+
|
|
332
|
+
Converts a jsPsych schema_version:1 SessionRecording to SessionRecording v2.
|
|
333
|
+
Refuses anything that is not exactly v1-shaped, and refuses to emit output that
|
|
334
|
+
fails schema-v2 strict validation. Absent stylesheets/stylesheet_events are the
|
|
335
|
+
one exception: they backfill to [] and say so in the provenance stamp.`;
|
|
336
|
+
|
|
337
|
+
// writeSync on fd 2 rather than process.stderr.write: on macOS a piped stderr
|
|
338
|
+
// is asynchronous, so an immediate process.exit() can truncate the very message
|
|
339
|
+
// that explains the refusal.
|
|
340
|
+
function die(message) {
|
|
341
|
+
writeSync(2, message + '\n');
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function main(argv) {
|
|
346
|
+
let inputPath = null;
|
|
347
|
+
let outPath = null;
|
|
348
|
+
let explicitStdout = false;
|
|
349
|
+
|
|
350
|
+
for (let i = 0; i < argv.length; i++) {
|
|
351
|
+
const arg = argv[i];
|
|
352
|
+
if (arg === '--help' || arg === '-h') {
|
|
353
|
+
process.stdout.write(USAGE + '\n');
|
|
354
|
+
return;
|
|
355
|
+
} else if (arg === '--stdout') {
|
|
356
|
+
explicitStdout = true;
|
|
357
|
+
} else if (arg === '--out' || arg === '-o') {
|
|
358
|
+
outPath = argv[++i];
|
|
359
|
+
if (outPath === undefined) die(`${CONVERTER_TOOL}: --out needs a path\n\n${USAGE}`);
|
|
360
|
+
} else if (arg.startsWith('-') && arg !== '-') {
|
|
361
|
+
die(`${CONVERTER_TOOL}: unknown option "${arg}"\n\n${USAGE}`);
|
|
362
|
+
} else if (inputPath === null) {
|
|
363
|
+
inputPath = arg;
|
|
364
|
+
} else {
|
|
365
|
+
die(`${CONVERTER_TOOL}: more than one input path given\n\n${USAGE}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (explicitStdout && outPath !== null) {
|
|
369
|
+
die(`${CONVERTER_TOOL}: --stdout and --out are mutually exclusive`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// fd 0 covers both the piped and the redirected case; "-" is the conventional
|
|
373
|
+
// spelling of "stdin" when a path would otherwise be expected.
|
|
374
|
+
const source = inputPath === null || inputPath === '-' ? 0 : inputPath;
|
|
375
|
+
let raw;
|
|
376
|
+
try {
|
|
377
|
+
raw = readFileSync(source, 'utf8');
|
|
378
|
+
} catch (e) {
|
|
379
|
+
die(`${CONVERTER_TOOL}: cannot read ${inputPath ?? 'stdin'}: ${e.message}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let v1;
|
|
383
|
+
try {
|
|
384
|
+
v1 = JSON.parse(raw);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
die(`${CONVERTER_TOOL}: input is not valid JSON: ${e.message}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let v2;
|
|
390
|
+
try {
|
|
391
|
+
v2 = convertRecording(v1);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
die(e.message);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The design's validation duty: a converted file that does not strict-validate
|
|
397
|
+
// is not a v2 recording, so it never reaches disk or a pipe. Imported here
|
|
398
|
+
// rather than at module scope so `convertRecording` never loads it (M1).
|
|
399
|
+
let validateStrict;
|
|
400
|
+
try {
|
|
401
|
+
({ validateStrict } = await import(VALIDATOR_SPECIFIER));
|
|
402
|
+
} catch (e) {
|
|
403
|
+
die(
|
|
404
|
+
`${CONVERTER_TOOL}: cannot load the schema-v2 validator (${VALIDATOR_SPECIFIER}): ` +
|
|
405
|
+
`${e.message}\nThe CLI strict-validates its ` +
|
|
406
|
+
`own output; the exported convertRecording() has no such dependency.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const verdict = validateStrict(v2);
|
|
410
|
+
if (!verdict.ok) {
|
|
411
|
+
die(
|
|
412
|
+
`${CONVERTER_TOOL}: converted output failed schema-v2 strict validation ` +
|
|
413
|
+
`(${verdict.errors.length} error${verdict.errors.length === 1 ? '' : 's'}):\n` +
|
|
414
|
+
verdict.errors.map(e => ' - ' + e).join('\n')
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const text = JSON.stringify(v2, null, 2) + '\n';
|
|
419
|
+
if (outPath !== null) {
|
|
420
|
+
writeFileSync(outPath, text);
|
|
421
|
+
// Progress chatter goes to stderr so stdout carries recordings and nothing
|
|
422
|
+
// else, whichever output mode is in use.
|
|
423
|
+
process.stderr.write(`Wrote ${outPath}\n`);
|
|
424
|
+
} else {
|
|
425
|
+
process.stdout.write(text);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Runs only when this file is the entry point (not on import from the test).
|
|
430
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
431
|
+
main(process.argv.slice(2)).catch(e => die(`${CONVERTER_TOOL}: ${e.stack ?? e.message}`));
|
|
432
|
+
}
|