@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// fang-hot-reloader — 2.5 热更新(分身内,不升红)
|
|
4
|
+
//
|
|
5
|
+
// 硬边界(CONTRACTS §1.4):失败不升红 —— 能回滚就回滚;回滚不了由编排器 reap 分身重新派生。
|
|
6
|
+
// 本插件只做一件事:把磁盘上的**新代码字节**换进来,并证明它换得住:
|
|
7
|
+
// 1) `?rev=<contentHash>` 动态 import(同一份字节复用同一 ESM 实例,改一字节就是新模块)
|
|
8
|
+
// 2) 在「临时等待位」里编译/初始化 —— 旧 fiber 此时完全没被触碰
|
|
9
|
+
// 3) 健康窗采样 `process.getActiveResourcesInfo`(kernel sampleResources/diffResources)
|
|
10
|
+
// 4) 只有健康窗通过才 dispose 旧 fiber;初始化失败或句柄泄漏 => dispose 新 fiber、旧 fiber 原样留下
|
|
11
|
+
//
|
|
12
|
+
// 不做:进程重启(归 restart-guard)、审批(审批面在 restart-guard/approver)、fork/缓冲区逻辑。
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { pathToFileURL } from 'node:url'
|
|
15
|
+
import { SERVICE, diffResources, sampleResources, sha256File, z } from '../kernel/index.mjs'
|
|
16
|
+
import { roleGate } from '../kernel/index.mjs'
|
|
17
|
+
|
|
18
|
+
export const name = 'fang-hot-reloader'
|
|
19
|
+
export const inject = []
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = Object.freeze({
|
|
22
|
+
healthWindowMs: 300,
|
|
23
|
+
healthMaxNewHandles: 2,
|
|
24
|
+
requireApprovalForSwap: true,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const Config = z.object({
|
|
28
|
+
healthWindowMs: z.number().min(50).max(10000).default(300)
|
|
29
|
+
.description('swap 后的健康窗(毫秒):窗口内新增被引用句柄超过阈值即回滚'),
|
|
30
|
+
healthMaxNewHandles: z.number().min(0).max(100).default(2)
|
|
31
|
+
.description('健康窗允许的最大新增被引用资源数(超过即 dispose 新 fiber 并回滚)'),
|
|
32
|
+
requireApprovalForSwap: z.boolean().default(true)
|
|
33
|
+
.description('保留字段(bundle 配置兼容):v1.0.0 的审批发生在 restart-guard/approver 面,swap() 自身不等待审批'),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// 本插件的 import 列表避开了 kernel barrel 的歧义导出(core/fsx 都导出 sleep),用本地实现。
|
|
37
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
|
38
|
+
const errText = (e) => String(e?.message ?? e)
|
|
39
|
+
const safeGet = (ctx, key) => { try { return ctx.get(key) } catch { return undefined } }
|
|
40
|
+
|
|
41
|
+
/** Cordis Fiber state: PENDING=0 LOADING=1 ACTIVE=2 FAILED=3 DISPOSED=4 */
|
|
42
|
+
const FIBER_FAILED = 3
|
|
43
|
+
|
|
44
|
+
let idSeq = 0
|
|
45
|
+
function nextId() {
|
|
46
|
+
idSeq = (idSeq + 1) % 1e6
|
|
47
|
+
return `hl_${Date.now().toString(36)}${idSeq.toString(36)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function apply(ctx, config) {
|
|
51
|
+
const gate = roleGate('hotReloader', { cfgRole: config?.role, logger: ctx.logger })
|
|
52
|
+
// v2 deployment matrix: this plugin is not deployed in this role — register nothing.
|
|
53
|
+
if (!gate.active) return
|
|
54
|
+
// 宿主已用 Config 校验过;直接调用 apply 时也走一次,保证两条路径同保护。
|
|
55
|
+
let validated
|
|
56
|
+
try { validated = Config(config ?? {}) } catch (e) {
|
|
57
|
+
throw new Error(`[fang-hot-reloader] config invalid: ${errText(e).replace(/^\$\./, '')}`)
|
|
58
|
+
}
|
|
59
|
+
const cfg = { ...DEFAULTS, ...validated }
|
|
60
|
+
|
|
61
|
+
/** name -> { id, name, file, rev, url, mod, handle, slot, config, sessionId, swappedAt } */
|
|
62
|
+
const mounted = new Map()
|
|
63
|
+
const stats = {
|
|
64
|
+
windowMs: cfg.healthWindowMs,
|
|
65
|
+
maxNewHandles: cfg.healthMaxNewHandles,
|
|
66
|
+
samples: 0,
|
|
67
|
+
swaps: 0,
|
|
68
|
+
rollbacks: 0,
|
|
69
|
+
lastDiff: null,
|
|
70
|
+
}
|
|
71
|
+
const log = (level, msg) => { try { ctx.logger?.[level]?.(`[fang-hot-reloader] ${msg}`) } catch { /* logging must never break a swap */ } }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 临时等待位:新代码先在隔离的作用域里初始化,失败时整块丢掉,旧 fiber 从未被触碰。
|
|
75
|
+
* 真宿主(Cordis)的 ctx.inject 返回 disposable 而不是子 ctx,拿不到子 ctx 就退化为原地挂载:
|
|
76
|
+
* 顺序仍是「先挂新、健康后才 dispose 旧」,只是隔离性弱一档,结果里如实标 degraded。
|
|
77
|
+
*/
|
|
78
|
+
const createWaitingSlot = () => {
|
|
79
|
+
let captured = null
|
|
80
|
+
if (typeof ctx.inject === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
const returned = ctx.inject([], (child) => { captured = child; return child })
|
|
83
|
+
const scope = (returned && typeof returned.plugin === 'function')
|
|
84
|
+
? returned
|
|
85
|
+
: (captured && typeof captured.plugin === 'function' ? captured : null)
|
|
86
|
+
if (scope) return { scope, temporary: true, degrade: null }
|
|
87
|
+
} catch { /* 宿主不支持/签名不同 => 退化 */ }
|
|
88
|
+
}
|
|
89
|
+
return { scope: ctx, temporary: false, degrade: 'no-child-scope' }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 把一个已 import 的模块挂到 scope 上,归一化成可 await / 可 dispose 的 handle。 */
|
|
93
|
+
const mountIn = (scope, mod, pluginConfig) => {
|
|
94
|
+
if (!scope || typeof scope.plugin !== 'function') throw new Error('ctx.plugin unavailable')
|
|
95
|
+
const raw = scope.plugin(mod, pluginConfig ?? {})
|
|
96
|
+
let dispose = null
|
|
97
|
+
if (raw && typeof raw.dispose === 'function') dispose = () => raw.dispose()
|
|
98
|
+
else if (typeof raw === 'function') dispose = () => raw()
|
|
99
|
+
return {
|
|
100
|
+
raw,
|
|
101
|
+
dispose, // null = 依赖等待位整体释放(测试 ctx 的 apply() 无返回值)
|
|
102
|
+
await: raw && typeof raw.await === 'function' ? () => raw.await() : null,
|
|
103
|
+
state: () => { try { return raw?.state ?? null } catch { return null } },
|
|
104
|
+
disposed: false,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** dispose 新 fiber(以及临时等待位)——失败只记录,绝不让回滚本身变成异常路径。 */
|
|
109
|
+
const disposeHandle = async (handle, slot) => {
|
|
110
|
+
const errors = []
|
|
111
|
+
if (handle && !handle.disposed) {
|
|
112
|
+
handle.disposed = true
|
|
113
|
+
try { if (handle.dispose) await handle.dispose() } catch (e) { errors.push(errText(e)) }
|
|
114
|
+
}
|
|
115
|
+
if (slot?.temporary && slot.scope && typeof slot.scope.dispose === 'function') {
|
|
116
|
+
try { await slot.scope.dispose() } catch (e) { errors.push(errText(e)) }
|
|
117
|
+
}
|
|
118
|
+
return errors
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 健康窗:等一段时间后看「仍被引用的资源」净增,> maxNewHandles 即判泄漏。 */
|
|
122
|
+
const runHealthWindow = async (baseline) => {
|
|
123
|
+
await sleep(cfg.healthWindowMs)
|
|
124
|
+
const diff = diffResources(baseline, sampleResources())
|
|
125
|
+
stats.samples += 1
|
|
126
|
+
stats.lastDiff = { ...diff, windowMs: cfg.healthWindowMs, at: Date.now() }
|
|
127
|
+
return diff
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const healthPayload = (diff, fiberState) => ({
|
|
131
|
+
leakedRefed: diff?.leakedRefed ?? 0,
|
|
132
|
+
freed: diff?.freed ?? 0,
|
|
133
|
+
windowMs: cfg.healthWindowMs,
|
|
134
|
+
fiberState,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* swap({name, file, config, sessionId?}) -> Promise<{ok, phase, ...}>
|
|
139
|
+
* 成功:{ok:true, phase:'swapped', from, to, url, fiber, health}
|
|
140
|
+
* 初始化失败:{ok:false, phase:'init-failed', reason:'import-failed'|'mount-failed'}
|
|
141
|
+
* 健康窗回滚:{ok:false, phase:'rolled-back', rolledBack:true, reason:'health-window'|'fiber-failed'}
|
|
142
|
+
* 两条失败路径下旧 fiber 都没被 dispose。
|
|
143
|
+
*/
|
|
144
|
+
const swap = async ({ name, file, config: pluginConfig = {}, sessionId = null } = {}) => {
|
|
145
|
+
const t0 = Date.now()
|
|
146
|
+
const entryName = String(name ?? '')
|
|
147
|
+
const filePath = file ? path.resolve(String(file)) : ''
|
|
148
|
+
if (!entryName || !filePath) {
|
|
149
|
+
return { ok: false, phase: 'init-failed', reason: 'bad-request', error: 'swap({name,file}) requires both' }
|
|
150
|
+
}
|
|
151
|
+
const prev = mounted.get(entryName) ?? null
|
|
152
|
+
const rev = sha256File(filePath) // 内容哈希:字节决定版本,mtime 不参与
|
|
153
|
+
const url = pathToFileURL(filePath).href + `?rev=${encodeURIComponent(rev ?? 'missing')}`
|
|
154
|
+
|
|
155
|
+
// 1) 预检 import:失败时旧 fiber 完全没被动过
|
|
156
|
+
let mod
|
|
157
|
+
try {
|
|
158
|
+
mod = await import(url)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
log('warn', `import failed for ${entryName}: ${errText(e)}`)
|
|
161
|
+
return {
|
|
162
|
+
ok: false, phase: 'init-failed', reason: 'import-failed', error: errText(e),
|
|
163
|
+
name: entryName, file: filePath, from: prev?.rev ?? null, to: rev, url,
|
|
164
|
+
health: { leakedRefed: 0, windowMs: cfg.healthWindowMs },
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 2) 临时等待位里初始化
|
|
169
|
+
const slot = createWaitingSlot()
|
|
170
|
+
const baseline = sampleResources()
|
|
171
|
+
let handle = null
|
|
172
|
+
try {
|
|
173
|
+
handle = mountIn(slot.scope, mod, pluginConfig)
|
|
174
|
+
if (handle.await) await handle.await()
|
|
175
|
+
if (handle.state() === FIBER_FAILED) throw new Error('fiber failed during init')
|
|
176
|
+
} catch (e) {
|
|
177
|
+
const disposeErrors = await disposeHandle(handle, slot)
|
|
178
|
+
log('warn', `init failed for ${entryName}: ${errText(e)}`)
|
|
179
|
+
return {
|
|
180
|
+
ok: false, phase: 'init-failed', reason: 'mount-failed', error: errText(e),
|
|
181
|
+
name: entryName, file: filePath, from: prev?.rev ?? null, to: rev, url,
|
|
182
|
+
health: { leakedRefed: 0, windowMs: cfg.healthWindowMs },
|
|
183
|
+
...(disposeErrors.length ? { disposeErrors } : {}),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3) 健康窗
|
|
188
|
+
const diff = await runHealthWindow(baseline)
|
|
189
|
+
const fiberState = handle.state()
|
|
190
|
+
const unstable = fiberState === FIBER_FAILED || diff.leakedRefed > cfg.healthMaxNewHandles
|
|
191
|
+
if (unstable) {
|
|
192
|
+
const disposeErrors = await disposeHandle(handle, slot)
|
|
193
|
+
stats.rollbacks += 1
|
|
194
|
+
log('warn', `rolled back ${entryName}: leakedRefed=${diff.leakedRefed} state=${fiberState}`)
|
|
195
|
+
return {
|
|
196
|
+
ok: false, phase: 'rolled-back', rolledBack: true,
|
|
197
|
+
reason: fiberState === FIBER_FAILED ? 'fiber-failed' : 'health-window',
|
|
198
|
+
name: entryName, file: filePath, from: prev?.rev ?? null, to: rev, url,
|
|
199
|
+
health: healthPayload(diff, fiberState),
|
|
200
|
+
...(disposeErrors.length ? { disposeErrors } : {}),
|
|
201
|
+
degraded: slot.degrade ?? null,
|
|
202
|
+
ms: Date.now() - t0,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 4) 健康 => 现在才 dispose 旧 fiber
|
|
207
|
+
let disposedOld = false
|
|
208
|
+
let oldDisposeError = null
|
|
209
|
+
if (prev) {
|
|
210
|
+
const errs = await disposeHandle(prev.handle, prev.slot)
|
|
211
|
+
disposedOld = errs.length === 0
|
|
212
|
+
if (errs.length) oldDisposeError = errs.join('; ')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const record = {
|
|
216
|
+
id: nextId(), name: entryName, file: filePath, rev, url, mod,
|
|
217
|
+
handle, slot, config: { ...(pluginConfig ?? {}) }, sessionId, swappedAt: Date.now(),
|
|
218
|
+
}
|
|
219
|
+
mounted.set(entryName, record)
|
|
220
|
+
stats.swaps += 1
|
|
221
|
+
return {
|
|
222
|
+
ok: true, phase: 'swapped', name: entryName, from: prev?.rev ?? null, to: rev, url,
|
|
223
|
+
fiber: { id: record.id, name: entryName, rev, url, state: handle.state() },
|
|
224
|
+
health: healthPayload(diff, handle.state()),
|
|
225
|
+
disposedOld: prev ? disposedOld : null,
|
|
226
|
+
...(oldDisposeError ? { oldDisposeError } : {}),
|
|
227
|
+
...(slot.degrade ? { degraded: slot.degrade } : {}),
|
|
228
|
+
ms: Date.now() - t0,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** unload({name}) -> {ok}:卸载后该名字回到「未装载」,可再次 swap。 */
|
|
233
|
+
const unload = async ({ name } = {}) => {
|
|
234
|
+
const entryName = String(name ?? '')
|
|
235
|
+
const record = mounted.get(entryName)
|
|
236
|
+
if (!record) return { ok: false, phase: 'not-loaded', reason: 'not-loaded', name: entryName }
|
|
237
|
+
mounted.delete(entryName) // 先摘表:即使 dispose 部分失败也不再有活动记录
|
|
238
|
+
const errors = await disposeHandle(record.handle, record.slot)
|
|
239
|
+
if (errors.length) {
|
|
240
|
+
log('warn', `unload ${entryName} dispose errors: ${errors.join('; ')}`)
|
|
241
|
+
return { ok: false, phase: 'dispose-failed', name: entryName, from: record.rev, disposeErrors: errors }
|
|
242
|
+
}
|
|
243
|
+
return { ok: true, phase: 'unloaded', name: entryName, from: record.rev }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** health() -> {windowMs, samples, lastDiff}(另附只读的装载快照,便于面板排障) */
|
|
247
|
+
const health = () => ({
|
|
248
|
+
windowMs: cfg.healthWindowMs,
|
|
249
|
+
samples: stats.samples,
|
|
250
|
+
lastDiff: stats.lastDiff ? { ...stats.lastDiff } : null,
|
|
251
|
+
maxNewHandles: cfg.healthMaxNewHandles,
|
|
252
|
+
swaps: stats.swaps,
|
|
253
|
+
rollbacks: stats.rollbacks,
|
|
254
|
+
loaded: [...mounted.values()].map(r => ({ name: r.name, rev: r.rev, url: r.url, swappedAt: r.swappedAt })),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const service = { swap, unload, health }
|
|
258
|
+
ctx.effect(() => ctx.provide(SERVICE.hotReloader, service))
|
|
259
|
+
|
|
260
|
+
// 插件卸载:把还挂着的 fiber 尽量拆掉(dispose 可能是异步的,这里不阻塞 effect 清理)。
|
|
261
|
+
// 临时等待位(子作用域)由父作用域自行回收,这里不重复 dispose:宿主对子 fiber 的 ownership
|
|
262
|
+
// 已经在 dispose 父 ctx 时生效(测试 helpers 的 ctx.dispose 也会回收 children)。
|
|
263
|
+
ctx.effect(() => () => {
|
|
264
|
+
for (const rec of mounted.values()) {
|
|
265
|
+
try {
|
|
266
|
+
const p = rec.handle?.dispose ? rec.handle.dispose() : null
|
|
267
|
+
if (p && typeof p.catch === 'function') p.catch(() => {})
|
|
268
|
+
} catch { /* ignore */ }
|
|
269
|
+
}
|
|
270
|
+
mounted.clear()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
return service
|
|
274
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// 零依赖 import 层扫描器:手写 tokenizer(跳过注释/字符串),再扫 token 流
|
|
4
|
+
// 局限(必须如实声明):只覆盖 **import 层**,不做调用层(exec/spawn)分析
|
|
5
|
+
|
|
6
|
+
const WORD_START = /[A-Za-z_$]/
|
|
7
|
+
const WORD_PART = /[A-Za-z0-9_$]/
|
|
8
|
+
|
|
9
|
+
/** 把源码切成 {t:'word'|'str'|'punct', v, line},跳过注释与空白 */
|
|
10
|
+
export function tokenize(src) {
|
|
11
|
+
const out = []
|
|
12
|
+
let i = 0
|
|
13
|
+
let line = 1
|
|
14
|
+
const n = src.length
|
|
15
|
+
|
|
16
|
+
const err = (msg) => { throw Object.assign(new Error(msg), { line }) }
|
|
17
|
+
|
|
18
|
+
while (i < n) {
|
|
19
|
+
const c = src[i]
|
|
20
|
+
|
|
21
|
+
if (c === '\n') { line++; i++; continue }
|
|
22
|
+
if (c === ' ' || c === '\t' || c === '\r') { i++; continue }
|
|
23
|
+
|
|
24
|
+
// 行注释
|
|
25
|
+
if (c === '/' && src[i + 1] === '/') { while (i < n && src[i] !== '\n') i++; continue }
|
|
26
|
+
// 块注释
|
|
27
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
28
|
+
i += 2
|
|
29
|
+
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') line++; i++ }
|
|
30
|
+
i += 2
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 字符串
|
|
35
|
+
if (c === '"' || c === "'") {
|
|
36
|
+
const q = c
|
|
37
|
+
const startLine = line
|
|
38
|
+
i++
|
|
39
|
+
let v = ''
|
|
40
|
+
while (i < n && src[i] !== q) {
|
|
41
|
+
if (src[i] === '\\') { v += src[i + 1] ?? ''; i += 2; continue }
|
|
42
|
+
if (src[i] === '\n') { line++; }
|
|
43
|
+
v += src[i]; i++
|
|
44
|
+
}
|
|
45
|
+
i++ // 关引号
|
|
46
|
+
out.push({ t: 'str', v, line: startLine, quote: q })
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 模板串:只取静态部分(含 ${} 的标为 tpl,specifier 判定时视为非字面量)
|
|
51
|
+
if (c === '`') {
|
|
52
|
+
const startLine = line
|
|
53
|
+
i++
|
|
54
|
+
let v = ''
|
|
55
|
+
let dynamic = false
|
|
56
|
+
while (i < n && src[i] !== '`') {
|
|
57
|
+
if (src[i] === '\\') { v += src[i + 1] ?? ''; i += 2; continue }
|
|
58
|
+
if (src[i] === '$' && src[i + 1] === '{') {
|
|
59
|
+
dynamic = true
|
|
60
|
+
let depth = 1
|
|
61
|
+
i += 2
|
|
62
|
+
while (i < n && depth > 0) {
|
|
63
|
+
if (src[i] === '{') depth++
|
|
64
|
+
else if (src[i] === '}') depth--
|
|
65
|
+
else if (src[i] === '\n') line++
|
|
66
|
+
i++
|
|
67
|
+
}
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (src[i] === '\n') line++
|
|
71
|
+
v += src[i]; i++
|
|
72
|
+
}
|
|
73
|
+
i++
|
|
74
|
+
out.push({ t: 'str', v, line: startLine, tpl: true, dynamic })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 标识符 / 关键字
|
|
79
|
+
if (WORD_START.test(c)) {
|
|
80
|
+
const start = i
|
|
81
|
+
const startLine = line
|
|
82
|
+
while (i < n && WORD_PART.test(src[i])) i++
|
|
83
|
+
out.push({ t: 'word', v: src.slice(start, i), line: startLine })
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 其它一律当标点(单字符足够用于本扫描)
|
|
88
|
+
out.push({ t: 'punct', v: c, line })
|
|
89
|
+
i++
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const RE_SPECIFIER_FROM_IMPORT = new Set(['import', 'export'])
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 抽取 import 说明符。
|
|
98
|
+
* kind: 'static' | 'side-effect' | 'dynamic' | 'require' | 'export-from'
|
|
99
|
+
*/
|
|
100
|
+
export function extractImports(tokens) {
|
|
101
|
+
const imports = []
|
|
102
|
+
const warnings = []
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
105
|
+
const tok = tokens[i]
|
|
106
|
+
if (tok.t !== 'word') continue
|
|
107
|
+
|
|
108
|
+
// require('x')
|
|
109
|
+
if (tok.v === 'require') {
|
|
110
|
+
const a = tokens[i + 1], b = tokens[i + 2], c = tokens[i + 3]
|
|
111
|
+
if (a?.t === 'punct' && a.v === '(' && b?.t === 'str' && c?.t === 'punct' && c.v === ')') {
|
|
112
|
+
imports.push({ kind: 'require', specifier: b.v, line: tok.line, literal: !b.dynamic && !b.tpl })
|
|
113
|
+
}
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!RE_SPECIFIER_FROM_IMPORT.has(tok.v)) continue
|
|
118
|
+
|
|
119
|
+
const next = tokens[i + 1]
|
|
120
|
+
|
|
121
|
+
// import('x') —— 动态
|
|
122
|
+
if (tok.v === 'import' && next?.t === 'punct' && next.v === '(') {
|
|
123
|
+
const arg = tokens[i + 2]
|
|
124
|
+
if (arg?.t === 'str') {
|
|
125
|
+
imports.push({ kind: 'dynamic', specifier: arg.v, line: tok.line, literal: !arg.dynamic && !arg.tpl })
|
|
126
|
+
} else {
|
|
127
|
+
warnings.push({ line: tok.line, reason: 'dynamic-import-nonliteral' })
|
|
128
|
+
}
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// import 'x' —— 副作用
|
|
133
|
+
if (tok.v === 'import' && next?.t === 'str') {
|
|
134
|
+
imports.push({ kind: 'side-effect', specifier: next.v, line: tok.line, literal: !next.dynamic && !next.tpl })
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// import ... from 'x' / export ... from 'x'
|
|
139
|
+
// 向后最多 200 个 token 找 from;遇到 ';' 或 '=' 视为不是该语句
|
|
140
|
+
let j = i + 1
|
|
141
|
+
let found = null
|
|
142
|
+
let limit = 0
|
|
143
|
+
while (j < tokens.length && limit++ < 200) {
|
|
144
|
+
const t = tokens[j]
|
|
145
|
+
if (t.t === 'punct' && (t.v === ';' || t.v === '=')) break
|
|
146
|
+
if (t.t === 'word' && t.v === 'from') { found = tokens[j + 1]; break }
|
|
147
|
+
j++
|
|
148
|
+
}
|
|
149
|
+
if (found && found.t === 'str') {
|
|
150
|
+
imports.push({
|
|
151
|
+
kind: tok.v === 'import' ? 'static' : 'export-from',
|
|
152
|
+
specifier: found.v,
|
|
153
|
+
line: tok.line,
|
|
154
|
+
literal: !found.dynamic && !found.tpl,
|
|
155
|
+
})
|
|
156
|
+
} else if (tok.v === 'import') {
|
|
157
|
+
// import.meta / import x = ... / 纯类型导入失败等
|
|
158
|
+
const maybe = tokens[i + 1]
|
|
159
|
+
if (!(maybe?.t === 'punct' && maybe.v === '.')) warnings.push({ line: tok.line, reason: 'unparsed-import' })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { imports, warnings }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function scanSource(src) {
|
|
166
|
+
const tokens = tokenize(src)
|
|
167
|
+
const { imports, warnings } = extractImports(tokens)
|
|
168
|
+
return { imports, warnings, tokenCount: tokens.length }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Deep scan: import layer + a deliberately small call-layer signal set.
|
|
173
|
+
* This is still token-based, not an AST/call graph: it detects direct call tokens
|
|
174
|
+
* (and the existing dynamic-import warning), and reports that coverage honestly.
|
|
175
|
+
*/
|
|
176
|
+
const CHILD_CALLS = new Set(['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', 'execFileSync', 'fork'])
|
|
177
|
+
const VM_CALLS = new Set(['runInContext', 'runInNewContext', 'runInThisContext', 'compileFunction'])
|
|
178
|
+
|
|
179
|
+
export function scanDeep(src) {
|
|
180
|
+
const scan = scanSource(src)
|
|
181
|
+
const tokens = tokenize(src)
|
|
182
|
+
const signals = [...importsToSignals(scan).signals]
|
|
183
|
+
const callSites = []
|
|
184
|
+
const seen = new Set()
|
|
185
|
+
const add = (id, weight, kind, line) => {
|
|
186
|
+
const key = `${id}@${line}`
|
|
187
|
+
if (seen.has(key)) return
|
|
188
|
+
seen.add(key)
|
|
189
|
+
callSites.push({ kind, line })
|
|
190
|
+
signals.push({ id, weight, evidence: { calls: [{ kind, line }] } })
|
|
191
|
+
}
|
|
192
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
193
|
+
const token = tokens[i]
|
|
194
|
+
if (token.t !== 'word') continue
|
|
195
|
+
const next = tokens[i + 1]
|
|
196
|
+
if (next?.t === 'punct' && next.v === '(') {
|
|
197
|
+
if (CHILD_CALLS.has(token.v)) add('S1-child_process_call', 30, token.v, token.line)
|
|
198
|
+
else if (token.v === 'eval') add('S7-eval_call', 35, 'eval', token.line)
|
|
199
|
+
else if (VM_CALLS.has(token.v)) add('S4-vm_call', 25, token.v, token.line)
|
|
200
|
+
}
|
|
201
|
+
if (token.v === 'new' && tokens[i + 1]?.t === 'word' && tokens[i + 1].v === 'Function'
|
|
202
|
+
&& tokens[i + 2]?.t === 'punct' && tokens[i + 2].v === '(') {
|
|
203
|
+
add('S7-eval_call', 35, 'new Function', token.line)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const warning of scan.warnings) {
|
|
207
|
+
if (warning.reason === 'dynamic-import-nonliteral') add('S8-dynamic_import_nonliteral', 12, 'dynamic-import', warning.line)
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
imports: scan.imports,
|
|
211
|
+
warnings: scan.warnings,
|
|
212
|
+
tokenCount: scan.tokenCount,
|
|
213
|
+
signals,
|
|
214
|
+
callSites,
|
|
215
|
+
coverage: 'import+call',
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─────────── 说明符 → 信号(只做 import 层) ───────────
|
|
220
|
+
const CHILD_PROCESS = new Set(['child_process', 'node:child_process'])
|
|
221
|
+
const NETWORK = new Set(['net', 'node:net', 'http', 'node:http', 'https', 'node:https',
|
|
222
|
+
'dgram', 'node:dgram', 'tls', 'node:tls', 'http2', 'node:http2',
|
|
223
|
+
'undici', 'node:undici', 'ws', 'axios', 'node-fetch', 'got'])
|
|
224
|
+
const FSCAP = new Set(['fs', 'node:fs', 'fs/promises', 'node:fs/promises'])
|
|
225
|
+
const VM = new Set(['vm', 'node:vm'])
|
|
226
|
+
|
|
227
|
+
export function importsToSignals(scan, pkg = {}) {
|
|
228
|
+
const seen = new Set()
|
|
229
|
+
const signals = []
|
|
230
|
+
const evidence = {}
|
|
231
|
+
const add = (id, weight, detail) => {
|
|
232
|
+
if (seen.has(id)) { evidence[id].push(detail); return }
|
|
233
|
+
seen.add(id)
|
|
234
|
+
evidence[id] = [detail]
|
|
235
|
+
signals.push({ id, weight, evidence: { modules: [detail] } })
|
|
236
|
+
}
|
|
237
|
+
for (const imp of scan.imports) {
|
|
238
|
+
const s = imp.specifier
|
|
239
|
+
if (CHILD_PROCESS.has(s)) add('S1-child_process', 30, s)
|
|
240
|
+
else if (NETWORK.has(s)) add('S2-network', 20, s)
|
|
241
|
+
else if (FSCAP.has(s)) add('S3-fs-capability', 15, s)
|
|
242
|
+
else if (VM.has(s)) add('S4-vm', 25, s)
|
|
243
|
+
}
|
|
244
|
+
// 合并同一信号的多个模块
|
|
245
|
+
for (const sig of signals) sig.evidence.modules = evidence[sig.id]
|
|
246
|
+
return { signals, coverage: 'import-only' }
|
|
247
|
+
}
|