persistence-formats.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /** Archive and verify complete Session format references through the current writer. */
  2. import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
  3. import { join, resolve } from 'node:path'
  4. import { parseArgs } from 'node:util'
  5. import { JSON_SCHEMA, load } from 'js-yaml'
  6. import { readCurrentSessionFormatVersion } from './gen-session-format-catalog.ts'
  7. import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
  8. import { persistenceFormatFactArtifacts } from './persistence-format-facts.ts'
  9. import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
  10. import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
  11. import { withoutPersistenceSourceLines } from './persistence-source-metadata.ts'
  12. const DIRECTORY = 'docs/persistence-changes/historical-formats'
  13. const CURRENT_DOCUMENT = 'docs/persistence-catalog.md'
  14. const CURRENT_SCHEMA = 'docs/persistence-schema.json'
  15. /** Historical checkout that supplies a format's complete declared persistence inventory. */
  16. export type PersistenceFormatSource = { readonly tag: string } | { readonly pullRequest: number }
  17. /** One complete format reference; the current catalog follows the historical entries. */
  18. export interface PersistenceFormatEntry {
  19. readonly version: number
  20. readonly document: string
  21. readonly schemaPath: string
  22. readonly inventory: PersistenceSchemaInventory
  23. readonly source?: PersistenceFormatSource
  24. }
  25. /** Contiguous format references ending at the source-declared writer version. */
  26. export interface PersistenceFormats {
  27. readonly currentVersion: number
  28. readonly entries: readonly PersistenceFormatEntry[]
  29. }
  30. interface PersistenceFormatRecord {
  31. readonly source: PersistenceFormatSource
  32. readonly roots: ReadonlyMap<string, string>
  33. }
  34. function fields(value: unknown, expected: readonly string[], label: string): Record<string, unknown> {
  35. if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: expected an object`)
  36. const record = value as Record<string, unknown>
  37. if (Object.keys(record).length !== expected.length || expected.some(key => !Object.hasOwn(record, key))) {
  38. throw new Error(`${label}: expected fields ${expected.join(', ')}`)
  39. }
  40. return record
  41. }
  42. function machineBlock(document: string, label: string): string {
  43. if (/\.[cm]?[jt]sx?(?::\d+(?::\d+)?|#L\d+(?:-L\d+)?)/u.test(document)) {
  44. throw new Error(`${label}: historical source references must omit line numbers`)
  45. }
  46. const frontmatter = /^---\n([\s\S]*?)\n---(?:\n|$)/u.exec(document)?.[1]
  47. const metadata: unknown = frontmatter === undefined ? undefined : load(frontmatter, { schema: JSON_SCHEMA })
  48. if (metadata === null || typeof metadata !== 'object' || !('kind' in metadata) || metadata.kind !== 'persistence-format') {
  49. throw new Error(`${label}: expected persistence-format frontmatter kind`)
  50. }
  51. const blocks = [...document.matchAll(/^```yaml persistence-format[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
  52. if (blocks.length !== 1) throw new Error(`${label}: expected exactly one persistence-format machine record`)
  53. return (blocks[0] as RegExpMatchArray)[1] as string
  54. }
  55. function parseSource(source: unknown, label: string): PersistenceFormatSource {
  56. if (source !== null && typeof source === 'object' && 'tag' in source) {
  57. const tag = fields(source, ['tag'], `${label} source`).tag
  58. if (typeof tag !== 'string' || !/^dsh-[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(tag)) throw new Error(`${label}: invalid source tag`)
  59. return { tag }
  60. }
  61. const pullRequest = fields(source, ['pullRequest'], `${label} source`).pullRequest
  62. if (!Number.isSafeInteger(pullRequest) || (pullRequest as number) <= 0) throw new Error(`${label}: source pullRequest must be a positive integer`)
  63. return { pullRequest: pullRequest as number }
  64. }
  65. function parseRecord(block: string, version: number): PersistenceFormatRecord {
  66. const label = `v${version}`
  67. const record = fields(load(block, { schema: JSON_SCHEMA }), ['schemaVersion', 'sessionFormatVersion', 'source', 'roots'], label)
  68. if (record.schemaVersion !== 1) throw new Error(`${label}: unsupported persistence format record schema version`)
  69. if (record.sessionFormatVersion !== version) throw new Error(`${label}: record sessionFormatVersion must match its filename`)
  70. if (record.roots === null || typeof record.roots !== 'object' || Array.isArray(record.roots)) throw new Error(`${label}: roots must be a mapping`)
  71. const roots = new Map<string, string>()
  72. for (const [key, digest] of Object.entries(record.roots)) {
  73. if (!/^(?:SessionHeader|JsonlHeaderLine|SessionEventEnvelope|event:.+)$/u.test(key)
  74. || typeof digest !== 'string' || !/^[a-f0-9]{64}$/u.test(digest)) throw new Error(`${label}: invalid recorded root ${key}`)
  75. roots.set(key, digest)
  76. }
  77. return { source: parseSource(record.source, label), roots }
  78. }
  79. function validateInventory(inventory: PersistenceSchemaInventory, version: number, current: boolean): ReadonlySet<string> {
  80. const label = `v${version}`
  81. for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
  82. const root = inventory.roots.find(root => root.key === key)
  83. if (root === undefined) throw new Error(`${label}: missing schema root ${key}`)
  84. if (root.kind !== 'header') continue
  85. const node = root.schema.nodes[0]
  86. const field = node?.kind === 'object' ? node.properties.find(property => property.name === 'version') : undefined
  87. const type = field === undefined ? undefined : root.schema.nodes[field.type]
  88. const legacyNumber = !(current && key === 'SessionHeader') && type?.kind === 'primitive' && type.type === 'number'
  89. if (field?.optional !== false || !(legacyNumber || type?.kind === 'literal' && type.value === version)) {
  90. throw new Error(`${label}: ${key}.version must match the ${current ? 'current writer' : 'recorded format'} version`)
  91. }
  92. }
  93. if (!inventory.roots.some(root => root.kind === 'event')) throw new Error(`${label}: complete inventory must include an event root`)
  94. const reachable = new Set(inventory.roots.flatMap(root => root.schema.nodes
  95. .map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index)))))
  96. const remaining = new Set(reachable)
  97. const listed = new Set<string>()
  98. for (const type of inventory.types) {
  99. if (listed.has(type.digest)) throw new Error(`${label}: duplicate schema type ${type.digest}`)
  100. listed.add(type.digest)
  101. // The current extractor can retain types erased by normalization; its generator gate checks that inventory.
  102. if (!current && !reachable.has(type.digest)) throw new Error(`${label}: unreferenced schema type ${type.digest}`)
  103. remaining.delete(type.digest)
  104. }
  105. if (remaining.size > 0) throw new Error(`${label}: schema types must cover every reachable type`)
  106. return reachable
  107. }
  108. function validateDocument(
  109. document: string, schemaName: string, inventory: PersistenceSchemaInventory, label: string, current: boolean,
  110. ): void {
  111. if (!document.includes(`](${schemaName})`)) throw new Error(`${label}: missing link to ${schemaName}`)
  112. if (!current) return
  113. for (const root of inventory.roots) {
  114. const row = `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`
  115. if (!document.includes(row)) throw new Error(`${label}: missing schema index entry for ${root.key}`)
  116. }
  117. }
  118. /**
  119. * Read every required format pair and complete inventory without Git or historical source extraction.
  120. * @param root - checkout root containing the writer declaration and format references.
  121. * @returns ordered historical references followed by the current generated catalog.
  122. */
  123. export function loadPersistenceFormats(root: string): PersistenceFormats {
  124. const currentVersion = readCurrentSessionFormatVersion(root)
  125. const directory = join(root, DIRECTORY)
  126. const files = new Set(existsSync(directory) ? readdirSync(directory) : [])
  127. const expected = new Set<string>()
  128. for (let version = 0; version < currentVersion; version += 1) {
  129. for (const suffix of ['.md', '.zh.md', '.schema.json']) {
  130. const name = `v${version}${suffix}`
  131. expected.add(name)
  132. if (!files.has(name)) throw new Error(`v${version}: missing persistence format artifact ${name}`)
  133. }
  134. expected.add(`v${version}.i18n.yaml`)
  135. }
  136. for (const file of files) {
  137. if (file.startsWith('v') && !expected.has(file)) throw new Error(`unexpected persistence format artifact ${file}`)
  138. }
  139. const read = (path: string): string => {
  140. if (!existsSync(join(root, path))) throw new Error(`missing persistence format artifact ${path}`)
  141. return readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')
  142. }
  143. const entries: PersistenceFormatEntry[] = []
  144. for (let version = 0; version <= currentVersion; version += 1) {
  145. const current = version === currentVersion
  146. const document = current ? CURRENT_DOCUMENT : `${DIRECTORY}/v${version}.md`
  147. const schemaPath = current ? CURRENT_SCHEMA : `${DIRECTORY}/v${version}.schema.json`
  148. const english = read(document)
  149. const chinese = read(document.replace(/\.md$/u, '.zh.md'))
  150. let record: PersistenceFormatRecord | undefined
  151. if (!current) {
  152. const block = machineBlock(english, document)
  153. if (block !== machineBlock(chinese, document.replace(/\.md$/u, '.zh.md'))) throw new Error(`v${version}: bilingual machine records differ`)
  154. record = parseRecord(block, version)
  155. }
  156. const inventory = (current ? parsePersistenceSnapshot : parseHistoricalPersistenceSnapshot)(JSON.parse(read(schemaPath)))
  157. validateInventory(inventory, version, current)
  158. const recordedRoots = record?.roots
  159. if (recordedRoots !== undefined && (recordedRoots.size !== inventory.roots.length
  160. || inventory.roots.some(root => recordedRoots.get(root.key) !== root.digest))) {
  161. throw new Error(`v${version}: recorded roots do not match the complete schema inventory`)
  162. }
  163. const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
  164. validateDocument(english, schemaName, inventory, document, current)
  165. validateDocument(chinese, schemaName, inventory, document.replace(/\.md$/u, '.zh.md'), current)
  166. entries.push({ version, document, schemaPath, inventory, ...(record === undefined ? {} : { source: record.source }) })
  167. }
  168. return { currentVersion, entries }
  169. }
  170. function archiveCurrentInventory(root: string, requestedVersion: string): string {
  171. const version = Number(requestedVersion)
  172. if (!/^(?:0|[1-9]\d*)$/u.test(requestedVersion) || !Number.isSafeInteger(version)) {
  173. throw new Error('--archive must be a non-negative safe integer')
  174. }
  175. const currentVersion = readCurrentSessionFormatVersion(root)
  176. if (version !== currentVersion) throw new Error(`Cannot archive v${version}: current writer is v${currentVersion}`)
  177. const path = `${DIRECTORY}/v${version}.schema.json`
  178. if (existsSync(join(root, path))) throw new Error(`Cannot archive v${version}: ${path} already exists`)
  179. const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(join(root, CURRENT_SCHEMA), 'utf8')))
  180. const reachable = validateInventory(inventory, version, true)
  181. const archive = withoutPersistenceSourceLines({ ...inventory, types: inventory.types.filter(type => reachable.has(type.digest)) })
  182. mkdirSync(join(root, DIRECTORY), { recursive: true })
  183. writeFileSync(join(root, path), JSON.stringify(archive, null, 2) + '\n', { flag: 'wx' })
  184. return `Archived Session format v${version} to ${path}.`
  185. }
  186. /**
  187. * Archive the current schema or validate and optionally refresh format references.
  188. * @param args - --archive N creates the current version's schema once; --write refreshes validated facts.
  189. * @param root - default checkout directory, overridden by --root when provided.
  190. * @returns the created schema path or verified format count and refreshed artifact count.
  191. */
  192. export function runPersistenceFormats(args: readonly string[], root = resolve(import.meta.dirname, '..')): string {
  193. const { values } = parseArgs({ args: [...args], options: { root: { type: 'string' }, write: { type: 'boolean' }, archive: { type: 'string' } } })
  194. root = resolve(values.root ?? root)
  195. if (values.archive !== undefined) {
  196. if (values.write) throw new Error('--archive and --write cannot be combined')
  197. return archiveCurrentInventory(root, values.archive)
  198. }
  199. const formats = loadPersistenceFormats(root)
  200. const changed = persistenceFormatFactArtifacts(root, formats).filter(artifact => !existsSync(join(root, artifact.path))
  201. || readFileSync(join(root, artifact.path), 'utf8') !== artifact.content)
  202. if (!values.write && changed.length > 0) throw new Error(`Stale persistence format facts: ${changed.map(artifact => artifact.path).join(', ')}. Run pnpm run verify-persistence-formats --write.`)
  203. if (values.write) for (const artifact of changed) writeFileSync(join(root, artifact.path), artifact.content)
  204. return `Persistence formats: v0 through v${formats.currentVersion} verified (${formats.entries.length} complete reference${formats.entries.length === 1 ? '' : 's'}).`
  205. + (values.write ? ` Refreshed ${changed.length} file${changed.length === 1 ? '' : 's'}.` : '')
  206. }
  207. if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
  208. try {
  209. console.log(runPersistenceFormats(process.argv.slice(2)))
  210. } catch (error: unknown) {
  211. console.error(error instanceof Error ? error.message : String(error))
  212. process.exitCode = 1
  213. }
  214. }