compose-stack.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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 or inside them fails loud; an external
  7. * bundle whose id is already claimed, or which declares one of its own ids
  8. * twice, is skipped whole and recorded as a conflict; a user layer's insert
  9. * of a claimed id drops that row and records it. One composition yields the
  10. * patches, the owner of every id, and the conflicts, and each contained
  11. * layer is rendered once. Boot, live recomposition, and the config dump all
  12. * compose through here, so they agree.
  13. * @module @deepseek-ai/dsh-app-boot/compose-stack
  14. */
  15. import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  16. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  17. import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
  18. import { visitIdentifiedRows, visitRowTree } from './patch-rows.ts'
  19. import type { ProfileLayer } from './profile.ts'
  20. /** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
  21. export interface StackUserLayer {
  22. /** How diagnostics name the layer (a file path or the flag that supplied it). */
  23. readonly label: string
  24. /** The layer's patches, as parsed from disk. */
  25. readonly patches: PatchOptions[]
  26. }
  27. /** One row a layer could not mount because its id is already declared. */
  28. export interface RowConflict {
  29. /** The id declared more than once. */
  30. readonly rowId: string
  31. /** The module the losing row named. */
  32. readonly moduleName: string
  33. /** The layer that lost: a bundle's package name or a user layer's label. */
  34. readonly layer: string
  35. /** The external bundle that lost, when the loser is one; its whole layer is left out. */
  36. readonly packageName?: string
  37. /**
  38. * The layer that already declares the id: a bundle's package name or a user
  39. * layer's label, or the losing layer itself when it declares the id twice.
  40. */
  41. readonly declaredBy: string
  42. /** The reason as diagnostics and the plugin list state it, without the layer that lost. */
  43. readonly message: string
  44. }
  45. /** The stack as the root include should mount it, with what it left out. */
  46. export interface ComposedStack {
  47. /** Patches in application order: bundle layers in manifest order, then the user layers as given. */
  48. readonly patches: PatchOptions[]
  49. /** The same patches per layer, labelled by package name or user-layer label; a skipped bundle has no entry. */
  50. readonly layers: StackUserLayer[]
  51. /** The bundle layer that owns each id a bundle layer introduces; rows user layers insert are not listed. */
  52. readonly owners: ReadonlyMap<string, ProfileLayer>
  53. /** Every row left out, in stack order. */
  54. readonly conflicts: RowConflict[]
  55. /** External bundles left out because a row id was already claimed or repeated. */
  56. readonly skippedBundles: string[]
  57. /**
  58. * Row ids the user layers disable with a literal `disabled: true`, as
  59. * composed; a `!!js` gate stays an expression node when read from disk, so
  60. * it is a condition of the composition, not a user decision.
  61. */
  62. readonly userDisabledRowIds: ReadonlySet<string>
  63. }
  64. /** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
  65. export interface LayerOwnership {
  66. /** The layer that owns each id a bundle layer introduces. */
  67. readonly owners: Map<string, ProfileLayer>
  68. /** The conflicts of each external bundle left out, by package name. */
  69. readonly skipped: Map<string, RowConflict[]>
  70. /** The composition of each contained layer that owns its ids, by package name; rendered once and mounted as is. */
  71. readonly composed: Map<string, ComposedExternalLayer>
  72. }
  73. /** One conflict with its message: the id's other declarer, or the losing layer itself declaring it twice. */
  74. function rowConflict(fields: Omit<RowConflict, 'message'>): RowConflict {
  75. const id = JSON.stringify(fields.rowId)
  76. const message = fields.declaredBy === fields.layer
  77. ? `row ${id} is declared twice by ${fields.layer}`
  78. : `row ${id} is already declared by ${fields.declaredBy}`
  79. return { ...fields, message }
  80. }
  81. /** The ids one inserted row carries: its own and, for a group, its children's. */
  82. function rowIds(row: EntryOptions): string[] {
  83. const ids: string[] = []
  84. visitRowTree(row, (entry) => {
  85. if (typeof entry.id === 'string') ids.push(entry.id)
  86. })
  87. return ids
  88. }
  89. /**
  90. * Decide row-id ownership across the bundle layers. A layer introduces the
  91. * rows it inserts and the rows its config overrides set as a group's
  92. * children. Built-in and boot-staged layers claim first, in manifest order;
  93. * an id two of them declare, or one inserts twice, is a defect of the shipped
  94. * composition and throws, while a config override restating a row under the
  95. * group the same layer declared it in is that layer keeping its own child;
  96. * the same id twice in one config list, or set under another group, is
  97. * declared twice. Contained external layers then claim in manifest order,
  98. * each rendered once here; one whose id is already owned, or which declares
  99. * an id twice, is left out whole.
  100. * @param layers - the profile's bundle layers, in manifest order.
  101. * @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one.
  102. * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  103. */
  104. export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
  105. const owners = new Map<string, ProfileLayer>()
  106. const declaredUnder = new Map<string, string | undefined>()
  107. for (const layer of layers) {
  108. if (isContainedLayer(layer)) continue
  109. visitIdentifiedRows(layer.patches, ({ id, source, place, listed }) => {
  110. const owner = owners.get(id)
  111. if (owner === layer) {
  112. // Restating a row under the group that already holds it is the layer
  113. // keeping its child; inserting it again, listing it twice in one
  114. // config, or setting it under another group is declaring it twice.
  115. if (source === 'insert' || listed.has(id) || declaredUnder.get(id) !== place.target) {
  116. throw new Error(`row ${JSON.stringify(id)} is declared twice by ${layer.packageName}`)
  117. }
  118. } else if (owner !== undefined) {
  119. throw new Error(`row ${JSON.stringify(id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
  120. } else {
  121. owners.set(id, layer)
  122. declaredUnder.set(id, place.target)
  123. }
  124. })
  125. }
  126. const skipped = new Map<string, RowConflict[]>()
  127. const composed = new Map<string, ComposedExternalLayer>()
  128. for (const layer of layers) {
  129. if (!isContainedLayer(layer)) continue
  130. const { packageName } = layer
  131. const composition = composeExternalLayer(layer)
  132. const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
  133. rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: packageName })
  134. ))
  135. for (const [rowId, moduleName] of composition.rows) {
  136. const owner = owners.get(rowId)
  137. if (owner !== undefined) {
  138. conflicts.push(rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: owner.packageName }))
  139. }
  140. }
  141. if (conflicts.length > 0) {
  142. skipped.set(packageName, conflicts)
  143. continue
  144. }
  145. for (const id of composition.rows.keys()) owners.set(id, layer)
  146. composed.set(packageName, composition)
  147. }
  148. return { owners, skipped, composed }
  149. }
  150. /**
  151. * Compose the stack the root include mounts: every bundle layer that owns its
  152. * ids, in manifest order, then the user layers with any insert of an already
  153. * owned id dropped. Patches are passed by reference; callers that mount them
  154. * clone, because the include pushes inserted rows into the tree as they are.
  155. * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  156. * @param layers - the profile's bundle layers, in manifest order.
  157. * @param userLayers - the user-owned layers, in application order.
  158. * @returns the patches to mount, the owner of every bundle id, the conflicts, the bundles left out, and the rows the user layers disable.
  159. * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  160. */
  161. export function composeProfileStack(
  162. binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
  163. ): ComposedStack {
  164. let ownership: LayerOwnership
  165. try {
  166. ownership = claimLayerIds(layers)
  167. } catch (error) {
  168. throw new Error(`${binName}: ${(error as Error).message}`)
  169. }
  170. const composedLayers: StackUserLayer[] = []
  171. const conflicts: RowConflict[] = []
  172. const skippedBundles: string[] = []
  173. for (const layer of layers) {
  174. const lost = ownership.skipped.get(layer.packageName)
  175. if (lost !== undefined) {
  176. conflicts.push(...lost)
  177. skippedBundles.push(layer.packageName)
  178. continue
  179. }
  180. const composition = ownership.composed.get(layer.packageName)
  181. composedLayers.push({ label: layer.packageName, patches: composition === undefined ? layer.patches : composition.patches })
  182. }
  183. const claimed = new Map<string, string>()
  184. for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
  185. const userDisabledRowIds = new Set<string>()
  186. for (const userLayer of userLayers) {
  187. const patches: PatchOptions[] = []
  188. for (const patch of userLayer.patches) {
  189. if (patch.insert === undefined) {
  190. if (patch.id !== undefined && patch.disabled === true) userDisabledRowIds.add(patch.id)
  191. patches.push(patch)
  192. continue
  193. }
  194. const kept: EntryOptions[] = []
  195. for (const row of patch.insert) {
  196. const ids = rowIds(row)
  197. const taken = ids.map(id => [id, claimed.get(id)] as const).find(([, owner]) => owner !== undefined)
  198. if (taken?.[1] !== undefined) {
  199. conflicts.push(rowConflict({ rowId: taken[0], moduleName: row.name, layer: userLayer.label, declaredBy: taken[1] }))
  200. continue
  201. }
  202. for (const id of ids) claimed.set(id, userLayer.label)
  203. kept.push(row)
  204. }
  205. if (kept.length === patch.insert.length) patches.push(patch)
  206. else if (kept.length > 0) patches.push({ ...patch, insert: kept })
  207. }
  208. composedLayers.push({ label: userLayer.label, patches })
  209. }
  210. return {
  211. patches: composedLayers.flatMap(layer => layer.patches),
  212. layers: composedLayers,
  213. owners: ownership.owners,
  214. conflicts,
  215. skippedBundles,
  216. userDisabledRowIds,
  217. }
  218. }
  219. /**
  220. * One diagnostic line for a conflict, as boot and the config dump print it.
  221. * @param conflict - the conflict to describe.
  222. * @returns the line, without a binary-name prefix.
  223. */
  224. export function formatRowConflict(conflict: RowConflict): string {
  225. return conflict.packageName === undefined
  226. ? `${conflict.layer}: insert of ${conflict.moduleName} skipped — ${conflict.message}`
  227. : `bundle ${conflict.packageName} left out — ${conflict.message}`
  228. }