persistence-changes.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. /** Current-tree persistence history rejects uncovered and incorrectly acknowledged type changes. */
  2. import { spawnSync } from 'node:child_process'
  3. import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join, resolve } from 'node:path'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
  8. import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty } from './persistence-schema-model.ts'
  9. import { extractPersistenceSchema } from './persistence-schema.ts'
  10. import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
  11. import {
  12. classifyPersistenceChange,
  13. loadPersistenceHistory,
  14. parseHistoricalPersistenceSnapshot,
  15. parsePersistenceSnapshot,
  16. runPersistenceChanges as executePersistenceChanges,
  17. validatePersistenceHistory,
  18. verifyPersistenceChanges,
  19. } from './persistence-changes.ts'
  20. import type { PersistenceChangeRecord, PersistenceHistoryEntry } from './persistence-changes.ts'
  21. function runPersistenceChanges(
  22. args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
  23. ): string {
  24. return executePersistenceChanges(args, root, extract, (_root, current) => [{
  25. path: 'docs/persistence-schema.json', content: JSON.stringify(current, null, 2) + '\n',
  26. }])
  27. }
  28. function jsonResult(source: string): { ok: boolean; files: readonly string[] } {
  29. return JSON.parse(source) as { ok: boolean; files: readonly string[] }
  30. }
  31. const AUTHORED_PROSE = {
  32. en: { summary: 'Adds optional metadata.', compatibility: 'Readers may omit the metadata.', verification: 'The focused tests passed.' },
  33. zh: { summary: '添加可选元数据。', compatibility: '读取方可省略元数据。', verification: '定向测试通过。' },
  34. }
  35. function proseFile(root: string): string {
  36. const path = join(root, 'prose.json')
  37. writeFileSync(path, JSON.stringify(AUTHORED_PROSE))
  38. return path
  39. }
  40. const roots: string[] = []
  41. afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
  42. function fixture(): string {
  43. const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-changes-'))
  44. roots.push(root)
  45. mkdirSync(join(root, 'docs/persistence-changes'), { recursive: true })
  46. return root
  47. }
  48. type Shape = 'string' | 'number' | 'boolean' | { readonly [property: string]: Shape }
  49. function typeRoot(key: string, shape: Shape, version = 3): PersistenceRoot {
  50. const nodes: SchemaNode[] = []
  51. function add(shape: Shape): number {
  52. const index = nodes.length
  53. nodes.push({ kind: 'primitive', type: 'never' })
  54. nodes[index] = typeof shape === 'string' ? { kind: 'primitive', type: shape } : {
  55. kind: 'object', indices: [], properties: Object.entries(shape).map(([name, child]) => ({
  56. name: name.replace(/\?$/u, ''), optional: name.endsWith('?'), type: add(child),
  57. })),
  58. }
  59. return index
  60. }
  61. if (key === 'SessionHeader') {
  62. const index = add(shape)
  63. const node = nodes[index]!
  64. if (node.kind !== 'object') throw new Error('header fixture requires an object')
  65. nodes[index] = { ...node, properties: [...node.properties, { name: 'version', optional: false, type: nodes.length }] }
  66. nodes.push({ kind: 'literal', value: version })
  67. } else if (key.startsWith('event:')) {
  68. add({ data: shape, type: 'string' })
  69. const node = nodes[0]!
  70. if (node.kind !== 'object') throw new Error('event fixture requires an object')
  71. const type = node.properties.find(item => item.name === 'type')!.type
  72. nodes[type] = { kind: 'literal', value: key.slice(6) }
  73. } else add(shape)
  74. const schema = canonicalizeSchema(nodes, 0)
  75. return { key, kind: ['SessionHeader', 'JsonlHeaderLine'].includes(key) ? 'header' : key === 'SessionEventEnvelope' ? 'envelope' : 'event',
  76. ...(key.startsWith('event:') ? { event: key.slice(6), surface: false } : {}), schema, digest: schemaDigest(schema) }
  77. }
  78. function inventory(eventShape: Shape = { value: 'string' }, version = 3): PersistenceSchemaInventory {
  79. return { formatVersion: 1, types: [], roots: [typeRoot('SessionHeader', { id: 'string' }, version), typeRoot('SessionEventEnvelope', { type: 'string' }), typeRoot('event:example/value', eventShape), typeRoot('JsonlHeaderLine', { version: 'number' })] }
  80. }
  81. function entry(
  82. id: string, schema: PersistenceSchemaInventory, previous: string | null,
  83. baseline = false, decision: 'same-version' | 'version-bump' = 'same-version',
  84. ): PersistenceHistoryEntry {
  85. return {
  86. record: { schemaVersion: 1, id, baseline, changes: schema.roots.map(root => ({
  87. root: root.key, previous, after: root.digest, decision,
  88. })) },
  89. snapshot: schema,
  90. }
  91. }
  92. const BASE_ID = '2026-09-11-baseline'
  93. const NEXT_ID = '2026-09-11-change'
  94. function historicalSurface(operation: 'required' | 'optional' | 'absent', event = 'example/value'): PersistenceSchemaInventory {
  95. const nodes: SchemaNode[] = [
  96. { kind: 'object', indices: [], properties: [
  97. { name: 'type', type: 1, optional: false },
  98. ...(operation === 'absent' ? [] : [{ name: 'surfaceOp', type: 2, optional: operation === 'optional' }]),
  99. ] },
  100. { kind: 'literal', value: event },
  101. { kind: 'primitive', type: 'string' },
  102. ]
  103. const schema = canonicalizeSchema(nodes, 0)
  104. return { formatVersion: 1, types: [], roots: [{
  105. key: 'event:example/value', kind: 'event', event: 'example/value', surface: true, schema, digest: schemaDigest(schema),
  106. }] }
  107. }
  108. describe('historical persistence snapshot parsing', () => {
  109. it('preserves source paths and fingerprints in historical inventories', () => {
  110. const snapshot = historicalSurface('required')
  111. const root = snapshot.roots[0]!
  112. const sources = ['packages/core/example/src/types.ts']
  113. const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: ['Example'], sources }] }
  114. expect(parseHistoricalPersistenceSnapshot(complete)).toEqual(complete)
  115. })
  116. it.each([':8', ':8:3', '#L8', '#L8-L12'])('rejects source coordinates %s only in historical inventories', (suffix) => {
  117. const snapshot = historicalSurface('required')
  118. const root = snapshot.roots[0]!
  119. const sources = [`packages/core/example/src/types.ts${suffix}`]
  120. const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: [], sources }] }
  121. expect(() => parseHistoricalPersistenceSnapshot(complete)).toThrow('historical schema sources must omit line numbers')
  122. expect(parsePersistenceSnapshot(complete)).toEqual(complete)
  123. })
  124. it('admits optional surface operations only through the historical parser', () => {
  125. const snapshot = historicalSurface('optional')
  126. expect(() => parsePersistenceSnapshot(snapshot)).toThrow('surface metadata')
  127. expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(snapshot)
  128. expect(parsePersistenceSnapshot(historicalSurface('required'))).toEqual(historicalSurface('required'))
  129. })
  130. it('still rejects absent operations, mismatched event tags, and invalid roots', () => {
  131. expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('absent'))).toThrow('surface metadata')
  132. expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('optional', 'wrong/tag'))).toThrow('type does not match')
  133. const snapshot = historicalSurface('optional')
  134. expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, key: 'event:wrong/key' })) })).toThrow('invalid event root key')
  135. expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, surface: false })) })).toThrow('surface metadata')
  136. })
  137. it('preserves graph and digest validation in historical mode', () => {
  138. const snapshot = historicalSurface('optional')
  139. expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, digest: 'f'.repeat(64) })) })).toThrow('digest mismatch')
  140. expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, schema: { root: 0, nodes: [{ kind: 'array', element: 9 }] } })) })).toThrow('unknown schema node')
  141. })
  142. })
  143. function onlyEvent(schema: PersistenceSchemaInventory): PersistenceSchemaInventory {
  144. return { ...schema, roots: schema.roots.filter(root => root.kind === 'event') }
  145. }
  146. function finishDocuments(root: string, id: string): void {
  147. for (const suffix of ['.md', '.zh.md']) {
  148. const path = join(root, 'docs/persistence-changes', id + suffix)
  149. writeFileSync(path, readFileSync(path, 'utf8').replaceAll('TODO: explain this change.', 'Optional payload metadata preserves the recorded value.')
  150. .replaceAll('TODO: record validation evidence.', 'The focused persistence-history tests passed.'))
  151. }
  152. }
  153. function commitCurrent(root: string, schema: PersistenceSchemaInventory): void {
  154. writeFileSync(join(root, 'docs/persistence-schema.json'), JSON.stringify(schema, null, 2) + '\n')
  155. }
  156. function baseline(root: string, schema = inventory()): void {
  157. runPersistenceChanges(['--baseline', BASE_ID], root, () => schema)
  158. finishDocuments(root, BASE_ID)
  159. commitCurrent(root, schema)
  160. }
  161. function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoot {
  162. const nodes: SchemaNode[] = [
  163. { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
  164. { kind: 'union', types: arms.map((_, index) => 5 + index) },
  165. { kind: 'primitive', type: 'string' },
  166. { kind: 'primitive', type: 'number' },
  167. { kind: 'primitive', type: 'boolean' },
  168. ...arms.map(properties => ({ kind: 'object' as const, indices: [], properties })),
  169. ]
  170. const schema = canonicalizeSchema(nodes, 0)
  171. return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
  172. }
  173. describe('persistence change classification', () => {
  174. it('treats a new optional payload subtree as one additive change even with required descendants', () => {
  175. const before = typeRoot('event:example/value', { value: 'string' })
  176. const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
  177. expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresVersionBump: false }])
  178. })
  179. it('permits required-to-optional payload properties while rejecting opposite changes, type changes, and removals', () => {
  180. const required = typeRoot('event:example/value', { value: 'string' })
  181. const optional = typeRoot('event:example/value', { 'value?': 'string' })
  182. expect(classifyPersistenceChange(required, optional).every(change => !change.requiresVersionBump)).toBe(true)
  183. for (const after of [required, typeRoot('event:example/value', { 'value?': 'number' }), typeRoot('event:example/value', {})]) {
  184. expect(classifyPersistenceChange(optional, after).some(change => change.requiresVersionBump)).toBe(true)
  185. }
  186. })
  187. it('pairs multiple discriminated union arms when a shared payload gains an optional property', () => {
  188. function unionRoot(optional: boolean): PersistenceRoot {
  189. const nodes: SchemaNode[] = [
  190. { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
  191. { kind: 'union', types: [2, 3] },
  192. { kind: 'object', indices: [], properties: [{ name: 'kind', type: 4, optional: false }, { name: 'payload', type: 6, optional: false }] },
  193. { kind: 'object', indices: [], properties: [{ name: 'kind', type: 5, optional: false }, { name: 'payload', type: 6, optional: false }] },
  194. { kind: 'literal', value: 'left' }, { kind: 'literal', value: 'right' },
  195. { kind: 'object', indices: [], properties: [{ name: 'value', type: 7, optional: false }, ...(optional ? [{ name: 'label', type: 7, optional: true }] : [])] },
  196. { kind: 'primitive', type: 'string' },
  197. ]
  198. const schema = canonicalizeSchema(nodes, 0)
  199. return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
  200. }
  201. const differences = classifyPersistenceChange(unionRoot(false), unionRoot(true))
  202. expect(differences.length).toBeGreaterThan(0)
  203. expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
  204. })
  205. it('allows optional additions across undiscriminated union arms', () => {
  206. const first = [{ name: 'a', type: 2, optional: false }]
  207. const second = [{ name: 'b', type: 3, optional: false }]
  208. const extra = { name: 'x', type: 4, optional: true }
  209. const differences = classifyPersistenceChange(unionBody([first, second]), unionBody([[...first, extra], [...second, extra]]))
  210. expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresVersionBump: false })])
  211. })
  212. it('allows required-to-optional fields across undiscriminated union arms', () => {
  213. const before = unionBody([[{ name: 'a', type: 2, optional: false }], [{ name: 'b', type: 3, optional: false }]])
  214. const after = unionBody([[{ name: 'a', type: 2, optional: true }], [{ name: 'b', type: 3, optional: true }]])
  215. const differences = classifyPersistenceChange(before, after)
  216. expect(differences.map(change => change.path).sort()).toEqual(['event:example/value.data.a', 'event:example/value.data.b'])
  217. expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresVersionBump)).toBe(true)
  218. })
  219. it('finds a complete matching when one union arm has multiple additive successors', () => {
  220. const required = [{ name: 'a', type: 2, optional: false }]
  221. const optional = [{ name: 'a', type: 2, optional: true }]
  222. const flexible = [{ name: '0', type: 4, optional: true }, ...optional]
  223. const constrained = [...required, { name: 'z', type: 4, optional: true }]
  224. const before = unionBody([required, optional])
  225. for (const after of [unionBody([flexible, constrained]), unionBody([constrained, flexible])]) {
  226. const differences = classifyPersistenceChange(before, after)
  227. expect(differences.length).toBeGreaterThan(0)
  228. expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
  229. }
  230. const noCompleteMatching = unionBody([flexible, [{ name: 'different', type: 4, optional: true }]])
  231. expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresVersionBump)).toBe(true)
  232. })
  233. it('keeps union cardinality, property removal, new required fields, and value types strict', () => {
  234. const first = [{ name: 'a', type: 2, optional: false }]
  235. const second = [{ name: 'b', type: 3, optional: false }]
  236. const third = [{ name: 'c', type: 4, optional: false }]
  237. const before = unionBody([first, second])
  238. const variants = [
  239. unionBody([first, second, third]),
  240. unionBody([first]),
  241. unionBody([first, third]),
  242. unionBody([[...first, { name: 'x', type: 4, optional: false }], [...second, { name: 'x', type: 4, optional: true }]]),
  243. unionBody([[{ name: 'a', type: 3, optional: true }], [{ name: 'b', type: 3, optional: true }]]),
  244. ]
  245. for (const after of variants) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
  246. })
  247. it('checks all recursive union arms without retaining provisional successes between candidates', () => {
  248. const next = { name: 'next', type: 1, optional: true }
  249. const first = [{ name: 'a', type: 2, optional: false }, next]
  250. const second = [{ name: 'b', type: 3, optional: false }, next]
  251. const extra = { name: 'x', type: 4, optional: true }
  252. const before = unionBody([first, second])
  253. const additive = unionBody([[...first, extra], [...second, extra]])
  254. expect(classifyPersistenceChange(before, additive).every(change => !change.requiresVersionBump)).toBe(true)
  255. const invalid = unionBody([[...first, extra], [{ name: 'b', type: 2, optional: false }, next, extra]])
  256. expect(classifyPersistenceChange(before, invalid).some(change => change.requiresVersionBump)).toBe(true)
  257. expect(classifyPersistenceChange(invalid, before).some(change => change.requiresVersionBump)).toBe(true)
  258. })
  259. it('preserves body scope through arrays and tuples and rejects a simultaneous value-type change', () => {
  260. function wrapped(optional: boolean, primitive: 'string' | 'number' = 'string'): PersistenceRoot {
  261. const nodes: SchemaNode[] = [
  262. { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
  263. { kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }] },
  264. { kind: 'array', element: 3 },
  265. { kind: 'object', indices: [], properties: [{ name: 'value', type: 4, optional }] },
  266. { kind: 'primitive', type: primitive },
  267. ]
  268. const schema = canonicalizeSchema(nodes, 0)
  269. return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
  270. }
  271. expect(classifyPersistenceChange(wrapped(false), wrapped(true)).every(change => !change.requiresVersionBump)).toBe(true)
  272. expect(classifyPersistenceChange(wrapped(false), wrapped(true, 'number')).some(change => change.requiresVersionBump)).toBe(true)
  273. })
  274. it('keeps optional header/envelope properties and surface event additions strict', () => {
  275. for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
  276. expect(classifyPersistenceChange(typeRoot(key, {}), typeRoot(key, { 'metadata?': 'string' }))[0]?.requiresVersionBump).toBe(true)
  277. }
  278. const added = typeRoot('event:example/added', { value: 'string' })
  279. expect(classifyPersistenceChange(null, added)[0]?.requiresVersionBump).toBe(false)
  280. expect(classifyPersistenceChange(null, { ...added, surface: true })[0]?.requiresVersionBump).toBe(true)
  281. expect(classifyPersistenceChange(added, null)[0]?.requiresVersionBump).toBe(true)
  282. })
  283. })
  284. describe('persistence history verification', () => {
  285. it('accepts a baseline and a successive optional addition without comparing the historical schema to current', () => {
  286. const before = inventory()
  287. const after = inventory({ value: 'string', 'label?': 'string' })
  288. const history = validatePersistenceHistory([entry(BASE_ID, before, null, true), entry(NEXT_ID, onlyEvent(after), BASE_ID)])
  289. expect(history.tips.get('event:example/value')?.root?.digest).toBe(after.roots[2]?.digest)
  290. expect(history.tips.get('SessionHeader')?.id).toBe(BASE_ID)
  291. })
  292. it('rejects missing predecessors, forks, cycles, duplicates, and missing baseline', () => {
  293. const base = entry(BASE_ID, inventory(), null, true)
  294. const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
  295. expect(() => validatePersistenceHistory([next])).toThrow('exactly one baseline')
  296. expect(() => validatePersistenceHistory([base, entry(NEXT_ID, next.snapshot, '2026-09-11-missing')])).toThrow('missing predecessor')
  297. expect(() => validatePersistenceHistory([base, next, entry('2026-09-11-fork', next.snapshot, BASE_ID)])).toThrow('forked')
  298. expect(() => validatePersistenceHistory([base, base])).toThrow('exactly one baseline')
  299. const cycle = entry('2026-09-11-cycle', { ...next.snapshot, roots: [typeRoot('event:cycle/event', {})] }, '2026-09-11-cycle')
  300. expect(() => validatePersistenceHistory([base, cycle])).toThrow('cycle')
  301. })
  302. it('binds breaking decisions to the same record header version transition', () => {
  303. const base = entry(BASE_ID, inventory(), null, true)
  304. const breaking = inventory({ value: 'number' }, 4)
  305. expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID)])).toThrow('requires a format version bump')
  306. expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID, false, 'version-bump')])).toThrow("this record's own")
  307. const touched = { ...breaking, roots: breaking.roots.filter(root => root.key === 'SessionHeader' || root.kind === 'event') }
  308. expect(validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump')]).tips.get('SessionHeader')?.id).toBe(NEXT_ID)
  309. const later = entry('2026-09-11-later', onlyEvent(inventory({ value: 'boolean' }, 4)), NEXT_ID, false, 'version-bump')
  310. expect(() => validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump'), later])).toThrow("this record's own")
  311. })
  312. it('retains deletion tombstones and requires re-added roots to continue them', () => {
  313. const base = entry(BASE_ID, inventory(), null, true)
  314. const after = inventory({}, 4)
  315. const header = after.roots[0]!
  316. const record: PersistenceChangeRecord = { schemaVersion: 1, id: NEXT_ID, baseline: false, changes: [
  317. { root: 'SessionHeader', previous: BASE_ID, after: header.digest, decision: 'version-bump' },
  318. { root: 'event:example/value', previous: BASE_ID, after: null, decision: 'version-bump' },
  319. ] }
  320. const deletion = { record, snapshot: { ...after, roots: [header] } }
  321. expect(validatePersistenceHistory([base, deletion]).tips.get('event:example/value')?.root).toBeNull()
  322. const restored = entry('2026-09-11-restore', onlyEvent(inventory()), NEXT_ID)
  323. expect(validatePersistenceHistory([base, deletion, restored]).tips.get('event:example/value')?.id).toBe(restored.record.id)
  324. expect(() => validatePersistenceHistory([base, deletion, entry('2026-09-11-restore', restored.snapshot, null)])).toThrow('forked')
  325. })
  326. it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
  327. const schema = inventory()
  328. const malformed = structuredClone(schema) as unknown as { roots: Array<{ schema: { nodes: unknown[] }; digest: string }> }
  329. malformed.roots[0]!.schema.nodes = [{ kind: 'array', element: 99 }]
  330. expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node')
  331. malformed.roots[0]!.schema.nodes = [{ kind: 'future' }]
  332. expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node kind')
  333. const tampered = structuredClone(schema)
  334. Object.assign(tampered.roots[0]!, { digest: '0'.repeat(64) })
  335. expect(() => parsePersistenceSnapshot(tampered)).toThrow('digest mismatch')
  336. expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 2 })).toThrow('normalization version')
  337. const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
  338. expect(() => validatePersistenceHistory([entry(BASE_ID, schema, null, true), { ...next, snapshot: inventory() }])).toThrow('snapshot roots')
  339. })
  340. })
  341. describe('persistence changes current-tree commands', () => {
  342. it('requires completed record prose, rejects stale generated output, and reports unacknowledged paths', () => {
  343. const root = fixture()
  344. const before = inventory()
  345. runPersistenceChanges(['--baseline', BASE_ID], root, () => before)
  346. expect(() => loadPersistenceHistory(root)).toThrow('complete compatibility')
  347. finishDocuments(root, BASE_ID)
  348. rmSync(join(root, 'docs/persistence-schema.json'))
  349. expect(() => verifyPersistenceChanges(root, before)).toThrow('is missing')
  350. commitCurrent(root, before)
  351. const after = inventory({ value: 'string', 'label?': 'string' })
  352. expect(() => verifyPersistenceChanges(root, after)).toThrow('is stale')
  353. commitCurrent(root, after)
  354. expect(() => verifyPersistenceChanges(root, after)).toThrow('event:example/value.data.label: optional property added (same-version allowed)')
  355. runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
  356. finishDocuments(root, NEXT_ID)
  357. expect(runPersistenceChanges([], root, () => after)).toContain('4 roots match 2 history records')
  358. expect(() => runPersistenceChanges(['--baseline', '2026-09-11-reset'], root, () => after)).toThrow('cannot reset history')
  359. expect(() => runPersistenceChanges(['--record', '2026-09-11-empty', '--decision', 'same-version'], root, () => after)).toThrow('no persistence type changes')
  360. })
  361. it('rejects changed bilingual machine declarations and unreferenced snapshots', () => {
  362. const root = fixture()
  363. baseline(root)
  364. const chinese = join(root, 'docs/persistence-changes', `${BASE_ID}.zh.md`)
  365. const original = readFileSync(chinese, 'utf8')
  366. writeFileSync(chinese, original.replace('baseline: true', 'baseline: false'))
  367. expect(() => loadPersistenceHistory(root)).toThrow('bilingual machine records differ')
  368. writeFileSync(chinese, original)
  369. writeFileSync(join(root, 'docs/persistence-changes/2026-09-11-orphan.schema.json'), '{}\n')
  370. expect(() => loadPersistenceHistory(root)).toThrow('unreferenced')
  371. })
  372. it('excludes nested historical format documents and schemas from acknowledgements', () => {
  373. const root = fixture()
  374. baseline(root)
  375. const directory = join(root, 'docs/persistence-changes/historical-formats')
  376. mkdirSync(directory)
  377. writeFileSync(join(directory, 'v0.md'), '---\nkind: persistence-format\n---\n')
  378. writeFileSync(join(directory, 'v0.schema.json'), '{}\n')
  379. expect(loadPersistenceHistory(root).entries.map(entry => entry.record.id)).toEqual([BASE_ID])
  380. })
  381. it.each([
  382. ['optional field', inventory({ value: 'string', 'label?': 'string' })],
  383. ['required-to-optional field', inventory({ 'value?': 'string' })],
  384. ['ordinary event addition', { ...inventory(), roots: [...inventory().roots, typeRoot('event:example/added', { count: 'number' })] }],
  385. ] as const)('infers same-version for %s', (_name, after) => {
  386. const root = fixture()
  387. baseline(root)
  388. runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
  389. const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
  390. expect(changes?.length).toBeGreaterThan(0)
  391. expect(changes?.every(change => change.decision === 'same-version')).toBe(true)
  392. verifyPersistenceChanges(root, after)
  393. })
  394. it('infers a version bump when the source includes its own increasing header version', () => {
  395. const root = fixture()
  396. baseline(root)
  397. const after = inventory({ value: 'number' }, 4)
  398. runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
  399. const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
  400. expect(changes?.map(change => change.root).sort()).toEqual(['SessionHeader', 'event:example/value'])
  401. expect(changes?.every(change => change.decision === 'version-bump')).toBe(true)
  402. verifyPersistenceChanges(root, after)
  403. })
  404. it('rejects inferred bumps without a header transition and explicit incorrect assertions before writing', () => {
  405. const root = fixture()
  406. baseline(root)
  407. const prose = proseFile(root)
  408. const directory = join(root, 'docs/persistence-changes')
  409. const files = readdirSync(directory).sort()
  410. const inventoryPath = join(root, 'docs/persistence-schema.json')
  411. const before = readFileSync(inventoryPath, 'utf8')
  412. for (const [args, code] of [
  413. [[], 'version-transition-required'],
  414. [['--decision', 'same-version'], 'version-bump-required'],
  415. ] as const) {
  416. const result = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--prose', prose, '--json', ...args], root,
  417. () => inventory({ value: 'number' })))
  418. expect(result).toMatchObject({ ok: false, code, files: [], changes: [expect.objectContaining({ requiresVersionBump: true })] })
  419. expect(readFileSync(inventoryPath, 'utf8')).toBe(before)
  420. expect(readdirSync(directory).sort()).toEqual(files)
  421. }
  422. })
  423. it('authors a complete pair from explicit prose and reports source changes even when generated artifacts are stale', () => {
  424. const root = fixture()
  425. baseline(root)
  426. const after = inventory({ value: 'string', 'label?': 'string' })
  427. const pending = jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after))
  428. expect(pending).toMatchObject({ ok: false, operation: 'check', code: 'stale-artifacts',
  429. roots: [{ root: 'event:example/value', kind: 'event', before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }], changes: [
  430. { root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresVersionBump: false },
  431. ] })
  432. const written = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root), '--json'], root, () => after))
  433. expect(written).toMatchObject({ ok: true, operation: 'record', recordId: NEXT_ID })
  434. expect(written.files).toEqual([
  435. 'docs/persistence-schema.json', `docs/persistence-changes/${NEXT_ID}.md`, `docs/persistence-changes/${NEXT_ID}.zh.md`,
  436. `docs/persistence-changes/${NEXT_ID}.i18n.yaml`, `docs/persistence-changes/${NEXT_ID}.schema.json`,
  437. ])
  438. expect(written).toMatchObject({ roots: [{ root: 'event:example/value', kind: 'event',
  439. before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }] })
  440. expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).toContain(AUTHORED_PROSE.en.compatibility)
  441. expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after)).ok).toBe(true)
  442. })
  443. it('refreshes only a terminal acknowledgement while preserving its authored prose and predecessor', () => {
  444. const root = fixture()
  445. baseline(root)
  446. const after = inventory({ value: 'string', 'label?': 'string' })
  447. const prose = proseFile(root)
  448. runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
  449. const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
  450. const beforeText = readFileSync(path, 'utf8')
  451. const refreshed = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number' })
  452. runPersistenceChanges(['--update', NEXT_ID], root, () => refreshed)
  453. const stripRecord = (value: string): string => value.replace(/```yaml persistence-change[\s\S]*?```/u, '')
  454. expect(stripRecord(readFileSync(path, 'utf8'))).toBe(stripRecord(beforeText))
  455. expect(loadPersistenceHistory(root).tips.get('event:example/value')?.id).toBe(NEXT_ID)
  456. expect(loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes[0]?.previous).toBe(BASE_ID)
  457. expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => refreshed)).ok).toBe(true)
  458. const final = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number', 'last?': 'boolean' })
  459. runPersistenceChanges(['--record', '2026-09-11-successor', '--decision', 'same-version', '--prose', prose], root, () => final)
  460. expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => final)).toThrow('with successors')
  461. expect(() => runPersistenceChanges(['--update', BASE_ID, '--decision', 'same-version'], root, () => final)).toThrow('cannot update the persistence baseline')
  462. })
  463. it('completes a scaffold through update and rejects invalid prose or decisions before changing files', () => {
  464. const root = fixture()
  465. baseline(root)
  466. const after = inventory({ value: 'string', 'label?': 'string' })
  467. runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
  468. const prose = proseFile(root)
  469. runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
  470. expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
  471. expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).not.toContain('TODO:')
  472. const pairedPaths = ['.md', '.zh.md', '.i18n.yaml'].map(suffix => join(root, `docs/persistence-changes/${NEXT_ID}${suffix}`))
  473. const completed = pairedPaths.map(path => readFileSync(path, 'utf8'))
  474. runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
  475. expect(pairedPaths.map(path => readFileSync(path, 'utf8'))).toEqual(completed)
  476. const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
  477. const before = readFileSync(path, 'utf8')
  478. const invalid = inventory({ value: 'number' })
  479. const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--json'], root, () => invalid))
  480. expect(result).toMatchObject({ ok: false, code: 'version-bump-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })] })
  481. expect(readFileSync(path, 'utf8')).toBe(before)
  482. for (const value of [
  483. { ...AUTHORED_PROSE, extra: 'unsupported' },
  484. { ...AUTHORED_PROSE, en: { ...AUTHORED_PROSE.en, compatibility: ' ' } },
  485. { ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: 'TODO: record validation evidence.' } },
  486. { ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: '```text\nUnpaired code.\n```' } },
  487. ]) {
  488. writeFileSync(prose, JSON.stringify(value))
  489. expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)).toThrow()
  490. expect(readFileSync(path, 'utf8')).toBe(before)
  491. }
  492. })
  493. // Six real CLI processes and TypeScript extraction share the Windows coverage test budget.
  494. it('executes the real CLI against a source-only temporary checkout without Git history', { timeout: 90_000 }, () => {
  495. const root = fixture()
  496. const physical = join(root, 'packages/session/session-persistence-jsonl/src')
  497. mkdirSync(physical, { recursive: true })
  498. writeFileSync(join(physical, 'format.ts'), "interface HeaderLine { type: 'session'; version: number; id: string; delegationDepth: number }; export {}\n")
  499. const session = join(root, 'packages/core/session/src')
  500. mkdirSync(session, { recursive: true })
  501. writeFileSync(join(session, '../package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-session' }))
  502. writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ compilerOptions: {
  503. target: 'ESNext', module: 'ESNext', moduleResolution: 'Bundler', types: [], skipLibCheck: true,
  504. paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
  505. } }))
  506. const source = [
  507. '/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
  508. '/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
  509. "'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
  510. '/** Persisted event names. */', 'export type SessionEventType = keyof SessionEventMap',
  511. '/** Surface placement. */', "export type SurfaceOp = 'append'", '/** Persisted event. */',
  512. 'export type SessionEvent<K extends keyof SessionEventMap = keyof SessionEventMap> =',
  513. ' { [P in K]: { type: P; seq: number; data: SessionEventMap[P]; surfaceOp: SurfaceOp } }[K]', '',
  514. ].join('\n')
  515. writeFileSync(join(session, 'types.ts'), source)
  516. const script = resolve(import.meta.dirname, 'persistence-changes.ts')
  517. const cli = (...args: string[]): ReturnType<typeof spawnSync> => spawnSync(process.execPath, ['--import', import.meta.resolve('tsx/esm'), script, '--root', root, ...args], {
  518. cwd: root, encoding: 'utf8', timeout: 120_000,
  519. })
  520. const prose = proseFile(root)
  521. const initialized = cli('--baseline', BASE_ID, '--prose', prose)
  522. expect(initialized.error).toBeUndefined()
  523. expect(initialized.signal).toBeNull()
  524. expect(initialized.status, String(initialized.stderr)).toBe(0)
  525. const accepted = cli('--check')
  526. expect(accepted.error).toBeUndefined()
  527. expect(accepted.signal).toBeNull()
  528. expect(accepted.status, String(accepted.stderr)).toBe(0)
  529. const optional = source.replace('value: string', 'value: string; label?: string')
  530. writeFileSync(join(session, 'types.ts'), optional)
  531. const authored = cli('--record', NEXT_ID, '--prose', prose, '--json')
  532. expect(authored.error).toBeUndefined()
  533. expect(authored.signal).toBeNull()
  534. expect(authored.status, String(authored.stderr)).toBe(0)
  535. expect(JSON.parse(String(authored.stdout)) as unknown).toMatchObject({ ok: true, operation: 'record' })
  536. const beforeUpdate = readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')
  537. writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number'))
  538. const updated = cli('--update', NEXT_ID, '--json')
  539. expect(updated.error).toBeUndefined()
  540. expect(updated.signal).toBeNull()
  541. expect(updated.status, String(updated.stderr)).toBe(0)
  542. expect(JSON.parse(String(updated.stdout)) as unknown).toMatchObject({ ok: true, operation: 'update' })
  543. expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8').replace(/```yaml persistence-change[\s\S]*?```/u, ''))
  544. .toBe(beforeUpdate.replace(/```yaml persistence-change[\s\S]*?```/u, ''))
  545. const generated = extractPersistenceSchema(root)
  546. for (const file of persistenceCatalogArtifacts(root, generated)) expect(readFileSync(join(root, file.path), 'utf8')).toBe(file.content)
  547. verifyPersistenceChanges(root, generated)
  548. writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number').replace('value: string', 'value: number'))
  549. commitCurrent(root, extractPersistenceSchema(root))
  550. const rejected = cli('--check')
  551. expect(rejected.error).toBeUndefined()
  552. expect(rejected.signal).toBeNull()
  553. expect(rejected.status).toBe(1)
  554. expect(String(rejected.stderr)).toContain('version-bump required')
  555. const structured = cli('--check', '--json')
  556. expect(structured.error).toBeUndefined()
  557. expect(structured.signal).toBeNull()
  558. expect(structured.status).toBe(1)
  559. expect(JSON.parse(String(structured.stdout)) as unknown).toMatchObject({
  560. ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
  561. })
  562. })
  563. })