@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.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 (48) hide show
  1. package/lib/client.js +126 -31
  2. package/lib/index.js +60 -9687
  3. package/lib/server/domain/ai-run.js +479 -0
  4. package/lib/server/domain/ai.js +246 -0
  5. package/lib/server/domain/compat.js +474 -0
  6. package/lib/server/domain/components.js +108 -0
  7. package/lib/server/domain/dep-source.js +122 -0
  8. package/lib/server/domain/format-contract.js +265 -0
  9. package/lib/server/domain/format-scan.js +434 -0
  10. package/lib/server/domain/framework.js +393 -0
  11. package/lib/server/domain/install-job.js +561 -0
  12. package/lib/server/domain/install.js +599 -0
  13. package/lib/server/domain/jobs.js +28 -0
  14. package/lib/server/domain/market.js +409 -0
  15. package/lib/server/domain/patch.js +203 -0
  16. package/lib/server/domain/presets.js +93 -0
  17. package/lib/server/domain/quarantine.js +224 -0
  18. package/lib/server/domain/release-source.js +504 -0
  19. package/lib/server/domain/repoland.js +119 -0
  20. package/lib/server/domain/revoke.js +184 -0
  21. package/lib/server/domain/runtime.js +118 -0
  22. package/lib/server/domain/selfupdate.js +319 -0
  23. package/lib/server/domain/skills.js +234 -0
  24. package/lib/server/domain/sources.js +297 -0
  25. package/lib/server/domain/suite.js +220 -0
  26. package/lib/server/infra/exec.js +98 -0
  27. package/lib/server/infra/fsx.js +163 -0
  28. package/lib/server/infra/fw-integrity-check.js +37 -0
  29. package/lib/server/infra/http.js +373 -0
  30. package/lib/server/infra/httpd.js +51 -0
  31. package/lib/server/infra/mask.js +19 -0
  32. package/lib/server/infra/paths.js +177 -0
  33. package/lib/server/infra/semver.js +168 -0
  34. package/lib/server/routes/ai.js +172 -0
  35. package/lib/server/routes/components.js +254 -0
  36. package/lib/server/routes/framework-preflight.js +160 -0
  37. package/lib/server/routes/framework-upgrade.js +679 -0
  38. package/lib/server/routes/framework.js +544 -0
  39. package/lib/server/routes/github-login.js +198 -0
  40. package/lib/server/routes/index.js +128 -0
  41. package/lib/server/routes/install.js +116 -0
  42. package/lib/server/routes/market.js +415 -0
  43. package/lib/server/routes/plugins.js +562 -0
  44. package/lib/server/routes/skills.js +107 -0
  45. package/lib/server/routes/sources.js +437 -0
  46. package/lib/server/routes/state.js +125 -0
  47. package/lib/server/state.js +22 -0
  48. package/package.json +1 -1
