@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/check.ts ADDED
@@ -0,0 +1,1239 @@
1
+ /**
2
+ * Profile composition diagnostics — issue #98 (phase 1): the check-only
3
+ * "plugin loading layer and conflict view".
4
+ *
5
+ * Pure filesystem analysis of one dsh profile directory; no processes, no
6
+ * network, no writes. It answers, for the profile the market is serving:
7
+ *
8
+ * 1. What is the actual bundle stack (dsh.profile.bundles order) and where
9
+ * does each layer come from (official in-box bundle vs community, the
10
+ * dependency spec, the resolved directory)?
11
+ * 2. Which loader entry ids does the composed tree contain, and are any
12
+ * duplicated across layers (the "duplicate loader entry id" boot failure
13
+ * from #98)? Which rows does a later layer override?
14
+ * 3. Does any installed plugin pull a DSH host core package
15
+ * (@deepseek-ai/dsh, @deepseek-ai/dsh-tools, @deepseek-ai/cordis, …) in
16
+ * as an ordinary dependency — the dsh-excel-chat failure mode where the
17
+ * plugin's copy gets hoisted to the profile root and shadows the host's
18
+ * version (tool calls die, minimal preset fails to mount)?
19
+ * 4. Are there multiple versions of one core package in the lockfile, and
20
+ * do plugin peerDependencies ranges match the resolved core version?
21
+ * 5. Do effective user/home patch entries reference npm package roots that
22
+ * are installed in the profile-visible node_modules ancestry?
23
+ *
24
+ * The composition step mirrors @deepseek-ai/dsh-app-boot's applyEntryPatches
25
+ * (same js-yaml dialect incl. `!!js` scalars), so the rows reported here are
26
+ * what actually mounts at boot.
27
+ */
28
+
29
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
30
+ import { createRequire, isBuiltin } from 'node:module'
31
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
32
+ import { JSON_SCHEMA, Type, load } from 'js-yaml'
33
+ import { findDshInstallDir } from './dsh-install.ts'
34
+ import { resolveDshHome } from './home-paths.ts'
35
+ import { INBOX_BUNDLES, readBundleRules, suggestOrder, validateOrder } from './order.ts'
36
+
37
+ export { findDshInstallDir } from './dsh-install.ts'
38
+
39
+ /** js-yaml dialect for `!!js` scalars — identical to dsh-app-boot's entryListSchema. */
40
+ const jsExpr = new Type('tag:yaml.org,2002:js', {
41
+ kind: 'scalar',
42
+ resolve: (data: unknown): boolean => typeof data === 'string',
43
+ construct: (data: unknown): unknown => ({ __jsExpr: String(data) }),
44
+ })
45
+ const entrySchema = JSON_SCHEMA.extend(jsExpr)
46
+
47
+ /** Boot-breaking or confirmed problems vs informational warnings. */
48
+ export interface CheckSummary {
49
+ ok: boolean
50
+ errors: string[]
51
+ warnings: string[]
52
+ }
53
+
54
+ /** One layer of the bundle stack, in `dsh.profile.bundles` order. */
55
+ export interface BundleLayer {
56
+ name: string
57
+ /** Dependency spec from the profile package.json (npm range, git, link:…). */
58
+ source: string
59
+ /** 'official' = in-box dsh bundle; 'community' = everything else. */
60
+ kind: 'official' | 'community'
61
+ /** Resolved package directory; null when the package is not installed. */
62
+ directory: string | null
63
+ /** Absolute path of the layer's patch file; null when undeclared/missing. */
64
+ patchPath: string | null
65
+ /**
66
+ * An in-box bundle whose directory could not be located — a gap in what
67
+ * this process can see, not a defect in the profile (#369). Distinct from
68
+ * `error`, which asserts the profile will not boot.
69
+ */
70
+ unresolvedInbox?: boolean
71
+ /** Why this layer cannot load at boot (missing dir / no dsh.bundle / …). */
72
+ error: string | null
73
+ /** Loader entry ids this bundle's patch inserts. */
74
+ entries: string[]
75
+ /** The patch file exists but could not be parsed as the entry-list dialect. */
76
+ parseError: string | null
77
+ /** Author-declared ordering constraints (issue #98 phase 2), when present. */
78
+ order?: {
79
+ before?: string[]
80
+ after?: string[]
81
+ /** Violated rules for THIS bundle in the current stack order. */
82
+ conflicts?: Array<{ name: string; reason: string }>
83
+ }
84
+ }
85
+
86
+ /** One loader row of the composed tree, with the layer that introduced it. */
87
+ export interface LoaderRow {
88
+ id: string
89
+ /** Bundle package name, 'user-patch' (profile cordis.patch.yml) or 'home-patch'. */
90
+ layer: string
91
+ kind: 'insert' | 'patch'
92
+ name?: string
93
+ }
94
+
95
+ /** An id present in more than one composed row — the #98 duplicate-id boot failure. */
96
+ export interface DuplicateId {
97
+ id: string
98
+ /** Every layer that inserts/defines this id. */
99
+ layers: string[]
100
+ count: number
101
+ }
102
+
103
+ /** A non-insert patch row that merged into an existing entry (later layer wins). */
104
+ export interface OverrideRow {
105
+ id: string
106
+ layer: string
107
+ /** Layers that introduced the targeted entry earlier in the stack. */
108
+ overriddenLayers: string[]
109
+ }
110
+
111
+ /** A patch row that matched nothing at boot (dsh warns and skips it). */
112
+ export interface OrphanRow {
113
+ id: string
114
+ layer: string
115
+ reason: string
116
+ }
117
+
118
+ /** Directional verdict for one confirmed peer mismatch (issue #201 diagnostics). */
119
+ export interface PeerRisk {
120
+ plugin: string
121
+ peer: string
122
+ range: string
123
+ resolved: string
124
+ direction: 'belowMin' | 'aboveMax'
125
+ }
126
+
127
+ export interface PeerWarning {
128
+ plugin: string
129
+ peer: string
130
+ range: string
131
+ resolved: string
132
+ reason: 'aboveMax' | 'optional'
133
+ }
134
+
135
+ /** classifyPeer result: risk / warning / none — none means informational. */
136
+ export type PeerVerdict =
137
+ | { kind: 'risk'; risk: PeerRisk }
138
+ | { kind: 'warning'; warning: PeerWarning }
139
+ | { kind: 'none' }
140
+
141
+ /** A plugin peerDependencies range vs the resolved version. */
142
+ export interface PeerMismatch {
143
+ plugin: string
144
+ name: string
145
+ range: string
146
+ resolved: string | null
147
+ /** False = confirmed incompatible; null = could not be evaluated. */
148
+ satisfied: boolean | null
149
+ /**
150
+ * The declaring plugin marked this peer `optional` in
151
+ * `peerDependenciesMeta`. Carried so the summary can hold the same line
152
+ * `classifyPeer` already does: an optional peer that does not match is
153
+ * the plugin saying "I work without this", not a broken install (#275).
154
+ */
155
+ optional?: boolean
156
+ /**
157
+ * The directional verdict, attached by `/dsh-market/check` for rows that
158
+ * did not match. It is computed on the server because the client cannot
159
+ * read `peerDependenciesMeta` off disk, and the tiering would otherwise
160
+ * have to be guessed from the range string (#201).
161
+ */
162
+ verdict?: PeerVerdict
163
+ }
164
+
165
+ /**
166
+ * Loader entries sharing one NAME across DIFFERENT layers — the Loader
167
+ * registers plugins by name, so a later layer's row with the same name
168
+ * shadows the earlier one at runtime. Same-layer rows sharing a name are
169
+ * routine (a bundle defining several entries under one name) and are never
170
+ * reported here. Unlike duplicate ids (report.duplicates), which fail the
171
+ * boot outright, shadowing names only decide which entry wins at runtime.
172
+ */
173
+ export interface DuplicateName {
174
+ name: string
175
+ /** Every layer that inserts/defines a row with this name. */
176
+ layers: string[]
177
+ count: number
178
+ }
179
+
180
+ /** Distinct resolved versions of one core package found in the lockfile. */
181
+ export interface MultiVersion {
182
+ name: string
183
+ versions: string[]
184
+ /** Version hoisted at the profile root, when present. */
185
+ hoisted: string | null
186
+ }
187
+
188
+ /** The full check report for one profile. */
189
+ export interface CheckReport {
190
+ profile: string
191
+ scannedAt: number
192
+ bundles: BundleLayer[]
193
+ rows: LoaderRow[]
194
+ duplicates: DuplicateId[]
195
+ duplicateNames: DuplicateName[]
196
+ overrides: OverrideRow[]
197
+ orphans: OrphanRow[]
198
+ peerMismatches: PeerMismatch[]
199
+ multiVersion: MultiVersion[]
200
+ /** Before/after rule conflicts in the CURRENT bundle order (issue #98 phase 2). */
201
+ orderConflicts: Array<{ name: string; reason: string }>
202
+ /** LOOT-style auto-fix: a community order satisfying every declared rule. */
203
+ suggestedOrder: { ok: true; order: string[] } | { ok: false; cycle: string[] } | null
204
+ summary: CheckSummary
205
+ }
206
+
207
+ export interface CheckOptions {
208
+ /** DSH host install dir; auto-detected from the CLI entry or Desktop resources when omitted. */
209
+ dshInstallDir?: string
210
+ /** ChatCode CLI home for the home-level patch layer; legacy DSH_HOME remains a read-only compatibility input. */
211
+ homeDir?: string
212
+ }
213
+
214
+ function isRecord(value: unknown): value is Record<string, unknown> {
215
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
216
+ }
217
+
218
+ /** Parse entry-list source with the DSH dialect; null when it is not a list. */
219
+ export function parsePatchText(text: string): unknown[] | null {
220
+ try {
221
+ const value = load(text, { schema: entrySchema })
222
+ return Array.isArray(value) ? value : null
223
+ } catch {
224
+ return null
225
+ }
226
+ }
227
+
228
+ /** Parse one entry-list patch file with the DSH dialect; null when unreadable. */
229
+ export function parsePatchFile(path: string): unknown[] | null {
230
+ try {
231
+ return parsePatchText(readFileSync(path, 'utf8'))
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ /** Whether `name` goes through the Loader's bare-module resolver. */
238
+ function isBareLoaderSpecifier(name: string): boolean {
239
+ return !name.startsWith('.')
240
+ && !name.startsWith('#')
241
+ && !isAbsolute(name)
242
+ && !/^[a-z][a-z\d+.-]*:/i.test(name)
243
+ }
244
+
245
+ /** Npm package root owning one bare Loader specifier. */
246
+ function packageRoot(specifier: string): string | null {
247
+ const parts = specifier.split('/')
248
+ const segments = specifier.startsWith('@') ? parts.slice(0, 2) : parts.slice(0, 1)
249
+ if (segments.length !== (specifier.startsWith('@') ? 2 : 1)) return null
250
+ if (segments.some(segment => segment === '' || segment === '.' || segment === '..'
251
+ || segment.includes('%') || segment.includes('\\'))) return null
252
+ if (specifier.startsWith('@') && (segments[0]?.length ?? 0) <= 1) return null
253
+ return segments.join('/')
254
+ }
255
+
256
+ /**
257
+ * Package-root presence from the Loader-visible profile ancestry only.
258
+ * An explicit walk avoids CommonJS global lookup directories that
259
+ * `createRequire(...).resolve.paths()` appends but Node ESM does not search.
260
+ */
261
+ function profilePackageInstalled(profileDirectory: string, name: string): boolean {
262
+ try {
263
+ const profile = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8')) as unknown
264
+ if (isRecord(profile) && profile.name === name
265
+ && profile.exports !== undefined && profile.exports !== null) return true
266
+ } catch { /* not a self-referencing profile package */ }
267
+ let directory = resolve(profileDirectory)
268
+ while (true) {
269
+ const packageDirectory = join(directory, 'node_modules', name)
270
+ // Node stops at the nearest matching package directory. A partial install
271
+ // there shadows any healthy parent copy and must not be accepted.
272
+ try {
273
+ if (statSync(packageDirectory).isDirectory()) {
274
+ return existsSync(join(packageDirectory, 'package.json'))
275
+ }
276
+ } catch { /* keep walking */ }
277
+ const parent = dirname(directory)
278
+ if (parent === directory) return false
279
+ directory = parent
280
+ }
281
+ }
282
+
283
+ /** Every id in one patch row's insert list, recursively (group configs included). */
284
+ function collectInsertIds(rows: unknown[]): string[] {
285
+ const ids: string[] = []
286
+ const walk = (value: unknown): void => {
287
+ if (!Array.isArray(value)) return
288
+ for (const entry of value) {
289
+ if (!isRecord(entry) || typeof entry.id !== 'string') continue
290
+ ids.push(entry.id)
291
+ if (Array.isArray(entry.config)) walk(entry.config)
292
+ }
293
+ }
294
+ for (const patch of rows) {
295
+ if (!isRecord(patch) || !Array.isArray(patch.insert)) continue
296
+ walk(patch.insert)
297
+ }
298
+ return ids
299
+ }
300
+
301
+ /** DSH host core packages: what the dsh installation ships under @deepseek-ai. */
302
+ export function corePackageNames(dshInstallDir: string | null): Set<string> {
303
+ const names = new Set<string>([
304
+ // Curated fallback seed (used when the install dir cannot be located).
305
+ '@deepseek-ai/dsh',
306
+ '@deepseek-ai/dsh-base',
307
+ '@deepseek-ai/dsh-web-app',
308
+ '@deepseek-ai/dsh-headless',
309
+ '@deepseek-ai/dsh-app-boot',
310
+ '@deepseek-ai/dsh-home-paths',
311
+ '@deepseek-ai/dsh-launch-environment',
312
+ '@deepseek-ai/dsh-cmdline',
313
+ '@deepseek-ai/dsh-tools',
314
+ '@deepseek-ai/dsh-llm',
315
+ '@deepseek-ai/dsh-system-prompt',
316
+ '@deepseek-ai/dsh-attachment',
317
+ '@deepseek-ai/dsh-agent',
318
+ '@deepseek-ai/dsh-agent-loop',
319
+ '@deepseek-ai/dsh-session',
320
+ '@deepseek-ai/dsh-subagent',
321
+ '@deepseek-ai/cordis',
322
+ '@deepseek-ai/cordis-plugin-loader',
323
+ '@deepseek-ai/cordis-plugin-include',
324
+ '@deepseek-ai/cordis-plugin-hmr',
325
+ '@deepseek-ai/cordis-plugin-timer',
326
+ '@deepseek-ai/cordis-plugin-group',
327
+ ])
328
+ if (dshInstallDir === null) return names
329
+ try {
330
+ // The install's own node_modules is the authoritative host-core inventory:
331
+ // everything under @deepseek-ai whose bare name starts with dsh or cordis,
332
+ // plus the app package itself.
333
+ for (const entry of readdirSync(join(dshInstallDir, 'node_modules', '@deepseek-ai'), { withFileTypes: true })) {
334
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
335
+ if (/^(?:dsh|cordis)/.test(entry.name)) names.add(`@deepseek-ai/${entry.name}`)
336
+ }
337
+ } catch { /* install node_modules unreadable — the curated seed stands */ }
338
+ try {
339
+ const manifest = JSON.parse(readFileSync(join(dshInstallDir, 'package.json'), 'utf8')) as { name?: unknown }
340
+ if (typeof manifest.name === 'string') names.add(manifest.name)
341
+ } catch { /* not a package dir */ }
342
+ return names
343
+ }
344
+
345
+ /** Version of `name` as physically resolved at `base`/node_modules, or null. */
346
+ function readNodeModulesVersion(base: string, name: string): string | null {
347
+ try {
348
+ const manifest = JSON.parse(
349
+ readFileSync(join(base, 'node_modules', name, 'package.json'), 'utf8'),
350
+ ) as { version?: unknown }
351
+ return typeof manifest.version === 'string' ? manifest.version : null
352
+ } catch {
353
+ return null
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Resolve one package's directory the way the dsh boot does
359
+ * (dsh-app-boot's resolveBundleDir): probe Node's own node_modules search
360
+ * paths from the installation anchor first, then the profile directory.
361
+ * Node resolution walks upward, so this also finds pnpm's workspace-root
362
+ * hoisting (`<profiles>/node_modules/…` when the profile lives under
363
+ * `<profiles>/<name>`) and mirrors the Loader's package search roots.
364
+ */
365
+ function resolvePackageDir(
366
+ anchorPackageJson: string,
367
+ name: string,
368
+ ignoredPackageDirectory?: string,
369
+ ): string | null {
370
+ let paths: string[] = []
371
+ try {
372
+ paths = createRequire(anchorPackageJson).resolve.paths(name) ?? []
373
+ } catch {
374
+ return null
375
+ }
376
+ const ignored = ignoredPackageDirectory === undefined
377
+ ? null
378
+ : resolve(ignoredPackageDirectory)
379
+ for (const searchPath of paths) {
380
+ const candidate = join(searchPath, name)
381
+ if (ignored !== null) {
382
+ const resolvedCandidate = resolve(candidate)
383
+ const matchesIgnored = process.platform === 'win32'
384
+ ? resolvedCandidate.toLowerCase() === ignored.toLowerCase()
385
+ : resolvedCandidate === ignored
386
+ if (matchesIgnored) continue
387
+ }
388
+ if (existsSync(join(candidate, 'package.json'))) return candidate
389
+ }
390
+ return null
391
+ }
392
+
393
+ /**
394
+ * Version of `name` visible to the profile's dependency tree: the profile's
395
+ * own node_modules first, then the workspace root (pnpm hoists shared deps
396
+ * there when the profile is a workspace member — the dsh layout keeps
397
+ * `<profiles>/node_modules` as the shared store for all profiles).
398
+ */
399
+ function readProfileVisibleVersion(profileDirectory: string, name: string): string | null {
400
+ const direct = readNodeModulesVersion(profileDirectory, name)
401
+ if (direct !== null) return direct
402
+ const workspaceRoot = dirname(profileDirectory)
403
+ if (workspaceRoot === profileDirectory) return null
404
+ return readNodeModulesVersion(workspaceRoot, name)
405
+ }
406
+
407
+ /** Top-level installed package names (incl. scoped), excluding pnpm internals. */
408
+ function installedPackageNames(profileDir: string): string[] {
409
+ const names: string[] = []
410
+ // Windows `link:` installs are junctions; Dirent.isDirectory() is false for
411
+ // them on some Node versions, so treat symlinks as packages too (B2).
412
+ const isPkgDir = (entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): boolean =>
413
+ entry.isDirectory() || entry.isSymbolicLink()
414
+ let root: string[]
415
+ try {
416
+ root = readdirSync(join(profileDir, 'node_modules'), { withFileTypes: true })
417
+ .filter(entry => isPkgDir(entry) && entry.name !== '.bin' && entry.name !== '.pnpm' && entry.name !== '.dsh-plugin-backups')
418
+ .map(entry => entry.name)
419
+ } catch {
420
+ return names
421
+ }
422
+ for (const name of root) {
423
+ if (!name.startsWith('@')) {
424
+ names.push(name)
425
+ continue
426
+ }
427
+ try {
428
+ for (const scoped of readdirSync(join(profileDir, 'node_modules', name), { withFileTypes: true })) {
429
+ if (isPkgDir(scoped)) names.push(`${name}/${scoped.name}`)
430
+ }
431
+ } catch { /* empty scope dir */ }
432
+ }
433
+ return names
434
+ }
435
+
436
+ // --- semver helpers (small subset sufficient for peer range checks) ---
437
+
438
+ interface Semver {
439
+ major: number
440
+ minor: number
441
+ patch: number
442
+ pre: string[]
443
+ }
444
+
445
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
446
+
447
+ function parseSemver(value: string): Semver | null {
448
+ const m = SEMVER_RE.exec(value.trim())
449
+ if (m === null) return null
450
+ return {
451
+ major: Number(m[1]),
452
+ minor: Number(m[2]),
453
+ patch: Number(m[3]),
454
+ pre: m[4] === undefined ? [] : m[4].split('.'),
455
+ }
456
+ }
457
+
458
+ function comparePre(a: string[], b: string[]): number {
459
+ const len = Math.max(a.length, b.length)
460
+ for (let i = 0; i < len; i += 1) {
461
+ const x = a[i]
462
+ const y = b[i]
463
+ if (x === undefined) return y === undefined ? 0 : -1 // a has fewer parts → a < b
464
+ if (y === undefined) return 1
465
+ if (x === y) continue
466
+ const xn = /^\d+$/.test(x)
467
+ const yn = /^\d+$/.test(y)
468
+ if (xn && yn) return Number(x) - Number(y) || 0
469
+ if (xn) return -1 // numeric identifiers sort before alphanumeric
470
+ if (yn) return 1
471
+ return x < y ? -1 : 1
472
+ }
473
+ return 0
474
+ }
475
+
476
+ /** Compare two semver strings: negative | zero | positive (prerelease < release of same base). */
477
+ export function compareSemver(a: string, b: string): number {
478
+ const av = parseSemver(a)
479
+ const bv = parseSemver(b)
480
+ if (av === null || bv === null) return a < b ? -1 : a > b ? 1 : 0
481
+ if (av.major !== bv.major) return av.major - bv.major || 0
482
+ if (av.minor !== bv.minor) return av.minor - bv.minor || 0
483
+ if (av.patch !== bv.patch) return av.patch - bv.patch || 0
484
+ if (av.pre.length === 0 && bv.pre.length === 0) return 0
485
+ if (av.pre.length === 0) return 1 // release > prerelease
486
+ if (bv.pre.length === 0) return -1
487
+ return comparePre(av.pre, bv.pre)
488
+ }
489
+
490
+ function gte(a: Semver, b: Semver): boolean {
491
+ const cmp = compareSemver(`${a.major}.${a.minor}.${a.patch}${a.pre.length > 0 ? `-${a.pre.join('.')}` : ''}`, `${b.major}.${b.minor}.${b.patch}${b.pre.length > 0 ? `-${b.pre.join('.')}` : ''}`)
492
+ return cmp >= 0
493
+ }
494
+
495
+ /** String form of a parsed Semver (the boundary objects carry no prerelease). */
496
+ function semverStr(v: Semver): string {
497
+ return `${v.major}.${v.minor}.${v.patch}${v.pre.length > 0 ? `-${v.pre.join('.')}` : ''}`
498
+ }
499
+
500
+ /**
501
+ * Minimal range matcher for the peer-range check: `*`, exact, ^, ~, >=, >,
502
+ * <=, <, whitespace-separated pairs, and `||` alternatives. Anything else
503
+ * returns null (unknown — reported, not asserted).
504
+ *
505
+ * Prerelease handling follows npm's semver rule, evaluated at the comparator
506
+ * SET level (one `||` alternative is one set): a version carrying a
507
+ * prerelease tag only satisfies a set when at least one comparator in that
508
+ * set shares the version's [major, minor, patch] tuple AND carries a
509
+ * prerelease of its own; then every comparator is checked normally. So
510
+ * `^0.1.0` never matches `0.2.0-rc.1` (nor `0.1.0-rc.5`), while
511
+ * `>=1.2.3-rc.1 <2.0.0` does match `1.2.3-rc.2` (issue #98 analysis).
512
+ * Discovery can opt into npm's `includePrerelease` behaviour because every
513
+ * published DSH host line is itself prerelease; diagnostics retain the npm
514
+ * default unless a caller explicitly asks for that wider admission.
515
+ */
516
+ export function satisfiesRange(
517
+ version: string,
518
+ range: string,
519
+ options: { includePrerelease?: boolean } = {},
520
+ ): boolean | null {
521
+ const v = parseSemver(version)
522
+ if (v === null) return null
523
+ const versionHasPre = v.pre.length > 0
524
+
525
+ // Mirror pnpm's peer-dependency publish transform. Unlike ordinary
526
+ // dependency specs, a workspace token may sit inside a larger peer range.
527
+ // Operator-only (or bare) tokens receive the linked sibling's version;
528
+ // explicit versions only lose the protocol prefix. Unsupported alias/path
529
+ // forms become an unknown range below rather than a definite mismatch.
530
+ const workspaceSemver = /workspace:([\^~*]|>=|>|<=|<)?((\d+|[xX*])(\.(\d+|[xX*])){0,2})?/
531
+ let normalizedRange = range
532
+ if (range.includes('workspace:')) {
533
+ const match = workspaceSemver.exec(range)
534
+ if (match === null) {
535
+ normalizedRange = range.replace('workspace:', '')
536
+ } else if (match[2] !== undefined) {
537
+ normalizedRange = range.replace('workspace:', '')
538
+ } else {
539
+ const operator = match[1] === '*' ? '' : (match[1] ?? '')
540
+ normalizedRange = range.replace(workspaceSemver, `${operator}${semverStr(v)}`)
541
+ }
542
+ }
543
+
544
+ const single = (part: string): boolean | null => {
545
+ const p = part.trim()
546
+ if (p === '' || p === '*' || p === 'x' || p === 'X') return true
547
+ const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
548
+ const op = m?.[1] ?? ''
549
+ const target = (m?.[2] ?? '').trim()
550
+ const tv = parseSemver(target)
551
+ if (tv === null) return null
552
+ const major = tv.major
553
+ const minor = tv.minor
554
+ const patch = tv.patch
555
+ switch (op) {
556
+ case '':
557
+ return compareSemver(version, target) === 0
558
+ case '>=':
559
+ return gte(v, tv)
560
+ case '<=':
561
+ return gte(tv, v)
562
+ case '>':
563
+ return compareSemver(version, target) > 0
564
+ case '<':
565
+ return compareSemver(version, target) < 0
566
+ case '^': {
567
+ // npm caret semantics: >= given, strictly < the next breaking bump.
568
+ const upper: Semver = major > 0
569
+ ? { major: major + 1, minor: 0, patch: 0, pre: [] }
570
+ : minor > 0
571
+ ? { major: 0, minor: minor + 1, patch: 0, pre: [] }
572
+ : { major: 0, minor: 0, patch: patch + 1, pre: [] }
573
+ return gte(v, tv) && compareSemver(semverStr(upper), version) > 0
574
+ }
575
+ case '~': {
576
+ // npm tilde semantics: >= given, strictly < the next minor.
577
+ const upper: Semver = { major, minor: minor + 1, patch: 0, pre: [] }
578
+ return gte(v, tv) && compareSemver(semverStr(upper), version) > 0
579
+ }
580
+ default:
581
+ return null
582
+ }
583
+ }
584
+
585
+ /** Parse one comparator part into op + target; null when unknown. */
586
+ const comparator = (part: string): { op: string; target: string } | null => {
587
+ const p = part.trim()
588
+ if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: '', target: '' }
589
+ const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
590
+ if (m === null) return null
591
+ const target = (m[2] ?? '').trim()
592
+ // Enforce this function's documented unknown-range contract before the
593
+ // prerelease admission gate. Otherwise an unknown protocol such as
594
+ // `workspace:^` or `catalog:default` is misreported as a definite false
595
+ // whenever the resolved version happens to carry a prerelease tag.
596
+ if (parseSemver(target) === null) return null
597
+ return { op: m[1] ?? '', target }
598
+ }
599
+
600
+ /** Evaluate ONE comparator set (a `||` alternative) as a conjunction. */
601
+ const evaluateSet = (set: string): boolean | null => {
602
+ const parts = set.trim().split(/\s+/).filter(part => part !== '')
603
+ if (parts.length === 0) return true
604
+ const parsed = parts.map(part => comparator(part))
605
+ if (parsed.some(part => part === null)) return null
606
+ if (versionHasPre && options.includePrerelease !== true) {
607
+ // npm gate (set-level): the set admits prerelease versions only when a
608
+ // comparator pins the SAME base tuple with a prerelease of its own.
609
+ const admitted = parsed.some((part) => {
610
+ if (part?.target === '') return false
611
+ const tv = parseSemver(part?.target ?? '')
612
+ return tv !== null && tv.pre.length > 0
613
+ && v.major === tv.major && v.minor === tv.minor && v.patch === tv.patch
614
+ })
615
+ if (!admitted) return false
616
+ }
617
+ const results = parsed.map(part => single(part?.op !== undefined ? `${part.op}${part.target}` : ''))
618
+ if (results.some(r => r === null)) return null
619
+ return results.every(r => r === true)
620
+ }
621
+
622
+ if (normalizedRange.includes('||')) {
623
+ const outcomes = normalizedRange.split('||').map(part => evaluateSet(part))
624
+ if (outcomes.some(out => out === true)) return true
625
+ if (outcomes.some(out => out === null)) return null
626
+ return false
627
+ }
628
+ return evaluateSet(normalizedRange)
629
+ }
630
+
631
+ // --- composition (mirrors dsh-app-boot applyEntryPatches) ---
632
+
633
+ interface EntryNode {
634
+ id: string
635
+ name?: string
636
+ layer?: string
637
+ group?: boolean
638
+ config?: unknown
639
+ disabled?: unknown
640
+ }
641
+
642
+ /** Flatten a tree of entries (group configs included) into row records. */
643
+ function flattenEntries(nodes: EntryNode[]): LoaderRow[] {
644
+ const rows: LoaderRow[] = []
645
+ const walk = (list: EntryNode[], inheritedLayer?: string): void => {
646
+ for (const node of list) {
647
+ // Nested group configs come straight from the parsed patch and do not
648
+ // carry the synthetic layer metadata attached to their parent insert.
649
+ const layer = typeof node.layer === 'string' ? node.layer : inheritedLayer
650
+ if (layer === undefined) continue
651
+ rows.push({ id: node.id, layer, kind: 'insert', name: node.name })
652
+ if (node.group === true && Array.isArray(node.config)) {
653
+ walk(node.config as EntryNode[], layer)
654
+ }
655
+ }
656
+ }
657
+ walk(nodes)
658
+ return rows
659
+ }
660
+
661
+ type DisabledState = 'active' | 'disabled' | 'conditional'
662
+
663
+ interface ResolvableLoaderRow extends LoaderRow {
664
+ activation: 'required' | 'conditional'
665
+ }
666
+
667
+ /** Literal values use Loader Boolean semantics; `!!js` stays indeterminate. */
668
+ function disabledState(value: unknown): DisabledState {
669
+ if (isRecord(value) && typeof value.__jsExpr === 'string') return 'conditional'
670
+ return Boolean(value) ? 'disabled' : 'active'
671
+ }
672
+
673
+ function combineDisabled(parent: DisabledState, own: DisabledState): DisabledState {
674
+ if (parent === 'disabled' || own === 'disabled') return 'disabled'
675
+ if (parent === 'conditional' || own === 'conditional') return 'conditional'
676
+ return 'active'
677
+ }
678
+
679
+ /**
680
+ * Rows whose specifiers the Loader can attempt to import. Group rows are
681
+ * always imported even when disabled (their disabled state gates children),
682
+ * while expression-gated non-group rows are retained as conditional.
683
+ */
684
+ function resolvableEntries(nodes: EntryNode[]): ResolvableLoaderRow[] {
685
+ const rows: ResolvableLoaderRow[] = []
686
+ const walk = (
687
+ list: EntryNode[],
688
+ inheritedLayer?: string,
689
+ parentDisabled: DisabledState = 'active',
690
+ ): void => {
691
+ for (const node of list) {
692
+ const layer = typeof node.layer === 'string' ? node.layer : inheritedLayer
693
+ if (layer === undefined) continue
694
+ const descendantsDisabled = combineDisabled(parentDisabled, disabledState(node.disabled))
695
+ const activation = node.group === true ? 'required' : descendantsDisabled
696
+ if (activation !== 'disabled') {
697
+ rows.push({
698
+ id: node.id,
699
+ layer,
700
+ kind: 'insert',
701
+ name: node.name,
702
+ activation: activation === 'conditional' ? 'conditional' : 'required',
703
+ })
704
+ }
705
+ if (node.group === true && Array.isArray(node.config)) {
706
+ walk(node.config as EntryNode[], layer, descendantsDisabled)
707
+ }
708
+ }
709
+ }
710
+ walk(nodes)
711
+ return rows
712
+ }
713
+
714
+ export interface LayerInput {
715
+ label: string
716
+ kind: 'bundle' | 'user' | 'home'
717
+ patches: unknown[]
718
+ parseError: string | null
719
+ }
720
+
721
+ interface Composed {
722
+ rows: LoaderRow[]
723
+ resolvableRows: ResolvableLoaderRow[]
724
+ duplicates: DuplicateId[]
725
+ overrides: OverrideRow[]
726
+ orphans: OrphanRow[]
727
+ }
728
+
729
+ /**
730
+ * Apply the layer stack over an empty root exactly like the dsh boot include.
731
+ * Exported so the trial-start validation (src/trial.ts) can replay the
732
+ * composition with a candidate bundle order BEFORE anything is written.
733
+ */
734
+ export function composeLayers(layers: LayerInput[]): Composed {
735
+ const tree: EntryNode[] = []
736
+ const orphans: OrphanRow[] = []
737
+ const overrides: OverrideRow[] = []
738
+ /**
739
+ * The boot's entryMap, mirrored incrementally: the LAST row registered for
740
+ * an id (top-level or nested group member) is the patch target, and later
741
+ * inserts overwrite the map entry — exactly dsh-app-boot's applyEntryPatches
742
+ * buildMap. Keeping the map instead of re-walking the tree pins the
743
+ * duplicate-id resolution to the boot's behavior (issue #98 analysis:
744
+ * explicit composition boundaries).
745
+ */
746
+ const entryMap = new Map<string, EntryNode>()
747
+ const buildMap = (nodes: EntryNode[]): void => {
748
+ for (const node of nodes) {
749
+ if (node.id !== '') entryMap.set(node.id, node)
750
+ if (node.group === true && Array.isArray(node.config)) buildMap(node.config as EntryNode[])
751
+ }
752
+ }
753
+ for (const layer of layers) {
754
+ for (const patch of layer.patches) {
755
+ if (!isRecord(patch)) continue
756
+ const { id, insert, name, ...overridesOf } = patch
757
+ // Boot boundary: `insert` and `id` are truthiness-checked, so a falsy
758
+ // `insert` (null/''/0) falls through to the patch path and an empty id
759
+ // makes an insert a plain top-level append (applyEntryPatches).
760
+ const hasId = typeof id === 'string' ? id !== '' : Boolean(id)
761
+ const lookupKey = hasId ? String(id) : ''
762
+ if (insert) {
763
+ if (!Array.isArray(insert)) {
764
+ orphans.push({ id: lookupKey === '' ? '(anonymous)' : lookupKey, layer: layer.label, reason: 'insert is not an array' })
765
+ continue
766
+ }
767
+ const nodes = (insert as unknown[]).filter(isRecord).map((entry): EntryNode | null => {
768
+ if (typeof entry.id !== 'string') return null
769
+ return {
770
+ id: entry.id,
771
+ name: typeof entry.name === 'string' ? entry.name : undefined,
772
+ layer: layer.label,
773
+ group: entry.group === true,
774
+ config: Array.isArray(entry.config) ? entry.config : undefined,
775
+ disabled: entry.disabled,
776
+ }
777
+ }).filter((n): n is EntryNode => n !== null)
778
+ if (hasId) {
779
+ const target = entryMap.get(lookupKey)
780
+ if (target === undefined) {
781
+ orphans.push({ id: lookupKey, layer: layer.label, reason: 'insert target not found' })
782
+ continue
783
+ }
784
+ if (target.group !== true) {
785
+ orphans.push({ id: lookupKey, layer: layer.label, reason: 'insert target is not a group' })
786
+ continue
787
+ }
788
+ // Boot boundary: a group with a non-array config is fixed up to an
789
+ // empty array before the append (applyEntryPatches does the same).
790
+ if (!Array.isArray(target.config)) target.config = []
791
+ target.config = [...(target.config as unknown[]), ...nodes]
792
+ } else {
793
+ tree.push(...nodes)
794
+ }
795
+ buildMap(nodes)
796
+ continue
797
+ }
798
+ if (!hasId) {
799
+ orphans.push({ id: '(anonymous)', layer: layer.label, reason: 'id required for non-insert patch' })
800
+ continue
801
+ }
802
+ const target = entryMap.get(lookupKey)
803
+ if (target === undefined) {
804
+ orphans.push({ id: lookupKey, layer: layer.label, reason: 'patch target not found' })
805
+ continue
806
+ }
807
+ // Boot boundary: the name guard is truthiness-based — an empty-string
808
+ // name on the patch row does not trigger the mismatch skip.
809
+ if (name && name !== target.name) {
810
+ orphans.push({ id: lookupKey, layer: layer.label, reason: `name mismatch (expected ${String(target.name)}, got ${String(name)})` })
811
+ continue
812
+ }
813
+ const priorLayers: string[] = []
814
+ for (const node of flattenEntries(tree)) {
815
+ if (node.id === lookupKey && !priorLayers.includes(node.layer)) priorLayers.push(node.layer)
816
+ }
817
+ if (priorLayers.some(prior => prior !== layer.label)) {
818
+ overrides.push({ id: lookupKey, layer: layer.label, overriddenLayers: priorLayers.filter(prior => prior !== layer.label) })
819
+ }
820
+ for (const [key, value] of Object.entries(overridesOf)) {
821
+ if (key === 'id') continue
822
+ ;(target as unknown as Record<string, unknown>)[key] = value
823
+ }
824
+ }
825
+ }
826
+ const rows = flattenEntries(tree)
827
+ const resolvableRows = resolvableEntries(tree)
828
+ const byId = new Map<string, string[]>()
829
+ for (const row of rows) {
830
+ const layers = byId.get(row.id) ?? []
831
+ if (!layers.includes(row.layer)) layers.push(row.layer)
832
+ byId.set(row.id, layers)
833
+ }
834
+ const duplicates: DuplicateId[] = []
835
+ const counts = new Map<string, number>()
836
+ for (const row of rows) counts.set(row.id, (counts.get(row.id) ?? 0) + 1)
837
+ for (const [id, count] of counts) {
838
+ if (count < 2) continue
839
+ duplicates.push({ id, layers: byId.get(id) ?? [], count })
840
+ }
841
+ duplicates.sort((a, b) => a.id.localeCompare(b.id))
842
+ return { rows, resolvableRows, duplicates, overrides, orphans }
843
+ }
844
+
845
+ /** Distinct versions of `@deepseek-ai/{dsh,cordis}*` packages in the lockfile. */
846
+ function lockfileCoreVersions(profileDir: string): Map<string, string[]> {
847
+ const found = new Map<string, Set<string>>()
848
+ let text: string
849
+ try {
850
+ text = readFileSync(join(profileDir, 'pnpm-lock.yaml'), 'utf8')
851
+ } catch {
852
+ return new Map()
853
+ }
854
+ for (const m of text.matchAll(/(@deepseek-ai\/(?:dsh|cordis)[^@\s'"]*?)@([0-9][^\s:'"()]*)/g)) {
855
+ const name = m[1] ?? ''
856
+ // pnpm v9 peer-resolution keys carry a suffix: `name@1.0.0(peer@x)`. The
857
+ // version capture stops at the `(` — and at `)` too, so the peer
858
+ // reference INSIDE the suffix (`name@1.0.0(@deepseek-ai/dsh-base@4.0.1)`)
859
+ // never yields a fake `4.0.1)` version that would invent a phantom
860
+ // multi-version report on a healthy profile (issue #98 analysis).
861
+ const version = m[2] ?? ''
862
+ // Registry resolutions only: skip link:/git forms and anything that is
863
+ // not a well-formed semver (parseSemver double-checks the capture).
864
+ if (parseSemver(version) === null) continue
865
+ const versions = found.get(name) ?? new Set<string>()
866
+ versions.add(version)
867
+ found.set(name, versions)
868
+ }
869
+ const out = new Map<string, string[]>()
870
+ for (const [name, versions] of found) out.set(name, [...versions].sort(compareSemver))
871
+ return out
872
+ }
873
+
874
+ /**
875
+ * Build the bundle layer stack for a profile under a GIVEN bundle order —
876
+ * the manifest order for analyzeProfile, or a candidate order for trial
877
+ * validation (src/trial.ts). Bundle resolution mirrors the boot exactly:
878
+ * the dsh installation anchor first (in-box bundles always come from the
879
+ * running dsh, never a profile-local copy), then Node's module search from
880
+ * the profile directory (covers community bundles and pnpm workspace-root
881
+ * hoisting). A single code path keeps the check report and the trial
882
+ * validation from ever disagreeing about what a bundle is or where it lives.
883
+ */
884
+ export function buildBundleLayers(
885
+ profileDirectory: string,
886
+ bundleNames: string[],
887
+ specs: Record<string, string>,
888
+ dshInstallDir: string | null,
889
+ ): { bundles: BundleLayer[]; layers: LayerInput[] } {
890
+ const bundles: BundleLayer[] = bundleNames.map((name) => {
891
+ // The real loader gives the DSH installation first refusal for in-box
892
+ // bundles. Desktop keeps that installation private from plugins, so a
893
+ // DIRECT profile-local copy with the same official name is only a stale
894
+ // shadow, never evidence for the layer the running host loaded (#371).
895
+ // Keep walking the profile anchor's parent search paths: Desktop heals an
896
+ // authoritative host fallback at <profiles>/node_modules.
897
+ const ignoredProfilePackage = dshInstallDir === null && INBOX_BUNDLES.has(name)
898
+ ? join(profileDirectory, 'node_modules', name)
899
+ : undefined
900
+ const anchors: Array<{ anchor: string | null; ignoredPackageDirectory?: string }> = [
901
+ { anchor: dshInstallDir !== null ? join(dshInstallDir, 'package.json') : null },
902
+ {
903
+ anchor: join(profileDirectory, 'package.json'),
904
+ ignoredPackageDirectory: ignoredProfilePackage,
905
+ },
906
+ ]
907
+ let directory: string | null = null
908
+ for (const { anchor, ignoredPackageDirectory } of anchors) {
909
+ if (anchor === null) continue
910
+ directory = resolvePackageDir(anchor, name, ignoredPackageDirectory)
911
+ if (directory !== null) break
912
+ }
913
+ const layer: BundleLayer = {
914
+ name,
915
+ source: specs[name] ?? '(not a direct dependency)',
916
+ kind: INBOX_BUNDLES.has(name) ? 'official' : 'community',
917
+ directory,
918
+ patchPath: null,
919
+ error: null,
920
+ entries: [],
921
+ parseError: null,
922
+ }
923
+ if (directory === null) {
924
+ // An in-box bundle is supplied by the dsh INSTALLATION, not by the
925
+ // profile — that is what makes it in-box. So failing to find one says
926
+ // we could not locate the installation, not that the profile is
927
+ // broken: unusual or older hosts can still hide the in-box installation
928
+ // from both the CLI-entry and packaged-Desktop discovery anchors.
929
+ //
930
+ // Calling that "will fail to boot" turned a working composition into a
931
+ // fatal verdict and rolled back a good update (#369) — while `dsh
932
+ // --dump-config` on the same profile exited 0. Unknown has to read as
933
+ // unknown; the profile's own bundles are still judged normally.
934
+ if (INBOX_BUNDLES.has(name)) {
935
+ layer.error = null
936
+ layer.unresolvedInbox = true
937
+ return layer
938
+ }
939
+ layer.error = 'bundle package is not installed — the profile will fail to boot'
940
+ return layer
941
+ }
942
+ let bundleManifest: { dsh?: { bundle?: { patch?: unknown; order?: unknown } } }
943
+ try {
944
+ bundleManifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as typeof bundleManifest
945
+ } catch {
946
+ layer.error = 'bundle package.json is unreadable'
947
+ return layer
948
+ }
949
+ const declared = bundleManifest.dsh?.bundle?.patch
950
+ if (typeof declared !== 'string') {
951
+ layer.error = 'bundle declares no dsh.bundle.patch — the profile will fail to boot'
952
+ return layer
953
+ }
954
+ const patchPath = join(directory, declared)
955
+ if (!existsSync(patchPath)) {
956
+ layer.error = `declared patch ${declared} is missing — the profile will fail to boot`
957
+ return layer
958
+ }
959
+ layer.patchPath = patchPath
960
+ const patches = parsePatchFile(patchPath)
961
+ if (patches === null) {
962
+ layer.parseError = 'patch file is not a valid entry list'
963
+ return layer
964
+ }
965
+ layer.entries = collectInsertIds(patches)
966
+ const order = bundleManifest.dsh?.bundle?.order
967
+ if (order !== null && typeof order === 'object' && !Array.isArray(order)) {
968
+ const listOf = (value: unknown): string[] | undefined => Array.isArray(value)
969
+ ? value.filter((item): item is string => typeof item === 'string')
970
+ : undefined
971
+ const after = listOf((order as Record<string, unknown>).after)
972
+ const before = listOf((order as Record<string, unknown>).before)
973
+ if (after !== undefined || before !== undefined) {
974
+ layer.order = { ...(before !== undefined ? { before } : {}), ...(after !== undefined ? { after } : {}) }
975
+ }
976
+ }
977
+ return layer
978
+ })
979
+ const layers: LayerInput[] = bundles.map((bundle) => ({
980
+ label: bundle.name,
981
+ kind: 'bundle' as const,
982
+ patches: bundle.patchPath !== null && bundle.parseError === null ? parsePatchFile(bundle.patchPath) ?? [] : [],
983
+ parseError: bundle.parseError,
984
+ }))
985
+ return { bundles, layers }
986
+ }
987
+
988
+ /**
989
+ * Analyze one profile directory (issue #98, phase 1). Pure function of the
990
+ * directory contents — safe to call on every market open.
991
+ */
992
+ export function analyzeProfile(profileDirectory: string, options: CheckOptions = {}): CheckReport {
993
+ const dshInstall = options.dshInstallDir ?? findDshInstallDir()
994
+ const home = resolveDshHome(options.homeDir)
995
+ const core = corePackageNames(dshInstall)
996
+
997
+ // --- 1. bundle stack ---
998
+ const manifest = (() => {
999
+ try {
1000
+ return JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8')) as {
1001
+ dependencies?: Record<string, string>
1002
+ dsh?: { profile?: { bundles?: unknown } }
1003
+ }
1004
+ } catch {
1005
+ return null
1006
+ }
1007
+ })()
1008
+ const bundleNames = Array.isArray(manifest?.dsh?.profile?.bundles)
1009
+ ? manifest.dsh.profile.bundles.filter((name): name is string => typeof name === 'string')
1010
+ : []
1011
+ const specs = manifest?.dependencies ?? {}
1012
+ const built = buildBundleLayers(profileDirectory, bundleNames, specs, dshInstall)
1013
+ const bundles = built.bundles
1014
+ const bundleLayers = built.layers
1015
+
1016
+ // --- 2. composed loader rows / duplicates / overrides / orphans ---
1017
+ const layers: LayerInput[] = [...bundleLayers]
1018
+ const userPatchPath = join(profileDirectory, 'cordis.patch.yml')
1019
+ if (existsSync(userPatchPath)) {
1020
+ const patches = parsePatchFile(userPatchPath)
1021
+ layers.push({ label: 'user-patch', kind: 'user', patches: patches ?? [], parseError: patches === null ? 'patch file is not a valid entry list' : null })
1022
+ }
1023
+ const homePatchPath = join(home, 'cordis.patch.yml')
1024
+ if (existsSync(homePatchPath)) {
1025
+ const patches = parsePatchFile(homePatchPath)
1026
+ layers.push({ label: 'home-patch', kind: 'home', patches: patches ?? [], parseError: patches === null ? 'patch file is not a valid entry list' : null })
1027
+ }
1028
+ const composed = composeLayers(layers)
1029
+
1030
+ // --- 3. peer dependency mismatches (every declared peer, core or not) ---
1031
+ // NOTE (issue #98 division of labor): the core-package-as-ordinary-
1032
+ // dependency check (coreDeps / shadowing) belongs to @yzke's manifest PR;
1033
+ // PR-A keeps only the peer-resolution check here.
1034
+ const peerMismatches: PeerMismatch[] = []
1035
+ const seenDeps = new Set<string>()
1036
+ for (const plugin of installedPackageNames(profileDirectory)) {
1037
+ let pkg: {
1038
+ peerDependencies?: Record<string, string>
1039
+ peerDependenciesMeta?: Record<string, { optional?: unknown }>
1040
+ }
1041
+ try {
1042
+ pkg = JSON.parse(readFileSync(join(profileDirectory, 'node_modules', plugin, 'package.json'), 'utf8')) as typeof pkg
1043
+ } catch {
1044
+ continue
1045
+ }
1046
+ const pluginDir = join(profileDirectory, 'node_modules', plugin)
1047
+ const map = pkg.peerDependencies
1048
+ if (map === null || typeof map !== 'object') continue
1049
+ for (const [name, spec] of Object.entries(map)) {
1050
+ if (typeof spec !== 'string') continue
1051
+ const key = `${plugin}\u0000${name}\u0000peer`
1052
+ if (seenDeps.has(key)) continue
1053
+ seenDeps.add(key)
1054
+ const hoisted = readProfileVisibleVersion(profileDirectory, name)
1055
+ const nested = readNodeModulesVersion(pluginDir, name)
1056
+ const host = dshInstall !== null ? readNodeModulesVersion(dshInstall, name) : null
1057
+ // Node resolves a plugin's peer from its OWN node_modules first
1058
+ // (nested), then the profile tree (hoisted), then the host install.
1059
+ const resolved = nested ?? hoisted ?? host
1060
+ // Peer checks cover EVERY declared peer, not just host core packages:
1061
+ // plugin-to-plugin peer mismatches break runtime registration just as
1062
+ // hard (issue #98 optimization round).
1063
+ const satisfied = resolved !== null ? satisfiesRange(resolved, spec) : null
1064
+ const optional = pkg.peerDependenciesMeta?.[name]?.optional === true
1065
+ peerMismatches.push({
1066
+ plugin, name, range: spec, resolved,
1067
+ satisfied: satisfied === null ? null : satisfied,
1068
+ ...(optional ? { optional: true } : {}),
1069
+ })
1070
+ }
1071
+ }
1072
+
1073
+ // --- 4. multi-version core packages from the lockfile ---
1074
+ const multiVersion: MultiVersion[] = []
1075
+ for (const [name, versions] of lockfileCoreVersions(profileDirectory)) {
1076
+ if (versions.length < 2) continue
1077
+ multiVersion.push({ name, versions, hoisted: readProfileVisibleVersion(profileDirectory, name) })
1078
+ }
1079
+ multiVersion.sort((a, b) => a.name.localeCompare(b.name))
1080
+
1081
+ // --- summary ---
1082
+ const errors: string[] = []
1083
+ const warnings: string[] = []
1084
+ for (const bundle of bundles) {
1085
+ if (bundle.error !== null) errors.push(`bundle ${bundle.name}: ${bundle.error}`)
1086
+ if (bundle.parseError !== null) errors.push(`bundle ${bundle.name}: ${bundle.parseError}`)
1087
+ }
1088
+ for (const layer of layers) {
1089
+ if (layer.parseError !== null && layer.kind !== 'bundle') errors.push(`${layer.label}: ${layer.parseError}`)
1090
+ }
1091
+ // User and home patches can insert packages independently of a bundle.
1092
+ // Only check rows that survived composition: an insert targeting a missing
1093
+ // group is skipped by the boot and therefore cannot cause module loading to
1094
+ // fail. Normalize package subpaths to their npm root and check the profile's
1095
+ // node_modules ancestry; exact exports/subpath validation and relative,
1096
+ // absolute, URL, builtin, package-import (#), or cordis: specifiers are
1097
+ // outside this check.
1098
+ const userLayerLabels = new Set(
1099
+ layers
1100
+ .filter(layer => layer.kind === 'user' || layer.kind === 'home')
1101
+ .map(layer => layer.label),
1102
+ )
1103
+ const candidates = new Map<string, { row: ResolvableLoaderRow, packageName: string }>()
1104
+ for (const row of composed.resolvableRows) {
1105
+ if (row.kind !== 'insert' || !userLayerLabels.has(row.layer)) continue
1106
+ if (row.name === undefined || row.name === '') {
1107
+ const message = `${row.layer}: loader entry ${JSON.stringify(row.id)} has no module name`
1108
+ if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1109
+ else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1110
+ continue
1111
+ }
1112
+ if (!isBareLoaderSpecifier(row.name)) continue
1113
+ if (isBuiltin(row.name)) continue
1114
+ const packageName = packageRoot(row.name)
1115
+ if (packageName === null) {
1116
+ const message = `${row.layer}: loader specifier ${JSON.stringify(row.name)} is not a valid bare package name`
1117
+ if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1118
+ else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1119
+ continue
1120
+ }
1121
+ const key = `${row.layer}\u0000${packageName}`
1122
+ const previous = candidates.get(key)
1123
+ if (previous === undefined || previous.row.activation === 'conditional' && row.activation === 'required') {
1124
+ candidates.set(key, { row, packageName })
1125
+ }
1126
+ }
1127
+ for (const { row, packageName } of candidates.values()) {
1128
+ if (profilePackageInstalled(profileDirectory, packageName)) continue
1129
+ const message = `${row.layer}: loader package ${packageName} is not installed in the profile`
1130
+ if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1131
+ else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1132
+ }
1133
+ for (const dup of composed.duplicates) {
1134
+ errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`)
1135
+ }
1136
+ for (const orphan of composed.orphans) {
1137
+ warnings.push(`${orphan.layer}: ${orphan.id} — ${orphan.reason}`)
1138
+ }
1139
+ for (const mismatch of peerMismatches) {
1140
+ // Only CONFIRMED incompatibilities warn. Un-evaluable peers (sat=null —
1141
+ // the peer is supplied by the host, an optional accelerator, or simply
1142
+ // absent) stay in the peerMismatches list for the UI but are not noise
1143
+ // in the summary (issue #98 optimization round).
1144
+ // ...and an OPTIONAL peer is not a confirmed incompatibility either.
1145
+ // classifyPeer already downgrades those to non-risk (compatibility.ts),
1146
+ // and the summary disagreeing with it meant a plugin that declares "I
1147
+ // work without this" still produced a scary warning line — including
1148
+ // for the market's own optional peer, on every profile that installs us
1149
+ // (#275 by @tjxjxjx).
1150
+ if (mismatch.satisfied === false && mismatch.optional !== true) {
1151
+ warnings.push(`${mismatch.plugin} peer range ${mismatch.name}@${mismatch.range} does not match resolved ${String(mismatch.resolved)}`)
1152
+ }
1153
+ }
1154
+ for (const mv of multiVersion) {
1155
+ const line = `${mv.name}: ${mv.versions.join(' / ')}${mv.hoisted !== null ? ` (hoisted ${mv.hoisted})` : ''}`
1156
+ if (core.has(mv.name)) errors.push(`multiple versions of core package — ${line}`)
1157
+ else warnings.push(`multiple versions of ${line}`)
1158
+ }
1159
+ // Current-order before/after rule conflicts (issue #98 phase 2): these are
1160
+ // informative for the ordering UI; the author-declared rule breaking the
1161
+ // CURRENT stack is worth a warning but not a boot failure. Conflicts are
1162
+ // exposed both at the report top level and per bundle (order.conflicts) so
1163
+ // the ordering panel can render them next to the bundle rows.
1164
+ const orderConflicts = validateOrder(bundleNames, readBundleRules(profileDirectory))
1165
+ for (const conflict of orderConflicts) {
1166
+ warnings.push(`${conflict.name}: ${conflict.reason}`)
1167
+ }
1168
+ for (const bundle of bundles) {
1169
+ const own = orderConflicts.filter(conflict => conflict.name === bundle.name)
1170
+ if (own.length > 0) bundle.order = { ...bundle.order, conflicts: own }
1171
+ }
1172
+ // LOOT-style auto-fix: suggest a minimal-change order satisfying every
1173
+ // declared before/after rule. No rules → no suggestion (nothing to fix);
1174
+ // with rules the suggestion keeps unconstrained bundles in their current
1175
+ // relative order (issue #125 review — never silently rewrites a hand-picked
1176
+ // order into an arbitrary canonical one).
1177
+ const suggestedOrder = suggestOrder(bundleNames, readBundleRules(profileDirectory))
1178
+ if (suggestedOrder === null) {
1179
+ // No declared rules — nothing to suggest and nothing to warn about.
1180
+ } else if (!suggestedOrder.ok) {
1181
+ warnings.push(`ordering constraints contain a cycle: ${suggestedOrder.cycle.join(' -> ')} — no compliant order exists / 排序约束存在循环依赖,无法得出合规顺序`)
1182
+ } else {
1183
+ // Only warn when the CURRENT order actually breaks a declared rule. A
1184
+ // hand-picked order that satisfies every rule but merely differs from the
1185
+ // suggestion is valid — flagging it would be a false alert on a healthy
1186
+ // profile (issue #98 analysis).
1187
+ if (orderConflicts.length > 0) {
1188
+ warnings.push('current bundle order violates declared rules — a better order is suggested / 当前 bundle 顺序违反声明规则,已给出更优顺序')
1189
+ }
1190
+ }
1191
+
1192
+ // Duplicate loader NAMES: the Loader registers plugins by name, so two rows
1193
+ // with the same name in DIFFERENT layers shadow each other at runtime (the
1194
+ // later layer wins). Rows sharing a name within ONE layer are routine — a
1195
+ // single bundle may define several entries under the same name — and are
1196
+ // skipped entirely: a same-layer "duplicate name" is a false positive, not
1197
+ // a conflict. The hard conflict is duplicate loader entry ids
1198
+ // (report.duplicates), which fail the boot outright and are unchanged.
1199
+ const nameCounts = new Map<string, string[]>()
1200
+ for (const row of composed.rows) {
1201
+ if (row.name === undefined) continue
1202
+ const layers = nameCounts.get(row.name) ?? []
1203
+ if (!layers.includes(row.layer)) layers.push(row.layer)
1204
+ nameCounts.set(row.name, layers)
1205
+ }
1206
+ const duplicateNames: DuplicateName[] = []
1207
+ for (const [name, layers] of nameCounts) {
1208
+ // Only cross-layer collisions are real shadowing candidates: all rows
1209
+ // with this name living in one layer is a normal multi-entry bundle.
1210
+ if (layers.length < 2) continue
1211
+ const count = composed.rows.filter(row => row.name === name).length
1212
+ duplicateNames.push({ name, layers, count })
1213
+ // Deliberately NOT pushed into summary.warnings: the report carries the
1214
+ // shadowing rows structurally (duplicateNames, rendered by the
1215
+ // diagnostics panel), and the cost of a false positive — a healthy
1216
+ // profile flagged with a warning — outweighs a false negative here.
1217
+ }
1218
+ duplicateNames.sort((a, b) => a.name.localeCompare(b.name))
1219
+
1220
+ return {
1221
+ profile: profileDirectory,
1222
+ scannedAt: Date.now(),
1223
+ bundles,
1224
+ rows: composed.rows,
1225
+ duplicates: composed.duplicates,
1226
+ duplicateNames,
1227
+ overrides: composed.overrides,
1228
+ orphans: composed.orphans,
1229
+ peerMismatches,
1230
+ multiVersion,
1231
+ orderConflicts,
1232
+ suggestedOrder,
1233
+ summary: {
1234
+ ok: errors.length === 0,
1235
+ errors,
1236
+ warnings,
1237
+ },
1238
+ }
1239
+ }