gen-session-format-catalog.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /** Generate the build-static Session format catalog from edge package metadata. */
  2. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. const root = resolve(import.meta.dirname, '..')
  6. const OUT = 'packages/session/session-format-catalog/src/generated.ts'
  7. /** Internal adjacent migration metadata discovered only in workspace migration packages. */
  8. interface SessionFormatMigrationDeclaration {
  9. /** Non-negative safe integer source version; negative zero is rejected. */
  10. from: number
  11. /** Non-negative safe integer target version, exactly from + 1. */
  12. to: number
  13. /** Non-empty package export path, such as `.` or `./migration`. */
  14. export: string
  15. /** Non-empty named export of the migration implementation. */
  16. migration: string
  17. /** Non-empty named export of the source version codec. */
  18. sourceCodec: string
  19. /** Non-empty named export of the target version codec. */
  20. targetCodec: string
  21. /** Non-empty named export of the target header validator. */
  22. targetHeaderValidator: string
  23. /** Non-empty named export of the target version restorer. */
  24. targetRestorer: string
  25. }
  26. /** Validated adjacent migration metadata with its resolved package import path. */
  27. export interface SessionFormatMigrationManifest extends Readonly<Omit<SessionFormatMigrationDeclaration, 'export'>> {
  28. readonly packageName: string
  29. readonly importPath: string
  30. }
  31. interface RawManifest {
  32. readonly name?: unknown
  33. readonly dependencies?: Readonly<Record<string, unknown>>
  34. readonly peerDependencies?: Readonly<Record<string, unknown>>
  35. readonly devDependencies?: Readonly<Record<string, unknown>>
  36. readonly dsh?: {
  37. readonly sessionFormatMigration?: Readonly<Record<string, unknown>>
  38. }
  39. }
  40. function readJson(path: string): RawManifest {
  41. return JSON.parse(readFileSync(path, 'utf8')) as RawManifest
  42. }
  43. function safeVersion(value: unknown, label: string): number {
  44. if (!Number.isSafeInteger(value) || (value as number) < 0 || Object.is(value, -0)) {
  45. throw new Error(`gen-session-format-catalog: ${label} must be a non-negative safe integer`)
  46. }
  47. return value as number
  48. }
  49. function nonempty(value: unknown, label: string): string {
  50. if (typeof value !== 'string' || value.length === 0) {
  51. throw new Error(`gen-session-format-catalog: ${label} must be a non-empty string`)
  52. }
  53. return value
  54. }
  55. /**
  56. * Read the current writer version from the core Session source of truth.
  57. * @param scanRoot - repository root whose Session source is authoritative.
  58. * @returns the current non-negative Session format version.
  59. */
  60. export function readCurrentSessionFormatVersion(scanRoot: string = root): number {
  61. const source = readFileSync(resolve(scanRoot, 'packages/core/session/src/types.ts'), 'utf8')
  62. const match = source.match(/export const SESSION_FORMAT_VERSION = (\d+)\b/)
  63. if (match === null) throw new Error('gen-session-format-catalog: cannot read SESSION_FORMAT_VERSION')
  64. return safeVersion(Number(match[1]), 'SESSION_FORMAT_VERSION')
  65. }
  66. /**
  67. * Collect and validate the unique complete adjacent migration inventory.
  68. * @param scanRoot - repository root containing migration package manifests.
  69. * @param currentVersion - writer version the inventory must reach exactly.
  70. * @returns ordered adjacent migration declarations from v0 to the current writer.
  71. */
  72. export function collectSessionFormatMigrations(
  73. scanRoot: string = root,
  74. currentVersion: number = readCurrentSessionFormatVersion(scanRoot),
  75. ): SessionFormatMigrationManifest[] {
  76. const declarations: SessionFormatMigrationManifest[] = []
  77. for (const discovered of globSync('packages/session/session-format-v*-to-v*/package.json', { cwd: scanRoot }).sort()) {
  78. const rel = discovered.replaceAll('\\', '/')
  79. const manifest = readJson(resolve(scanRoot, rel))
  80. const metadata = manifest.dsh?.sessionFormatMigration
  81. if (metadata === undefined) {
  82. throw new Error(`gen-session-format-catalog: ${rel} lacks dsh.sessionFormatMigration`)
  83. }
  84. const allowed: ReadonlySet<string> = new Set<keyof SessionFormatMigrationDeclaration>([
  85. 'from', 'to', 'export', 'migration', 'sourceCodec', 'targetCodec',
  86. 'targetHeaderValidator', 'targetRestorer',
  87. ])
  88. const extra = Object.keys(metadata).find(key => !allowed.has(key))
  89. if (extra !== undefined) throw new Error(`gen-session-format-catalog: ${rel} has unknown metadata member ${extra}`)
  90. const packageName = nonempty(manifest.name, `${rel} name`)
  91. const from = safeVersion(metadata['from'], `${rel} from`)
  92. const to = safeVersion(metadata['to'], `${rel} to`)
  93. if (to !== from + 1) throw new Error(`gen-session-format-catalog: ${rel} must declare adjacent v${from}->v${from + 1}`)
  94. const expectedPackageName = `@deepseek-ai/dsh-session-format-v${from}-to-v${to}`
  95. if (packageName !== expectedPackageName) {
  96. throw new Error(`gen-session-format-catalog: ${rel} name must be ${expectedPackageName}`)
  97. }
  98. const directoryMatch = rel.match(/session-format-v(\d+)-to-v(\d+)\/package\.json$/)
  99. if (directoryMatch === null || Number(directoryMatch[1]) !== from || Number(directoryMatch[2]) !== to) {
  100. throw new Error(`gen-session-format-catalog: ${rel} directory does not match v${from}->v${to}`)
  101. }
  102. const exportPath = nonempty(metadata['export'], `${rel} export`)
  103. declarations.push({
  104. packageName,
  105. importPath: exportPath === '.' ? packageName : `${packageName}/${exportPath.replace(/^\.\//, '')}`,
  106. from,
  107. to,
  108. migration: nonempty(metadata['migration'], `${rel} migration`),
  109. sourceCodec: nonempty(metadata['sourceCodec'], `${rel} sourceCodec`),
  110. targetCodec: nonempty(metadata['targetCodec'], `${rel} targetCodec`),
  111. targetHeaderValidator: nonempty(metadata['targetHeaderValidator'], `${rel} targetHeaderValidator`),
  112. targetRestorer: nonempty(metadata['targetRestorer'], `${rel} targetRestorer`),
  113. })
  114. }
  115. declarations.sort((left, right) => left.from - right.from)
  116. for (let version = 0; version < currentVersion; version += 1) {
  117. const matches = declarations.filter(item => item.from === version)
  118. if (matches.length !== 1) {
  119. throw new Error(`gen-session-format-catalog: expected exactly one v${version}->v${version + 1} package, found ${matches.length}`)
  120. }
  121. }
  122. const extra = declarations.find(item => item.from >= currentVersion)
  123. if (extra !== undefined || declarations.length !== currentVersion) {
  124. throw new Error(`gen-session-format-catalog: migration inventory does not end exactly at current v${currentVersion}`)
  125. }
  126. const catalog = readJson(resolve(scanRoot, 'packages/session/session-format-catalog/package.json'))
  127. if (catalog.dependencies?.['@deepseek-ai/dsh-session'] !== undefined
  128. || catalog.peerDependencies?.['@deepseek-ai/dsh-session'] === undefined
  129. || catalog.devDependencies?.['@deepseek-ai/dsh-session'] === undefined) {
  130. throw new Error(
  131. 'gen-session-format-catalog: catalog must share @deepseek-ai/dsh-session through peer + dev dependencies',
  132. )
  133. }
  134. for (const [index, declaration] of declarations.entries()) {
  135. if (catalog.dependencies?.[declaration.packageName] === undefined) {
  136. throw new Error(`gen-session-format-catalog: catalog package lacks dependency ${declaration.packageName}`)
  137. }
  138. const previous = declarations[index - 1]
  139. if (previous === undefined) continue
  140. if (declaration.sourceCodec !== previous.targetCodec) {
  141. throw new Error(
  142. `gen-session-format-catalog: v${declaration.from} source codec ${declaration.sourceCodec} `
  143. + `does not continue ${previous.targetCodec}`,
  144. )
  145. }
  146. const manifest = readJson(resolve(
  147. scanRoot,
  148. `packages/session/session-format-v${declaration.from}-to-v${declaration.to}/package.json`,
  149. ))
  150. if (manifest.dependencies?.[previous.packageName] === undefined) {
  151. throw new Error(
  152. `gen-session-format-catalog: ${declaration.packageName} must depend on ${previous.packageName} `
  153. + 'to share the adjacent source codec',
  154. )
  155. }
  156. }
  157. return declarations
  158. }
  159. /**
  160. * Render the deterministic direct-import catalog source.
  161. * @param declarations - validated adjacent migrations in version order.
  162. * @param currentVersion - writer version reached by the final declaration.
  163. * @returns complete generated TypeScript source.
  164. */
  165. export function renderSessionFormatCatalog(
  166. declarations: readonly SessionFormatMigrationManifest[],
  167. currentVersion: number,
  168. ): string {
  169. const imports = declarations.map((item) => {
  170. const names = [item.targetCodec, item.migration]
  171. if (item.from === 0) names.push(item.sourceCodec)
  172. if (item.to === currentVersion) names.push(item.targetRestorer, item.targetHeaderValidator)
  173. return `import { ${[...new Set(names)].sort().join(', ')} } from '${item.importPath}'`
  174. })
  175. const first = declarations[0]
  176. const codecs = first === undefined
  177. ? []
  178. : [first.sourceCodec, ...declarations.map(item => item.targetCodec)]
  179. const restorer = declarations.at(-1)?.targetRestorer
  180. const headerValidator = declarations.at(-1)?.targetHeaderValidator
  181. const currentCodec = declarations.at(-1)?.targetCodec
  182. if (restorer === undefined) throw new Error('gen-session-format-catalog: current format has no target restorer')
  183. if (headerValidator === undefined) {
  184. throw new Error('gen-session-format-catalog: current format has no target header validator')
  185. }
  186. if (currentCodec === undefined) throw new Error('gen-session-format-catalog: current format has no target codec')
  187. return [
  188. '/**',
  189. ' * GENERATED by `scripts/gen-session-format-catalog.ts` — do not edit by hand.',
  190. ' * The direct imports make historical readability independent of mounted plugins.',
  191. ' */',
  192. '',
  193. "import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'",
  194. "import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'",
  195. "import { validateInstalledCurrentSessionArtifact, validateInstalledCurrentSessionHeader } from './current.ts'",
  196. ...imports,
  197. '',
  198. '/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */',
  199. 'export const sessionFormatCatalog = createSessionFormatCatalog({',
  200. ` currentVersion: ${currentVersion},`,
  201. ` codecs: [${codecs.join(', ')}],`,
  202. ` currentEncoder: ${currentCodec},`,
  203. ` migrations: [${declarations.map(item => item.migration).join(', ')}],`,
  204. ' restoreCurrent(artifact) {',
  205. ` const restored = ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`,
  206. ' validateInstalledCurrentSessionArtifact(restored)',
  207. ' return restored',
  208. ' },',
  209. ' restoreTransformedCurrent(artifact) {',
  210. ` return ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`,
  211. ' },',
  212. ' restoreCurrentHeader(header) {',
  213. ` ${headerValidator}(header)`,
  214. ' validateInstalledCurrentSessionHeader(header)',
  215. ' return header',
  216. ' },',
  217. '})',
  218. '',
  219. ].join('\n')
  220. }
  221. function main(): void {
  222. const currentVersion = readCurrentSessionFormatVersion(root)
  223. const declarations = collectSessionFormatMigrations(root, currentVersion)
  224. const output = renderSessionFormatCatalog(declarations, currentVersion)
  225. const target = resolve(root, OUT)
  226. if (process.argv.includes('--check')) {
  227. let current = ''
  228. try { current = readFileSync(target, 'utf8') } catch { /* missing is stale */ }
  229. if (current !== output) {
  230. console.error(`gen-session-format-catalog: ${OUT} is stale; run pnpm run gen-session-format-catalog`)
  231. process.exitCode = 1
  232. return
  233. }
  234. console.log(`gen-session-format-catalog: ${OUT} is up to date.`)
  235. return
  236. }
  237. writeFileSync(target, output)
  238. console.log(`gen-session-format-catalog: wrote ${OUT}.`)
  239. }
  240. if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main()