gen-cordis-catalog-partition.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /**
  2. * Acceptance-path coverage for the cordis-surface partition backstops
  3. * (`walkPartitionProblems` + the AST scan helpers): a declared Context key or
  4. * Events member the rendering projection cannot see must carry a named walk
  5. * exemption, an exemption must stay live in both directions, and the scan
  6. * itself must reach nested (`src/**`) and Events-only merge files.
  7. */
  8. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { afterEach, describe, expect, it } from 'vitest'
  12. import ts from 'typescript'
  13. import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
  14. import { walkPartitionProblems } from './gen-cordis-catalog.ts'
  15. import type { WalkPartitionInput, WalkPartitionMaps } from './gen-cordis-catalog.ts'
  16. const roots: string[] = []
  17. afterEach(() => {
  18. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  19. })
  20. /** A consistent baseline the red cases mutate one facet at a time. */
  21. function baseline(): { input: WalkPartitionInput; maps: WalkPartitionMaps } {
  22. return {
  23. input: {
  24. renderedKeys: new Map([['llm', 'packages/llm/llm/src/index.ts:10']]),
  25. renderedScopes: new Set(['llm']),
  26. renderedEventNames: new Set(['llm/request']),
  27. declaredKeys: new Map([
  28. ['llm', 'packages/llm/llm/src/index.ts'],
  29. ['theme', 'packages/client/ui-theme/src/client/index.ts'],
  30. ]),
  31. declaredEvents: new Map([
  32. ['llm/request', 'packages/llm/llm/src/index.ts'],
  33. ['theme/change', 'packages/client/ui-theme/src/client/index.ts'],
  34. ]),
  35. },
  36. maps: {
  37. servicePage: { llm: 'llm-streaming.md' },
  38. serviceWalkExemptions: { theme: 'client-side — packages/client/ui-theme/README.md owns the surface' },
  39. eventScopePage: { llm: 'llm-streaming.md' },
  40. eventWalkExemptions: { 'theme/change': 'client-face — packages/client/ui-theme/README.md owns the surface' },
  41. },
  42. }
  43. }
  44. describe('walkPartitionProblems', () => {
  45. it('accepts a partition where every declared key and event is rendered or exempted', () => {
  46. const { input, maps } = baseline()
  47. expect(walkPartitionProblems(input, maps)).toEqual([])
  48. })
  49. it('rejects a declared event that is neither rendered nor exempted, naming its file', () => {
  50. const { input, maps } = baseline()
  51. const problems = walkPartitionProblems(input, { ...maps, eventWalkExemptions: {} })
  52. expect(problems).toEqual([
  53. expect.stringContaining("event 'theme/change' (packages/client/ui-theme/src/client/index.ts) is declared in an Events merge but invisible"),
  54. ])
  55. })
  56. it('rejects an event exemption whose event the projection renders', () => {
  57. const { input, maps } = baseline()
  58. // A projection that renders theme/change necessarily renders the theme
  59. // scope too; the fixture models that and maps the scope so the only
  60. // violation is the stale exemption.
  61. const rendered = {
  62. ...input,
  63. renderedScopes: new Set(['llm', 'theme']),
  64. renderedEventNames: new Set(['llm/request', 'theme/change']),
  65. }
  66. const mapped = { ...maps, eventScopePage: { llm: 'llm-streaming.md', theme: 'client-modules.md' } }
  67. expect(walkPartitionProblems(rendered, mapped)).toEqual([
  68. expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
  69. ])
  70. })
  71. it('rejects rendered surface the independent scan cannot see, naming the scan as the defect', () => {
  72. const { input, maps } = baseline()
  73. const blind = {
  74. ...input,
  75. declaredKeys: new Map([['theme', 'packages/client/ui-theme/src/client/index.ts']]),
  76. declaredEvents: new Map([['theme/change', 'packages/client/ui-theme/src/client/index.ts']]),
  77. }
  78. expect(walkPartitionProblems(blind, maps)).toEqual([
  79. expect.stringContaining('ctx.llm is rendered by the projection but the independent scan finds no Context merge declaring it'),
  80. expect.stringContaining("event 'llm/request' is rendered by the projection but the independent scan finds no Events merge declaring it"),
  81. ])
  82. })
  83. it('rejects an event exemption no Events merge declares', () => {
  84. const { input, maps } = baseline()
  85. const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
  86. expect(walkPartitionProblems(input, stale)).toEqual([
  87. expect.stringContaining("EVENT_WALK_EXEMPTIONS names 'gone/away' but no Events merge declares it"),
  88. ])
  89. })
  90. it('rejects a declared Context key that is neither rendered nor exempted', () => {
  91. const { input, maps } = baseline()
  92. const problems = walkPartitionProblems(input, { ...maps, serviceWalkExemptions: {} })
  93. expect(problems).toEqual([
  94. expect.stringContaining('ctx.theme (packages/client/ui-theme/src/client/index.ts) is declared in a Context merge but invisible'),
  95. ])
  96. })
  97. it('rejects an unmapped rendered service with its source pointer, and stale page maps both ways', () => {
  98. const { input, maps } = baseline()
  99. const problems = walkPartitionProblems(input, {
  100. ...maps,
  101. servicePage: { ghost: 'core.md' },
  102. eventScopePage: { specter: 'core.md' },
  103. })
  104. expect(problems).toEqual(expect.arrayContaining([
  105. expect.stringContaining('service ctx.llm (packages/llm/llm/src/index.ts:10) has no SERVICE_PAGE entry'),
  106. expect.stringContaining("event scope 'llm/*' has no EVENT_SCOPE_PAGE entry"),
  107. expect.stringContaining("SERVICE_PAGE maps 'ctx.ghost' but the projection discovers no such service"),
  108. expect.stringContaining("EVENT_SCOPE_PAGE maps 'specter/*' but the projection discovers no such scope"),
  109. ]))
  110. expect(problems).toHaveLength(4)
  111. })
  112. })
  113. describe('cordis-walk scan reach', () => {
  114. it('finds Context keys and Events names in nested Events-only merge files', () => {
  115. const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
  116. roots.push(root)
  117. const dir = join(root, 'packages/client/ui-x/src/client')
  118. mkdirSync(dir, { recursive: true })
  119. writeFileSync(join(dir, 'index.ts'), [
  120. "declare module '@deepseek-ai/cordis' {",
  121. ' interface Events {',
  122. " 'x/changed'(): void",
  123. ' }',
  124. '}',
  125. 'export {}',
  126. '',
  127. ].join('\n'))
  128. const merges = contextMergeFiles(root, 'packages/*/*/src/**/*.ts')
  129. expect(merges.map(m => m.rel)).toEqual(['packages/client/ui-x/src/client/index.ts'])
  130. const only = merges[0]
  131. if (!only) throw new Error('scan returned no merge')
  132. expect(eventNameList(only.body, only.sf)).toEqual(['x/changed'])
  133. expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
  134. })
  135. it('yields every merge block of a multi-block file, double-quoted heads, and .tsx sources', () => {
  136. const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
  137. roots.push(root)
  138. const dir = join(root, 'packages/client/ui-x/src')
  139. mkdirSync(dir, { recursive: true })
  140. // The Typert analyzer reads every cordis module block in a file; the
  141. // backstop must not stop at the first one, skip the double-quoted legal
  142. // form, or ignore .tsx sources.
  143. writeFileSync(join(dir, 'split.ts'), [
  144. "declare module '@deepseek-ai/cordis' {",
  145. ' interface Context {',
  146. ' first: FirstService',
  147. ' }',
  148. '}',
  149. 'declare module "@deepseek-ai/cordis" {',
  150. ' interface Events {',
  151. " 'second/changed'(): void",
  152. ' }',
  153. '}',
  154. 'export {}',
  155. '',
  156. ].join('\n'))
  157. writeFileSync(join(dir, 'view.tsx'), [
  158. "declare module '@deepseek-ai/cordis' {",
  159. ' interface Context {',
  160. ' fromTsx: TsxService',
  161. ' }',
  162. '}',
  163. 'export {}',
  164. '',
  165. ].join('\n'))
  166. const merges = contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])
  167. expect(merges.map(m => m.rel)).toEqual([
  168. 'packages/client/ui-x/src/split.ts',
  169. 'packages/client/ui-x/src/split.ts',
  170. 'packages/client/ui-x/src/view.tsx',
  171. ])
  172. const keys = merges.flatMap(m => [...contextKeyMap(m.body, m.sf).keys()])
  173. const events = merges.flatMap(m => eventNameList(m.body, m.sf))
  174. expect(keys).toEqual(['first', 'fromTsx'])
  175. expect(events).toEqual(['second/changed'])
  176. })
  177. it('reads string-literal and identifier member names from an Events merge', () => {
  178. const sf = ts.createSourceFile('x.ts', [
  179. "declare module '@deepseek-ai/cordis' {",
  180. ' interface Events {',
  181. " 'scope/list'(items: string[]): void",
  182. ' plain(): void',
  183. ' }',
  184. ' interface Context {',
  185. ' thing: ThingService',
  186. ' }',
  187. '}',
  188. '',
  189. ].join('\n'), ts.ScriptTarget.Latest, true)
  190. const body = sf.statements[0] && ts.isModuleDeclaration(sf.statements[0]) && sf.statements[0].body
  191. && ts.isModuleBlock(sf.statements[0].body)
  192. ? sf.statements[0].body
  193. : null
  194. if (!body) throw new Error('fixture did not parse to a module block')
  195. expect(eventNameList(body, sf)).toEqual(['scope/list', 'plain'])
  196. expect([...contextKeyMap(body, sf)]).toEqual([['thing', 'ThingService']])
  197. })
  198. })