@noob-stupid/dsh-plugin-console 0.5.29 → 0.5.30

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.
@@ -0,0 +1,218 @@
1
+ // L1 · domain —— patch-composition-audit.js(补丁「行 → 包」可解析性体检;0.5.30 加法)
2
+ //
3
+ // 为什么必须有它(**真机事故 2026-09-28**):
4
+ // 框架 0.2.0-rc.1 移除了 `@deepseek-ai/dsh-workflow-worker-thread`(引擎重构为 `…-workflow-ptc`),
5
+ // 而 `profiles/web/cordis.patch.yml` 里三处行仍指向旧包名。该行解析失败 → 隔离组里
6
+ // `workflowEngine` 就没有提供方 → `dsh-tool-workflow` / `dsh-tool-ralph`(`inject` 含 `workflowEngine`)
7
+ // 永久 waiting → 老会话 resume 失败,真机报错原文:
8
+ // resume failed for session "…": RemoteError: tool-workflow (@deepseek-ai/dsh-tool-workflow):
9
+ // waiting for workflowEngine tool-ralph (@deepseek-ai/dsh-tool-ralph): waiting for workflowEngine
10
+ // 而现有 format-scan / framework-preflight **只扫「生产方源码里的 API 引用」**,
11
+ // 不扫「补丁里引用的包在当前框架树里还在不在」→ 升级前判「无 blocker」,升级后才炸。
12
+ //
13
+ // 本模块只做三件事(纯函数;`resolve` / `existsFile` 全部可注入,便于离线断言):
14
+ // ① 解析补丁行:`- id:` / `name:` / `disabled: true`(嵌套行同样收;缩进语义与 preset-rows.js 同源);
15
+ // ② 分类:解析失败=blocker;**已 disabled** 的解析失败=warning;`file://` 目标缺失=blocker;
16
+ // 子路径(`pkg/sub`)解析失败=warning(父包还在,只是导出变了);
17
+ // ③ 疑似改名建议:同 scope + 公共 token,纯启发式 —— **只提示,绝不自动改写用户补丁**。
18
+ //
19
+ // 行数预算:独立模块,留在 600 行架构硬顶内。
20
+
21
+ const ROW_RE = /^(\s*)-\s*id:\s*(\S+)\s*$/u
22
+ const NAME_RE = /^(\s*)name:\s*(.+?)\s*$/u
23
+ const DISABLED_RE = /^(\s*)disabled:\s*true\s*$/u
24
+ const CONFIG_RE = /^(\s*)config:\s*$/u
25
+
26
+ /** 去引号(单/双);空串视作未提供。 */
27
+ function unquote(raw) {
28
+ const text = String(raw ?? '').trim()
29
+ const quoted = text.length >= 2
30
+ && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))
31
+ return quoted ? text.slice(1, -1) : text
32
+ }
33
+
34
+ /** 解析补丁文本 → 行数组 `{ id, name, disabled, indent, line }`(CRLF 先归一:真机补丁是 CRLF)。 */
35
+ function parsePatchRows(text) {
36
+ const lines = String(text ?? '').replace(/\r\n/gu, '\n').split('\n')
37
+ const rows = []
38
+ let cur = null
39
+ for (let i = 0; i < lines.length; i += 1) {
40
+ const raw = lines[i]
41
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) continue
42
+ const item = raw.match(ROW_RE)
43
+ if (item !== null) {
44
+ cur = { id: unquote(item[2]), indent: item[1].length, name: null, disabled: false, config: false, keys: 0, line: i + 1 }
45
+ rows.push(cur)
46
+ continue
47
+ }
48
+ if (cur === null) continue
49
+ const indent = (raw.match(/^ */u) ?? [''])[0].length
50
+ if (indent < cur.indent) { cur = null; continue }
51
+ const name = raw.match(NAME_RE)
52
+ if (name !== null && indent > cur.indent && cur.name === null) { cur.name = unquote(name[2]); continue }
53
+ if (DISABLED_RE.test(raw) && indent > cur.indent) cur.disabled = true
54
+ if (CONFIG_RE.test(raw) && indent > cur.indent) cur.config = true
55
+ // 行里出现的**任何**键(config / disabled / isolate / …)都说明这是「补丁行」(改已存在条目的配置或开关),
56
+ // 不是「声明行」—— 声明行才需要 name。真机补丁里 `- id: web-ui-pet` + `disabled: true` 就是这种。
57
+ const kv = raw.match(/^(\s*)([A-Za-z_][\w-]*):/u)
58
+ if (kv !== null && indent > cur.indent && kv[2] !== 'id') cur.keys += 1
59
+ }
60
+ return rows
61
+ }
62
+
63
+ /** scope 名(`@a/b` → `@a`;裸名 → 空串)。 */
64
+ function scopeOf(name) {
65
+ return String(name).startsWith('@') ? String(name).split('/')[0] : ''
66
+ }
67
+
68
+ /** 词根集合:去掉 scope、按 `-`/`_`/`/` 切,丢弃长度 < 3 的碎片。 */
69
+ function tokensOf(name) {
70
+ return String(name)
71
+ .replace(/^@[^/]+\//u, '')
72
+ .split(/[-_/]/u)
73
+ .map((t) => t.toLowerCase())
74
+ .filter((t) => t.length >= 3)
75
+ }
76
+
77
+ /**
78
+ * **真机确证的改名片 —— 建议的权威来源**(每条都有真机错误/发布物背书,同 preset-rows.js 的判据文化)。
79
+ * 表里没有的一律只给「严格启发式候选」,猜不到就不猜(宁缺勿滥)。
80
+ */
81
+ const KNOWN_RENAMES = [
82
+ {
83
+ from: '@deepseek-ai/dsh-workflow-worker-thread',
84
+ to: '@deepseek-ai/dsh-workflow-ptc',
85
+ since: '0.2.0-rc.1',
86
+ evidence: '2026-09-28 真机:升级 0.2.0-rc.1 后 web profile 老会话 resume 失败'
87
+ + '(RemoteError: tool-workflow … waiting for workflowEngine);旧包已从 .pnpm store 消失,'
88
+ + '新引擎 README「最小配置」列出 ptc,且 config.provider 默认 spawn(与旧行一致)',
89
+ },
90
+ ]
91
+
92
+ /** 疑似改名建议:① 真机确证表命中即权威返回;② 否则严格启发式(同 scope + 前两词根一致 + 交集 ≥ 3)。
93
+ * 返回 `{ names, source }`,`source: 'known' | 'heuristic' | null`。 */
94
+ function suggestRenames(name, candidates, limit = 3) {
95
+ const known = KNOWN_RENAMES.filter((r) => r.from === name).map((r) => r.to)
96
+ if (known.length > 0) return { names: known.slice(0, limit), source: 'known' }
97
+ const scope = scopeOf(name)
98
+ const want = tokensOf(name)
99
+ const scored = []
100
+ for (const cand of (Array.isArray(candidates) ? candidates : [])) {
101
+ if (cand === name || scopeOf(cand) !== scope) continue
102
+ const have = tokensOf(cand)
103
+ const common = want.filter((t) => have.includes(t)).length
104
+ const headSame = want.length >= 2 && have.length >= 2 && want[0] === have[0] && want[1] === have[1]
105
+ if (!headSame || common < 3) continue
106
+ scored.push({ name: cand, score: common })
107
+ }
108
+ scored.sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name))
109
+ const names = scored.slice(0, limit).map((s) => s.name)
110
+ return { names, source: names.length > 0 ? 'heuristic' : null }
111
+ }
112
+
113
+ /**
114
+ * 分类体检(纯函数)。`resolve(name)` → `{ ok: true, from }` / `{ ok: false, reason }`;
115
+ * `existsFile(name)` → boolean(`file://` 目标存在性,未注入则跳过该项)。
116
+ */
117
+ function classifyPatchRows(rows, { resolve, existsFile = null, candidates = [] } = {}) {
118
+ const blockers = []
119
+ const warnings = []
120
+ const resolvable = []
121
+ const patchOnly = []
122
+ for (const row of (Array.isArray(rows) ? rows : [])) {
123
+ if (typeof row?.name !== 'string' || row.name === '') {
124
+ // 只改配置的行(`- id: webserver` + `config:`)是**合法的补丁形态**:它在改已存在条目的配置,
125
+ // 没有 name 可言 —— 真机补丁前 30 行全是这种,判红就是误杀(2026-09-28 真机实测抓到)。
126
+ if ((row?.keys ?? 0) > 0) { patchOnly.push(row.id ?? null); continue }
127
+ warnings.push({ code: 'row-without-name', row: row?.id ?? null, line: row?.line ?? null, detail: `行 ${row?.id ?? '?'} 既没有 name 也没有 config`, hint: '补上 name(包名或 file:/// URL),或确认它是配置补丁行' })
128
+ continue
129
+ }
130
+ const name = row.name
131
+ if (/^file:\/\//iu.test(name)) {
132
+ if (existsFile === null) continue
133
+ let present = null
134
+ try { present = existsFile(name) } catch { present = null }
135
+ if (present === false) {
136
+ blockers.push({ code: 'file-target-missing', row: row.id, line: row.line, name, detail: `行 ${row.id} 的 file:// 目标不存在:${name}`, hint: '补齐文件或修正引用(写一行必然加载失败的声明等于把缺陷留给运行期)' })
137
+ }
138
+ continue
139
+ }
140
+ if (/^\.\.?\//u.test(name) || /^cordis:/iu.test(name)) continue
141
+ const res = (() => { try { return resolve(name) } catch (error) { return { ok: false, reason: String(error?.message ?? error) } } })()
142
+ if (res?.ok === true) { resolvable.push(name); continue }
143
+ const segments = name.split('/')
144
+ const isSubpath = segments.length > (name.startsWith('@') ? 2 : 1)
145
+ const entry = {
146
+ code: isSubpath ? 'subpath-unresolvable' : 'package-unresolvable',
147
+ row: row.id,
148
+ line: row.line,
149
+ name,
150
+ reason: res?.reason ?? null,
151
+ ...(() => {
152
+ const s = isSubpath ? { names: [], source: null } : suggestRenames(name, candidates)
153
+ return { suggestions: s.names, suggestionSource: s.source }
154
+ })(),
155
+ detail: isSubpath
156
+ ? `行 ${row.id} 的 ${name} 解析不到(父包在,子路径不在?)`
157
+ : `行 ${row.id} 引用的包 ${name} 在当前框架树里解析不到`,
158
+ hint: isSubpath
159
+ ? '该子路径可能已改名/移除 —— 请对照当前框架版本的 exports 修正'
160
+ : '该包可能已被移除或改名:先确认新包名再改这一行;**在修好之前,依赖它提供的服务的行会永久 waiting**',
161
+ }
162
+ if (isSubpath) warnings.push(entry)
163
+ else if (row.disabled === true) warnings.push({ ...entry, disabled: true })
164
+ else blockers.push(entry)
165
+ }
166
+ return { blockers, warnings, resolvable, patchOnly }
167
+ }
168
+
169
+ /** 组合入口:文本 + 依赖 → 报告。 */
170
+ function auditPatchText(text, deps = {}) {
171
+ const rows = parsePatchRows(text)
172
+ const report = classifyPatchRows(rows, deps)
173
+ return { rows: rows.length, ...report }
174
+ }
175
+
176
+ /**
177
+ * 从行里挑出「随框架版本走」的包名(`@deepseek-ai/*`)并带上行号 ——
178
+ * 预检要拿它们去**目标版本的 registry** 上核对(真机事故:0.2.0-rc.1 移除了
179
+ * `@deepseek-ai/dsh-workflow-worker-thread`,而补丁三处仍引用它 → 该行解析失败 →
180
+ * 隔离组里 workflowEngine 没有提供方 → 老会话 resume 失败)。
181
+ * 子路径归一成父包(`pkg/sub` → `pkg`):版本随框架走的是包,不是子路径。
182
+ */
183
+ function frameworkPackageRows(rows) {
184
+ const map = new Map()
185
+ for (const row of (Array.isArray(rows) ? rows : [])) {
186
+ const name = typeof row?.name === 'string' ? row.name : ''
187
+ if (!name.startsWith('@deepseek-ai/')) continue
188
+ if (/^file:\/\//iu.test(name) || /^\.\.?\//u.test(name)) continue
189
+ const base = name.split('/').slice(0, 2).join('/')
190
+ if (!map.has(base)) map.set(base, [])
191
+ map.get(base).push(row.line ?? null)
192
+ }
193
+ return [...map.entries()].map(([name, lines]) => ({ name, lines }))
194
+ }
195
+
196
+ /**
197
+ * 目标版本可用性判定(纯函数):`publishedBy` 是 `Map<name, true|false|null>`
198
+ * (true=目标版本已发布 / false=没有该版本 / null=未知,网络失败或元数据缺失)。
199
+ * **未知一律不判死**(宁可漏报,也不因为查不动就把用户吓住)——只进 `unknown`。
200
+ */
201
+ function classifyTargetAvailability(entries, { publishedBy, targetVersion, candidates = [] } = {}) {
202
+ const missing = []
203
+ const unknown = []
204
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
205
+ const name = entry?.name
206
+ if (typeof name !== 'string' || name === '') continue
207
+ const state = publishedBy && typeof publishedBy.get === 'function' ? publishedBy.get(name) : null
208
+ if (state === true) continue
209
+ const base = { name, lines: Array.isArray(entry.lines) ? entry.lines : [], targetVersion: targetVersion ?? null }
210
+ if (state === false) missing.push({ ...base, suggestions: suggestRenames(name, candidates).names })
211
+ else unknown.push(base)
212
+ }
213
+ return { missing, unknown }
214
+ }
215
+ export {
216
+ KNOWN_RENAMES, auditPatchText, classifyPatchRows, classifyTargetAvailability, frameworkPackageRows,
217
+ parsePatchRows, scopeOf, suggestRenames, tokensOf, unquote,
218
+ }
@@ -3,9 +3,10 @@
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
5
  import { dirname } from 'node:path'
6
+ import { frameworkBases, isFrameworkModuleName } from '../infra/framework-root.js'
6
7
  import { queuedWrite } from '../infra/fsx.js'
7
8
  import { escapeRegExp } from '../infra/mask.js'
8
- import { resolvePackageJson } from '../infra/paths.js'
9
+ import { makePackageResolver } from '../infra/package-resolve.js'
9
10
 
10
11
  /** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
11
12
  async function readPatchState(patchPath) {
@@ -145,15 +146,39 @@ function parseInsertNames(text) {
145
146
  /**
146
147
  * 补丁安全自愈(服务永不崩机制):
147
148
  * ① 核心行被误禁用 → 自动移除禁用块恢复;
148
- * ② 启用态用户 insert 行的模块缺失(如引用未安装包的行)→ 自动禁用(loader 对缺失模块会致命崩溃)。
149
- * return { healed:[], autoDisabled:[], healedAt } —— healedAt=0 表示本次无修改。
149
+ * ② 启用态用户 insert 行的模块**确证缺失**(如引用未安装包的行)→ 自动禁用(loader 对缺失模块会致命崩溃)。
150
+ *
151
+ * ★ 2026-09-30 改错(真机事故 2026-09-29 01:05:44):② 过去只用**单基准**
152
+ * `resolvePackageJson(name, profileDir)` 判定"缺失",而框架 0.2.0-rc.1 起 `@deepseek-ai/*` 的实体
153
+ * 全在 `.pnpm` **内部**那层、profile 顶层只剩旧版残影 → 框架**真存在**的内置包
154
+ * `dsh-agent-preset` / `dsh-workflow-ptc` 被判成缺失 → 给它们自动写了 4 个 `disabled: true` 块,
155
+ * 把用户的 preset-router-spec/standard/react 三条全停掉。
156
+ * 现在改为**多基准并集**(profile 目录 + 框架运行时根,见 infra/framework-root.js):
157
+ * 任一基准能解析即算命中 → 不再误判。
158
+ *
159
+ * 语义收紧(三条硬约束):
160
+ * · **确证缺失才禁用**:解析不确定(resolver 报 uncertain / 一个基准都没有 / 解析器抛异常)→ 只报告不写盘;
161
+ * · **框架命名空间 `@deepseek-ai/*` 永不自动禁用**:它们是框架自带包(随版本增删由框架自己管),
162
+ * 写 `disabled: true` 等于用旧判断把新框架的组件停掉 —— 命中一律只报告;
163
+ * · **幂等零写盘**:没有真正需要修复的内容时,一个字节都不写(`healedAt=0`),
164
+ * 且即便算出了新内容也要与原文逐字节比较后才落盘(`GET /state` 每 ≤2min 调一次本函数)。
165
+ * 写盘失败**如实回报**(`written:false` + `writeError`),绝不静默吞掉。
166
+ *
167
+ * return { healed:[], autoDisabled:[], skipped:[], uncertain:[], healedAt, written, writeError }
168
+ * healedAt=0 表示本次无修改。
150
169
  */
151
- async function healPatchSafety(patchPath) {
170
+ async function healPatchSafety(patchPath, deps = {}) {
152
171
  const profileDir = dirname(patchPath)
172
+ const readText = deps.readText ?? ((p) => readFile(p, 'utf8'))
173
+ const writeText = deps.writeText ?? ((p, data) => writeFile(p, data, 'utf8'))
153
174
  const { text } = await readPatchState(patchPath)
154
- let next = text
155
175
  const healed = []
156
176
  const autoDisabled = []
177
+ const skipped = []
178
+ const uncertain = []
179
+ let next = text
180
+
181
+ // ① 核心行被误禁用 → 恢复(与判据无关,先做)
157
182
  for (const id of CORE_PATCH_ROW_IDS) {
158
183
  const re = new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu')
159
184
  if (re.test(next)) {
@@ -161,23 +186,51 @@ async function healPatchSafety(patchPath) {
161
186
  healed.push(id)
162
187
  }
163
188
  }
189
+
190
+ // ② 启用态 insert 行的模块缺失 → 自动禁用(仅在**确证缺失**时)
164
191
  if (healed.length === 0) {
165
192
  const insertNames = parseInsertNames(next)
166
- let require = null
193
+ const bases = Array.isArray(deps.bases)
194
+ ? deps.bases.filter((b) => typeof b === 'string' && b !== '')
195
+ : frameworkBases(deps.baseDir ?? null, profileDir)
196
+ const resolve = deps.resolve ?? (bases.length > 0 ? makePackageResolver(bases) : null)
167
197
  for (const [id, moduleName] of insertNames) {
168
198
  if (!moduleName || moduleName.startsWith('cordis:')) continue
169
- let ok = true
170
- try { ok = resolvePackageJson(moduleName, profileDir) !== null } catch { ok = false }
171
- if (!ok && !new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu').test(next)) {
172
- next = `${next.trimEnd()}\n- id: ${id}\n disabled: true\n`
173
- autoDisabled.push(id)
174
- }
199
+ const alreadyDisabled = new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu').test(next)
200
+ if (alreadyDisabled) continue
201
+ // framework 命名空间:随框架版本走,永不自动禁用(只报告)
202
+ if (isFrameworkModuleName(moduleName)) { skipped.push({ id, moduleName, reason: 'framework-owned' }); continue }
203
+ // 没有解析器 / 没有任何基准 → 无法判定,只报告
204
+ if (resolve === null) { uncertain.push({ id, moduleName, reason: 'no-resolution-base' }); continue }
205
+ let res = null
206
+ try { res = resolve(moduleName) } catch (error) { res = { ok: false, uncertain: true, reason: String(error?.message ?? error) } }
207
+ // 兼容两种 resolver 契约:布尔(旧测试桩)/ `{ ok, uncertain, reason }`(infra/package-resolve.js)
208
+ const ok = res === true || res?.ok === true
209
+ if (ok) continue
210
+ if (res?.uncertain === true) { uncertain.push({ id, moduleName, reason: res?.reason ?? 'uncertain' }); continue }
211
+ next = `${next.trimEnd()}\n- id: ${id}\n disabled: true\n`
212
+ autoDisabled.push(id)
175
213
  }
176
214
  }
177
- if (next !== text) {
178
- await writeFile(patchPath, sanitizePatchText(next), 'utf8')
215
+
216
+ // ③ 幂等零写盘:没有任何需要修复的内容 / 内容与原文逐字节相同 → 一个字节都不写
217
+ if (next === text) {
218
+ return { healed, autoDisabled, skipped, uncertain, healedAt: 0, written: false, writeError: null }
219
+ }
220
+ const payload = sanitizePatchText(next)
221
+ if (payload === text) {
222
+ return { healed, autoDisabled, skipped, uncertain, healedAt: 0, written: false, writeError: null }
223
+ }
224
+ // ④ 写盘失败如实回报(不静默)
225
+ try {
226
+ await writeText(patchPath, payload)
227
+ } catch (error) {
228
+ return { healed, autoDisabled, skipped, uncertain, healedAt: 0, written: false, writeError: String(error?.message ?? error) }
179
229
  }
180
- return { healed, autoDisabled, healedAt: next !== text ? Date.now() : 0 }
230
+ // 读回核实:写盘后重新读一次,只有内容真的落盘才算 healedAt
231
+ let verified = false
232
+ try { verified = (await readText(patchPath)) === payload } catch { verified = false }
233
+ return { healed, autoDisabled, skipped, uncertain, healedAt: Date.now(), written: true, writeError: verified ? null : '写盘后读回内容与期望不一致' }
181
234
  }
182
235
 
183
236
  /** 从「子包版本对齐」记录解析纯包名(如 '@linxin666/dsh-pet@0.2.1(新装)')。 */
@@ -0,0 +1,144 @@
1
+ // L1 · domain —— preset-audit.js(用户预设 ↔ profile 声明行 的跨源对账;0.5.30 加法)
2
+ //
3
+ // 真机背景(2026-09-28):框架 0.1.7-rc.1 起官方移除了磁盘发现,老会话 resume 靠 profile 补丁里的
4
+ // **声明行**;而预设目录里的 `agent.cordis.yml` 会随后续装配被更新(本机 router-spec 在升级前 1 分钟
5
+ // 被改成 v10),补丁里的行却还是旧的(meta 指向 v1)→ 老会话 resume 到错的模块。
6
+ // 真机证据:`profiles/web/cordis.patch.yml` 指向 `router-bootstrap-v1.mjs`,
7
+ // 而 `router-spec/agent.cordis.yml` 自己写的是 `./router-bootstrap-v10.mjs`。
8
+ //
9
+ // 本模块只做**纯对账**(IO 全注入):
10
+ // · parsePresetDeclarations(text) 预设自身声明的 `{ id → 目标 }`(相对引用保留原样)
11
+ // · auditPresetDeclarations({...}) 与补丁里的 file:/// 行逐条比对,输出四类差异
12
+ //
13
+ // 判据(保守、不猜):
14
+ // · **目标按目录归属**匹配 —— 补丁行落在哪个预设目录下就算哪个预设的(router-* 三个预设各有
15
+ // 同名 id `router-bootstrap`,只能靠目录区分,不能靠 id 全局匹配)。
16
+ // · 只报"能证明不一致"的:id 相同但目标不同(陈旧)、预设里有补丁里没有(缺失)、
17
+ // 补丁里有但预设目录里查无此 id(悬空)、任一目标文件不存在(悬空)。
18
+ // · 行**显式 disabled** 的,降级为 warning(用户已经关掉它了,不算错,但要看得见)。
19
+
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { parsePatchRows } from './patch-composition-audit.js'
24
+
25
+ /** 预设自身声明的模块:`{ id, target, line, disabled }`(只收有 name 的行)。 */
26
+ function parsePresetDeclarations(text) {
27
+ const lines = String(text ?? '').replace(/\r\n/gu, '\n').split('\n')
28
+ const out = []
29
+ let cur = null
30
+ for (let i = 0; i < lines.length; i += 1) {
31
+ const raw = lines[i]
32
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) continue
33
+ const item = raw.match(/^(\s*)-\s*id:\s*(\S+)\s*$/u)
34
+ if (item !== null) {
35
+ cur = { id: item[2].replace(/^['"]|['"]$/gu, ''), target: null, line: i + 1, disabled: false, indent: item[1].length }
36
+ out.push(cur)
37
+ continue
38
+ }
39
+ if (cur === null) continue
40
+ const indent = (raw.match(/^ */u) ?? [''])[0].length
41
+ if (indent <= cur.indent) continue
42
+ const name = raw.match(/^\s*name:\s*(.+?)\s*$/u)
43
+ if (name !== null && cur.target === null) { cur.target = name[1].replace(/^['"]|['"]$/gu, ''); continue }
44
+ if (/^\s*disabled:\s*true\s*$/u.test(raw)) cur.disabled = true
45
+ }
46
+ // 2026-09-28 改错(真机试跑抓到 89 条假"缺失"):预设里的 `name:` 大量是**包名**(`@deepseek-ai/dsh-persona` 等),
47
+ // 它们在补丁里也是包名行、不归"按目录归属"的文件匹配管 —— 只有**文件型声明**(相对/绝对路径)
48
+ // 才会被改写成 `file:///…` 的声明行。这里只收文件型,避免把包名行误判成"声明行缺失"。
49
+ return out.filter((row) => typeof row.target === 'string' && row.target !== '' && /^(\.\.?[\\/]|[A-Za-z]:[\\/]|file:)/u.test(row.target))
50
+ }
51
+
52
+ /**
53
+ * 跨源对账(纯函数,IO 注入)。
54
+ * 入参:
55
+ * · presets: `[{ dir, modules: parsePresetDeclarations(text) }]`
56
+ * · patchRows: `[{ id, name, line, disabled }]`(补丁里的行;`name` 可以是 file:/// 也可以是别的)
57
+ * · toLocalPath(url) / joinPath(dir, rel) / exists(path) / dirnameOf(path)(可注入,便于离线断言)
58
+ * 返回 `{ stale, missing, orphan, targetMissing, ok }`。
59
+ */
60
+ function auditPresetDeclarations({ presets = [], patchRows = [], toLocalPath, joinPath, exists, dirnameOf } = {}) {
61
+ const stale = []
62
+ const missing = []
63
+ const orphan = []
64
+ const targetMissing = []
65
+ const patchFileRows = []
66
+ for (const row of (Array.isArray(patchRows) ? patchRows : [])) {
67
+ if (row === null || typeof row !== 'object') continue
68
+ const name = typeof row.name === 'string' ? row.name : ''
69
+ if (!/^file:\/\//iu.test(name)) continue
70
+ let local = null
71
+ try { local = toLocalPath(name) } catch { local = null }
72
+ if (local === null) continue
73
+ patchFileRows.push({ id: row.id ?? null, local, line: row.line ?? null, disabled: row.disabled === true })
74
+ }
75
+ for (const preset of (Array.isArray(presets) ? presets : [])) {
76
+ if (preset === null || typeof preset !== 'object' || typeof preset.dir !== 'string') continue
77
+ const mine = patchFileRows.filter((row) => dirnameOf(row.local) === preset.dir)
78
+ const declared = Array.isArray(preset.modules) ? preset.modules : []
79
+ for (const mod of declared) {
80
+ const target = joinPath(preset.dir, mod.target)
81
+ const hit = mine.find((row) => row.id === mod.id) ?? null
82
+ if (hit === null) {
83
+ missing.push({ preset: preset.dir, id: mod.id, expected: target, declaredAt: mod.line, disabled: mod.disabled === true })
84
+ continue
85
+ }
86
+ if (hit.local !== target) {
87
+ stale.push({ preset: preset.dir, id: mod.id, declared: target, patchTarget: hit.local, patchLine: hit.line, disabled: hit.disabled })
88
+ continue
89
+ }
90
+ let present = null
91
+ try { present = exists(target) } catch { present = null }
92
+ if (present === false) targetMissing.push({ preset: preset.dir, id: mod.id, target, source: 'declaration' })
93
+ }
94
+ const ids = new Set(declared.map((m) => m.id))
95
+ for (const row of mine) {
96
+ if (ids.has(row.id)) continue
97
+ orphan.push({ preset: preset.dir, id: row.id, patchTarget: row.local, patchLine: row.line, disabled: row.disabled })
98
+ let present = null
99
+ try { present = exists(row.local) } catch { present = null }
100
+ if (present === false) targetMissing.push({ preset: preset.dir, id: row.id, target: row.local, source: 'patch' })
101
+ }
102
+ }
103
+ const blockers = stale.length + missing.length + orphan.filter((o) => o.disabled !== true).length
104
+ + targetMissing.length
105
+ return { stale, missing, orphan, targetMissing, blockers, ok: blockers === 0 }
106
+ }
107
+
108
+
109
+ /** IO 收集层(preset-audit 路由与升级前预检共用):扫磁盘预设 + 读补丁行 → 出报告。 */
110
+ function collectPresetAudit({ presetsRoot, patchPath, deps = {} } = {}) {
111
+ const {
112
+ exists = existsSync, readdir = readdirSync, readText = (p) => readFileSync(p, 'utf8'),
113
+ toLocal = (url) => fileURLToPath(url), joinPath = join, dirOf = dirname,
114
+ } = deps
115
+ if (typeof presetsRoot !== 'string' || presetsRoot === '' || !exists(presetsRoot)) {
116
+ return { ok: false, error: '没有用户预设目录(无需体检)', presetCount: 0, stale: [], missing: [], orphan: [], targetMissing: [], blockers: 0 }
117
+ }
118
+ const presets = []
119
+ try {
120
+ for (const entry of readdir(presetsRoot, { withFileTypes: true })) {
121
+ if (!entry.isDirectory()) continue
122
+ const name = entry.name
123
+ if (name.startsWith('.') || name.startsWith('_') || name.includes('.bak')) continue
124
+ const dir = joinPath(presetsRoot, name)
125
+ const file = joinPath(dir, 'agent.cordis.yml')
126
+ if (!exists(file)) continue
127
+ const modules = parsePresetDeclarations(readText(file))
128
+ if (modules.length > 0) presets.push({ dir, modules })
129
+ }
130
+ } catch {}
131
+ let patchRows = []
132
+ try { patchRows = parsePatchRows(readText(patchPath)) } catch { patchRows = [] }
133
+ const report = auditPresetDeclarations({
134
+ presets,
135
+ patchRows: patchRows.map((r) => ({ id: r.id, name: r.name, line: r.line, disabled: r.disabled })),
136
+ toLocalPath: toLocal,
137
+ joinPath,
138
+ exists,
139
+ dirnameOf: dirOf,
140
+ })
141
+ return { ok: report.ok, presetCount: presets.length, ...report }
142
+ }
143
+
144
+ export { auditPresetDeclarations, collectPresetAudit, parsePresetDeclarations }
@@ -33,14 +33,35 @@ const CMD_META_RE = /[\^&|<>()]/gu
33
33
  * · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
34
34
  * · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓(含空格 / `&` / `|` / `>` 的参数
35
35
  * 在段内加 `^` 转义后也原样到达)
36
- * 所以本函数产出的是"**内层**命令字符串"(形如 `"corepack" "pnpm" "add" "x"`,**永不含 `\"`**),
36
+ * 所以本函数产出的是"**内层**命令字符串"(形如 `corepack pnpm add x`,**永不含 `\"`**;只有含空白的段才带引号 —— 2026-09-28 改错,见函数内注释),
37
37
  * 最外层那对引号由 cmdShellArgv 负责加。
38
38
  * 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
39
39
  * **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
40
40
  * 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。 */
41
41
  function cmdCommandLine(parts) {
42
42
  return parts
43
- .map((part) => `"${String(part).replace(CMD_META_RE, (ch) => `^${ch}`)}"`)
43
+ .map((part) => String(part))
44
+ .map((text) => {
45
+ // 2026-09-28 改错(真机:`cmd-corepack` 一条都跑不动 → pnpm-runners / suite-fallback /
46
+ // install-smoke 三套红,报 `cmd-corepack :: status=1 out=""`):
47
+ // `corepack` 在本机是 **cmd 批处理 shim**(`D:\nvm4w\nodejs\corepack.cmd` →
48
+ // `node …\corepack\dist\corepack.js %*`)。对批处理,cmd **不会剥掉参数外层的引号**,
49
+ // 引号会随 `%*` 一路进到 node 的 argv —— 于是 corepack 收到的是 `"pnpm"` / `"--version"`
50
+ // (带引号当成了包管理器名)→ 直接崩:status=1、stdout 空。
51
+ // 真 cmd.exe + windowsVerbatimArguments 实测(同一台机):
52
+ // · `""corepack" "pnpm" "--version""` → status=1、stdout=""(现行形态,坏)
53
+ // · `"corepack pnpm --version"` → status=0、stdout="11.21.0"(按需引号,好)
54
+ // 所以**只为含空白的段加引号**;其余裸串 + `^` 转义 cmd 元字符(裸串里 `^` 是标准且可靠的转义)。
55
+ // 含空白的段仍走「引号 + 段内 ^ 转义」,与既有行为一致,不改语义。
56
+ // 已知边界(如实记):段内含 `"` 时无法在 cmd 里安全表达 —— 我们生成的 argv(包名/registry/布尔
57
+ // 选项/绝对路径)不含裸引号;真出现时该通道会失败并如实报错,不会静默跑成别的命令。
58
+ // 「干净 token」(无空白、无 cmd 元字符)→ 裸串。**只有这种情况**能去引号:
59
+ // 含空白的段必须带引号;含 `& | < > ( ) ^` 的段,实测**裸串 + `^` 转义会失效**
60
+ // (真机 ⑥:裸的 `a^&b` 被 cmd 当分隔符断开,收到 `[a`),必须沿用「引号 + 段内 ^」的既有形态。
61
+ const clean = !/[\s]/u.test(text) && !/[\^&|<>()]/u.test(text)
62
+ if (clean) return text
63
+ return `"${text.replace(CMD_META_RE, (ch) => `^${ch}`)}"`
64
+ })
44
65
  .join(' ')
45
66
  }
46
67
 
@@ -0,0 +1,99 @@
1
+ // L0 · infra —— framework-root.js(框架运行时解析基准;0.5.30 加法)
2
+ //
3
+ // 为什么必须有它(**真机事故 2026-09-28 / 2026-09-29**):
4
+ // 框架 0.2.0-rc.1 把 `@deepseek-ai/*` 的实体全部搬进 `.pnpm` **内部**那层,profile 顶层
5
+ // projection 还停在旧版(本机实测:顶层 239 个 `@deepseek-ai` 目录仍是 0.1.5-rc.2 的残影)。
6
+ // 任何"只拿 profileDir 当解析基准"的判断都会把**真存在的框架内置包**(`dsh-agent-preset`、
7
+ // `dsh-workflow-ptc`)判成"缺失" —— 实测后果:`healPatchSafety()` 给它们自动写了 4 个
8
+ // `disabled: true` 块(2026-09-29 01:05:44),把用户的 preset-router-* 三条全停掉。
9
+ //
10
+ // 所以「框架内置包在不在」必须走**多基准并集**:任一基准能解析即算命中。
11
+ // 本模块把该判据集中一处,供只读体检(routes/patch-audit.js)与补丁自愈(domain/patch.js)共用 ——
12
+ // 两处若各写一份,下次框架再挪目录就会只有一处被修好。
13
+ //
14
+ // 行数预算:独立小模块,留在 600 行架构硬顶内。
15
+
16
+ import { existsSync } from 'node:fs'
17
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
18
+ import { resolvePackageJson } from './paths.js'
19
+
20
+ /** 框架命名空间:`@deepseek-ai/*` 随框架版本走,承载者永远是框架安装树。 */
21
+ const FRAMEWORK_SCOPE = '@deepseek-ai/'
22
+
23
+ /** 包名归一:`@scope/name/sub` → `@scope/name`;裸名原样。 */
24
+ function basePackageName(moduleName) {
25
+ const text = String(moduleName ?? '')
26
+ if (text.startsWith('@')) {
27
+ const parts = text.split('/')
28
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : text
29
+ }
30
+ return text.split('/')[0]
31
+ }
32
+
33
+ /** 归一后的包名是否属于框架命名空间(`@deepseek-ai/dsh-*`)。 */
34
+ function isFrameworkModuleName(moduleName) {
35
+ const name = basePackageName(moduleName)
36
+ return name === FRAMEWORK_SCOPE.slice(0, -1) || name.startsWith(FRAMEWORK_SCOPE)
37
+ }
38
+
39
+ /** 路径是否落在给定根目录内(大小写不敏感,Windows 与 POSIX 都成立)。 */
40
+ function isUnder(childPath, rootDir) {
41
+ if (typeof childPath !== 'string' || childPath === '' || typeof rootDir !== 'string' || rootDir === '') return false
42
+ let child = resolve(childPath).toLowerCase()
43
+ let root = resolve(rootDir).toLowerCase()
44
+ if (!root.endsWith(sep)) root += sep
45
+ if (!child.endsWith(sep)) child += sep
46
+ return child.startsWith(root)
47
+ }
48
+
49
+ /**
50
+ * 框架运行时解析基准(**有序**,先准后宽):
51
+ * ① `@deepseek-ai/dsh` 所在的那层 `node_modules`(= `.pnpm` 实体内部,框架内置包的真正家)
52
+ * ② 从 ① 向上逐级找到的第一个含 `.pnpm` 的目录(与 ① 同层时去重)
53
+ * ③ `@deepseek-ai/dsh` 解析不出来时的兜底:`baseDir` / `profileDir` 自身(供离线测试桩使用)
54
+ * 返回去重后的绝对路径数组;一个都拿不到时返回 `[]`(调用方必须把空数组当"无法判定"而非"缺失")。
55
+ */
56
+ function frameworkBases(baseDir, profileDir) {
57
+ const out = []
58
+ const push = (dir) => {
59
+ if (typeof dir !== 'string' || dir === '' || !isAbsolute(dir)) return
60
+ const norm = resolve(dir)
61
+ if (!out.includes(norm)) out.push(norm)
62
+ }
63
+ let found = null
64
+ // base 有两种合法写法(`…/profiles/web` 这种**含 node_modules 的目录**,或 `…/.pnpm/<pkg>/node_modules`
65
+ // 这种 **node_modules 本身**);resolvePackageJson 只认前者,所以后者要退一级再试 ——
66
+ // 否则框架基准这一路永远解析不到 `@deepseek-ai/dsh`,整个框架基准列表会退化成兜底值。
67
+ for (const base of [baseDir, profileDir]) {
68
+ if (typeof base !== 'string' || base === '') continue
69
+ for (const cand of [base, dirname(base)]) {
70
+ if (typeof cand !== 'string' || cand === '') continue
71
+ try {
72
+ found = resolvePackageJson('@deepseek-ai/dsh', cand, profileDir)
73
+ } catch { found = null }
74
+ if (found !== null) break
75
+ }
76
+ if (found !== null) break
77
+ }
78
+ if (found !== null) {
79
+ // ★ 2026-09-30 改错(本批修 A 事故时抓到):这里过去是 `dirname(dirname(pkgPath))` ——
80
+ // pkgPath 形如 `<…>/node_modules/@deepseek-ai/dsh/package.json`,两层 dirname 只退到
81
+ // **scope 目录** `<…>/node_modules/@deepseek-ai`(实测 `existsSync(inner@deepseek-ai)` = false),
82
+ // 拿它当解析基准必然解析不到任何包。正确是**三层**:package.json → dsh → @deepseek-ai → node_modules。
83
+ const inner = dirname(dirname(dirname(found)))
84
+ push(inner)
85
+ let dir = inner
86
+ for (let i = 0; i < 6; i += 1) {
87
+ if (existsSync(join(dir, '.pnpm'))) { push(dir); break }
88
+ const parent = dirname(dir)
89
+ if (parent === dir) break
90
+ dir = parent
91
+ }
92
+ }
93
+ // 兜底:框架树找不到时,至少把 profile 目录本身当基准 —— 否则 `bases` 为空会让
94
+ // 自愈把所有 insert 行都判成"无法确认"(宁缺勿滥,但那会让真缺失的行不再被停用)。
95
+ if (out.length === 0) { push(profileDir); push(baseDir) }
96
+ return out
97
+ }
98
+
99
+ export { FRAMEWORK_SCOPE, basePackageName, frameworkBases, isFrameworkModuleName, isUnder }