session-fixture-layout.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /** Canonical packed-row layout helpers for repository session fixtures. */
  2. import { deepStrictEqual } from 'node:assert'
  3. import { execFileSync } from 'node:child_process'
  4. import { existsSync, readFileSync } from 'node:fs'
  5. import { resolve } from 'node:path'
  6. import { decodeStorageRecord, packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session'
  7. /** One repository session fixture and its canonical packed representation. */
  8. export interface SessionFixtureLayout {
  9. /** Repository-relative path with `/` separators. */
  10. path: string
  11. /** Current fixture bytes decoded as UTF-8. */
  12. source: string
  13. /** Canonical packed fixture bytes. */
  14. canonical: string
  15. }
  16. interface RecordLine {
  17. line: number
  18. text: string
  19. }
  20. function recordLines(content: string): RecordLine[] {
  21. return content.split(/\r?\n/).flatMap((text, index) => (
  22. text.trim().length === 0 ? [] : [{ line: index + 1, text }]
  23. ))
  24. }
  25. function parseRecord(line: RecordLine, label: string): unknown {
  26. try {
  27. return JSON.parse(line.text) as unknown
  28. } catch (error) {
  29. const detail = error instanceof Error ? error.message : String(error)
  30. throw new Error(`${label}:${line.line}: invalid JSON: ${detail}`, { cause: error })
  31. }
  32. }
  33. function isSessionHeader(value: unknown): boolean {
  34. return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === 'session'
  35. }
  36. function decodeBody(lines: readonly RecordLine[], label: string): SessionEvent[] {
  37. return lines.flatMap((line) => {
  38. const record = parseRecord(line, label)
  39. try {
  40. return decodeStorageRecord(record)
  41. } catch (error) {
  42. const detail = error instanceof Error ? error.message : String(error)
  43. throw new Error(`${label}:${line.line}: invalid session storage record: ${detail}`, { cause: error })
  44. }
  45. })
  46. }
  47. function renderFixture(headerLine: string, events: readonly SessionEvent[]): string {
  48. return [
  49. headerLine,
  50. ...packChunkRuns(events).map(record => JSON.stringify(record)),
  51. '',
  52. ].join('\n')
  53. }
  54. /**
  55. * Canonicalize one JSONL document when its first record is a session header.
  56. * The header line remains byte-identical; body records decode to logical events
  57. * and re-encode with {@link packChunkRuns}. Non-session JSONL returns undefined.
  58. *
  59. * @param content - JSONL source text.
  60. * @param label - path-like diagnostic label.
  61. * @returns Canonical text for a session fixture, otherwise undefined.
  62. */
  63. export function canonicalSessionFixture(content: string, label = '<session-fixture>'): string | undefined {
  64. const lines = recordLines(content)
  65. const header = lines[0]
  66. if (header === undefined) return undefined
  67. let headerValue: unknown
  68. try {
  69. headerValue = JSON.parse(header.text) as unknown
  70. } catch {
  71. return undefined
  72. }
  73. if (!isSessionHeader(headerValue)) return undefined
  74. const events = decodeBody(lines.slice(1), label)
  75. const canonical = renderFixture(header.text, events)
  76. const canonicalLines = recordLines(canonical)
  77. const decoded = decodeBody(canonicalLines.slice(1), label)
  78. try {
  79. deepStrictEqual(decoded, events)
  80. } catch (error) {
  81. throw new Error(`${label}: packed rewrite changed the decoded event stream`, { cause: error })
  82. }
  83. if (renderFixture(header.text, decoded) !== canonical) {
  84. throw new Error(`${label}: packed rewrite is not idempotent`)
  85. }
  86. return canonical
  87. }
  88. /**
  89. * Discover tracked and unignored untracked JSONL files through Git.
  90. *
  91. * @param root - repository root.
  92. * @returns Stable repository-relative paths.
  93. */
  94. function discoverJsonlFiles(root: string): string[] {
  95. return execFileSync(
  96. 'git',
  97. ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.jsonl'],
  98. { cwd: root, encoding: 'utf8' },
  99. ).split('\0')
  100. .filter(path => path.length > 0 && existsSync(resolve(root, path)))
  101. .sort()
  102. }
  103. /**
  104. * Inspect every repository JSONL whose first record is a session header.
  105. *
  106. * @param root - repository root.
  107. * @returns Session fixtures with current and canonical text.
  108. */
  109. export function inspectSessionFixtureLayouts(root: string): SessionFixtureLayout[] {
  110. return discoverJsonlFiles(root).flatMap((path) => {
  111. const source = readFileSync(resolve(root, path), 'utf8')
  112. const canonical = canonicalSessionFixture(source, path)
  113. return canonical === undefined ? [] : [{ path, source, canonical }]
  114. })
  115. }