snapshot-normalize.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /**
  2. * Pure normalizers for the ACP snapshot goldens. They replace the
  3. * non-deterministic values in the two captured surfaces — the stdout JSON-RPC
  4. * transcript and the persisted session JSONL — with stable tokens, so a golden
  5. * compare reflects behavior, not run-to-run noise. Kept dependency-free and
  6. * side-effect-free so they unit-test trivially.
  7. *
  8. * Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp`
  9. * cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header);
  10. * JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event
  11. * `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's
  12. * `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq`
  13. * (deterministic — `seq = log.length`, part of the event-log contract).
  14. *
  15. * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md.
  16. */
  17. const SESSION_ID = '{{sessionId}}'
  18. const CWD = '{{cwd}}'
  19. /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
  20. const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
  21. /** Inputs the normalizers need to recognize a run's volatile values. */
  22. export interface NormalizeContext {
  23. /** The session id(s) the run issued — replaced with `{{sessionId}}`. */
  24. sessionIds: string[]
  25. /** The temp cwd the run used — replaced with `{{cwd}}`. */
  26. cwd: string
  27. }
  28. /** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
  29. function scrubString(value: string, ctx: NormalizeContext): string {
  30. let out = value
  31. // cwd first (longest, most specific), then explicit session ids, then any
  32. // residual UUID (covers ids that appear in places we didn't enumerate).
  33. out = out.split(ctx.cwd).join(CWD)
  34. for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
  35. out = out.replace(UUID_RE, SESSION_ID)
  36. return out
  37. }
  38. /** Recursively scrub a parsed JSON value (strings replaced; structure kept). */
  39. function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
  40. if (typeof value === 'string') return scrubString(value, ctx)
  41. if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx))
  42. if (value !== null && typeof value === 'object') {
  43. const out: Record<string, unknown> = {}
  44. for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx)
  45. return out
  46. }
  47. return value
  48. }
  49. /**
  50. * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a
  51. * stable golden in the SAME shape as the wire: one compact JSON frame per line
  52. * (NDJSON), with the JSON-RPC `id` rewritten to a per-transcript sequence
  53. * (1, 2, 3, …) and all volatile strings scrubbed. Throws if any non-empty line
  54. * is not valid JSON — that doubles as the stdout-purity check (no logger leaked
  55. * onto the protocol).
  56. */
  57. export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string {
  58. const lines = rawStdout.split('\n').filter(line => line.trim().length > 0)
  59. // Map each distinct JSON-RPC id (request/response correlate by id) to a stable
  60. // sequence number, in first-seen order, so id churn doesn't perturb the golden.
  61. const idSeq = new Map<string, number>()
  62. const stableId = (id: unknown): number => {
  63. const key = JSON.stringify(id)
  64. let n = idSeq.get(key)
  65. if (n === undefined) { n = idSeq.size + 1; idSeq.set(key, n) }
  66. return n
  67. }
  68. const frames = lines.map((line) => {
  69. const frame = JSON.parse(line) as Record<string, unknown>
  70. if ('id' in frame && frame.id !== undefined && frame.id !== null) {
  71. frame.id = stableId(frame.id)
  72. }
  73. return scrubValue(frame, ctx) as Record<string, unknown>
  74. })
  75. return frames.map(f => JSON.stringify(f)).join('\n') + '\n'
  76. }
  77. /**
  78. * Normalize a session JSONL log into a stable golden: the header line's
  79. * volatile fields (`createdAt`, `id`, `cwd`) and every event's `time` are
  80. * zeroed/scrubbed, all volatile strings scrubbed, and `seq` is LEFT INTACT
  81. * (deterministic by contract). Output is JSONL in the same shape as the input —
  82. * one compact record per line.
  83. */
  84. export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): string {
  85. const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
  86. const records = lines.map((line) => {
  87. const record = JSON.parse(line) as Record<string, unknown>
  88. // Header line: { type: 'session', createdAt, id, cwd, … }.
  89. if (record.type === 'session') {
  90. if ('createdAt' in record) record.createdAt = 0
  91. } else if ('time' in record) {
  92. // Event line: zero the epoch-ms timestamp; keep seq (deterministic).
  93. record.time = 0
  94. // A hook/result carries the hook's wall-clock runtime (`data.durationMs`),
  95. // which is run-to-run noise like `time` — zero it so the golden reflects
  96. // the hook's decision/exit, not how long the shell took.
  97. if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') {
  98. const data = record.data as Record<string, unknown>
  99. if ('durationMs' in data) data.durationMs = 0
  100. }
  101. }
  102. return scrubValue(record, ctx) as Record<string, unknown>
  103. })
  104. return records.map(r => JSON.stringify(r)).join('\n') + '\n'
  105. }