gen-persistence-catalog.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /**
  2. * Negative-path tests for the persistence log catalog generator
  3. * (`scripts/gen-persistence-catalog.ts`).
  4. */
  5. import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { afterEach, describe, expect, it } from 'vitest'
  9. import {
  10. annotateSurface,
  11. collectEventEnvelopeTypes,
  12. collectLogEvents,
  13. collectSurfaceEventTypes,
  14. render,
  15. renderKnownEventTypes,
  16. } from '../../../../scripts/gen-persistence-catalog.ts'
  17. /** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */
  18. function fixtureRoot(files: Record<string, string>): string {
  19. const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-'))
  20. for (const [rel, source] of Object.entries(files)) {
  21. const abs = join(root, rel)
  22. mkdirSync(join(abs, '..'), { recursive: true })
  23. writeFileSync(abs, source)
  24. }
  25. return root
  26. }
  27. const roots: string[] = []
  28. const make = (files: Record<string, string>): string => {
  29. const r = fixtureRoot(files)
  30. roots.push(r)
  31. return r
  32. }
  33. /** A merge-form declaration file wrapping `members` in the session module. */
  34. const merge = (members: string): string =>
  35. `declare module '@deepseek-ai/dsh-session/types' {\n interface SessionEventMap {\n${members}\n }\n}\n`
  36. afterEach(() => {
  37. while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
  38. })
  39. /** The manifest that marks a fixture package as the owning session package. */
  40. const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n'
  41. describe('gen-persistence-catalog collectLogEvents', () => {
  42. it('generates required interpreter types from plugin-owned event declarations', () => {
  43. const events = collectLogEvents(make({
  44. 'packages/group/fix/src/types.ts': merge(' /**\n * Projects existing messages.\n * @messageProjection\n */\n \'fix/project\': { seq: number }'),
  45. }))
  46. expect(events[0]?.messageProjection).toBe(true)
  47. expect(renderKnownEventTypes(annotateSurface(events, [])))
  48. .toContain("MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet<string> = new Set([\n 'fix/project',\n])")
  49. expect(() => annotateSurface(events, ['fix/project'])).toThrow(/cannot declare both/)
  50. const ordinary = events.map(({ messageProjection: _projection, ...event }) => event)
  51. expect(renderKnownEventTypes(annotateSurface(ordinary, [])))
  52. .toContain('MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet<string> = new Set([\n])')
  53. })
  54. it('extracts a documented member of the owning top-level interface', () => {
  55. const events = collectLogEvents(make({
  56. 'packages/core/fix/package.json': OWNER_MANIFEST,
  57. 'packages/core/fix/src/types.ts':
  58. 'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n',
  59. }))
  60. expect(events).toHaveLength(1)
  61. expect(events[0]).toMatchObject({
  62. name: 'fix/happened',
  63. scope: 'fix',
  64. doc: 'A thing was recorded.',
  65. payload: '{ turn: number }',
  66. declaration: '/** A thing was recorded. */\n\'fix/happened\': { turn: number }',
  67. source: 'packages/core/fix/src/types.ts:3',
  68. })
  69. })
  70. it('hard-errors on a top-level interface outside the owning package', () => {
  71. expect(() => collectLogEvents(make({
  72. 'packages/group/alien/package.json': '{ "name": "@deepseek-ai/dsh-alien" }\n',
  73. 'packages/group/alien/src/types.ts':
  74. 'export interface SessionEventMap {\n /** Not the real vocabulary. */\n \'alien/event\': { turn: number }\n}\n',
  75. }))).toThrow(/top-level interface SessionEventMap .* is outside @deepseek-ai\/dsh-session \(package @deepseek-ai\/dsh-alien\)/)
  76. })
  77. it('hard-errors on a non-exported top-level interface even in the owning package', () => {
  78. expect(() => collectLogEvents(make({
  79. 'packages/core/fix/package.json': OWNER_MANIFEST,
  80. 'packages/core/fix/src/helper.ts':
  81. 'interface SessionEventMap {\n /** A local helper, not the vocabulary. */\n \'fix/local\': { turn: number }\n}\nexport const use: SessionEventMap | null = null\n',
  82. }))).toThrow(/is not exported; the owning vocabulary is the single exported declaration/)
  83. })
  84. it('hard-errors when the owning interface is exported from two files', () => {
  85. expect(() => collectLogEvents(make({
  86. 'packages/core/fix/package.json': OWNER_MANIFEST,
  87. 'packages/core/fix/src/a.ts': 'export interface SessionEventMap {\n /** First home. */\n \'fix/a\': { turn: number }\n}\n',
  88. 'packages/core/fix/src/b.ts': 'export interface SessionEventMap {\n /** Second home. */\n \'fix/b\': { turn: number }\n}\n',
  89. }))).toThrow(/is already declared at packages\/core\/fix\/src\/a\.ts:1; the owning vocabulary has exactly one home/)
  90. })
  91. it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => {
  92. expect(() => collectLogEvents(make({
  93. 'packages/group/fix/src/types.ts':
  94. 'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session/types\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n',
  95. }))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/)
  96. })
  97. it('extracts a member declaration-merged via the session module', () => {
  98. const events = collectLogEvents(make({
  99. 'packages/group/fix/src/types.ts': merge(' /** Merged event source record. */\n \'fix/merged\': { id: string }'),
  100. }))
  101. expect(events).toHaveLength(1)
  102. expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged event source record.' })
  103. })
  104. it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
  105. const events = collectLogEvents(make({
  106. 'packages/group/fix/src/types.ts': merge(
  107. ' /** Wide payload. */\n \'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
  108. ),
  109. }))
  110. expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
  111. expect(events[0]?.declaration).toBe(
  112. '/** Wide payload. */\n\'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n}',
  113. )
  114. })
  115. it('hard-errors on a member with no description prose', () => {
  116. expect(() => collectLogEvents(make({
  117. 'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'),
  118. }))).toThrow(/no description prose/)
  119. })
  120. it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => {
  121. expect(() => collectLogEvents(make({
  122. 'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'),
  123. }))).toThrow(/carries an @mode tag/)
  124. })
  125. it('hard-errors on an extra-indented @mode tag (does not leak into prose)', () => {
  126. expect(() => collectLogEvents(make({
  127. 'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/indented\': { turn: number }'),
  128. }))).toThrow(/carries an @mode tag/)
  129. })
  130. it('hard-errors on a method-form member (it still joins keyof SessionEventMap)', () => {
  131. expect(() => collectLogEvents(make({
  132. 'packages/group/fix/src/types.ts': merge(' /** Documented, wrong shape. */\n \'fix/method\'(turn: number): void'),
  133. }))).toThrow(/not a property signature with an explicit payload type/)
  134. })
  135. it('hard-errors on a property member with no payload type annotation', () => {
  136. expect(() => collectLogEvents(make({
  137. 'packages/group/fix/src/types.ts': merge(' /** Documented, no payload. */\n \'fix/bare\''),
  138. }))).toThrow(/not a property signature with an explicit payload type/)
  139. })
  140. it('hard-errors on a non-literal member name', () => {
  141. expect(() => collectLogEvents(make({
  142. 'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'),
  143. }))).toThrow(/non-literal name/)
  144. })
  145. it('hard-errors when the same event is declared twice', () => {
  146. expect(() => collectLogEvents(make({
  147. 'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'),
  148. 'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'),
  149. }))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/)
  150. })
  151. it('aggregates every violation into one error instead of failing fast', () => {
  152. expect(() => collectLogEvents(make({
  153. 'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'),
  154. }))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
  155. })
  156. })
  157. describe('gen-persistence-catalog collectEventEnvelopeTypes', () => {
  158. const declarations = `/** Event keys. */
  159. export type SessionEventType = keyof SessionEventMap
  160. /** Surface-producing event keys. */
  161. export type SurfaceEventType = 'fix/message'
  162. /** Surface placement. */
  163. export type SurfaceOp = 'append'
  164. /** One persisted event. */
  165. export type SessionEvent<T extends SessionEventType = SessionEventType> = { type: T }
  166. `
  167. it('extracts the envelope declarations with their complete JSDoc in canonical order', () => {
  168. const entries = collectEventEnvelopeTypes(make({
  169. 'packages/core/fix/package.json': OWNER_MANIFEST,
  170. 'packages/core/fix/src/types.ts': declarations,
  171. }))
  172. expect(entries.map(entry => entry.name)).toEqual([
  173. 'SessionEventType',
  174. 'SurfaceEventType',
  175. 'SurfaceOp',
  176. 'SessionEvent',
  177. ])
  178. expect(entries[3]).toMatchObject({
  179. declaration: '/** One persisted event. */\nexport type SessionEvent<T extends SessionEventType = SessionEventType> = { type: T }',
  180. source: 'packages/core/fix/src/types.ts:8',
  181. })
  182. })
  183. it('hard-errors when an envelope declaration is missing', () => {
  184. expect(() => collectEventEnvelopeTypes(make({
  185. 'packages/core/fix/package.json': OWNER_MANIFEST,
  186. 'packages/core/fix/src/types.ts': declarations.replace('/** Surface placement. */\nexport type SurfaceOp = \'append\'\n', ''),
  187. }))).toThrow(/missing event-envelope declaration\(s\): SurfaceOp/)
  188. })
  189. it('hard-errors on duplicate, unexported, undocumented, or mistagged envelope declarations', () => {
  190. const violations = new RegExp([
  191. '4 JSDoc completeness violation\\(s\\)',
  192. '[\\s\\S]*not exported',
  193. '[\\s\\S]*@mode tag',
  194. '[\\s\\S]*SurfaceOp.*no description prose',
  195. '[\\s\\S]*SessionEvent.*already declared',
  196. ].join(''))
  197. expect(() => collectEventEnvelopeTypes(make({
  198. 'packages/core/fix/package.json': OWNER_MANIFEST,
  199. 'packages/core/fix/src/types.ts': declarations
  200. .replace('/** Event keys. */\nexport type SessionEventType', '/** Event keys.\n * @mode emit\n */\ntype SessionEventType')
  201. .replace('/** Surface placement. */\n', '')
  202. + '/** Duplicate event. */\nexport type SessionEvent = { type: never }\n',
  203. }))).toThrow(violations)
  204. })
  205. })
  206. describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
  207. it('parses the literal union', () => {
  208. const types = collectSurfaceEventTypes(make({
  209. 'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n',
  210. }))
  211. expect(types).toEqual(['fix/a', 'fix/b'])
  212. })
  213. it('hard-errors when no union is declared', () => {
  214. expect(() => collectSurfaceEventTypes(make({
  215. 'packages/core/fix/src/types.ts': 'export const unrelated = 1\n',
  216. }))).toThrow(/no SurfaceEventType union found/)
  217. })
  218. it('hard-errors when the union is declared more than once', () => {
  219. expect(() => collectSurfaceEventTypes(make({
  220. 'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n',
  221. 'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n',
  222. }))).toThrow(/declared more than once/)
  223. })
  224. it('hard-errors on a non-string-literal union member', () => {
  225. expect(() => collectSurfaceEventTypes(make({
  226. 'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n',
  227. }))).toThrow(/non-string-literal member/)
  228. })
  229. })
  230. describe('gen-persistence-catalog annotateSurface + render', () => {
  231. const entry = (name: string) => ({
  232. name,
  233. scope: name.split('/')[0] ?? name,
  234. payload: '{ turn: number }',
  235. doc: `Records ${name}.`,
  236. declaration: `/** Records ${name}. */\n'${name}': { turn: number }`,
  237. source: 'packages/core/fix/src/types.ts:3',
  238. })
  239. const envelopeTypes = [
  240. 'SessionEventType',
  241. 'SurfaceEventType',
  242. 'SurfaceOp',
  243. 'SessionEvent',
  244. ].map(name => ({
  245. name: name as 'SessionEventType' | 'SurfaceEventType' | 'SurfaceOp' | 'SessionEvent',
  246. declaration: `/** ${name}. */\nexport type ${name} = never`,
  247. source: 'packages/core/fix/src/types.ts:1',
  248. }))
  249. it('badges union members surface and everything else log-only', () => {
  250. const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
  251. expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
  252. })
  253. it('hard-errors on a union member naming no declared event', () => {
  254. expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost']))
  255. .toThrow(/'fix\/ghost' name no declared log event/)
  256. })
  257. it('renders badges, declaration fences, and the generated-file header', () => {
  258. const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']), envelopeTypes)
  259. expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
  260. expect(out).toContain('# Session Persistence Event Catalog')
  261. expect(out).toContain('```ts persistence-catalog\n/** SessionEventType. */\nexport type SessionEventType = never')
  262. expect(out).toContain('#### `fix/message` — surface')
  263. expect(out).toContain('#### `fix/marker` — log-only')
  264. expect(out).toContain('```ts persistence-catalog\n/** Records fix/marker. */\n\'fix/marker\': { turn: number }\n```')
  265. })
  266. })