gen-persistence-catalog.spec.ts 13 KB

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