@@ -0,0 +1,122 @@
1
+ // L1 · domain —— dep-source.js(依赖**来源**判定与写回:registry 探测 / plugin-src 物化 / link: 规格)
2
+ //
3
+ // 为什么单开一个模块:install.js 有 600 行棘轮(test-architecture-guard.mjs),而这块逻辑既被
4
+ // install.js 家族用,也被 selfupdate.js 的 lock 对账用——放在自己这里两边都能引,且互不牵连。
5
+ //
6
+ // 缺陷②修复(0.3.63 / 0.4.0-beta.16)背景(用户 issue 草案「缺陷②」,2026-09-22 实测):
7
+ // release 通道装的包只存在于 GitHub release,npm registry 里查无此包;而 0.3.57 起的 lock 对账
8
+ // 一律 `pnpm add <name>@<installed>` —— pnpm 看到「已装版本满足新 spec」就**静默**把 profile
9
+ // package.json 的 dependencies 改写为裸版本号(输出 `Already up to date`、EXIT=0,面板显示成功),
10
+ // 同时把 lock 的 specifier 也改成版本号、却保留旧的 tarball 解析。装完一切正常,直到有人重建
11
+ // lock(删 lock / 清 node_modules / 换机 / CI)→ `ERR_PNPM_FETCH_404`,而报错指向 npm registry,
12
+ // 用户根本联想不到是几周前面板安装改写造成的。
13
+ //
14
+ // 两条硬约束(本机真 pnpm 10.34.5 实测矩阵见 D:\dsh\dsh-plugin-hub-plan\refactor-bugs.zh.md 第 23 节):
15
+ // ① 写回前必须确认「这个包的**这个版本**」在 registry 可解析,否则**绝不**写裸版本号;
16
+ // ② 不可解析时**也不能**写 tarball URL:pnpm 10 对 direct-URL 依赖只在冷缓存真下载时记 integrity,
17
+ // 命中缓存重写 lock 时 resolution 里没有 integrity → `ERR_PNPM_MISSING_TARBALL_INTEGRITY`,
18
+ // 而且 pnpm 会把 lock 文件直接删掉,形成「删 lock 修不好、不删 lock 装不动」的死循环。
19
+ // 改用 `link:<DSH_HOME>/plugin-src/<包名>`:pnpm 的 link 协议只建符号链接,不经 registry 解析、
20
+ // 不经 tarball 完整性校验,lock 删掉重建、node_modules 清空重装都稳定通过。
21
+
22
+ import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import { copyTree } from '../infra/fsx.js'
25
+ import { fetchJsonUrl } from '../infra/http.js'
26
+ import { dshHome } from '../infra/paths.js'
27
+
28
+ /** 物化目录的根(与用户 issue 里手工规避用的 `/root/.dsh/plugin-src/...` 同一位置)。 */
29
+ const PLUGIN_SRC_DIR = 'plugin-src'
30
+
31
+ /**
32
+ * registry 能否解析该包的指定版本(按顺序多源尝试,任一源可解析即通过)。
33
+ * 404 / 网络失败都归为「不可解析」——调用方据此决定**绝不写裸版本号**。
34
+ * `version` 为 null 时只判包是否存在;`hasVersion` 表示指定版本是否在 versions 里
35
+ * (release 通道装的版本可能比 registry 上的 latest 还新,只判包名存在是不够的)。
36
+ */
37
+ async function probeRegistryPackage(packageName, registries = [], options = {}) {
38
+ const fetchJson = typeof options.fetchJson === 'function' ? options.fetchJson : fetchJsonUrl
39
+ const version = typeof options.version === 'string' && options.version !== '' ? options.version : null
40
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 8000
41
+ const list = (Array.isArray(registries) ? registries : []).filter((r) => typeof r === 'string' && r.trim() !== '')
42
+ const tries = list.length > 0 ? list : ['https://registry.npmmirror.com']
43
+ const encoded = packageName.startsWith('@')
44
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
45
+ : encodeURIComponent(packageName)
46
+ const tried = []
47
+ for (const reg of tries) {
48
+ const base = String(reg).replace(/\/+$/u, '')
49
+ try {
50
+ const meta = await fetchJson(`${base}/${encoded}`, timeoutMs)
51
+ const versions = meta && typeof meta === 'object' && meta.versions && typeof meta.versions === 'object' ? meta.versions : null
52
+ if (versions === null) {
53
+ tried.push(`${base}:返回体没有 versions 字段`)
54
+ continue
55
+ }
56
+ const latest = typeof meta['dist-tags']?.latest === 'string' ? meta['dist-tags'].latest : null
57
+ return {
58
+ resolvable: true,
59
+ hasVersion: version === null || Object.prototype.hasOwnProperty.call(versions, version),
60
+ latest,
61
+ registry: base,
62
+ tried,
63
+ }
64
+ } catch (error) {
65
+ tried.push(`${base}:${error instanceof Error ? error.message : String(error)}`)
66
+ }
67
+ }
68
+ return { resolvable: false, hasVersion: false, latest: null, registry: null, tried }
69
+ }
70
+
71
+ /**
72
+ * 把 profile 里**已装好的**包物化一份到 `<DSH_HOME>/plugin-src/<包名>`,返回该绝对路径。
73
+ * 为什么必须另存一份而不是直接 link node_modules 里的目录:pnpm 重建 node_modules 时会先删掉
74
+ * 整个目录,link 目标随即消失;plugin-src 是 pnpm 不管理的独立目录,跨 lock 重建、
75
+ * 跨 node_modules 清空都稳定存在(这也是用户手工规避时选的位置)。
76
+ * 返回 null 表示源目录不存在或复制失败 —— 调用方必须**跳过对齐**并如实记 note,绝不改 package.json。
77
+ */
78
+ function materializePackageForLink(profileDir, packageName, options = {}) {
79
+ const home = typeof options.home === 'string' && options.home !== '' ? options.home : dshHome()
80
+ const src = join(profileDir, 'node_modules', ...packageName.split('/'))
81
+ if (!existsSync(join(src, 'package.json'))) return null
82
+ const dest = join(home, PLUGIN_SRC_DIR, ...packageName.split('/'))
83
+ // 已经是指向 plugin-src 的链接(重复对账)→ 不能先删再复制:那样源就成了悬空链接
84
+ try {
85
+ if (existsSync(dest) && realpathSync(src) === realpathSync(dest)) return dest
86
+ } catch {}
87
+ try {
88
+ mkdirSync(dirname(dest), { recursive: true })
89
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
90
+ copyTree(src, dest)
91
+ } catch {
92
+ return null
93
+ }
94
+ return existsSync(join(dest, 'package.json')) ? dest : null
95
+ }
96
+
97
+ /** pnpm 的 `link:` 规格:写绝对路径(反斜杠转正斜杠,跨平台且 lock 可读)。 */
98
+ function linkSpecFor(dir) {
99
+ return `link:${String(dir).replace(/\\/gu, '/')}`
100
+ }
101
+
102
+ /**
103
+ * manifest 里的 `link:` 规格当前是否**真的**还生效(`node_modules/<包名>` 就是指向它的那个链接)。
104
+ * 为什么必须查:release / curl 通道更新包时是「先 rmSync 再 copyTree」,会把 `node_modules/<包名>`
105
+ * 从"链接"换成"真实目录",而 manifest 与 lock 里仍写着 `link:<plugin-src/…>` —— 光看版本号看不出来
106
+ * (lock 里本来就是 `link:`),但**之后任何一次 pnpm 操作都会按 lock 重建链接**,把刚更新上去的版本
107
+ * 还原成 `plugin-src` 里的旧副本(与 0.3.56 修过的「自更新被 lock 还原」同族)。
108
+ * 返回 false 时调用方会重新物化(把新副本刷进 plugin-src)并重放 `link:`,实测能把链接与版本一起恢复。
109
+ */
110
+ function linkSpecIsIntact(profileDir, packageName, spec) {
111
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) return false
112
+ const target = spec.slice('link:'.length)
113
+ const src = join(profileDir, 'node_modules', ...packageName.split('/'))
114
+ try {
115
+ if (!existsSync(src) || !existsSync(target)) return false
116
+ return realpathSync(src) === realpathSync(target)
117
+ } catch {
118
+ return false
119
+ }
120
+ }
121
+
122
+ export { PLUGIN_SRC_DIR, probeRegistryPackage, materializePackageForLink, linkSpecFor, linkSpecIsIntact }
@@ -0,0 +1,265 @@
1
+ // L1 · domain —— format-contract.js:会话格式契约预检(Step 1 · 契约抓取)
2
+ //
3
+ // 背景(2026-09-24 真机事故):框架 0.1.5-rc.2 → 0.1.7-rc.1 把会话消息格式升到 V4,
4
+ // 要求每条被解释的消息 source 带「producer-owned kind」(非空、且不等于字面量 'plugin')。
5
+ // 框架自带的 @deepseek-ai/dsh-session-format-v3-to-v4 只迁移**磁盘上的历史会话**;
6
+ // 运行期新产生的消息由本地生产方(agent-presets + 插件运行时代码)自己构造——它们仍写
7
+ // V3 老形状 `{ kind: 'plugin', plugin: X }`,于是「一发消息就报错、整个会话不可用」。
8
+ // 现有的包级适配门(peerDependencies / 版本号)**看不到**这类运行期契约破坏 ——
9
+ // 生产方(预设文件、插件 runtime)根本没有版本元数据。
10
+ //
11
+ // 发现路径(实网探明,2026-09-24):顶层 @deepseek-ai/dsh 只挂中间包,**迁移边全集挂在
12
+ // @deepseek-ai/dsh-session-format-catalog 的 dependencies 上**——所以先问 catalog(一次请求拿到
13
+ // 该版本的全部边),catalog 缺失时退回扫顶层 dsh 依赖。核心信号:
14
+ // **目标版本的迁移边集合 比 当前已装版本的 多出一条** ⇒ 这就是一次会打在运行期生产方身上的契约变更。
15
+ //
16
+ // 本模块全部网络 I/O 可注入(离线可测):抓取失败不抛,返回 error 让上层降级成「只用内置规则扫描」。
17
+
18
+ import { parseFrameworkVersion } from '../infra/semver.js'
19
+
20
+ /** 会话格式迁移边包名:@deepseek-ai/dsh-session-format-v3-to-v4。 */
21
+ const FORMAT_EDGE_RE = /^@deepseek-ai\/dsh-session-format-v(\d+)-to-v(\d+)$/u
22
+
23
+ /** 迁移边全集的权威载体(实网确认:0.1.7-rc.1 的 catalog 依赖里含 v0→v1…v3→v4 四条)。 */
24
+ const CATALOG_PKG = '@deepseek-ai/dsh-session-format-catalog'
25
+
26
+ /** 一次预检最多为几条边拉 README(按 to 从高到低,最高那条才带准入规则)。 */
27
+ const README_BUDGET = 3
28
+
29
+ /** npm 源上的包名编码(scope 斜杠要转义,npmmirror 与 registry.npmjs.org 都吃 %2f)。 */
30
+ function encodePkgName(name) {
31
+ return String(name).replace('/', '%2f')
32
+ }
33
+
34
+ /** 解析一条迁移边包名;不是迁移边返回 null。 */
35
+ function parseFormatEdge(pkgName) {
36
+ const m = FORMAT_EDGE_RE.exec(String(pkgName))
37
+ if (m === null) return null
38
+ return { name: pkgName, from: Number(m[1]), to: Number(m[2]) }
39
+ }
40
+
41
+ /** 从依赖表里挑出全部迁移边(按 to 升序;同一 from/to 只留一条)。 */
42
+ function formatEdgesFromDependencies(dependencies) {
43
+ const edges = []
44
+ for (const name of Object.keys(dependencies ?? {})) {
45
+ const edge = parseFormatEdge(name)
46
+ if (edge !== null) edges.push(edge)
47
+ }
48
+ return edges.sort((a, b) => (a.to - b.to) || (a.from - b.from))
49
+ }
50
+
51
+ /** 依赖里的版本区间(`^0.1.7-rc.1` / `~1.2.3`)→ 具体版本字符串。 */
52
+ function concreteVersionOf(range) {
53
+ const m = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/u.exec(String(range ?? ''))
54
+ return m === null ? null : m[1]
55
+ }
56
+
57
+ /**
58
+ * 取 README 里某个小节(到下一个二级/三级标题或锚点为止);找不到返回 null。
59
+ * ★ 必须锚定「标题行」而不是 `indexOf`:README 顶部的目录(ToC)里也会出现同样的小节名,
60
+ * 用 indexOf 会命中目录那一行,切出来的段落里没有表格 → 规则静默丢失(实网复验抓到过)。
61
+ */
62
+ function sectionOf(readme, heading) {
63
+ const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
64
+ const m = new RegExp(`^#{2,3}\\s+${escaped}\\s*$`, 'mu').exec(readme)
65
+ if (m === null) return null
66
+ const rest = readme.slice(m.index + m[0].length)
67
+ const end = rest.search(/^#{2,3}\s|\n<a id=/mu)
68
+ return end === -1 ? rest : rest.slice(0, end)
69
+ }
70
+
71
+ /**
72
+ * 取小节里「V3 plugin → V4 kind」那张映射表的行。
73
+ * 同一个小节内还有别的表(事件位置表 `| \`user/message\` | \`data\` |` 等),形状一模一样,
74
+ * 所以必须先认表头(同时含 `plugin` 与 `kind` 的那一行),只吃它下面连续的行。
75
+ */
76
+ function mappingRowsFromSection(section) {
77
+ const rows = []
78
+ let inTable = false
79
+ for (const line of section.split(/\r?\n/u)) {
80
+ if (!inTable) {
81
+ if (/^\|/u.test(line) && /`plugin`/u.test(line) && /`kind`/u.test(line)) inTable = true
82
+ continue
83
+ }
84
+ if (!/^\|/u.test(line)) break
85
+ if (/^\|\s*:?-{2,}/u.test(line)) continue
86
+ rows.push(line)
87
+ }
88
+ return rows
89
+ }
90
+
91
+ /**
92
+ * 从迁移包 README 抽规则。只认语义明确的两处,抽不到就如实留空(宁可少报,不乱报):
93
+ * · `| Producer attribution | … a nonempty, non-`plugin` kind … |`
94
+ * · `| Any other plugin name | `plugin:` followed by the complete original name |`
95
+ * · 重命名表**只在「Message-source conversion」小节、且只在认得出表头的那张表里**解析 ——
96
+ * README 里表格形状高度雷同,全局解析会混进 `user/message → data` 这类噪音(实网抓到过)。
97
+ */
98
+ function extractContractRules(readme) {
99
+ const rules = {
100
+ producerKindRequired: false,
101
+ forbiddenKinds: ['plugin'],
102
+ pluginPrefix: null,
103
+ renames: {},
104
+ attributionExcerpt: null,
105
+ }
106
+ if (typeof readme !== 'string' || readme === '') return rules
107
+ const attribution = /\|\s*Producer attribution\s*\|([^|]*)\|/u.exec(readme)
108
+ if (attribution !== null) {
109
+ // 注意要去掉反引号之后再判断:原文是 non-`plugin`
110
+ const cleaned = attribution[1].replace(/`/gu, '').replace(/\s+/gu, ' ').trim()
111
+ rules.attributionExcerpt = cleaned
112
+ rules.producerKindRequired = /non-?plugin/u.test(cleaned) || /producer-owned/u.test(cleaned)
113
+ }
114
+ const prefix = /Any other plugin name\s*\|\s*`([^`]+)`/u.exec(readme)
115
+ if (prefix !== null) rules.pluginPrefix = prefix[1].trim()
116
+ const sourceSection = sectionOf(readme, 'Message-source conversion')
117
+ if (sourceSection !== null) {
118
+ // 只收「首列是单个代码片段」的行(跳过 `x, on a system-role message` 这类角色敏感行)
119
+ for (const row of mappingRowsFromSection(sourceSection)) {
120
+ const m = /^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|/u.exec(row)
121
+ if (m !== null && !m[1].includes(',')) rules.renames[m[1]] = m[2]
122
+ }
123
+ }
124
+ return rules
125
+ }
126
+
127
+ /**
128
+ * 纯函数:把「目标版本依赖表 + 各迁移边 README」组装成契约对象。
129
+ * @param dependencies 目标版本的依赖表(来自 catalog 或顶层 dsh manifest)
130
+ * @param currentDependencies 当前已装版本的依赖表(用于算「新增了哪条边」)
131
+ */
132
+ function buildContract({ targetVersion = null, currentVersion = null, dependencies = {}, currentDependencies = {}, edgeSource = 'dsh', edgeReadmes = {}, now = () => Date.now() } = {}) {
133
+ const edges = formatEdgesFromDependencies(dependencies).map((edge) => {
134
+ const readme = typeof edgeReadmes[edge.name] === 'string' ? edgeReadmes[edge.name] : null
135
+ return {
136
+ name: edge.name,
137
+ from: edge.from,
138
+ to: edge.to,
139
+ version: concreteVersionOf(dependencies[edge.name]),
140
+ readme: { available: readme !== null },
141
+ rules: readme === null ? null : extractContractRules(readme),
142
+ }
143
+ })
144
+ const sessionFormatVersion = edges.reduce((max, edge) => (edge.to > max ? edge.to : max), 0) || null
145
+ const currentKeys = new Set(formatEdgesFromDependencies(currentDependencies).map((e) => `${e.from}->${e.to}`))
146
+ const edgesAdded = currentVersion === null || currentVersion === targetVersion
147
+ ? []
148
+ : edges.filter((e) => !currentKeys.has(`${e.from}->${e.to}`)).map((e) => ({ name: e.name, from: e.from, to: e.to }))
149
+ // 规则取「最高那条边」(最新格式的准入规则才是要适配的目标)
150
+ const latest = edges.filter((e) => e.to === sessionFormatVersion).pop() ?? null
151
+ const rules = latest?.rules ?? { producerKindRequired: false, forbiddenKinds: ['plugin'], pluginPrefix: null, renames: {}, attributionExcerpt: null }
152
+ return {
153
+ targetVersion,
154
+ currentVersion,
155
+ sessionFormatVersion,
156
+ edgeSource,
157
+ edges,
158
+ edgesAdded,
159
+ rules,
160
+ fetchedAt: now(),
161
+ }
162
+ }
163
+
164
+ /** README 文本的多通道候选(jsDelivr npm CDN 优先,npmmirror files API 兜底)。 */
165
+ function readmeUrls(name, version, registry) {
166
+ const urls = []
167
+ if (typeof version === 'string' && version !== '') {
168
+ urls.push(`https://cdn.jsdelivr.net/npm/${name}@${version}/README.md`)
169
+ urls.push(`${registry}/${encodePkgName(name)}/${version}/files/README.md`)
170
+ }
171
+ urls.push(`${registry}/${encodePkgName(name)}/files/README.md`)
172
+ return urls
173
+ }
174
+
175
+ /**
176
+ * 问一个版本「它带哪些会话格式迁移边」:catalog 优先(一次拿全),缺失则退回顶层 dsh 依赖。
177
+ * @returns { dependencies, edgeSource };版本不存在时抛错(由上层兜住)。
178
+ */
179
+ async function edgesForVersion({ version, fetchJson, registry }) {
180
+ const catalogUrl = `${registry}/${encodePkgName(CATALOG_PKG)}/${version}`
181
+ try {
182
+ const catalog = await fetchJson(catalogUrl)
183
+ const fromCatalog = formatEdgesFromDependencies(catalog?.dependencies)
184
+ if (fromCatalog.length > 0) return { dependencies: catalog.dependencies ?? {}, edgeSource: 'catalog', manifest: catalog }
185
+ } catch {}
186
+ const manifest = await fetchJson(`${registry}/${encodePkgName('@deepseek-ai/dsh')}/${version}`)
187
+ return { dependencies: manifest?.dependencies ?? {}, edgeSource: 'dsh', manifest }
188
+ }
189
+
190
+ /**
191
+ * 抓取目标版本的会话格式契约。
192
+ * 任何一步失败都不抛出:返回 { contract, error },让上层降级成「只看内置规则」的扫描。
193
+ */
194
+ async function discoverFormatContract({ targetVersion, currentVersion = null, fetchJson, fetchText, registry = 'https://registry.npmmirror.com', now = () => Date.now() } = {}) {
195
+ if (typeof targetVersion !== 'string' || targetVersion === '') return { contract: null, error: '缺少目标版本' }
196
+ if (typeof fetchJson !== 'function') return { contract: null, error: '缺少 fetchJson 注入' }
197
+ let target = null
198
+ try {
199
+ target = await edgesForVersion({ version: targetVersion, fetchJson, registry })
200
+ } catch (error) {
201
+ return { contract: null, error: `拉取目标版本信息失败:${error instanceof Error ? error.message : String(error)}` }
202
+ }
203
+ let currentDependencies = {}
204
+ if (typeof currentVersion === 'string' && currentVersion !== '' && currentVersion !== targetVersion) {
205
+ try {
206
+ currentDependencies = (await edgesForVersion({ version: currentVersion, fetchJson, registry })).dependencies
207
+ } catch {}
208
+ }
209
+ // README 只拉「最高 to 的几条边」(准入规则在高位边里;低位边规则对本次适配没有指导意义)
210
+ const edges = formatEdgesFromDependencies(target.dependencies)
211
+ const edgeReadmes = {}
212
+ const warnings = []
213
+ if (typeof fetchText === 'function') {
214
+ for (const edge of [...edges].sort((a, b) => b.to - a.to).slice(0, README_BUDGET)) {
215
+ const version = concreteVersionOf(target.dependencies[edge.name])
216
+ let got = null
217
+ for (const url of readmeUrls(edge.name, version, registry)) {
218
+ try {
219
+ const text = await fetchText(url)
220
+ if (typeof text === 'string' && text.trim() !== '') { got = text; break }
221
+ } catch {}
222
+ }
223
+ if (got === null) warnings.push(`${edge.name}: README 拉取失败(契约规则未知)`)
224
+ else edgeReadmes[edge.name] = got
225
+ }
226
+ }
227
+ const contract = buildContract({
228
+ targetVersion,
229
+ currentVersion,
230
+ dependencies: target.dependencies,
231
+ currentDependencies,
232
+ edgeSource: target.edgeSource,
233
+ edgeReadmes,
234
+ now,
235
+ })
236
+ return { contract, error: null, warnings }
237
+ }
238
+
239
+ /** 契约摘要(面板/日志用的一句话)。 */
240
+ function summarizeContract(contract) {
241
+ if (contract === null || contract === undefined) return '契约未知'
242
+ if (contract.sessionFormatVersion === null) return '目标版本没有会话格式迁移包(格式未变更)'
243
+ const all = contract.edges.map((e) => `v${e.from}→v${e.to}`).join('、')
244
+ if ((contract.edgesAdded ?? []).length > 0) {
245
+ const added = contract.edgesAdded.map((e) => `v${e.from}→v${e.to}`).join('、')
246
+ return `会话格式 v${contract.sessionFormatVersion},本次新增迁移边 ${added} —— 契约变更,必须先预检本地生产方(全部边:${all})`
247
+ }
248
+ return `会话格式 v${contract.sessionFormatVersion}(迁移边:${all};相比当前版本无新增)`
249
+ }
250
+
251
+ export {
252
+ FORMAT_EDGE_RE,
253
+ CATALOG_PKG,
254
+ README_BUDGET,
255
+ encodePkgName,
256
+ parseFormatEdge,
257
+ formatEdgesFromDependencies,
258
+ concreteVersionOf,
259
+ extractContractRules,
260
+ buildContract,
261
+ readmeUrls,
262
+ edgesForVersion,
263
+ discoverFormatContract,
264
+ summarizeContract,
265
+ }