@perrylink/dsh-plugin-doctor 0.2.2 → 0.2.3

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.
@@ -1,230 +1,230 @@
1
- // 静态·cordis 契约扫描(K1–K9,启发式)
2
- // 依据:cordiverse/cordis v4 源码契约 + DSH 官方 cordis 文档(2026-09-07 调研):
3
- // - apply 可同步,亦可 async 返回 Promise<disposer>;返回非法形状抛 TypeError('Invalid effect')
4
- // - inject 硬依赖 = (keyof M)[] | { name?: interceptConfig };可选依赖走 ctx.get(name)
5
- // - v4 删除 v3 的 fork/reusable/using/scope/runtime/lifecycle/config/collect/accept/decline/alias/off
6
- // - ctx 活数据不可 JSON.stringify/structuredClone/展开
7
- import path from 'node:path'
8
- import { readFileSync, readdirSync } from 'node:fs'
9
- import { pass, fail, warn, skip, findFiles } from './util.mjs'
10
-
11
- export const GROUP = '静态·cordis 契约扫描'
12
-
13
- // v4 Context 固有成员(访问无需 inject)。v3 已删除成员(scope/config/fork/...)故意不列入,
14
- // 由 K7 精确追责。
15
- const CTX_INTRINSIC = new Set([
16
- 'get', 'on', 'once', 'effect', 'set', 'root', 'plugin', 'emit',
17
- 'parallel', 'waterfall', 'bail', 'serial', 'logger', 'name', 'deps',
18
- 'isolate', 'intercept', 'extend', 'accessor', 'mixin', 'provide',
19
- 'reflect', 'registry', 'inject', 'fiber', 'filter', 'select',
20
- ])
21
- const KNOWN_SEAMS = new Set([
22
- 'tools', 'llm', 'shell', 'jobs', 'commands', 'web', 'logger', 'config', 'storage',
23
- 'session', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope',
24
- 'approval', 'credentials', 'subagents', 'settings', 'systemPrompt',
25
- ])
26
-
27
- // 剥注释后再扫描(JSDoc 里的 ctx.xxx 引用是假阳性大户);(^|[^:]) 守卫避免伤及字符串内 ://
28
- function stripComments(text) {
29
- return text
30
- .replace(/\/\*[\s\S]*?\*\//g, '')
31
- .replace(/(^|[^:])\/\/.*$/gm, '$1')
32
- }
33
-
34
- function readAll(files) {
35
- return files.map((f) => { try { return { f, text: stripComments(readFileSync(f, 'utf8')) } } catch { return { f, text: '' } } })
36
- }
37
-
38
- const short = (f) => f.split(/[\\/]/).pop()
39
-
40
- // 源文件发现策略(R-fix 0A 修正):
41
- // ① src/** + 根目录一层 JS/TS
42
- // ② 无 src 时,若 main 指向 lib/ → 把 lib/** 当源码(**去掉旧的 `!pkg.scripts?.build` 门槛**:
43
- // 已发布 tarball / 已装包目录里没有 src/,但 package.json 仍保留 build 脚本,
44
- // 旧条件因此永不成立 → K1–K9 九项全 skip → 汇总仍 exit 0(假绿))
45
- // ③ main 未做 stripDot 时 `./index.mjs` 不匹配 'lib/',7 个仓的 lib/ 从不被扫(同样修正)
46
- // 覆盖率写入 ctx.coverage.K,供报告与「整组未真跑」判定使用。
47
- function collectSourceFiles(ctx) {
48
- const { repoPath, pkg } = ctx
49
- const main = String(pkg.main ?? '').replace(/^\.\//, '')
50
- const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
51
- for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
52
- if (!entry.isFile()) continue
53
- if (/\.(mjs|js|cjs|ts|mts|cts)$/.test(entry.name)) srcFiles.push(path.join(repoPath, entry.name))
54
- }
55
- let files = [...new Set(srcFiles)]
56
- let mode = 'src'
57
- let fallbackDir = null
58
- if (!files.length) {
59
- // 无 src 时按「构建产物目录」兜底:main 所在目录 + lib/ + dist/。
60
- // 不再要求 main 必须以 'lib/' 开头 —— 实测第三方插件里 main 落在 dist/ 或包根是常见形态,
61
- // 只认 lib/ 会让这些包 K 组九项全 skip(本仓 THIRD-PARTY-RK-SCAN.md 里 3/20 即此情形)。
62
- const mainDir = path.posix.dirname(main)
63
- const dirs = [...new Set([mainDir !== '.' && mainDir !== '' ? mainDir : null, 'lib', 'dist'].filter(Boolean))]
64
- for (const d of dirs) {
65
- const hit = findFiles(repoPath, d, /\.(mjs|js|cjs)$/)
66
- if (hit.length) { files = [...new Set(hit)]; mode = 'lib-fallback'; fallbackDir = d; break }
67
- }
68
- }
69
- if (!files.length) mode = 'none'
70
- ctx.coverage = { ...(ctx.coverage ?? {}), K: { filesInspected: files.length, mode, fallbackDir } }
71
- return files
72
- }
73
-
74
- export function addChecks(doctor, ctx) {
75
- const srcFiles = collectSourceFiles(ctx)
76
-
77
- doctor.add(GROUP, 'K1 服务访问与 inject 声明一致', () => {
78
- if (!srcFiles.length) return skip('无源文件可扫描')
79
- const all = readAll(srcFiles)
80
- // 全仓 inject 并集(多文件插件架构:inject 常集中声明在入口文件)
81
- const injectList = new Set()
82
- for (const { text } of all) {
83
- const injectDecl = text.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
84
- for (const s of injectDecl?.[1]?.match(/['"][^'"]*['"]/g) ?? []) injectList.add(s.slice(1, -1))
85
- }
86
- const candidates = new Map()
87
- for (const { f, text } of all) {
88
- // 局部类型参数(如 createState(ctx: { credentials: ... }),非 cordis Context)——跳过该文件
89
- if (/ctx\s*:\s*\{/.test(text)) continue
90
- for (const m of text.matchAll(/ctx\.([A-Za-z_$][\w$]*)/g)) {
91
- const name = m[1]
92
- if (CTX_INTRINSIC.has(name) || injectList.has(name)) continue
93
- if (!candidates.has(name)) candidates.set(name, [])
94
- candidates.get(name).push(f)
95
- }
96
- }
97
- if (!candidates.size) return pass('未发现未声明的 ctx.<服务> 访问')
98
- const lines = [...candidates.entries()].slice(0, 10)
99
- .map(([n, fs]) => `ctx.${n}(${fs.length} 处,如 ${short(fs[0])})`)
100
- return warn(
101
- `以下服务被直接访问但未在 inject 声明(硬依赖必须 inject,否则运行时抛 "cannot get property ... without inject";可选依赖应改用 ctx.get()):\n${lines.join('\n')}`,
102
- )
103
- })
104
-
105
- doctor.add(GROUP, 'K2 活数据序列化红线', () => {
106
- if (!srcFiles.length) return skip('无源文件可扫描')
107
- const hits = []
108
- for (const { f, text } of readAll(srcFiles)) {
109
- for (const m of text.matchAll(/JSON\.stringify\(\s*ctx\b|structuredClone\(\s*ctx\b|\{\s*\.\.\.ctx\b/g)) {
110
- hits.push(`${short(f)}: ${m[0]}`)
111
- }
112
- }
113
- if (hits.length) return fail(`检测到对 ctx 活数据的序列化/展开(Context 是 Proxy,序列化会丢 def/use-site 追踪或触发代理陷阱):\n${hits.slice(0, 5).join('\n')}`)
114
- return pass('未发现 ctx 序列化/展开')
115
- })
116
-
117
- doctor.add(GROUP, 'K3 定时器/全局监听生命周期', () => {
118
- if (!srcFiles.length) return skip('无源文件可扫描')
119
- const hits = []
120
- for (const { f, text } of readAll(srcFiles)) {
121
- const lines = text.split('\n')
122
- for (let i = 0; i < lines.length; i++) {
123
- const line = lines[i]
124
- if (!/(setInterval|setTimeout|setImmediate|process\.on|addEventListener\()/.test(line)) continue
125
- // 变量级清理检测:同文件存在 clearTimeout/clearInterval/unref/removeEventListener 对应清理 → 自清理,跳过
126
- // (变量声明可能在上一行,如三元表达式换行的 `const timer = cond\n ? setInterval(...)`)
127
- let varMatch = line.match(/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/)
128
- if (!varMatch && i > 0) varMatch = lines[i - 1].match(/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/)
129
- const listenerCleaned = line.includes('addEventListener') && /removeEventListener\(/.test(text)
130
- if (varMatch) {
131
- const name = varMatch[1]
132
- const timerCleaned = new RegExp(`clear(?:Timeout|Interval)\\(\\s*${name}\\b|${name}\\??\\.\\s*unref\\s*\\(`).test(text)
133
- if (timerCleaned || listenerCleaned) continue
134
- } else if (listenerCleaned) {
135
- continue
136
- }
137
- const before = lines.slice(Math.max(0, i - 3), i).join('\n')
138
- if (!/ctx\.effect|ctx\.on\b/.test(before) && !/return\s*\(\)\s*=>/.test(before + line)) {
139
- hits.push(`${short(f)}:${i + 1} ${line.trim()}`)
140
- }
141
- }
142
- }
143
- if (hits.length) return warn(`定时器/全局监听疑似未包装进 ctx.effect("Cordis API 之外的资源必须包在 ctx.effect 中"——教程 02;卸载后不回收,HMR 泄漏):\n${hits.slice(0, 5).join('\n')}`)
144
- return pass('未发现裸定时器/全局监听')
145
- })
146
-
147
- doctor.add(GROUP, 'K4 Schema 含函数', () => {
148
- if (!srcFiles.length) return skip('无源文件可扫描')
149
- const hits = []
150
- for (const { f, text } of readAll(srcFiles)) {
151
- if (/Schema\./.test(text) && /=>/.test(text)) hits.push(short(f))
152
- }
153
- if (hits.length) return warn(`以下文件同时出现 Schema 与箭头函数(函数值进不了 schema:不可校验、不可持久化):\n${hits.slice(0, 5).join('\n')}`)
154
- return pass('未发现 Schema 定义旁箭头函数')
155
- })
156
-
157
- doctor.add(GROUP, 'K5 apply 返回形状(v4 Effect 契约)', () => {
158
- if (!srcFiles.length) return skip('无源文件可扫描')
159
- const notes = []
160
- for (const { f, text } of readAll(srcFiles)) {
161
- if (/export\s+async\s+function\s+apply|apply\s*:\s*async\s*\(/.test(text)) {
162
- notes.push(`${short(f)}: async apply(v4 允许,但必须返回 Promise<disposer>;settle 前 fiber 停留 LOADING、其服务对依赖方不可见)`)
163
- }
164
- }
165
- if (notes.length) return warn(notes.join('\n') + '\nv4 合法返回值:函数 disposer / null / undefined / Promise<disposer> / (async) iterable;其余形状抛 TypeError("Invalid effect")')
166
- return pass('apply 为同步声明')
167
- })
168
-
169
- doctor.add(GROUP, 'K6 inject 服务名属于已知 seam', () => {
170
- if (!srcFiles.length) return skip('无源文件可扫描')
171
- const unknown = new Set()
172
- const provided = new Set()
173
- for (const { text } of readAll(srcFiles)) {
174
- const injectDecl = text.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
175
- for (const s of injectDecl?.[1]?.match(/['"][^'"]*['"]/g) ?? []) {
176
- const name = s.slice(1, -1)
177
- if (!KNOWN_SEAMS.has(name) && !name.includes('.')) unknown.add(name)
178
- }
179
- // 插件自 provide 的服务不算"未知"(自建 capability seam)
180
- for (const m of text.matchAll(/(?:ctx\.)?provide\s*\(\s*['"]([^'"]+)['"]/g)) provided.add(m[1])
181
- }
182
- for (const p of provided) unknown.delete(p)
183
- if (unknown.size) return warn(`inject 引用未知服务名(请确认由宿主/其它插件提供,否则 fiber 永久 PENDING、apply 永不执行): ${[...unknown].join(', ')}`)
184
- return pass('inject 服务名均在已知 seam 内或为自 provide 能力')
185
- })
186
-
187
- doctor.add(GROUP, 'K7 v3 遗留 API(3.x→4.x 已删除)', () => {
188
- if (!srcFiles.length) return skip('无源文件可扫描')
189
- const hard = []
190
- const soft = []
191
- for (const { f, text } of readAll(srcFiles)) {
192
- for (const m of text.matchAll(/ctx\.(using|scope|runtime|lifecycle|collect|accept|decline|alias|off|fork)\b/g)) {
193
- hard.push(`${short(f)}: ctx.${m[1]}(v3 API,v4 已删除)`)
194
- }
195
- if (/ctx\.config\b/.test(text)) hard.push(`${short(f)}: ctx.config(v4 改为 ctx.fiber.config)`)
196
- if (/ctx\.(start|stop)\(/.test(text)) hard.push(`${short(f)}: ctx.start()/stop()(v4 改为 fiber.await()/dispose()/update())`)
197
- if (/inject\s*:\s*\{[^}]*\b(required|optional)\b|\binject\s*\.\s*(required|optional)/.test(text)) hard.push(`${short(f)}: inject {required,optional} 形状(v3;v4 为 string[] 或 {name: interceptConfig},可选依赖用 ctx.get)`)
198
- if (/\b(reusable|reactive)\s*:/.test(text)) soft.push(`${short(f)}: reusable/reactive 元数据(v4 已删除 fork 机制,该字段被忽略)`)
199
- if (/\busing\s*:/.test(text)) soft.push(`${short(f)}: using 元数据(v3 的 inject 别名)`)
200
- if (/static\s+immediate|protected\s+(start|stop|fork)\s*\(/.test(text)) soft.push(`${short(f)}: v3 Service 写法(static immediate/protected start/stop)`)
201
- if (/return\s*\{\s*dispose\s*[:(]/.test(text)) soft.push(`${short(f)}: 返回 {dispose} 对象(v3 DisposableLike,v4 抛 Invalid effect,须返回函数 disposer)`)
202
- }
203
- if (hard.length) return fail([...new Set(hard)].slice(0, 8).join('\n'))
204
- if (soft.length) return warn([...new Set(soft)].slice(0, 8).join('\n'))
205
- return pass('未发现 v3 遗留 API')
206
- })
207
-
208
- doctor.add(GROUP, 'K8 Config 必须是 Standard Schema 校验器', () => {
209
- if (!srcFiles.length) return skip('无源文件可扫描')
210
- const hits = []
211
- for (const { f, text } of readAll(srcFiles)) {
212
- const m = text.match(/export\s+const\s+Config\s*=\s*([^\n]*)/)
213
- if (m && !/Schema\./.test(m[1]) && !/interface|type\s/.test(text.match(/export\s+(?:interface|type)\s+Config/)?.[0] ?? '')) {
214
- if (m[1].trim().startsWith('{')) hits.push(`${short(f)}: export const Config = {普通对象}("将普通对象导出为 Config 无法工作"——教程 05;应导出 schemastery Schema)`)
215
- }
216
- }
217
- if (hits.length) return fail(hits.join('\n'))
218
- return pass('Config 声明为 Schema 校验器或纯类型')
219
- })
220
-
221
- doctor.add(GROUP, 'K9 插件 name 特例', () => {
222
- if (!srcFiles.length) return skip('无源文件可扫描')
223
- const hits = []
224
- for (const { f, text } of readAll(srcFiles)) {
225
- if (/name\s*:\s*['"]apply['"]/.test(text)) hits.push(short(f))
226
- }
227
- if (hits.length) return warn(`对象插件 name === 'apply' 会被框架重置为 undefined(registry.ts 特例),诊断名丢失:\n${hits.join('\n')}`)
228
- return pass('无 name="apply" 特例')
229
- })
230
- }
1
+ // 静态·cordis 契约扫描(K1–K9,启发式)
2
+ // 依据:cordiverse/cordis v4 源码契约 + DSH 官方 cordis 文档(2026-09-07 调研):
3
+ // - apply 可同步,亦可 async 返回 Promise<disposer>;返回非法形状抛 TypeError('Invalid effect')
4
+ // - inject 硬依赖 = (keyof M)[] | { name?: interceptConfig };可选依赖走 ctx.get(name)
5
+ // - v4 删除 v3 的 fork/reusable/using/scope/runtime/lifecycle/config/collect/accept/decline/alias/off
6
+ // - ctx 活数据不可 JSON.stringify/structuredClone/展开
7
+ import path from 'node:path'
8
+ import { readFileSync, readdirSync } from 'node:fs'
9
+ import { pass, fail, warn, skip, findFiles } from './util.mjs'
10
+
11
+ export const GROUP = '静态·cordis 契约扫描'
12
+
13
+ // v4 Context 固有成员(访问无需 inject)。v3 已删除成员(scope/config/fork/...)故意不列入,
14
+ // 由 K7 精确追责。
15
+ const CTX_INTRINSIC = new Set([
16
+ 'get', 'on', 'once', 'effect', 'set', 'root', 'plugin', 'emit',
17
+ 'parallel', 'waterfall', 'bail', 'serial', 'logger', 'name', 'deps',
18
+ 'isolate', 'intercept', 'extend', 'accessor', 'mixin', 'provide',
19
+ 'reflect', 'registry', 'inject', 'fiber', 'filter', 'select',
20
+ ])
21
+ const KNOWN_SEAMS = new Set([
22
+ 'tools', 'llm', 'shell', 'jobs', 'commands', 'web', 'logger', 'config', 'storage',
23
+ 'session', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsScope',
24
+ 'approval', 'credentials', 'subagents', 'settings', 'systemPrompt',
25
+ ])
26
+
27
+ // 剥注释后再扫描(JSDoc 里的 ctx.xxx 引用是假阳性大户);(^|[^:]) 守卫避免伤及字符串内 ://
28
+ function stripComments(text) {
29
+ return text
30
+ .replace(/\/\*[\s\S]*?\*\//g, '')
31
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
32
+ }
33
+
34
+ function readAll(files) {
35
+ return files.map((f) => { try { return { f, text: stripComments(readFileSync(f, 'utf8')) } } catch { return { f, text: '' } } })
36
+ }
37
+
38
+ const short = (f) => f.split(/[\\/]/).pop()
39
+
40
+ // 源文件发现策略(R-fix 0A 修正):
41
+ // ① src/** + 根目录一层 JS/TS
42
+ // ② 无 src 时,若 main 指向 lib/ → 把 lib/** 当源码(**去掉旧的 `!pkg.scripts?.build` 门槛**:
43
+ // 已发布 tarball / 已装包目录里没有 src/,但 package.json 仍保留 build 脚本,
44
+ // 旧条件因此永不成立 → K1–K9 九项全 skip → 汇总仍 exit 0(假绿))
45
+ // ③ main 未做 stripDot 时 `./index.mjs` 不匹配 'lib/',7 个仓的 lib/ 从不被扫(同样修正)
46
+ // 覆盖率写入 ctx.coverage.K,供报告与「整组未真跑」判定使用。
47
+ function collectSourceFiles(ctx) {
48
+ const { repoPath, pkg } = ctx
49
+ const main = String(pkg.main ?? '').replace(/^\.\//, '')
50
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
51
+ for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
52
+ if (!entry.isFile()) continue
53
+ if (/\.(mjs|js|cjs|ts|mts|cts)$/.test(entry.name)) srcFiles.push(path.join(repoPath, entry.name))
54
+ }
55
+ let files = [...new Set(srcFiles)]
56
+ let mode = 'src'
57
+ let fallbackDir = null
58
+ if (!files.length) {
59
+ // 无 src 时按「构建产物目录」兜底:main 所在目录 + lib/ + dist/。
60
+ // 不再要求 main 必须以 'lib/' 开头 —— 实测第三方插件里 main 落在 dist/ 或包根是常见形态,
61
+ // 只认 lib/ 会让这些包 K 组九项全 skip(本仓 THIRD-PARTY-RK-SCAN.md 里 3/20 即此情形)。
62
+ const mainDir = path.posix.dirname(main)
63
+ const dirs = [...new Set([mainDir !== '.' && mainDir !== '' ? mainDir : null, 'lib', 'dist'].filter(Boolean))]
64
+ for (const d of dirs) {
65
+ const hit = findFiles(repoPath, d, /\.(mjs|js|cjs)$/)
66
+ if (hit.length) { files = [...new Set(hit)]; mode = 'lib-fallback'; fallbackDir = d; break }
67
+ }
68
+ }
69
+ if (!files.length) mode = 'none'
70
+ ctx.coverage = { ...(ctx.coverage ?? {}), K: { filesInspected: files.length, mode, fallbackDir } }
71
+ return files
72
+ }
73
+
74
+ export function addChecks(doctor, ctx) {
75
+ const srcFiles = collectSourceFiles(ctx)
76
+
77
+ doctor.add(GROUP, 'K1 服务访问与 inject 声明一致', () => {
78
+ if (!srcFiles.length) return skip('无源文件可扫描')
79
+ const all = readAll(srcFiles)
80
+ // 全仓 inject 并集(多文件插件架构:inject 常集中声明在入口文件)
81
+ const injectList = new Set()
82
+ for (const { text } of all) {
83
+ const injectDecl = text.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
84
+ for (const s of injectDecl?.[1]?.match(/['"][^'"]*['"]/g) ?? []) injectList.add(s.slice(1, -1))
85
+ }
86
+ const candidates = new Map()
87
+ for (const { f, text } of all) {
88
+ // 局部类型参数(如 createState(ctx: { credentials: ... }),非 cordis Context)——跳过该文件
89
+ if (/ctx\s*:\s*\{/.test(text)) continue
90
+ for (const m of text.matchAll(/ctx\.([A-Za-z_$][\w$]*)/g)) {
91
+ const name = m[1]
92
+ if (CTX_INTRINSIC.has(name) || injectList.has(name)) continue
93
+ if (!candidates.has(name)) candidates.set(name, [])
94
+ candidates.get(name).push(f)
95
+ }
96
+ }
97
+ if (!candidates.size) return pass('未发现未声明的 ctx.<服务> 访问')
98
+ const lines = [...candidates.entries()].slice(0, 10)
99
+ .map(([n, fs]) => `ctx.${n}(${fs.length} 处,如 ${short(fs[0])})`)
100
+ return warn(
101
+ `以下服务被直接访问但未在 inject 声明(硬依赖必须 inject,否则运行时抛 "cannot get property ... without inject";可选依赖应改用 ctx.get()):\n${lines.join('\n')}`,
102
+ )
103
+ })
104
+
105
+ doctor.add(GROUP, 'K2 活数据序列化红线', () => {
106
+ if (!srcFiles.length) return skip('无源文件可扫描')
107
+ const hits = []
108
+ for (const { f, text } of readAll(srcFiles)) {
109
+ for (const m of text.matchAll(/JSON\.stringify\(\s*ctx\b|structuredClone\(\s*ctx\b|\{\s*\.\.\.ctx\b/g)) {
110
+ hits.push(`${short(f)}: ${m[0]}`)
111
+ }
112
+ }
113
+ if (hits.length) return fail(`检测到对 ctx 活数据的序列化/展开(Context 是 Proxy,序列化会丢 def/use-site 追踪或触发代理陷阱):\n${hits.slice(0, 5).join('\n')}`)
114
+ return pass('未发现 ctx 序列化/展开')
115
+ })
116
+
117
+ doctor.add(GROUP, 'K3 定时器/全局监听生命周期', () => {
118
+ if (!srcFiles.length) return skip('无源文件可扫描')
119
+ const hits = []
120
+ for (const { f, text } of readAll(srcFiles)) {
121
+ const lines = text.split('\n')
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const line = lines[i]
124
+ if (!/(setInterval|setTimeout|setImmediate|process\.on|addEventListener\()/.test(line)) continue
125
+ // 变量级清理检测:同文件存在 clearTimeout/clearInterval/unref/removeEventListener 对应清理 → 自清理,跳过
126
+ // (变量声明可能在上一行,如三元表达式换行的 `const timer = cond\n ? setInterval(...)`)
127
+ let varMatch = line.match(/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/)
128
+ if (!varMatch && i > 0) varMatch = lines[i - 1].match(/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/)
129
+ const listenerCleaned = line.includes('addEventListener') && /removeEventListener\(/.test(text)
130
+ if (varMatch) {
131
+ const name = varMatch[1]
132
+ const timerCleaned = new RegExp(`clear(?:Timeout|Interval)\\(\\s*${name}\\b|${name}\\??\\.\\s*unref\\s*\\(`).test(text)
133
+ if (timerCleaned || listenerCleaned) continue
134
+ } else if (listenerCleaned) {
135
+ continue
136
+ }
137
+ const before = lines.slice(Math.max(0, i - 3), i).join('\n')
138
+ if (!/ctx\.effect|ctx\.on\b/.test(before) && !/return\s*\(\)\s*=>/.test(before + line)) {
139
+ hits.push(`${short(f)}:${i + 1} ${line.trim()}`)
140
+ }
141
+ }
142
+ }
143
+ if (hits.length) return warn(`定时器/全局监听疑似未包装进 ctx.effect("Cordis API 之外的资源必须包在 ctx.effect 中"——教程 02;卸载后不回收,HMR 泄漏):\n${hits.slice(0, 5).join('\n')}`)
144
+ return pass('未发现裸定时器/全局监听')
145
+ })
146
+
147
+ doctor.add(GROUP, 'K4 Schema 含函数', () => {
148
+ if (!srcFiles.length) return skip('无源文件可扫描')
149
+ const hits = []
150
+ for (const { f, text } of readAll(srcFiles)) {
151
+ if (/Schema\./.test(text) && /=>/.test(text)) hits.push(short(f))
152
+ }
153
+ if (hits.length) return warn(`以下文件同时出现 Schema 与箭头函数(函数值进不了 schema:不可校验、不可持久化):\n${hits.slice(0, 5).join('\n')}`)
154
+ return pass('未发现 Schema 定义旁箭头函数')
155
+ })
156
+
157
+ doctor.add(GROUP, 'K5 apply 返回形状(v4 Effect 契约)', () => {
158
+ if (!srcFiles.length) return skip('无源文件可扫描')
159
+ const notes = []
160
+ for (const { f, text } of readAll(srcFiles)) {
161
+ if (/export\s+async\s+function\s+apply|apply\s*:\s*async\s*\(/.test(text)) {
162
+ notes.push(`${short(f)}: async apply(v4 允许,但必须返回 Promise<disposer>;settle 前 fiber 停留 LOADING、其服务对依赖方不可见)`)
163
+ }
164
+ }
165
+ if (notes.length) return warn(notes.join('\n') + '\nv4 合法返回值:函数 disposer / null / undefined / Promise<disposer> / (async) iterable;其余形状抛 TypeError("Invalid effect")')
166
+ return pass('apply 为同步声明')
167
+ })
168
+
169
+ doctor.add(GROUP, 'K6 inject 服务名属于已知 seam', () => {
170
+ if (!srcFiles.length) return skip('无源文件可扫描')
171
+ const unknown = new Set()
172
+ const provided = new Set()
173
+ for (const { text } of readAll(srcFiles)) {
174
+ const injectDecl = text.match(/(?:export\s+)?(?:const\s+)?inject\s*[:=]\s*\[([^\]]*)\]/)
175
+ for (const s of injectDecl?.[1]?.match(/['"][^'"]*['"]/g) ?? []) {
176
+ const name = s.slice(1, -1)
177
+ if (!KNOWN_SEAMS.has(name) && !name.includes('.')) unknown.add(name)
178
+ }
179
+ // 插件自 provide 的服务不算"未知"(自建 capability seam)
180
+ for (const m of text.matchAll(/(?:ctx\.)?provide\s*\(\s*['"]([^'"]+)['"]/g)) provided.add(m[1])
181
+ }
182
+ for (const p of provided) unknown.delete(p)
183
+ if (unknown.size) return warn(`inject 引用未知服务名(请确认由宿主/其它插件提供,否则 fiber 永久 PENDING、apply 永不执行): ${[...unknown].join(', ')}`)
184
+ return pass('inject 服务名均在已知 seam 内或为自 provide 能力')
185
+ })
186
+
187
+ doctor.add(GROUP, 'K7 v3 遗留 API(3.x→4.x 已删除)', () => {
188
+ if (!srcFiles.length) return skip('无源文件可扫描')
189
+ const hard = []
190
+ const soft = []
191
+ for (const { f, text } of readAll(srcFiles)) {
192
+ for (const m of text.matchAll(/ctx\.(using|scope|runtime|lifecycle|collect|accept|decline|alias|off|fork)\b/g)) {
193
+ hard.push(`${short(f)}: ctx.${m[1]}(v3 API,v4 已删除)`)
194
+ }
195
+ if (/ctx\.config\b/.test(text)) hard.push(`${short(f)}: ctx.config(v4 改为 ctx.fiber.config)`)
196
+ if (/ctx\.(start|stop)\(/.test(text)) hard.push(`${short(f)}: ctx.start()/stop()(v4 改为 fiber.await()/dispose()/update())`)
197
+ if (/inject\s*:\s*\{[^}]*\b(required|optional)\b|\binject\s*\.\s*(required|optional)/.test(text)) hard.push(`${short(f)}: inject {required,optional} 形状(v3;v4 为 string[] 或 {name: interceptConfig},可选依赖用 ctx.get)`)
198
+ if (/\b(reusable|reactive)\s*:/.test(text)) soft.push(`${short(f)}: reusable/reactive 元数据(v4 已删除 fork 机制,该字段被忽略)`)
199
+ if (/\busing\s*:/.test(text)) soft.push(`${short(f)}: using 元数据(v3 的 inject 别名)`)
200
+ if (/static\s+immediate|protected\s+(start|stop|fork)\s*\(/.test(text)) soft.push(`${short(f)}: v3 Service 写法(static immediate/protected start/stop)`)
201
+ if (/return\s*\{\s*dispose\s*[:(]/.test(text)) soft.push(`${short(f)}: 返回 {dispose} 对象(v3 DisposableLike,v4 抛 Invalid effect,须返回函数 disposer)`)
202
+ }
203
+ if (hard.length) return fail([...new Set(hard)].slice(0, 8).join('\n'))
204
+ if (soft.length) return warn([...new Set(soft)].slice(0, 8).join('\n'))
205
+ return pass('未发现 v3 遗留 API')
206
+ })
207
+
208
+ doctor.add(GROUP, 'K8 Config 必须是 Standard Schema 校验器', () => {
209
+ if (!srcFiles.length) return skip('无源文件可扫描')
210
+ const hits = []
211
+ for (const { f, text } of readAll(srcFiles)) {
212
+ const m = text.match(/export\s+const\s+Config\s*=\s*([^\n]*)/)
213
+ if (m && !/Schema\./.test(m[1]) && !/interface|type\s/.test(text.match(/export\s+(?:interface|type)\s+Config/)?.[0] ?? '')) {
214
+ if (m[1].trim().startsWith('{')) hits.push(`${short(f)}: export const Config = {普通对象}("将普通对象导出为 Config 无法工作"——教程 05;应导出 schemastery Schema)`)
215
+ }
216
+ }
217
+ if (hits.length) return fail(hits.join('\n'))
218
+ return pass('Config 声明为 Schema 校验器或纯类型')
219
+ })
220
+
221
+ doctor.add(GROUP, 'K9 插件 name 特例', () => {
222
+ if (!srcFiles.length) return skip('无源文件可扫描')
223
+ const hits = []
224
+ for (const { f, text } of readAll(srcFiles)) {
225
+ if (/name\s*:\s*['"]apply['"]/.test(text)) hits.push(short(f))
226
+ }
227
+ if (hits.length) return warn(`对象插件 name === 'apply' 会被框架重置为 undefined(registry.ts 特例),诊断名丢失:\n${hits.join('\n')}`)
228
+ return pass('无 name="apply" 特例')
229
+ })
230
+ }