compose-stack.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * Tree-wide row-id ownership across the profile stack: built-in layers claim
  3. * first and fail loud on a duplicate, an external bundle that collides is left
  4. * out and recorded, a bundle that repeats one of its own ids is left out the
  5. * same way, a user insert of a taken id is dropped, and every conflict
  6. * carries its message.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  10. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  11. import { claimLayerIds, composeProfileStack, formatRowConflict, type ProfileLayer } from '../src/index.ts'
  12. const NAME = 'dsh-test-bin'
  13. function layer(
  14. packageName: string, trust: ProfileLayer['trust'], patches: PatchOptions[], stage: ProfileLayer['stage'] = 'runtime',
  15. ): ProfileLayer {
  16. return { packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml', trust, stage, patches }
  17. }
  18. const base = layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [
  19. { id: 'settings', name: 'settings' },
  20. { id: 'tools', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'bash' }] },
  21. ] }])
  22. describe('claimLayerIds', () => {
  23. it('lets built-in layers own their ids, including group children, before any external layer', () => {
  24. const ext = layer('ext', 'external', [{ insert: [{ id: 'tool-bash', name: 'ext' }] }])
  25. const { owners, skipped } = claimLayerIds([ext, base])
  26. expect(owners.get('tool-bash')?.packageName).toBe('@deepseek-ai/dsh-base')
  27. expect(owners.get('tools')?.packageName).toBe('@deepseek-ai/dsh-base')
  28. expect(skipped.get('ext')).toEqual([
  29. {
  30. rowId: 'tool-bash', moduleName: 'ext', layer: 'ext', packageName: 'ext', declaredBy: '@deepseek-ai/dsh-base',
  31. message: 'row "tool-bash" is already declared by @deepseek-ai/dsh-base',
  32. },
  33. ])
  34. })
  35. it('throws when two built-in or boot-staged layers declare one id, or one declares it twice', () => {
  36. const twin = layer('twin', 'external', [{ insert: [{ id: 'settings', name: 'twin' }] }], 'boot')
  37. expect(() => claimLayerIds([base, twin])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and twin/)
  38. const stutter = layer('stutter', 'builtin', [{ insert: [{ id: 'x', name: 'a' }] }, { insert: [{ id: 'x', name: 'b' }] }])
  39. expect(() => claimLayerIds([stutter])).toThrow(/row "x" is declared twice by stutter/)
  40. })
  41. it('leaves out a bundle whose config override sets a row another layer owns', () => {
  42. const hijack = layer('hijack', 'external', [
  43. { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [] }] },
  44. { id: 'own', config: [{ id: 'settings', name: 'hijack/impostor' }] },
  45. ])
  46. const { skipped } = claimLayerIds([base, hijack])
  47. expect(skipped.get('hijack')).toEqual([
  48. {
  49. rowId: 'settings', moduleName: 'hijack/impostor', layer: 'hijack', packageName: 'hijack', declaredBy: '@deepseek-ai/dsh-base',
  50. message: 'row "settings" is already declared by @deepseek-ai/dsh-base',
  51. },
  52. ])
  53. const stack = composeProfileStack(NAME, [base, hijack], [])
  54. expect(stack.skippedBundles).toEqual(['hijack'])
  55. expect(stack.owners.get('settings')?.packageName).toBe('@deepseek-ai/dsh-base')
  56. })
  57. it('lets a built-in layer restate its own rows through a config override, and rejects another layer\'s', () => {
  58. const restating = layer('restating', 'builtin', [
  59. { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] }] },
  60. { id: 'g', config: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }] },
  61. ])
  62. expect([...claimLayerIds([restating]).owners.keys()]).toEqual(['g', 'a', 'b'])
  63. const taking = layer('taking', 'builtin', [
  64. { insert: [{ id: 'h', name: 'cordis:group', group: true, config: [] }] },
  65. { id: 'h', config: [{ id: 'settings', name: 'taking/impostor' }] },
  66. ])
  67. expect(() => claimLayerIds([base, taking])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and taking/)
  68. // Setting a row under another group would move it; listing it twice would fail the group's update.
  69. const moving = layer('moving', 'builtin', [
  70. { insert: [
  71. { id: 'g', name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] },
  72. { id: 'h', name: 'cordis:group', group: true, config: [] },
  73. ] },
  74. { id: 'h', config: [{ id: 'a', name: 'a' }] },
  75. ])
  76. expect(() => claimLayerIds([moving])).toThrow(/row "a" is declared twice by moving/)
  77. const twice = layer('twice', 'builtin', [
  78. { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [] }] },
  79. { id: 'g', config: [{ id: 'a', name: 'a' }, { id: 'a', name: 'a' }] },
  80. ])
  81. expect(() => claimLayerIds([twice])).toThrow(/row "a" is declared twice by twice/)
  82. // A child of a group without an id cannot be restated: no patch can address that group.
  83. const anonymous = layer('anonymous', 'builtin', [
  84. { insert: [
  85. // A row leaves its id to the Loader, as YAML rows may; the type asks for one.
  86. { name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] } as unknown as EntryOptions,
  87. { id: 'h', name: 'cordis:group', group: true, config: [] },
  88. ] },
  89. { id: 'h', config: [{ id: 'a', name: 'a' }] },
  90. ])
  91. expect(() => claimLayerIds([anonymous])).toThrow(/row "a" is declared twice by anonymous/)
  92. })
  93. it('leaves out a bundle whose config override of its own group lists an id twice', () => {
  94. const self = layer('self', 'external', [
  95. { insert: [{ id: 'self-group', name: 'cordis:group', group: true, config: [{ id: 'row', name: 'self/row' }] }] },
  96. { id: 'self-group', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
  97. ])
  98. const { skipped, composed } = claimLayerIds([base, self])
  99. expect(skipped.get('self')?.map(conflict => conflict.message)).toEqual(['row "row" is declared twice by self'])
  100. expect(composed.has('self')).toBe(false)
  101. })
  102. it('leaves out a bundle that declares one of its own ids twice and composes each mounted bundle once', () => {
  103. const stutter = layer('stutter', 'external', [{ insert: [{ id: 'x', name: 'stutter/a' }, { id: 'x', name: 'stutter/b' }] }])
  104. const clean = layer('clean', 'external', [{ insert: [{ id: 'y', name: 'clean' }] }])
  105. const { owners, skipped, composed } = claimLayerIds([base, stutter, clean])
  106. expect(skipped.get('stutter')).toEqual([
  107. { rowId: 'x', moduleName: 'stutter/b', layer: 'stutter', packageName: 'stutter', declaredBy: 'stutter', message: 'row "x" is declared twice by stutter' },
  108. ])
  109. expect(owners.has('x')).toBe(false)
  110. expect(owners.get('y')?.packageName).toBe('clean')
  111. expect([...composed.keys()]).toEqual(['clean'])
  112. expect(composed.get('clean')?.patches[0]).toEqual({ insert: [{ id: 'y', name: 'clean' }] })
  113. })
  114. it('gives the earlier external bundle the id and leaves the later one out whole', () => {
  115. const first = layer('first', 'external', [{ insert: [{ id: 'hello', name: 'first' }, { id: 'only-first', name: 'first/x' }] }])
  116. const second = layer('second', 'external', [{ insert: [{ id: 'hello', name: 'second' }, { id: 'only-second', name: 'second/x' }] }])
  117. const { owners, skipped } = claimLayerIds([base, first, second])
  118. expect(owners.get('hello')?.packageName).toBe('first')
  119. expect(owners.has('bundle/first')).toBe(false)
  120. expect(owners.has('only-second')).toBe(false)
  121. expect(skipped.get('second')?.map(conflict => conflict.rowId)).toEqual(['hello'])
  122. })
  123. })
  124. describe('composeProfileStack', () => {
  125. it('collects the rows the user layers disable with a literal disabled: true', () => {
  126. const stack = composeProfileStack(NAME, [base], [{ label: '/p/cordis.patch.yml', patches: [
  127. { id: 'a', disabled: true },
  128. // A gate read from disk is an expression node: a condition of the composition, not a decision.
  129. { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
  130. { id: 'c', config: {} },
  131. { insert: [{ id: 'd', name: 'x', disabled: true }] },
  132. ] }])
  133. expect([...stack.userDisabledRowIds]).toEqual(['a'])
  134. })
  135. it('mounts owning layers in manifest order and drops a user insert of a taken id', () => {
  136. const ext = layer('ext', 'external', [{ insert: [{ id: 'ext-tool', name: 'ext' }] }])
  137. const stack = composeProfileStack(NAME, [base, ext], [
  138. { label: '/p/cordis.patch.yml', patches: [
  139. { id: 'settings', config: { path: '/x' } },
  140. { insert: [{ id: 'mine', name: 'mine' }, { id: 'ext-tool', name: 'clash' }] },
  141. { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'nested-clash' }] }] },
  142. ] },
  143. { label: '/home/cordis.patch.yml', patches: [
  144. { insert: [{ id: 'mine', name: 'twice' }] },
  145. { insert: [{ id: 'clean', name: 'clean' }, { name: 'anonymous' } as EntryOptions] },
  146. ] },
  147. ])
  148. expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base', 'ext', '/p/cordis.patch.yml', '/home/cordis.patch.yml'])
  149. expect(stack.layers[1]?.patches[0]?.insert?.[0]).toMatchObject({ id: 'ext-tool', name: 'ext' })
  150. expect([...stack.owners.keys()]).toEqual(['settings', 'tools', 'tool-bash', 'ext-tool'])
  151. expect(stack.layers[2]?.patches).toEqual([
  152. { id: 'settings', config: { path: '/x' } },
  153. { insert: [{ id: 'mine', name: 'mine' }] },
  154. ])
  155. // An insert with no conflict passes through as the same object; an anonymous row claims nothing.
  156. expect(stack.layers[3]?.patches).toEqual([{ insert: [{ id: 'clean', name: 'clean' }, { name: 'anonymous' }] }])
  157. expect(stack.patches).toEqual(stack.layers.flatMap(current => current.patches))
  158. expect(stack.skippedBundles).toEqual([])
  159. expect(stack.conflicts).toEqual([
  160. { rowId: 'ext-tool', moduleName: 'clash', layer: '/p/cordis.patch.yml', declaredBy: 'ext', message: 'row "ext-tool" is already declared by ext' },
  161. {
  162. rowId: 'tool-bash', moduleName: 'cordis:group', layer: '/p/cordis.patch.yml', declaredBy: '@deepseek-ai/dsh-base',
  163. message: 'row "tool-bash" is already declared by @deepseek-ai/dsh-base',
  164. },
  165. {
  166. rowId: 'mine', moduleName: 'twice', layer: '/home/cordis.patch.yml', declaredBy: '/p/cordis.patch.yml',
  167. message: 'row "mine" is already declared by /p/cordis.patch.yml',
  168. },
  169. ])
  170. })
  171. it('leaves a colliding external bundle out of the stack and reports it', () => {
  172. const clash = layer('clash', 'external', [{ insert: [{ id: 'settings', name: 'clash' }] }])
  173. const stack = composeProfileStack(NAME, [base, clash], [])
  174. expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base'])
  175. expect(stack.skippedBundles).toEqual(['clash'])
  176. expect(stack.conflicts).toEqual([
  177. {
  178. rowId: 'settings', moduleName: 'clash', layer: 'clash', packageName: 'clash', declaredBy: '@deepseek-ai/dsh-base',
  179. message: 'row "settings" is already declared by @deepseek-ai/dsh-base',
  180. },
  181. ])
  182. })
  183. it('prefixes a built-in duplicate with the binary name', () => {
  184. const twin = layer('twin', 'builtin', [{ insert: [{ id: 'settings', name: 'twin' }] }])
  185. expect(() => composeProfileStack(NAME, [base, twin], [])).toThrow(/^dsh-test-bin: row "settings" is declared by both/)
  186. })
  187. })
  188. describe('formatRowConflict', () => {
  189. it('names the bundle left out, or the user layer whose insert was skipped', () => {
  190. const message = 'row "x" is already declared by base'
  191. expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: 'pkg', packageName: 'pkg', declaredBy: 'base', message }))
  192. .toBe('bundle pkg left out — row "x" is already declared by base')
  193. expect(formatRowConflict({ rowId: 'x', moduleName: 'm', layer: '/p/cordis.patch.yml', declaredBy: 'base', message }))
  194. .toBe('/p/cordis.patch.yml: insert of m skipped — row "x" is already declared by base')
  195. })
  196. })