dsh-my-guardian 0.4.2 → 0.4.4

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.4] - 2026-09-25
9
+
10
+ ### 变更
11
+
12
+ - fix(guardian): #407 依赖预检解析 profiles 根宿主包与子路径导出 (#412)
13
+ - fix(dsh-my-guardian): 适配 0.1.7 HMR 重构,取证不绑包名并登记退役事件
14
+ - fix(test): 补测试临时目录配对清理,加有界兜底清扫防陈旧残留
15
+
16
+ ## [Unreleased]
17
+
18
+ ### 变更
19
+
20
+ - fix(guardian): 依赖预检解析 profiles 根 node_modules 的宿主包与子路径导出,消除「缺少依赖」误报
21
+
22
+ ## [0.4.3] - 2026-09-25
23
+
24
+ ### 变更
25
+
26
+ - fix: 适配宿主 0.1.7-rc.2 的事件名与席位契约,修正失效文档断言
27
+
8
28
  ## [0.4.2] - 2026-09-17
9
29
 
10
30
  ### 变更
package/lib/client.js CHANGED
@@ -1210,7 +1210,9 @@ exports.apply = function apply(ctx) {
1210
1210
  id: TAB_ID_PKG,
1211
1211
  kind: TAB_ID,
1212
1212
  title: () => strings.title(),
1213
- guide: [{ order: TAB_ORDER, title: () => strings.title() }],
1213
+ // guide 条目 id 必填(宿主 SidebarRightGuideEntry):缺了它注册不报错,但宿主
1214
+ // 会把 entryId: undefined 传给 sidebar.right.tab.guide.entry 席位(静默降级)。
1215
+ guide: [{ id: TAB_ID_PKG, order: TAB_ORDER, title: () => strings.title() }],
1214
1216
  }),
1215
1217
  'dsh-my-guardian: tab type',
1216
1218
  )
@@ -19,13 +19,28 @@ export function findModuleDir(nmRoot, packageName) {
19
19
  const dir = join(nmRoot, packageName);
20
20
  return existsSync(join(dir, 'package.json')) ? dir : null;
21
21
  }
22
- // Resolve a dependency from the plugin's nested node_modules or the profile
23
- // node_modules (hoisted installs). Returns the dir or null when absent.
22
+ // Base package of a specifier: 'pkg' -> 'pkg', '@scope/pkg' -> '@scope/pkg',
23
+ // 'pkg/sub' -> 'pkg', '@scope/pkg/sub' -> '@scope/pkg'. Peer specs are package
24
+ // roots today, but resolving the base keeps the lookup correct for any spec.
25
+ export function basePackage(spec) {
26
+ const parts = spec.split('/');
27
+ return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
28
+ }
29
+ // Resolve a dependency from the plugin's nested node_modules, the profile
30
+ // node_modules (hoisted installs), or the profiles-root node_modules where the
31
+ // harness installs its host-provided @deepseek-ai/* packages. The pre-check
32
+ // previously stopped at the profile dir, so every plugin declaring a host
33
+ // package as a peer was reported as missing even though Node resolves it by
34
+ // walking up to $DSH_HOME/profiles/node_modules. Returns the dir or null.
24
35
  function resolveDependencyDir(profileDir, pluginDir, dep) {
25
- const nested = pluginDir === null ? null : findModuleDir(join(pluginDir, 'node_modules'), dep);
36
+ const base = basePackage(dep);
37
+ const nested = pluginDir === null ? null : findModuleDir(join(pluginDir, 'node_modules'), base);
26
38
  if (nested !== null)
27
39
  return nested;
28
- return findModuleDir(join(profileDir, 'node_modules'), dep);
40
+ const inProfile = findModuleDir(join(profileDir, 'node_modules'), base);
41
+ if (inProfile !== null)
42
+ return inProfile;
43
+ return findModuleDir(join(profileDir, '..', 'node_modules'), base);
29
44
  }
