compose-stack.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * The profile patch stack composed with tree-wide row-id ownership. Entry ids
  3. * are unique per Loader tree, and the vendored group `create()` re-parents an
  4. * existing id instead of rejecting it, so a shared id namespace needs its
  5. * check before anything mounts: built-in and boot-staged layers claim their
  6. * ids first and a duplicate among them fails loud; an external bundle whose
  7. * id is already claimed is skipped whole and recorded as a conflict; a user
  8. * layer's insert of a claimed id drops that row and records it. Boot, live
  9. * recomposition, and the config dump all compose through here, so they agree.
  10. * @module @deepseek-ai/dsh-app-boot/compose-stack
  11. */
  12. import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  13. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  14. import { bundleLayerPatches, composeExternalLayer, isContainedLayer } from './external-bundles.ts'
  15. import type { ProfileLayer } from './profile.ts'
  16. /** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
  17. export interface StackUserLayer {
  18. /** How diagnostics name the layer (a file path or the flag that supplied it). */
  19. readonly label: string
  20. /** The layer's patches, as parsed from disk. */
  21. readonly patches: PatchOptions[]
  22. }
  23. /** One row a layer could not mount because another layer already declares its id. */
  24. export interface RowConflict {
  25. /** The id both layers declare. */
  26. readonly rowId: string
  27. /** The module the losing row named. */
  28. readonly moduleName: string
  29. /** The layer that lost: a bundle's package name or a user layer's label. */
  30. readonly layer: string
  31. /** The external bundle that lost, when the loser is one; its whole layer is left out. */
  32. readonly packageName?: string
  33. /** The layer that owns the id: a bundle's package name or a user layer's label. */
  34. readonly declaredBy: string
  35. }
  36. /** The stack as the root include should mount it, with what it left out. */
  37. export interface ComposedStack {
  38. /** Patches in application order: bundle layers in manifest order, then the user layers as given. */
  39. readonly patches: PatchOptions[]
  40. /** The same patches per layer, labelled by package name or user-layer label; a skipped bundle has no entry. */
  41. readonly layers: StackUserLayer[]
  42. /** Every row left out, in stack order. */
  43. readonly conflicts: RowConflict[]
  44. /** External bundles left out because a row id was already claimed. */
  45. readonly skippedBundles: string[]
  46. }
  47. /** Row-id ownership across the bundle layers: who owns each id, and which external bundles lost. */
  48. export interface LayerOwnership {
  49. /** The layer that owns each id a bundle layer introduces. */
  50. readonly owners: Map<string, ProfileLayer>
  51. /** The conflicts of each external bundle left out, by package name. */
  52. readonly skipped: Map<string, RowConflict[]>
  53. }
  54. /** Every `(id, module)` a patch list inserts, recursing into inserted groups. */
  55. function insertedRows(patches: readonly PatchOptions[]): Map<string, string> {
  56. const rows = new Map<string, string>()
  57. const visit = (row: EntryOptions): void => {
  58. if (typeof row.id === 'string') rows.set(row.id, row.name)
  59. if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
  60. }
  61. for (const patch of patches) patch.insert?.forEach(visit)
  62. return rows
  63. }
  64. /** The ids one inserted row carries: its own and, for a group, its children's. */
  65. function rowIds(row: EntryOptions): string[] {
  66. const ids: string[] = []
  67. const visit = (entry: EntryOptions): void => {
  68. if (typeof entry.id === 'string') ids.push(entry.id)
  69. if (entry.group && Array.isArray(entry.config)) (entry.config as EntryOptions[]).forEach(visit)
  70. }
  71. visit(row)
  72. return ids
  73. }
  74. /**
  75. * Decide row-id ownership across the bundle layers. Built-in and boot-staged
  76. * layers claim first, in manifest order; a duplicate among them is a defect
  77. * of the shipped composition and throws. Contained external layers then claim
  78. * in manifest order, and one whose id is already owned is left out whole.
  79. * @param layers - the profile's bundle layers, in manifest order.
  80. * @returns the owner of every claimed id and the conflicts of each skipped bundle.
  81. * @throws when two built-in or boot-staged layers declare the same id.
  82. */
  83. export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
  84. const owners = new Map<string, ProfileLayer>()
  85. for (const layer of layers) {
  86. if (isContainedLayer(layer)) continue
  87. for (const id of insertedRows(layer.patches).keys()) {
  88. const owner = owners.get(id)
  89. if (owner !== undefined) {
  90. throw new Error(`row ${JSON.stringify(id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
  91. }
  92. owners.set(id, layer)
  93. }
  94. }
  95. const skipped = new Map<string, RowConflict[]>()
  96. for (const layer of layers) {
  97. if (!isContainedLayer(layer)) continue
  98. const { rows } = composeExternalLayer(layer)
  99. const conflicts: RowConflict[] = []
  100. for (const [rowId, moduleName] of rows) {
  101. const owner = owners.get(rowId)
  102. if (owner === undefined) continue
  103. conflicts.push({ rowId, moduleName, layer: layer.packageName, packageName: layer.packageName, declaredBy: owner.packageName })
  104. }
  105. if (conflicts.length > 0) {
  106. skipped.set(layer.packageName, conflicts)
  107. continue
  108. }
  109. for (const id of rows.keys()) owners.set(id, layer)
  110. }
  111. return { owners, skipped }
  112. }
  113. /**
  114. * Compose the stack the root include mounts: every bundle layer that owns its
  115. * ids, in manifest order, then the user layers with any insert of an already
  116. * owned id dropped. Patches are passed by reference; callers that mount them
  117. * clone, because the include pushes inserted rows into the tree as they are.
  118. * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  119. * @param layers - the profile's bundle layers, in manifest order.
  120. * @param userLayers - the user-owned layers, in application order.
  121. * @returns the patches to mount, the conflicts, and the bundles left out.
  122. * @throws when two built-in or boot-staged layers declare the same id.
  123. */
  124. export function composeProfileStack(
  125. binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
  126. ): ComposedStack {
  127. let ownership: LayerOwnership
  128. try {
  129. ownership = claimLayerIds(layers)
  130. } catch (error) {
  131. throw new Error(`${binName}: ${(error as Error).message}`)
  132. }
  133. const composedLayers: StackUserLayer[] = []
  134. const conflicts: RowConflict[] = []
  135. const skippedBundles: string[] = []
  136. for (const layer of layers) {
  137. const lost = ownership.skipped.get(layer.packageName)
  138. if (lost !== undefined) {
  139. conflicts.push(...lost)
  140. skippedBundles.push(layer.packageName)
  141. continue
  142. }
  143. composedLayers.push({ label: layer.packageName, patches: bundleLayerPatches(layer) })
  144. }
  145. const claimed = new Map<string, string>()
  146. for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
  147. for (const userLayer of userLayers) {
  148. const patches: PatchOptions[] = []
  149. for (const patch of userLayer.patches) {
  150. if (patch.insert === undefined) {
  151. patches.push(patch)
  152. continue
  153. }
  154. const kept: EntryOptions[] = []
  155. for (const row of patch.insert) {
  156. const ids = rowIds(row)
  157. const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
  158. if (taken?.[1] !== undefined) {
  159. conflicts.push({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] })
  160. continue
  161. }
  162. for (const id of ids) claimed.set(id, userLayer.label)
  163. kept.push(row)
  164. }
  165. if (kept.length === patch.insert.length) patches.push(patch)
  166. else if (kept.length > 0) patches.push({ ...patch, insert: kept })
  167. }
  168. composedLayers.push({ label: userLayer.label, patches })
  169. }
  170. return { patches: composedLayers.flatMap(layer => layer.patches), layers: composedLayers, conflicts, skippedBundles }
  171. }
  172. /**
  173. * One diagnostic line for a conflict, as boot and the config dump print it.
  174. * @param conflict - the conflict to describe.
  175. * @returns the line, without a binary-name prefix.
  176. */
  177. export function formatRowConflict(conflict: RowConflict): string {
  178. const owner = `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`
  179. return conflict.packageName === undefined
  180. ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${owner}`
  181. : `bundle ${conflict.packageName} left out — ${owner}`
  182. }