@noob-stupid/dsh-plugin-console 0.3.66 → 0.4.0
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +127 -32
- package/lib/index.js +60 -9687
- package/lib/server/domain/ai-run.js +479 -0
- package/lib/server/domain/ai.js +246 -0
- package/lib/server/domain/compat.js +474 -0
- package/lib/server/domain/components.js +108 -0
- package/lib/server/domain/dep-source.js +122 -0
- package/lib/server/domain/format-contract.js +265 -0
- package/lib/server/domain/format-scan.js +431 -0
- package/lib/server/domain/framework.js +393 -0
- package/lib/server/domain/install-job.js +561 -0
- package/lib/server/domain/install.js +599 -0
- package/lib/server/domain/jobs.js +28 -0
- package/lib/server/domain/market.js +409 -0
- package/lib/server/domain/patch.js +203 -0
- package/lib/server/domain/presets.js +93 -0
- package/lib/server/domain/quarantine.js +224 -0
- package/lib/server/domain/release-source.js +504 -0
- package/lib/server/domain/repoland.js +119 -0
- package/lib/server/domain/revoke.js +184 -0
- package/lib/server/domain/runtime.js +118 -0
- package/lib/server/domain/selfupdate.js +319 -0
- package/lib/server/domain/skills.js +234 -0
- package/lib/server/domain/sources.js +297 -0
- package/lib/server/domain/suite.js +220 -0
- package/lib/server/infra/exec.js +98 -0
- package/lib/server/infra/fsx.js +163 -0
- package/lib/server/infra/fw-integrity-check.js +37 -0
- package/lib/server/infra/http.js +373 -0
- package/lib/server/infra/httpd.js +51 -0
- package/lib/server/infra/mask.js +19 -0
- package/lib/server/infra/paths.js +177 -0
- package/lib/server/infra/semver.js +168 -0
- package/lib/server/routes/ai.js +172 -0
- package/lib/server/routes/components.js +254 -0
- package/lib/server/routes/framework-preflight.js +154 -0
- package/lib/server/routes/framework-upgrade.js +679 -0
- package/lib/server/routes/framework.js +544 -0
- package/lib/server/routes/github-login.js +198 -0
- package/lib/server/routes/index.js +128 -0
- package/lib/server/routes/install.js +116 -0
- package/lib/server/routes/market.js +415 -0
- package/lib/server/routes/plugins.js +562 -0
- package/lib/server/routes/skills.js +107 -0
- package/lib/server/routes/sources.js +437 -0
- package/lib/server/routes/state.js +125 -0
- package/lib/server/state.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// L1 · domain —— format-scan.js:会话格式契约预检(Step 2 · 本地生产方扫描 + 补丁)
|
|
2
|
+
//
|
|
3
|
+
// 扫描面(这次事故的三类生产方,正是它们让「一发消息就炸」):
|
|
4
|
+
// ① ~/.dsh/agent-presets/** —— 用户自己的 agent 预设(10 个文件 11 处)
|
|
5
|
+
// ② profile 内已装插件包里的运行时代码 —— agent.inject / createUserMessage 的构造点(3 处)
|
|
6
|
+
// ③ 插件里引用了目标版本已移除的框架 API(报告项,不自动改)
|
|
7
|
+
//
|
|
8
|
+
// 补丁原则(宁可少改,不可改错):
|
|
9
|
+
// · 只改 `kind: 'plugin'` 这个**字面量**,且要求同一对象字面量里确有 `plugin:` 字段(V3 包装形状);
|
|
10
|
+
// · `plugin:` 的值是字符串字面量 → 直接写 `kind: 'plugin:<值>'`;是标识符/成员表达式 → 写成
|
|
11
|
+
// `` kind: `plugin:${表达式}` ``(与 2026-09-24 手工修复脚本产出的形状一致);
|
|
12
|
+
// · 任何无法安全内联的写法一律**只报告不修改**(unresolved),交人工处理;
|
|
13
|
+
// · 保留 `plugin:` 字段本身:V4 的迁移器「保留其它自有字段」,少删一个键就少一份语法风险;
|
|
14
|
+
// · 写盘前备份 `<file>.bak-preflight-<时间戳>`,且只允许改「本次上报名单内」的文件。
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
18
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
19
|
+
import { parseFrameworkVersion } from '../infra/semver.js'
|
|
20
|
+
|
|
21
|
+
/** 走盘限制:预检是只读扫描,不能因为某个巨型包把服务拖死。 */
|
|
22
|
+
const MAX_FILES = 4000
|
|
23
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'coverage', '__tests__', 'test', 'tests'])
|
|
25
|
+
/** pnpm 中断安装留下的 `xxx_tmp_<pid>_<n>` 半成品目录:不是可加载插件,扫它只会制造噪音。 */
|
|
26
|
+
const SKIP_DIR_RE = /_tmp_\d+(?:_\d+)?$/u
|
|
27
|
+
const SKIP_FILE_RE = /\.(?:test|spec)\.[cm]?js$/u
|
|
28
|
+
const CODE_FILE_RE = /\.(?:mjs|cjs|js)$/u
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 已知的框架 API 破坏(报告项,永不自动改)。只收「有真机证据」的条目——
|
|
32
|
+
* 每条都要能被复现、能指向报错原文,避免把猜测当规则到处误报。
|
|
33
|
+
* ★ probe 字段:光看源码里有没有 `.volatile(` 是不够的 —— 2026-09-24 实测,插件只是**解析到了陈旧副本**
|
|
34
|
+
* (profile 作用域铺着 schemastery 3.18.1,而它声明 ^3.18.4),把依赖对齐后同一个文件立刻正常。
|
|
35
|
+
* 所以带 probe 的规则必须先「按该文件的位置真的解析一次依赖」,确认那份副本确实缺这个 API 才报,
|
|
36
|
+
* 否则修好依赖后规则会永远误报(自相矛盾的报告没人会信)。
|
|
37
|
+
*/
|
|
38
|
+
const KNOWN_BREAKAGES = [
|
|
39
|
+
{
|
|
40
|
+
id: 'schemastery-volatile',
|
|
41
|
+
// 真机 2026-09-24:@linxin666 的 skin-center / task-board / git-graph / pet / model-capabilities /
|
|
42
|
+
// ssh / liangshen 七个包导入失败,报 `z.boolean(...).default(...).volatile is not a function`。
|
|
43
|
+
pattern: /\.volatile\s*\(/gu,
|
|
44
|
+
since: '0.1.7',
|
|
45
|
+
fixable: false,
|
|
46
|
+
probe: 'schemastery-volatile',
|
|
47
|
+
note: '调用方声明 @deepseek-ai/schemastery ^3.18.4,但它实际解析到的副本缺少 .volatile()(3.18.4 起在 Schema.prototype 上提供)——把该副本对齐到声明版本即可,不必改插件源码',
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/** 探针缓存(同一个包目录只解析一次)。 */
|
|
52
|
+
const probeCache = new Map()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 探针:某个源码文件在**运行时**会解析到哪份 @deepseek-ai/schemastery,那份副本有没有 .volatile()。
|
|
56
|
+
* @returns { ok: boolean|null, version: string|null, dir: string|null, error: string|null }(ok=null 表示无法判定)
|
|
57
|
+
*/
|
|
58
|
+
function probeSchemasteryVolatile(file, cache = probeCache) {
|
|
59
|
+
const key = file
|
|
60
|
+
if (cache.has(key)) return cache.get(key)
|
|
61
|
+
let result = { ok: null, version: null, dir: null, error: null }
|
|
62
|
+
try {
|
|
63
|
+
const require = createRequire(join(file, '..', 'package.json'))
|
|
64
|
+
const pkgPath = require.resolve('@deepseek-ai/schemastery/package.json')
|
|
65
|
+
const dir = pkgPath.slice(0, pkgPath.length - 'package.json'.length - 1)
|
|
66
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
67
|
+
const main = typeof pkg.main === 'string' && pkg.main !== '' ? pkg.main : 'lib/index.cjs'
|
|
68
|
+
let text = ''
|
|
69
|
+
try { text = readFileSync(join(dir, main), 'utf8') } catch {}
|
|
70
|
+
if (text === '') {
|
|
71
|
+
// main 不是 CJS(或路径不同):兜底扫 lib 下的入口文件
|
|
72
|
+
for (const candidate of ['lib/index.mjs', 'lib/index.js', 'lib/index.cjs']) {
|
|
73
|
+
try { text = readFileSync(join(dir, candidate), 'utf8'); break } catch {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
result = { ok: /prototype\.volatile\s*=|\.volatile\s*=\s*function|volatile\s*\(\)\s*\{/u.test(text), version: pkg.version ?? null, dir, error: null }
|
|
77
|
+
} catch (error) {
|
|
78
|
+
result = { ok: null, version: null, dir: null, error: error instanceof Error ? error.message : String(error) }
|
|
79
|
+
}
|
|
80
|
+
cache.set(key, result)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 版本比较:target ≥ since 才报(只比 major.minor.patch,与框架内其它判断一致)。 */
|
|
85
|
+
function versionAtLeast(target, since) {
|
|
86
|
+
const t = parseFrameworkVersion(target)
|
|
87
|
+
const s = parseFrameworkVersion(since)
|
|
88
|
+
if (t === -1 || s === -1) return false
|
|
89
|
+
if (t.maj !== s.maj) return t.maj > s.maj
|
|
90
|
+
if (t.min !== s.min) return t.min > s.min
|
|
91
|
+
return t.pat >= s.pat
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 行号(1 基)——报告里要能直接跳过去。 */
|
|
95
|
+
function lineOf(text, index) {
|
|
96
|
+
let line = 1
|
|
97
|
+
for (let i = 0; i < index && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1
|
|
98
|
+
return line
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 把**注释**替换成等长空格(保留换行),字符串原样保留。
|
|
103
|
+
* ★ 为什么必须做:真实数据里 `dsh-better-sidebar` 的 JSDoc 写着
|
|
104
|
+
* "is stamped `kind: 'plugin'` so recognition is structural" —— 纯文本扫描会把注释当代码,
|
|
105
|
+
* 报出一条「可自动改」的假 blocker(改注释毫无意义,还会污染 diff)。
|
|
106
|
+
* ★ 为什么不连字符串一起抹:合法写法本身就是字符串字面量(`kind: 'plugin'`),抹了就再也认不出来。
|
|
107
|
+
* 掩码等长 ⇒ 偏移量与原文一一对应,行号/片段都还能用原文取。
|
|
108
|
+
*/
|
|
109
|
+
function maskComments(text) {
|
|
110
|
+
let out = ''
|
|
111
|
+
let i = 0
|
|
112
|
+
let state = 'code'
|
|
113
|
+
while (i < text.length) {
|
|
114
|
+
const ch = text[i]
|
|
115
|
+
const next = text[i + 1]
|
|
116
|
+
if (state === 'code') {
|
|
117
|
+
if (ch === '/' && next === '/') { state = 'line'; out += ' '; i += 2; continue }
|
|
118
|
+
if (ch === '/' && next === '*') { state = 'block'; out += ' '; i += 2; continue }
|
|
119
|
+
if (ch === "'" || ch === '"' || ch === '`') { state = ch === "'" ? 'single' : ch === '"' ? 'double' : 'template'; out += ch; i += 1; continue }
|
|
120
|
+
out += ch; i += 1; continue
|
|
121
|
+
}
|
|
122
|
+
if (state === 'line') { if (ch === '\n') { state = 'code'; out += ch } else out += ' '; i += 1; continue }
|
|
123
|
+
if (state === 'block') {
|
|
124
|
+
if (ch === '*' && next === '/') { state = 'code'; out += ' '; i += 2; continue }
|
|
125
|
+
out += ch === '\n' ? '\n' : ' '; i += 1; continue
|
|
126
|
+
}
|
|
127
|
+
const quote = state === 'single' ? "'" : state === 'double' ? '"' : '`'
|
|
128
|
+
if (ch === '\\') { out += text.slice(i, i + 2); i += 2; continue }
|
|
129
|
+
if (ch === quote) { state = 'code'; out += ch; i += 1; continue }
|
|
130
|
+
out += ch; i += 1
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 包住 index 的对象字面量边界(向前找 `{`、向后配平 `}`);找不到返回 null。 */
|
|
136
|
+
function objectBounds(text, index) {
|
|
137
|
+
const backLimit = Math.max(0, index - 2000)
|
|
138
|
+
let start = -1
|
|
139
|
+
for (let i = index; i >= backLimit; i -= 1) {
|
|
140
|
+
if (text[i] === '{') { start = i; break }
|
|
141
|
+
if (text[i] === '}') return null
|
|
142
|
+
}
|
|
143
|
+
if (start === -1) return null
|
|
144
|
+
let depth = 0
|
|
145
|
+
const fwdLimit = Math.min(text.length, index + 4000)
|
|
146
|
+
for (let i = start; i < fwdLimit; i += 1) {
|
|
147
|
+
const ch = text[i]
|
|
148
|
+
if (ch === '{') depth += 1
|
|
149
|
+
else if (ch === '}') {
|
|
150
|
+
depth -= 1
|
|
151
|
+
if (depth === 0) return { start, end: i + 1 }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 表达式能否安全内联进模板字符串(标识符 / 成员访问 / 模板字符串)。 */
|
|
158
|
+
function inlineExpression(expr) {
|
|
159
|
+
const trimmed = expr.trim()
|
|
160
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[\w$]+)*$/u.test(trimmed)) return { kind: 'identifier', value: trimmed }
|
|
161
|
+
if (/^`[\s\S]*`$/u.test(trimmed)) return { kind: 'template', value: trimmed.slice(1, -1) }
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 纯函数:在源码文本里规划「V3 插件来源包装 → V4 producer-owned kind」的重写。
|
|
167
|
+
* @returns { rewrites: [{start,end,before,after,line}], unresolved: [{line,reason,snippet}] }
|
|
168
|
+
*/
|
|
169
|
+
function planSourceKindRewrites(text, rules = {}) {
|
|
170
|
+
const prefix = typeof rules.pluginPrefix === 'string' && rules.pluginPrefix !== '' ? rules.pluginPrefix : 'plugin:'
|
|
171
|
+
const renames = rules.renames ?? {}
|
|
172
|
+
const rewrites = []
|
|
173
|
+
const unresolved = []
|
|
174
|
+
// 只在「去掉注释」的等长掩码上匹配:注释里的同名字样不是代码,绝不能被改写
|
|
175
|
+
const masked = maskComments(text)
|
|
176
|
+
const re = /kind\s*:\s*(['"])plugin\1/gu
|
|
177
|
+
let m
|
|
178
|
+
while ((m = re.exec(masked)) !== null) {
|
|
179
|
+
const bounds = objectBounds(masked, m.index)
|
|
180
|
+
if (bounds === null) {
|
|
181
|
+
unresolved.push({ line: lineOf(text, m.index), reason: '无法确定所属对象字面量(括号不配平或跨度过大)', snippet: m[0] })
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const window = masked.slice(bounds.start, bounds.end)
|
|
185
|
+
const pluginKey = /\bplugin\s*:\s*([^,}\n]+)/u.exec(window)
|
|
186
|
+
if (pluginKey === null) {
|
|
187
|
+
unresolved.push({ line: lineOf(text, m.index), reason: "kind: 'plugin' 所在对象里没有 plugin: 字段(不是 V3 包装形状,需人工确认)", snippet: m[0] })
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const exprRaw = pluginKey[1].trim()
|
|
191
|
+
// kind 字面量 `'plugin'` 的绝对区间:m[0] 结尾就是闭引号之后
|
|
192
|
+
const literalStart = m.index + m[0].length - ('plugin'.length + 2)
|
|
193
|
+
const literalEnd = m.index + m[0].length
|
|
194
|
+
const quote = m[1]
|
|
195
|
+
const stringLiteral = new RegExp(`^${quote === "'" ? "'" : '"'}([\\s\\S]*)${quote === "'" ? "'" : '"'}$`, 'u').exec(exprRaw)
|
|
196
|
+
let after = null
|
|
197
|
+
if (stringLiteral !== null) {
|
|
198
|
+
const name = stringLiteral[1]
|
|
199
|
+
const mapped = renames[name]
|
|
200
|
+
after = mapped === undefined ? `${quote}${prefix}${name}${quote}` : `${quote}${mapped}${quote}`
|
|
201
|
+
} else {
|
|
202
|
+
const inline = inlineExpression(exprRaw)
|
|
203
|
+
if (inline === null) {
|
|
204
|
+
unresolved.push({ line: lineOf(text, m.index), reason: `plugin: 的写法无法安全内联(${exprRaw.slice(0, 40)}…),需人工改成 \`${prefix}\${…}\``, snippet: m[0] })
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
after = '`' + prefix + '${' + inline.value + '}`'
|
|
208
|
+
}
|
|
209
|
+
rewrites.push({
|
|
210
|
+
start: literalStart,
|
|
211
|
+
end: literalEnd,
|
|
212
|
+
before: text.slice(literalStart, literalEnd),
|
|
213
|
+
after,
|
|
214
|
+
line: lineOf(text, m.index),
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
return { rewrites, unresolved }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 按区间(倒序)写回文本。 */
|
|
221
|
+
function applyRewrites(text, rewrites) {
|
|
222
|
+
let out = text
|
|
223
|
+
for (const rw of [...rewrites].sort((a, b) => b.start - a.start)) {
|
|
224
|
+
out = out.slice(0, rw.start) + rw.after + out.slice(rw.end)
|
|
225
|
+
}
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 扫一段源码文本(纯函数,no fs;探针通过参数注入以便测试)。 */
|
|
230
|
+
function scanProducerText({ file, text, rules = {}, targetVersion = null, knownBreakages = KNOWN_BREAKAGES, probe = null }) {
|
|
231
|
+
const findings = []
|
|
232
|
+
const { rewrites, unresolved } = planSourceKindRewrites(text, rules)
|
|
233
|
+
for (const rw of rewrites) {
|
|
234
|
+
findings.push({
|
|
235
|
+
file, line: rw.line, rule: 'legacy-source-kind', severity: 'blocker', fixable: true,
|
|
236
|
+
before: rw.before, after: rw.after,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
for (const item of unresolved) {
|
|
240
|
+
findings.push({
|
|
241
|
+
file, line: item.line, rule: 'legacy-source-kind-unresolved', severity: 'blocker', fixable: false,
|
|
242
|
+
reason: item.reason, snippet: item.snippet,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
for (const kb of knownBreakages) {
|
|
246
|
+
if (typeof kb.since === 'string' && targetVersion !== null && !versionAtLeast(targetVersion, kb.since)) continue
|
|
247
|
+
const matches = [...text.matchAll(new RegExp(kb.pattern.source, kb.pattern.flags.includes('g') ? kb.pattern.flags : kb.pattern.flags + 'g'))]
|
|
248
|
+
if (matches.length === 0) continue
|
|
249
|
+
// 带探针的规则:先确认运行时真的缺这个 API,否则不报(避免修好依赖后永远误报)
|
|
250
|
+
let extra = ''
|
|
251
|
+
let probed = null
|
|
252
|
+
if (kb.probe !== null && kb.probe !== undefined && typeof probe === 'function') {
|
|
253
|
+
probed = probe(file)
|
|
254
|
+
if (probed !== null && probed !== undefined) {
|
|
255
|
+
if (probed.ok === true) continue
|
|
256
|
+
extra = probed.ok === false
|
|
257
|
+
? `(实测解析到 @deepseek-ai/schemastery@${probed.version ?? '?'},该副本没有 .volatile())`
|
|
258
|
+
: `(无法判定解析到的副本:${probed.error ?? '未知'})`
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const m of matches) {
|
|
262
|
+
findings.push({
|
|
263
|
+
file, line: lineOf(text, m.index), rule: kb.id, severity: 'warn', fixable: kb.fixable === true,
|
|
264
|
+
note: `${kb.note}${extra}`, snippet: m[0].trim(), probe: probed,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return findings
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 递归收集一个根目录下的生产方源码文件(带文件数/体积上限)。 */
|
|
272
|
+
function walkProducerRoot({ root, kind, moduleName = null, out, counters }) {
|
|
273
|
+
if (typeof root !== 'string' || root === '' || !existsSync(root)) return
|
|
274
|
+
let entries
|
|
275
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { return }
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (counters.files >= MAX_FILES) return
|
|
278
|
+
const full = join(root, entry.name)
|
|
279
|
+
if (entry.isDirectory()) {
|
|
280
|
+
if (SKIP_DIRS.has(entry.name) || SKIP_DIR_RE.test(entry.name)) continue
|
|
281
|
+
walkProducerRoot({ root: full, kind, moduleName, out, counters })
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
if (!entry.isFile() || !CODE_FILE_RE.test(entry.name) || SKIP_FILE_RE.test(entry.name)) continue
|
|
285
|
+
try { if (statSync(full).size > MAX_FILE_BYTES) continue } catch { continue }
|
|
286
|
+
out.push({ file: full, kind, moduleName })
|
|
287
|
+
counters.files += 1
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 收集全部生产方文件(root 由路由层给出:预设目录 + 各插件包目录)。 */
|
|
292
|
+
function collectProducerTargets(roots) {
|
|
293
|
+
const out = []
|
|
294
|
+
const counters = { files: 0 }
|
|
295
|
+
for (const item of roots ?? []) walkProducerRoot({ ...item, out, counters })
|
|
296
|
+
// 去重(同一文件被多个包目录覆盖时只扫一次)
|
|
297
|
+
const seen = new Set()
|
|
298
|
+
return out.filter((t) => {
|
|
299
|
+
const key = resolve(t.file).toLowerCase()
|
|
300
|
+
if (seen.has(key)) return false
|
|
301
|
+
seen.add(key)
|
|
302
|
+
return true
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 框架自带包(与框架同源安装、位于 npx 缓存里)命中旧形状时**降级为提示且不可自动改**:
|
|
308
|
+
* 改它等于污染框架安装树(下次升级就被覆盖),正确处置是回滚框架 / 等官方修复 / 由适配门禁用它。
|
|
309
|
+
* 实网只读扫描抓到过真例子:@deepseek-ai/dsh-schedule 仍写 `kind: 'plugin'`(它正是被适配门自动禁用的包)。
|
|
310
|
+
*/
|
|
311
|
+
function downgradeForFramework(hits) {
|
|
312
|
+
return hits.map((f) => (f.fixable === true ? {
|
|
313
|
+
...f,
|
|
314
|
+
rule: `${f.rule}-framework`,
|
|
315
|
+
severity: 'warn',
|
|
316
|
+
fixable: false,
|
|
317
|
+
reason: '框架自带包(与框架同源安装):预检不会改框架文件,正确处置是回滚框架或等官方修复',
|
|
318
|
+
} : f))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 扫描全部生产方文件。
|
|
323
|
+
*/
|
|
324
|
+
function scanProducerFiles({ targets, rules = {}, targetVersion = null, readFile = (f) => readFileSync(f, 'utf8'), probe = probeSchemasteryVolatile }) {
|
|
325
|
+
const findings = []
|
|
326
|
+
const files = []
|
|
327
|
+
for (const target of targets ?? []) {
|
|
328
|
+
let text = null
|
|
329
|
+
try { text = readFile(target.file) } catch { continue }
|
|
330
|
+
const raw = scanProducerText({ file: target.file, text, rules, targetVersion, probe })
|
|
331
|
+
const hits = target.kind === 'framework' ? downgradeForFramework(raw) : raw
|
|
332
|
+
files.push({ file: target.file, kind: target.kind, moduleName: target.moduleName, findings: hits.length })
|
|
333
|
+
findings.push(...hits)
|
|
334
|
+
}
|
|
335
|
+
findings.sort((a, b) => (a.file === b.file ? a.line - b.line : (a.file < b.file ? -1 : 1)))
|
|
336
|
+
const blockers = findings.filter((f) => f.severity === 'blocker')
|
|
337
|
+
const warnings = findings.filter((f) => f.severity === 'warn')
|
|
338
|
+
const byRule = {}
|
|
339
|
+
for (const f of findings) byRule[f.rule] = (byRule[f.rule] ?? 0) + 1
|
|
340
|
+
return {
|
|
341
|
+
filesScanned: files.length,
|
|
342
|
+
files,
|
|
343
|
+
findings,
|
|
344
|
+
byRule,
|
|
345
|
+
blockers: blockers.length,
|
|
346
|
+
warnings: warnings.length,
|
|
347
|
+
ok: blockers.length === 0,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 路径落在允许名单内(写盘前的最后一道闸:绝不允许改名单外的文件)。 */
|
|
352
|
+
function isInsideAllowed(file, allowed) {
|
|
353
|
+
const target = resolve(file).toLowerCase()
|
|
354
|
+
return allowed.some((root) => {
|
|
355
|
+
const base = resolve(root).toLowerCase()
|
|
356
|
+
return target === base || target.startsWith(base.endsWith(sep) ? base : base + sep)
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 应用补丁:只改「本次扫描出的 blocker 且 fixable」的位置,逐文件备份后写回。
|
|
362
|
+
* @returns { changes, backups, skipped, unresolved }
|
|
363
|
+
*/
|
|
364
|
+
function applyFormatPatch({ targets, rules = {}, targetVersion = null, stamp = String(Date.now()), dryRun = false, readFile = (f) => readFileSync(f, 'utf8'), writeFile = (f, t) => writeFileSync(f, t, 'utf8'), copyFile = null } = {}) {
|
|
365
|
+
const allowedRoots = (targets ?? []).map((t) => t.file)
|
|
366
|
+
const changes = []
|
|
367
|
+
const backups = []
|
|
368
|
+
const skipped = []
|
|
369
|
+
const unresolved = []
|
|
370
|
+
for (const target of targets ?? []) {
|
|
371
|
+
let text = null
|
|
372
|
+
try { text = readFile(target.file) } catch { continue }
|
|
373
|
+
const rawHits = scanProducerText({ file: target.file, text, rules, targetVersion })
|
|
374
|
+
// 框架自带包:降级为提示 → 下面按 severity==='blocker' 过滤时自然被排除,绝不会被写盘
|
|
375
|
+
const hits = (target.kind === 'framework' ? downgradeForFramework(rawHits) : rawHits).filter((f) => f.severity === 'blocker')
|
|
376
|
+
if (hits.length === 0) continue
|
|
377
|
+
const fixable = hits.filter((f) => f.fixable)
|
|
378
|
+
for (const notFixable of hits.filter((f) => !f.fixable)) unresolved.push(notFixable)
|
|
379
|
+
if (fixable.length === 0) continue
|
|
380
|
+
const { rewrites } = planSourceKindRewrites(text, rules)
|
|
381
|
+
if (rewrites.length === 0) continue
|
|
382
|
+
if (!isInsideAllowed(target.file, allowedRoots)) { skipped.push({ file: target.file, reason: '不在本次上报名单内(拒绝写入)' }); continue }
|
|
383
|
+
let next = text
|
|
384
|
+
try { next = applyRewrites(text, rewrites) } catch (error) {
|
|
385
|
+
skipped.push({ file: target.file, reason: `重写失败:${error instanceof Error ? error.message : String(error)}` })
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (next === text) continue
|
|
389
|
+
if (dryRun) {
|
|
390
|
+
for (const rw of rewrites) changes.push({ file: target.file, line: rw.line, before: rw.before, after: rw.after, moduleName: target.moduleName ?? null })
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
const backup = `${target.file}.bak-preflight-${stamp}`
|
|
394
|
+
try {
|
|
395
|
+
if (typeof copyFile === 'function') copyFile(target.file, backup)
|
|
396
|
+
else writeFile(backup, text)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
skipped.push({ file: target.file, reason: `备份失败,已放弃改动:${error instanceof Error ? error.message : String(error)}` })
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
try { writeFile(target.file, next) } catch (error) {
|
|
402
|
+
skipped.push({ file: target.file, reason: `写入失败:${error instanceof Error ? error.message : String(error)}` })
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
backups.push(backup)
|
|
406
|
+
for (const rw of rewrites) changes.push({ file: target.file, line: rw.line, before: rw.before, after: rw.after, moduleName: target.moduleName ?? null })
|
|
407
|
+
}
|
|
408
|
+
return { changes, backups, skipped, unresolved }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export {
|
|
412
|
+
MAX_FILES,
|
|
413
|
+
MAX_FILE_BYTES,
|
|
414
|
+
SKIP_DIRS,
|
|
415
|
+
SKIP_DIR_RE,
|
|
416
|
+
KNOWN_BREAKAGES,
|
|
417
|
+
versionAtLeast,
|
|
418
|
+
lineOf,
|
|
419
|
+
maskComments,
|
|
420
|
+
objectBounds,
|
|
421
|
+
inlineExpression,
|
|
422
|
+
planSourceKindRewrites,
|
|
423
|
+
applyRewrites,
|
|
424
|
+
probeSchemasteryVolatile,
|
|
425
|
+
scanProducerText,
|
|
426
|
+
collectProducerTargets,
|
|
427
|
+
scanProducerFiles,
|
|
428
|
+
downgradeForFramework,
|
|
429
|
+
isInsideAllowed,
|
|
430
|
+
applyFormatPatch,
|
|
431
|
+
}
|