30
45
  function readPackageJson(dir) {
31
46
  if (dir === null)
@@ -97,7 +112,7 @@ function skippedResult(reason) {
97
112
  * (ok: true) so an unusual install layout is never a false block.
98
113
  */
99
114
  export function checkPeerDependencies({ profileDir, pluginName, }) {
100
- const pluginDir = findModuleDir(join(profileDir, 'node_modules'), pluginName);
115
+ const pluginDir = findModuleDir(join(profileDir, 'node_modules'), basePackage(pluginName));
101
116
  if (pluginDir === null)
102
117
  return skippedResult(`无法定位插件 ${pluginName}(未在 profile node_modules 找到 package.json)`);
103
118
  const pkg = readPackageJson(pluginDir);
package/lib/events.js CHANGED
@@ -5,16 +5,169 @@
5
5
  import { ERROR_SNIP, EVENT_LIMIT } from './state.js';
6
6
  /** Append a diagnostic event to the shared state's ring buffer. */
7
7
  export function logEvent(shared, type, message) {
8
- shared.state.events.push({
8
+ const record = {
9
9
  time: Date.now(),
10
10
  type,
11
11
  message: String(message).slice(0, ERROR_SNIP),
12
- });
12
+ };
13
+ shared.state.events.push(record);
13
14
  if (shared.state.events.length > EVENT_LIMIT)
14
15
  shared.state.events.splice(0, shared.state.events.length - EVENT_LIMIT);
16
+ return record;
15
17
  }
16
- /** Register the loader/HMR diagnostic listeners (R9/R10). */
18
+ /**
19
+ * 本插件监听的宿主事件名(登记表)。
20
+ *
21
+ * 判据在 test/host-event-contract.mjs:这些名字必须命中**目标宿主真实事件表**
22
+ * (test/fixtures/host-events.json,由 scripts/host-events.mjs 从宿主源码取证:
23
+ * `interface Events` 声明 ∪ `ctx.<emit|parallel|...>` 派发),否则必须有
24
+ * HOST_EVENT_FALLBACKS 降级信号。新增/删除 ctx.on 时必须同步本表。
25
+ *
26
+ * 0.1.7-rc.2 起:`loader/entry-init`、`loader/partial-dispose` 仍由宿主
27
+ * cordis-plugin-loader 派发(有效);`hmr/config-update-failed` 已被删除,只剩
28
+ * "已退役 0.1.5-rc.1 兼容"意义,实际诊断走 HOST_EVENT_FALLBACKS 的日志通道。
29
+ */
30
+ export const LISTENED_HOST_EVENTS = [
31
+ 'loader/entry-init',
32
+ 'loader/partial-dispose',
33
+ 'hmr/config-update-failed',
34
+ ];
35
+ /** 宿主在配置热更新失败点打的结构化 warn 首参(逐字取自宿主源码,测试会校验)。 */
36
+ const CONFIG_FAILURE_MARKER = 'config reload at %C failed';
37
+ /** cordis exporter 的 verbosity 阈值:2 = 收 warn 及更严重的消息(error/warn)。 */
38
+ const WARN_LEVEL = 2;
39
+ /** marker 之后紧跟的 `ctx.logger.warn(error)` 配对窗口(ms)。 */
40
+ const PAIR_WINDOW_MS = 50;
41
+ /** 双版本去重窗口(ms):0.1.5-rc.1 上同一失败既打日志又发事件。 */
42
+ const DEDUP_WINDOW_MS = 1000;
43
+ /** 去重表上限(长跑内存护栏)。 */
44
+ const DEDUP_LIMIT = 50;
45
+ /** marker 先到时错误文本未知的占位(紧随的 Error warn 会补齐)。 */
46
+ const PENDING_DETAIL = '(error detail logged by host)';
47
+ /**
48
+ * 降级信号登记:宿主 0.1.7-rc.2 把 HMR 换成 @deepseek-ai/dsh-hmr,事件表只剩
49
+ * `hmr/change` / `hmr/reload` —— `hmr/config-update-failed` 被删除,`ctx.on` 该事件
50
+ * 在新宿主上**静默失效**(不报错、不告警),"配置热更新失败"诊断整体消失。
51
+ *
52
+ * 替代可观测量(逐字取证,非猜测):0.1.5-rc.1 的 cordis-plugin-hmr 与 0.1.7-rc.2 的
53
+ * dsh-hmr **在同一个 catch 里先打同一对 warn、然后旧版才发事件**:
54
+ * ctx.logger.warn('config reload at %C failed', filename); ctx.logger.warn(error)
55
+ * (旧:cordis-plugin-hmr/lib/index.js;新:packages/boot/hmr/src/watch-config.ts,
56
+ * 已装宿主 0.1.7-rc.2 对应 node_modules/@deepseek-ai/dsh-hmr/lib/index.js)
57
+ * 所以结构化日志是两个宿主共有的可观测面,用它接手被删事件的诊断职责。
58
+ *
59
+ * 宿主升级到 0.1.7-rc.2 之后:已装宿主也变成 0.1.7-rc.2,`hmr/config-update-failed`
60
+ * 在两个通道里都不存在——该 `ctx.on` 只剩"已退役 0.1.5-rc.1 兼容"意义(注册未知
61
+ * 事件不报错,回退宿主仍可诊断),因此 legacySource 转为 legacyRetired 退役登记。
62
+ * 新宿主的诊断职责全部由结构化 warn 通道承担(marker 在参考源与已装宿主双向取证)。
63
+ */
64
+ export const HOST_EVENT_FALLBACKS = [
65
+ {
66
+ event: 'hmr/config-update-failed',
67
+ kind: 'logger-warn',
68
+ marker: CONFIG_FAILURE_MARKER,
69
+ targetSource: 'packages/boot/hmr/src/watch-config.ts',
70
+ legacyRetired: {
71
+ version: '0.1.5-rc.1',
72
+ source: 'node_modules/@deepseek-ai/cordis-plugin-hmr/lib/index.js',
73
+ reason: '0.1.7-rc.2 以 @deepseek-ai/dsh-hmr 取代 cordis-plugin-hmr,旧包已从已装宿主移除,双通道并存的在场取证不可复现',
74
+ },
75
+ },
76
+ ];
77
+ /** 失败原因文本:Error 取 message、其他字符串化、未配对到时给占位。 */
78
+ function failureText(error) {
79
+ if (error === undefined)
80
+ return PENDING_DETAIL;
81
+ return error instanceof Error ? error.message : String(error);
82
+ }
83
+ /**
84
+ * 创建"配置热更新失败"记录器(两条通道共用一张最近失败表)。
85
+ *
86
+ * 去重依据同上:旧宿主在同一个 catch 里先 warn 再发事件,同一失败会经两条通道
87
+ * 到达(日志通道在前),只记一条;不同文件名照常各记一条。
88
+ * @param ctx DSH server 端 Context。
89
+ * @param shared 插件共享状态(环形缓冲 + 落盘)。
90
+ * @param now 注入时钟(默认 Date.now;测试用于验证窗口过期分支)。
91
+ */
92
+ export function createConfigFailureTracker(ctx, shared, now = Date.now) {
93
+ const recent = new Map();
94
+ let pending;
95
+ /** 该文件名在去重窗口内是否首次失败。 */
96
+ const firstTime = (key) => {
97
+ const time = now();
98
+ const last = recent.get(key);
99
+ if (last !== undefined && time - last < DEDUP_WINDOW_MS)
100
+ return false;
101
+ recent.set(key, time);
102
+ if (recent.size > DEDUP_LIMIT)
103
+ recent.delete(String(recent.keys().next().value));
104
+ return true;
105
+ };
106
+ const write = (filename, error) => {
107
+ const key = String(filename);
108
+ if (!firstTime(key))
109
+ return undefined;
110
+ const record = logEvent(shared, 'update-failed', `${key}: ${failureText(error)}`);
111
+ ctx.logger?.warn(`[dsh-my-guardian] config update failed (rolled back): ${key}`);
112
+ shared.persistSoon();
113
+ return record;
114
+ };
115
+ return {
116
+ fromEvent(filename, error) {
117
+ write(filename, error);
118
+ },
119
+ fromLogMarker(filename, loggerName) {
120
+ const record = write(filename, undefined);
121
+ if (record !== undefined)
122
+ pending = { record, key: String(filename), name: loggerName, at: now() };
123
+ },
124
+ fromLogDetail(error, loggerName) {
125
+ const state = pending;
126
+ pending = undefined;
127
+ if (state === undefined)
128
+ return;
129
+ if (now() - state.at > PAIR_WINDOW_MS || loggerName !== state.name)
130
+ return;
131
+ state.record.message = `${state.key}: ${failureText(error)}`.slice(0, ERROR_SNIP);
132
+ },
133
+ };
134
+ }
135
+ /**
136
+ * 注册结构化日志降级通道(0.1.7-rc.2 起被删事件的替代可观测量)。
137
+ *
138
+ * `ctx.logger.exporter()` 是 cordis 日志服务的公开扩展点:注册的 exporter 随当前
139
+ * fiber 释放(实测插件卸载后 exporter 一并移除,不泄漏),收到的是**未格式化的**
140
+ * 结构化消息(`args` 里的 marker 字面量可直接比对,无需解析格式串)。
141
+ * logger 缺失或没有 exporter(老/最小 ctx)时静默降级,不抛异常。
142
+ */
143
+ function attachConfigFailureLogFallback(ctx, failures) {
144
+ const logger = ctx.logger;
145
+ if (logger === undefined || typeof logger.exporter !== 'function')
146
+ return;
147
+ logger.exporter({
148
+ levels: { default: WARN_LEVEL },
149
+ export(message) {
150
+ if (message?.type !== 'warn')
151
+ return;
152
+ const args = Array.isArray(message.args) ? message.args : [];
153
+ if (args[0] === CONFIG_FAILURE_MARKER) {
154
+ failures.fromLogMarker(args[1], message.name);
155
+ }
156
+ else if (args.length === 1 && args[0] instanceof Error) {
157
+ failures.fromLogDetail(args[0], message.name);
158
+ }
159
+ },
160
+ });
161
+ }
162
+ /**
163
+ * Register the loader/HMR diagnostic listeners (R9/R10)。
164
+ *
165
+ * 两条通道都注册:`hmr/config-update-failed` 只对已退役的 0.1.5-rc.1 有效,在
166
+ * 0.1.7-rc.2(当前唯一在场宿主)上永不触发(注册未知事件不报错,回退宿主仍可诊断),
167
+ * 当前宿主的诊断完全由日志通道承担——靠 createConfigFailureTracker 去重合并成一条。
168
+ */
17
169
  export function attachEventListeners(ctx, shared) {
170
+ const failures = createConfigFailureTracker(ctx, shared);
18
171
  ctx.on('loader/entry-init', (entry) => {
19
172
  logEvent(shared, 'entry-init', `entry ${entryLabelOf(entry)} initialized`);
20
173
  });
@@ -22,10 +175,9 @@ export function attachEventListeners(ctx, shared) {
22
175
  logEvent(shared, 'entry-dispose', `entry ${entryLabelOf(entry)} disposed`);
23
176
  });
24
177
  ctx.on('hmr/config-update-failed', (filename, error) => {
25
- logEvent(shared, 'update-failed', `${String(filename)}: ${error instanceof Error ? error.message : String(error)}`);
26
- ctx.logger?.warn(`[dsh-my-guardian] config update failed (rolled back): ${String(filename)}`);
27
- shared.persistSoon();
178
+ failures.fromEvent(filename, error);
28
179
  });
180
+ attachConfigFailureLogFallback(ctx, failures);
29
181
  }
30
182
  /**
31
183
  * entry 可读标识——⚠️ 只读 options 字段,绝不访问 `entry.id` getter:
@@ -33,7 +33,9 @@ exports.apply = function apply(ctx) {
33
33
  id: TAB_ID_PKG,
34
34
  kind: TAB_ID,
35
35
  title: () => strings.title(),
36
- guide: [{ order: TAB_ORDER, title: () => strings.title() }],
36
+ // guide 条目 id 必填(宿主 SidebarRightGuideEntry):缺了它注册不报错,但宿主
37
+ // 会把 entryId: undefined 传给 sidebar.right.tab.guide.entry 席位(静默降级)。
38
+ guide: [{ id: TAB_ID_PKG, order: TAB_ORDER, title: () => strings.title() }],
37
39
  }),
38
40
  'dsh-my-guardian: tab type',
39
41
  )
@@ -14,7 +14,7 @@
14
14
  * tree simply yields no roster and an empty report.
15
15
  */
16
16
  import { join } from 'node:path';
17
- import { findModuleDir, checkPeerDependencies, buildDependencyMessage } from './dep-precheck.js';
17
+ import { findModuleDir, checkPeerDependencies, buildDependencyMessage, basePackage } from './dep-precheck.js';
18
18
  import { writeStartupIssuesFile } from './state.js';
19
19
  import { logEvent } from './events.js';
20
20
  /** Profile-node_modules root used for roster resolvability checks. */
@@ -83,19 +83,21 @@ export function isDisabledEntry(entry) {
83
83
  }
84
84
  /** Removal hint shared by every issue kind (guardian never rewrites YAML). */
85
85
  const REMOVE_HINT = '从启动名册(cordis.patch.yml / profile)中删除该条目行,或标记 disabled: true 暂缓加载';
86
- /** Build an unresolvable-package issue (import stage would fail). */
87
- function unresolvedIssue(id, name) {
86
+ /** Build an unresolvable-package issue (import stage would fail). `installTarget`
87
+ * is the base package: a roster row may name a subpath export, and
88
+ * `dsh plugin add pkg/sub` would install the wrong thing. */
89
+ function unresolvedIssue(id, name, installTarget) {
88
90
  return {
89
91
  type: 'unresolvable',
90
92
  entryId: id,
91
93
  name,
92
94
  message: `插件包 ${name} 无法解析(profile node_modules 中不存在),启动 import 将失败`,
93
- fix: `dsh plugin add ${name}`,
95
+ fix: `dsh plugin add ${installTarget}`,
94
96
  remove: REMOVE_HINT,
95
97
  };
96
98
  }
97
99
  /** Build a dependency issue reusing the staged-mount pre-check result. */
98
- function dependencyIssue(id, name, precheck) {
100
+ function dependencyIssue(id, name, precheck, installTarget) {
99
101
  return {
100
102
  type: 'dependency',
101
103
  entryId: id,
@@ -103,7 +105,7 @@ function dependencyIssue(id, name, precheck) {
103
105
  message: buildDependencyMessage(precheck),
104
106
  missingDeps: [...precheck.missing, ...precheck.mismatched.map((item) => item.name)],
105
107
  installHint: precheck.suggestions[0] ?? null,
106
- fix: precheck.suggestions[0] ?? `dsh plugin add ${name}`,
108
+ fix: precheck.suggestions[0] ?? `dsh plugin add ${installTarget}`,
107
109
  remove: REMOVE_HINT,
108
110
  };
109
111
  }
@@ -133,14 +135,19 @@ function checkPluginItem(issues, item, nmRoot, profileDir) {
133
135
  const label = name !== '' ? name : id;
134
136
  if (label === '' || !label.startsWith('dsh-'))
135
137
  return;
136
- const pluginDir = findModuleDir(nmRoot, label);
138
+ // A row may name a subpath export ('dsh-openwrite/bridge'), which is not a
139
+ // directory under node_modules; the installable package is the base and the
140
+ // export map resolves the subpath at import time. Treating the whole name
141
+ // as a directory produced a false "unresolvable" for every subpath row.
142
+ const base = basePackage(label);
143
+ const pluginDir = findModuleDir(nmRoot, base);
137
144
  if (pluginDir === null) {
138
- issues.push(unresolvedIssue(id, label));
145
+ issues.push(unresolvedIssue(id, label, base));
139
146
  return;
140
147
  }
141
- const precheck = checkPeerDependencies({ profileDir, pluginName: label });
148
+ const precheck = checkPeerDependencies({ profileDir, pluginName: base });
142
149
  if (!precheck.ok)
143
- issues.push(dependencyIssue(id, label, precheck));
150
+ issues.push(dependencyIssue(id, label, precheck, base));
144
151
  }
145
152
  /** Append one duplicate-id issue per id seen in more than one roster row. */
146
153
  function collectDuplicateIssues(issues, byId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-guardian",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "DSH 插件治理守护:新装/更新插件先进候选区,启动后热挂载——成功转正、失败自动禁用、连续失败冻结,一键安全模式,侧边栏诊断面板。DSH web plugin: staged plugin loading with auto-disable on failure, freeze, safe mode and a sidebar panel.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",