@noob-stupid/dsh-plugin-console 0.5.31 → 0.5.32
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 +32 -0
- package/README.zh.md +32 -0
- package/lib/client.js +15 -1
- package/lib/server/domain/auto-disable.js +226 -0
- package/lib/server/domain/auto-preflight-run.js +184 -0
- package/lib/server/domain/auto-preflight.js +345 -0
- package/lib/server/domain/compat-state.js +57 -8
- package/lib/server/domain/compat.js +6 -2
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/safe-boot.js +139 -0
- package/package.json +1 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// L1 · domain —— auto-preflight.js(环境指纹变化 → 自动预检的**判定与记录层**;0.5.32 加法 D1)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它(用户 2026-09-29 诉求,原话):
|
|
4
|
+
// 「走**别的**更新通道(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)改了框架后,我们**仍要自动守门**」。
|
|
5
|
+
// 现状(0.5.31):`domain/compat-state.js` 的 `compareFingerprint` 已经能**发现**任何来源的框架变更
|
|
6
|
+
// (`routes/compat.js` 每次状态查询现场算指纹并与基线比对),但发现之后**只上报一行文案**,
|
|
7
|
+
// 不会自动跑预检;而「启动失败隔离」(`domain/quarantine.js`)的输入是我们自己的升级脚本留下的
|
|
8
|
+
// 记录 —— 别人升级时**没有这份记录**,兜底因此不触发。本模块补上中间那一段:
|
|
9
|
+
//
|
|
10
|
+
// 指纹变化(changed=true)→ **自动跑一次只读预检** → 落成三样东西:
|
|
11
|
+
// ① 兼容清单(受影响行 + 判据) ② 隔离计划(建议禁用哪些行、为什么) ③ 本次变更摘要(版本 from→to / 新增消失的包 / 指纹 reasons)
|
|
12
|
+
//
|
|
13
|
+
// 四条硬约束(全部可离线断言,见 tests/test-auto-preflight.mjs):
|
|
14
|
+
// · **只读**:不下载、不安装、不改补丁、不碰 loader —— 本模块连 ctx/ports 都不认识(只吃注入的窄接口);
|
|
15
|
+
// · **同一指纹只自动跑一次**:以指纹为 key 持久化「已扫过」标记;失败**如实记录原因**且**允许重试**;
|
|
16
|
+
// · **绝不阻塞状态查询**:超时/异常一律降级成「未完成,可手动重跑」,状态查询该返回什么还返回什么;
|
|
17
|
+
// · 纯函数为主:判定(shouldAutoRun)、超时包裹(withTimeout)、增量(summarizeDelta)、
|
|
18
|
+
// 记录合并(upsertRecord)都不做 IO,IO 只出现在 store 读写与 runAutoPreflight 的调用缝上。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 无 ctx、无 HTTP、不抛(异常在 runAutoPreflight 内被收成 state='failed')。
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { dshHome } from '../infra/paths.js'
|
|
25
|
+
|
|
26
|
+
/** 记录文件(与我们其余的常驻状态同住 `dshHome()/plugin-console/`,不碰用户 profile)。 */
|
|
27
|
+
const autoPreflightFile = () => join(dshHome(), 'plugin-console', 'auto-preflight.json')
|
|
28
|
+
|
|
29
|
+
/** 保留最近的扫描记录条数(防止指纹频繁抖动把状态文件撑大)。 */
|
|
30
|
+
const MAX_RECORDS = 12
|
|
31
|
+
|
|
32
|
+
/** 自动预检的默认超时(毫秒)。宁可降级成「未完成,可手动重跑」,也不让状态查询等它。 */
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 4000
|
|
34
|
+
|
|
35
|
+
const STATES = ['pending', 'done', 'failed', 'timeout']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 指纹的**稳定 key**:只取参与变更判定的三个字段(`at` 每次都变,绝不能进 key)。
|
|
39
|
+
* 归一化:字符串去空白 + 小写(win32 路径大小写不敏感;两个指纹「实际相同」时不许各跑一次)。
|
|
40
|
+
*/
|
|
41
|
+
function fingerprintKey(fp) {
|
|
42
|
+
const norm = (value, lower = false) => {
|
|
43
|
+
if (value === null || value === undefined) return ''
|
|
44
|
+
const text = String(value).trim()
|
|
45
|
+
return lower ? text.toLowerCase() : text
|
|
46
|
+
}
|
|
47
|
+
// 空包数按 '?' 归一:null 与 undefined 是同一件事(数不出来),别当成"变了"
|
|
48
|
+
const entities = fp?.pnpmEntities === null || fp?.pnpmEntities === undefined ? '?' : String(fp.pnpmEntities)
|
|
49
|
+
// 路径:反斜杠归一成正斜杠(Windows 的 `C:\x` 与 `C:/x` 是同一棵树)+ 小写(win32 不区分大小写)
|
|
50
|
+
const root = norm(fp?.pnpmRoot, true).replace(/\\/gu, '/')
|
|
51
|
+
return [norm(fp?.frameworkVersion), root, entities].join('|')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 两份指纹是否「实际相同」(同一 key)。 */
|
|
55
|
+
function sameFingerprint(a, b) {
|
|
56
|
+
return fingerprintKey(a) === fingerprintKey(b)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readStore(file = autoPreflightFile()) {
|
|
60
|
+
try {
|
|
61
|
+
const rec = JSON.parse(readFileSync(file, 'utf8'))
|
|
62
|
+
if (rec === null || typeof rec !== 'object') return { schema: 1, records: [] }
|
|
63
|
+
return { schema: 1, records: Array.isArray(rec.records) ? rec.records : [] }
|
|
64
|
+
} catch {
|
|
65
|
+
return { schema: 1, records: [] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 只增不删的合并:同 key 记录用新值覆盖,别的 key 原样保留;按 at 倒序裁剪到 MAX_RECORDS。 */
|
|
70
|
+
function upsertRecord(records, record, limit = MAX_RECORDS) {
|
|
71
|
+
const list = Array.isArray(records) ? records.filter((r) => r !== null && typeof r === 'object') : []
|
|
72
|
+
const key = typeof record?.key === 'string' ? record.key : fingerprintKey(record?.fingerprint)
|
|
73
|
+
const next = list.filter((r) => (typeof r.key === 'string' ? r.key : fingerprintKey(r.fingerprint)) !== key)
|
|
74
|
+
next.push({ ...record, key })
|
|
75
|
+
next.sort((a, b) => (Number(b?.at ?? 0) - Number(a?.at ?? 0)))
|
|
76
|
+
return next.slice(0, limit)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeStore(store, file = autoPreflightFile()) {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
81
|
+
writeFileSync(file, `${JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), records: store.records }, null, 2)}\n`, 'utf8')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 从记录集里取某个指纹的那条记录(没有则 null)。 */
|
|
85
|
+
function findRecord(records, fp) {
|
|
86
|
+
const key = fingerprintKey(fp)
|
|
87
|
+
return (Array.isArray(records) ? records : []).find((r) => (typeof r?.key === 'string' ? r.key : fingerprintKey(r?.fingerprint)) === key) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 自动预检的**执行判定**(纯函数)。三种「不跑」:
|
|
92
|
+
* · state='done' → 同一指纹已经扫过(这就是"同指纹只自动跑一次"的闸门);
|
|
93
|
+
* · state='pending' → 上一次还挂在超时边缘(没有结论),交给手动重跑,不叠加并发;
|
|
94
|
+
* · state='failed' / 'timeout' → **允许重试一次**(失败要能重试),但同一指纹最多自动重试 limit 次。
|
|
95
|
+
* @returns {{ run: boolean, reason: string, attempt: number }}
|
|
96
|
+
*/
|
|
97
|
+
function shouldAutoRun(record, { retryLimit = 1 } = {}) {
|
|
98
|
+
if (record === null || record === undefined) return { run: true, reason: 'first-seen', attempt: 1 }
|
|
99
|
+
const state = typeof record.state === 'string' ? record.state : 'failed'
|
|
100
|
+
const attempts = Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : 0
|
|
101
|
+
if (state === 'done') return { run: false, reason: 'already-scanned', attempt: attempts }
|
|
102
|
+
if (state === 'pending') return { run: false, reason: 'in-flight', attempt: attempts }
|
|
103
|
+
if (attempts >= retryLimit + 1) return { run: false, reason: 'retry-exhausted', attempt: attempts }
|
|
104
|
+
return { run: true, reason: state === 'timeout' ? 'retry-after-timeout' : 'retry-after-failure', attempt: attempts + 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 超时包裹(纯函数,**不抛**):到点就返回 `{ timedOut: true }`,绝不把异常甩给调用方。
|
|
109
|
+
* 注意它**不会取消**底层 promise(JS 没有取消语义)—— 底层跑完后的写盘由 runAutoPreflight 收口,
|
|
110
|
+
* 但那不影响状态查询:调用方拿到 timedOut 就立刻回响应。
|
|
111
|
+
*/
|
|
112
|
+
function withTimeout(promise, ms, onTimeout = null) {
|
|
113
|
+
const limit = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : DEFAULT_TIMEOUT_MS
|
|
114
|
+
let timer = null
|
|
115
|
+
const timeout = new Promise((resolve) => {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
if (typeof onTimeout === 'function') { try { onTimeout() } catch {} }
|
|
118
|
+
resolve({ timedOut: true, value: null })
|
|
119
|
+
}, limit)
|
|
120
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
121
|
+
})
|
|
122
|
+
const wrapped = Promise.resolve(promise).then(
|
|
123
|
+
(value) => ({ timedOut: false, value }),
|
|
124
|
+
(error) => ({ timedOut: false, value: null, error: error instanceof Error ? error.message : String(error) }),
|
|
125
|
+
)
|
|
126
|
+
return Promise.race([wrapped, timeout]).finally(() => { if (timer !== null) clearTimeout(timer) })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 兼容清单:把补丁体检与源码扫描的结论收敛成**受影响行**(面板按行显示,一行一句)。
|
|
131
|
+
* 只做汇总与排序,不新增判据(判据留在 patch-composition-audit / format-scan 两个模块里)。
|
|
132
|
+
*/
|
|
133
|
+
function affectedRows({ patchReport = null, scan = null, rows = [], limit = 40 } = {}) {
|
|
134
|
+
const out = []
|
|
135
|
+
const nameById = new Map((Array.isArray(rows) ? rows : []).map((r) => [r?.id, r?.name ?? null]))
|
|
136
|
+
for (const item of (patchReport?.blockers ?? [])) {
|
|
137
|
+
out.push({
|
|
138
|
+
rowId: item.row ?? null, moduleName: item.name ?? nameById.get(item.row) ?? null, severity: 'blocker',
|
|
139
|
+
code: item.code ?? 'patch-row', line: item.line ?? null, detail: item.detail ?? null,
|
|
140
|
+
hint: item.hint ?? null, suggestions: item.suggestions ?? [],
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
for (const item of (patchReport?.warnings ?? [])) {
|
|
144
|
+
out.push({
|
|
145
|
+
rowId: item.row ?? null, moduleName: item.name ?? nameById.get(item.row) ?? null, severity: 'warn',
|
|
146
|
+
code: item.code ?? 'patch-row', line: item.line ?? null, detail: item.detail ?? null,
|
|
147
|
+
hint: item.hint ?? null, suggestions: item.suggestions ?? [],
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
for (const f of (scan?.findings ?? [])) {
|
|
151
|
+
out.push({
|
|
152
|
+
rowId: null, moduleName: f.moduleName ?? null, severity: f.severity ?? 'warn',
|
|
153
|
+
code: f.rule ?? 'source-scan', line: f.line ?? null,
|
|
154
|
+
detail: `${f.file ?? '?'}${f.line === null || f.line === undefined ? '' : `:${f.line}`}${f.before === undefined ? '' : ` → ${f.after ?? ''}`}`,
|
|
155
|
+
hint: f.reason ?? f.note ?? null, suggestions: [],
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const rank = { blocker: 0, warn: 1 }
|
|
159
|
+
out.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) || String(a.rowId ?? '').localeCompare(String(b.rowId ?? '')))
|
|
160
|
+
return out.slice(0, limit)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 隔离计划:**建议**禁用哪些行 + 为什么(绝不自动写盘;真正落盘只走用户显式触发的
|
|
165
|
+
* `POST /plugin-console/safe-boot {action:'disable-suspects'}` 或离线脚本)。
|
|
166
|
+
* 只收「点名 + 可开关 + 非核心」的行:核心行禁掉只会让服务更起不来(正确动作是回滚框架)。
|
|
167
|
+
*/
|
|
168
|
+
function planSuspectRows(affected, { coreRowIds = new Set(), toggleableById = new Map(), limit = 20 } = {}) {
|
|
169
|
+
const suspects = []
|
|
170
|
+
const skipped = []
|
|
171
|
+
for (const item of (Array.isArray(affected) ? affected : [])) {
|
|
172
|
+
if (item.severity !== 'blocker' || typeof item.rowId !== 'string' || item.rowId === '') continue
|
|
173
|
+
if (coreRowIds.has(item.rowId)) { skipped.push({ rowId: item.rowId, reason: 'core-row' }); continue }
|
|
174
|
+
if (item.rowId === 'plugin-console') { skipped.push({ rowId: item.rowId, reason: 'self' }); continue }
|
|
175
|
+
if (toggleableById.has(item.rowId) && toggleableById.get(item.rowId) === false) { skipped.push({ rowId: item.rowId, reason: 'not-toggleable' }); continue }
|
|
176
|
+
if (suspects.some((s) => s.rowId === item.rowId)) continue
|
|
177
|
+
if (suspects.length >= limit) break
|
|
178
|
+
suspects.push({ rowId: item.rowId, moduleName: item.moduleName ?? null, reason: item.code, detail: item.detail ?? null, line: item.line ?? null })
|
|
179
|
+
}
|
|
180
|
+
return { suspects, skipped }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 本次变更摘要:框架版本 from→to + 新增/消失的包 + 指纹 reasons(面板一句话,详情放长文)。 */
|
|
184
|
+
function summarizeDelta({ current = null, previous = null, currentPackages = [], previousPackages = [] } = {}) {
|
|
185
|
+
const from = previous?.frameworkVersion ?? null
|
|
186
|
+
const to = current?.frameworkVersion ?? null
|
|
187
|
+
const before = new Set((Array.isArray(previousPackages) ? previousPackages : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
188
|
+
const after = new Set((Array.isArray(currentPackages) ? currentPackages : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
189
|
+
const added = [...after].filter((n) => !before.has(n)).sort()
|
|
190
|
+
const removed = [...before].filter((n) => !after.has(n)).sort()
|
|
191
|
+
return {
|
|
192
|
+
frameworkVersionFrom: from,
|
|
193
|
+
frameworkVersionTo: to,
|
|
194
|
+
versionChanged: from !== to,
|
|
195
|
+
packagesAdded: added.slice(0, 50),
|
|
196
|
+
packagesRemoved: removed.slice(0, 50),
|
|
197
|
+
packagesAddedCount: added.length,
|
|
198
|
+
packagesRemovedCount: removed.length,
|
|
199
|
+
rootChanged: (previous?.pnpmRoot ?? null) !== (current?.pnpmRoot ?? null),
|
|
200
|
+
entitiesChanged: (previous?.pnpmEntities ?? null) !== (current?.pnpmEntities ?? null),
|
|
201
|
+
rationaleAvailable: before.size > 0 || after.size > 0,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 面板/离线脚本共用的一句话短句(中英双语;面板只放这一句,长文进 detail)。 */
|
|
206
|
+
function autoPreflightNote(state, { blockers = 0, suspects = 0 } = {}) {
|
|
207
|
+
const zh = {
|
|
208
|
+
pending: '环境变了,正在自动预检…',
|
|
209
|
+
done: blockers > 0 ? `环境变了:预检发现 ${blockers} 行受影响${suspects > 0 ? `,建议禁用 ${suspects} 行` : ''}` : '环境变了:自动预检未发现受影响行',
|
|
210
|
+
failed: '环境变了,但自动预检失败(可手动重跑)',
|
|
211
|
+
timeout: '环境变了,自动预检超时(可手动重跑)',
|
|
212
|
+
idle: '环境未变化,无需自动预检',
|
|
213
|
+
}
|
|
214
|
+
const en = {
|
|
215
|
+
pending: 'Environment changed — auto-preflight running…',
|
|
216
|
+
done: blockers > 0 ? `Environment changed: ${blockers} affected row(s)${suspects > 0 ? `, ${suspects} suggested for disable` : ''}` : 'Environment changed: auto-preflight found no affected rows',
|
|
217
|
+
failed: 'Environment changed, but auto-preflight failed (you can re-run it)',
|
|
218
|
+
timeout: 'Environment changed; auto-preflight timed out (you can re-run it)',
|
|
219
|
+
idle: 'No environment change — auto-preflight not needed',
|
|
220
|
+
}
|
|
221
|
+
const key = Object.prototype.hasOwnProperty.call(zh, state) ? state : 'idle'
|
|
222
|
+
return { state: key, zh: zh[key], en: en[key] }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 自动预检收口(**这条路径不阻塞任何调用方**:调用方不 await 它)。
|
|
227
|
+
* 流程:判定是否该跑 → 写 state='pending' 占位(同指纹只跑一次的闸门立刻生效)→ 跑注入的扫描
|
|
228
|
+
* → 落 `{state:'done', preflight, affected, suspects, delta}`;失败/超时如实落 `failed`/`timeout` + reason。
|
|
229
|
+
* 写盘失败也不抛(返回 `{ok:false, error}`),因为它的调用方是只读状态查询。
|
|
230
|
+
*
|
|
231
|
+
* ★ 并发纪律(2026-09-29 实测踩到):**终态写入必须只落自己的那一条**。
|
|
232
|
+
* 早先的写法是"读整份 store → push → 整份写回",两个指纹的扫描同时收尾时会互相覆盖
|
|
233
|
+
* (谁后写谁赢,另一条的终态**永远停在 pending** —— 面板就永远显示"正在自动预检…")。
|
|
234
|
+
* 现在终态写盘只 upsert **当前 key 的那一条**,其余记录原样保留。
|
|
235
|
+
*/
|
|
236
|
+
async function runAutoPreflight({ current, previous, scan, timeoutMs = DEFAULT_TIMEOUT_MS, now = () => Date.now(), file = autoPreflightFile() } = {}) {
|
|
237
|
+
const key = fingerprintKey(current)
|
|
238
|
+
const startedAt = now()
|
|
239
|
+
try {
|
|
240
|
+
const store = readStore(file)
|
|
241
|
+
const existing = findRecord(store.records, current)
|
|
242
|
+
const verdict = shouldAutoRun(existing)
|
|
243
|
+
if (!verdict.run) return { ok: true, ran: false, reason: verdict.reason, key, state: existing?.state ?? 'idle' }
|
|
244
|
+
const pendingRecord = {
|
|
245
|
+
...(existing ?? {}), key, at: startedAt, startedAt, finishedAt: null, state: 'pending',
|
|
246
|
+
attempts: verdict.attempt, reason: verdict.reason,
|
|
247
|
+
fingerprint: current ?? null, previousFingerprint: previous ?? null,
|
|
248
|
+
}
|
|
249
|
+
writeStore({ records: upsertRecord(store.records, pendingRecord) }, file)
|
|
250
|
+
let result = null
|
|
251
|
+
const raced = await withTimeout(
|
|
252
|
+
Promise.resolve().then(() => scan({ current, previous, key })),
|
|
253
|
+
timeoutMs,
|
|
254
|
+
)
|
|
255
|
+
if (raced.timedOut) {
|
|
256
|
+
result = { ok: false, state: 'timeout', reason: `自动预检超过 ${timeoutMs}ms 未完成(已降级为「未完成,可手动重跑」,未阻塞状态查询)` }
|
|
257
|
+
} else if (raced.error !== undefined && raced.error !== null) {
|
|
258
|
+
result = { ok: false, state: 'failed', reason: `自动预检抛错:${raced.error}` }
|
|
259
|
+
} else {
|
|
260
|
+
const value = raced.value ?? {}
|
|
261
|
+
result = value.ok === false
|
|
262
|
+
? { ok: false, state: 'failed', reason: value.reason ?? '自动预检未产出结论' }
|
|
263
|
+
: { ok: true, state: 'done', preflight: value }
|
|
264
|
+
}
|
|
265
|
+
const finishedAt = now()
|
|
266
|
+
const record = {
|
|
267
|
+
...pendingRecord,
|
|
268
|
+
finishedAt,
|
|
269
|
+
state: result.state,
|
|
270
|
+
error: result.ok ? null : result.reason,
|
|
271
|
+
summary: result.ok ? (result.preflight?.summary ?? null) : null,
|
|
272
|
+
affected: result.ok ? (result.preflight?.affected ?? []) : [],
|
|
273
|
+
suspects: result.ok ? (result.preflight?.suspects ?? []) : [],
|
|
274
|
+
evidence: result.ok ? (result.preflight?.evidence ?? null) : null,
|
|
275
|
+
delta: result.ok ? (result.preflight?.delta ?? null) : null,
|
|
276
|
+
skipped: result.ok ? (result.preflight?.skipped ?? []) : [],
|
|
277
|
+
}
|
|
278
|
+
// 终态写盘:只 upsert 自己这条(读最新一份 + 只替换同 key),不覆盖别的指纹已经写好的结论
|
|
279
|
+
const after = readStore(file)
|
|
280
|
+
writeStore({ records: upsertRecord(after.records, record) }, file)
|
|
281
|
+
return { ok: result.ok, ran: true, key, state: result.state, record, error: result.ok ? null : result.reason, reason: verdict.reason }
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return { ok: false, ran: false, key, state: 'failed', error: `自动预检记录写入失败:${error instanceof Error ? error.message : String(error)}` }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 状态查询下发的视图(**加法字段**,不改 `/compat-status` 既有字段):
|
|
289
|
+
* 面板只需要一句短句 + 是否有结论 + 有几行受影响;长文(reasons/affected/delta)放在详情里。
|
|
290
|
+
*/
|
|
291
|
+
function autoPreflightView({ changed, current, records = null, file = autoPreflightFile() } = {}) {
|
|
292
|
+
const list = Array.isArray(records) ? records : readStore(file).records
|
|
293
|
+
// 指纹没变 → 不下发任何「上次的扫描结论」:那是上一个环境的结论,混在一起会让面板说假话。
|
|
294
|
+
const record = changed === true ? findRecord(list, current) : null
|
|
295
|
+
const state = record === null ? 'idle' : (STATES.includes(record.state) ? record.state : 'idle')
|
|
296
|
+
const preflight = record === null ? null : {
|
|
297
|
+
state: record.state ?? null,
|
|
298
|
+
startedAt: record.startedAt ?? null,
|
|
299
|
+
finishedAt: record.finishedAt ?? null,
|
|
300
|
+
attempts: record.attempts ?? 0,
|
|
301
|
+
error: record.error ?? null,
|
|
302
|
+
summary: record.summary ?? null,
|
|
303
|
+
affected: record.affected ?? [],
|
|
304
|
+
suspects: record.suspects ?? [],
|
|
305
|
+
skipped: record.skipped ?? [],
|
|
306
|
+
evidence: record.evidence ?? null,
|
|
307
|
+
delta: record.delta ?? null,
|
|
308
|
+
}
|
|
309
|
+
const blockers = (preflight?.affected ?? []).filter((a) => a.severity === 'blocker').length
|
|
310
|
+
return {
|
|
311
|
+
changed: changed === true,
|
|
312
|
+
state,
|
|
313
|
+
key: fingerprintKey(current),
|
|
314
|
+
ranAt: record?.startedAt ?? null,
|
|
315
|
+
scannedAt: record?.finishedAt ?? null,
|
|
316
|
+
blockers,
|
|
317
|
+
affectedCount: (preflight?.affected ?? []).length,
|
|
318
|
+
suspects: (preflight?.suspects ?? []).map((s) => s.rowId),
|
|
319
|
+
note: autoPreflightNote(state, { blockers, suspects: (preflight?.suspects ?? []).length }),
|
|
320
|
+
error: record?.error ?? null,
|
|
321
|
+
retryable: state === 'failed' || state === 'timeout',
|
|
322
|
+
preflight,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export {
|
|
327
|
+
DEFAULT_TIMEOUT_MS,
|
|
328
|
+
MAX_RECORDS,
|
|
329
|
+
STATES,
|
|
330
|
+
affectedRows,
|
|
331
|
+
autoPreflightFile,
|
|
332
|
+
autoPreflightNote,
|
|
333
|
+
autoPreflightView,
|
|
334
|
+
findRecord,
|
|
335
|
+
fingerprintKey,
|
|
336
|
+
planSuspectRows,
|
|
337
|
+
readStore,
|
|
338
|
+
runAutoPreflight,
|
|
339
|
+
sameFingerprint,
|
|
340
|
+
shouldAutoRun,
|
|
341
|
+
summarizeDelta,
|
|
342
|
+
upsertRecord,
|
|
343
|
+
withTimeout,
|
|
344
|
+
writeStore,
|
|
345
|
+
}
|
|
@@ -17,6 +17,44 @@ const COMPAT_MODES = ['managed', 'observer']
|
|
|
17
17
|
// 默认**观察者**(用户 2026-09-24 定):只守门、不接管框架升级;一旦用户走了本控制台的框架升级,服务端会自动切回托管
|
|
18
18
|
const DEFAULT_MODE = 'observer'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 模式写入的**来源**(0.5.32 加法 D4;缺字段一律按 `manual` 兼容 —— 老记录等于"用户手动设定过",永不自动改):
|
|
22
|
+
* · `manual` 用户在本控制台手动拨过(`POST /compat-mode`)→ 之后**永不自动改**
|
|
23
|
+
* · `auto:console-upgrade` 走了本控制台的框架升级 → 自动切托管(可被桌面端启动拨回)
|
|
24
|
+
* · `auto:desktop-host` 检测到桌面端宿主形态 → 自动切观察者
|
|
25
|
+
*/
|
|
26
|
+
const MODE_SOURCES = ['manual', 'auto:console-upgrade', 'auto:desktop-host']
|
|
27
|
+
const DEFAULT_MODE_SOURCE = 'manual'
|
|
28
|
+
|
|
29
|
+
/** 自动切换的判定(纯函数):**只在"上次不是用户手动设定"时才自动改**。 */
|
|
30
|
+
const AUTO_MODE_SOURCES = ['auto:console-upgrade', 'auto:desktop-host']
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 「桌面端宿主启动 → 自动拨回观察者」的判定(纯函数,加法 D4)。
|
|
34
|
+
* 规则(三条,全部可离线断言):
|
|
35
|
+
* ① 不是桌面端宿主 → 不动(`standalone` 实例的既有行为一字不改);
|
|
36
|
+
* ② 当前已经是 observer → 不动(**零写盘**:连 source 都不刷新);
|
|
37
|
+
* ③ 当前模式的来源是 `manual`(含缺字段的老记录)→ **不动**(用户手动优先,绝不抢用户意图)。
|
|
38
|
+
* @returns {{ action: 'none'|'switch-to-observer', mode: string, from: string, reason: string|null, note: {zh,en}|null }}
|
|
39
|
+
*/
|
|
40
|
+
function planDesktopHostSwitch(modeRecord, { hosted = false } = {}) {
|
|
41
|
+
const mode = typeof modeRecord?.mode === 'string' && COMPAT_MODES.includes(modeRecord.mode) ? modeRecord.mode : DEFAULT_MODE
|
|
42
|
+
const source = typeof modeRecord?.source === 'string' && MODE_SOURCES.includes(modeRecord.source) ? modeRecord.source : DEFAULT_MODE_SOURCE
|
|
43
|
+
if (hosted !== true) return { action: 'none', mode, from: source, reason: 'not-desktop-host', note: null }
|
|
44
|
+
if (mode === 'observer') return { action: 'none', mode, from: source, reason: 'already-observer', note: null }
|
|
45
|
+
if (!AUTO_MODE_SOURCES.includes(source)) return { action: 'none', mode, from: source, reason: 'user-set-manual', note: null }
|
|
46
|
+
return {
|
|
47
|
+
action: 'switch-to-observer',
|
|
48
|
+
mode: 'observer',
|
|
49
|
+
from: source,
|
|
50
|
+
reason: 'desktop-host-detected',
|
|
51
|
+
note: {
|
|
52
|
+
zh: '检测到桌面端托管,已切观察者:不接管框架升级',
|
|
53
|
+
en: 'Desktop host detected — switched to Observer: framework upgrades are not taken over',
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
20
58
|
const stateDir = () => join(dshHome(), 'plugin-console')
|
|
21
59
|
const modeFile = () => join(stateDir(), 'compat-mode.json')
|
|
22
60
|
const fingerprintFile = () => join(stateDir(), 'env-fingerprint.json')
|
|
@@ -36,25 +74,36 @@ function writeJson(path, value) {
|
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
77
|
+
* 读运行模式。**默认观察者** —— 纯加法:没有设置过的机器行为与之前完全一致。
|
|
78
|
+
* `source` 缺字段(0.5.32 之前写下的记录)按 `manual` 兼容:视为用户手动设定过,绝不自动改。
|
|
79
|
+
* @returns {{ mode: string, at: number|null, source: 'manual'|'auto:console-upgrade'|'auto:desktop-host', reason: string|null, fileSource: 'file'|'default' }}
|
|
41
80
|
*/
|
|
42
81
|
function readCompatMode() {
|
|
43
82
|
const rec = readJson(modeFile())
|
|
44
83
|
const mode = typeof rec?.mode === 'string' && COMPAT_MODES.includes(rec.mode) ? rec.mode : DEFAULT_MODE
|
|
45
|
-
|
|
84
|
+
const source = typeof rec?.source === 'string' && MODE_SOURCES.includes(rec.source) ? rec.source : DEFAULT_MODE_SOURCE
|
|
85
|
+
return { mode, at: typeof rec?.at === 'number' ? rec.at : null, source, reason: typeof rec?.reason === 'string' ? rec.reason : null, fileSource: rec === null ? 'default' : 'file' }
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
/**
|
|
49
89
|
* 写运行模式(只接受白名单值,避免拼错导致「模式静默失效」)。
|
|
50
|
-
*
|
|
90
|
+
* **幂等零写盘**(0.5.32 加法 D4):模式与来源都没变时一个字节都不写 —— 否则"桌面端每次启动都重写一份
|
|
91
|
+
* 状态文件",而用户看不出任何区别(也破坏"已是 observer 时零写盘"的断言)。
|
|
92
|
+
* @returns {{ ok: boolean, mode: string, source: string, changed: boolean, error?: string }}
|
|
51
93
|
*/
|
|
52
94
|
function writeCompatMode(mode, meta = {}) {
|
|
53
95
|
if (typeof mode !== 'string' || !COMPAT_MODES.includes(mode)) {
|
|
54
|
-
|
|
96
|
+
const current = readCompatMode()
|
|
97
|
+
return { ok: false, mode: current.mode, source: current.source, changed: false, error: `未知模式 ${String(mode)}(可选:${COMPAT_MODES.join(' / ')})` }
|
|
98
|
+
}
|
|
99
|
+
const source = typeof meta.source === 'string' && MODE_SOURCES.includes(meta.source) ? meta.source : DEFAULT_MODE_SOURCE
|
|
100
|
+
const reason = typeof meta.reason === 'string' ? meta.reason : null
|
|
101
|
+
const current = readCompatMode()
|
|
102
|
+
if (current.mode === mode && current.source === source && current.reason === reason && current.at !== null) {
|
|
103
|
+
return { ok: true, mode, source, changed: false }
|
|
55
104
|
}
|
|
56
|
-
writeJson(modeFile(), { mode, at: Date.now(),
|
|
57
|
-
return { ok: true, mode }
|
|
105
|
+
writeJson(modeFile(), { mode, at: Date.now(), source, reason })
|
|
106
|
+
return { ok: true, mode, source, changed: true }
|
|
58
107
|
}
|
|
59
108
|
|
|
60
109
|
/**
|
|
@@ -124,4 +173,4 @@ function readRollbackRecord() {
|
|
|
124
173
|
}
|
|
125
174
|
}
|
|
126
175
|
|
|
127
|
-
export { COMPAT_MODES, DEFAULT_MODE, compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
|
|
176
|
+
export { AUTO_MODE_SOURCES, COMPAT_MODES, DEFAULT_MODE, DEFAULT_MODE_SOURCE, MODE_SOURCES, compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
|
|
@@ -194,10 +194,13 @@ function writeCompatPending(payload) {
|
|
|
194
194
|
|
|
195
195
|
/** 兼容门总开关(用户定案 2026-09-11):用户可关掉自动行为,回到纯手动。
|
|
196
196
|
* autoDisable —— 升级前是否自动禁用判定不适配的行(关掉 = 只提示不动开关)
|
|
197
|
-
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示)
|
|
197
|
+
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示)
|
|
198
|
+
* autoDisableOnEvidence —— 0.5.32 加法 D5:**指纹变化自动预检 / 启动失败记录**给出**确证证据**时
|
|
199
|
+
* 是否自动写 `disabled: true`(默认启用 = 按用户意图;关掉 = 只报告 + 等用户点「只禁可疑行」)。
|
|
200
|
+
* 它与 autoDisable 是两个独立开关:后者管"我们升级时",前者管"别人升级 / 启动失败时"。 */
|
|
198
201
|
const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
|
|
199
202
|
|
|
200
|
-
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, noticeMode: 'once-per-version' }
|
|
203
|
+
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, autoDisableOnEvidence: true, noticeMode: 'once-per-version' }
|
|
201
204
|
|
|
202
205
|
function readCompatGate() {
|
|
203
206
|
try {
|
|
@@ -205,6 +208,7 @@ function readCompatGate() {
|
|
|
205
208
|
return {
|
|
206
209
|
autoDisable: raw?.autoDisable !== false,
|
|
207
210
|
autoDetect: raw?.autoDetect !== false,
|
|
211
|
+
autoDisableOnEvidence: raw?.autoDisableOnEvidence !== false,
|
|
208
212
|
// 兼容提示的展示模式(2026-09-28 加法):非法/缺失一律回落默认,**不影响 supported 判定**
|
|
209
213
|
noticeMode: NOTICE_MODES.includes(raw?.noticeMode) ? raw.noticeMode : DEFAULT_NOTICE_MODE,
|
|
210
214
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// L1 · domain —— patch-yaml-check.js(`cordis.patch.yml` 的**结构校验**;0.5.32 加法 D3)
|
|
2
|
+
//
|
|
3
|
+
// 为什么不能直接复用 `domain/preset-yaml.js#validateAgentConfig`(**真机实测**,2026-09-29):
|
|
4
|
+
// 那把尺子是按 **agent 预设文件**(顶层 `- id:` 序列)校准的,拿它量真机补丁会**整片误杀**:
|
|
5
|
+
// 两个 live profile 的 `cordis.patch.yml`(web 56 KB / desktop 55 KB)实测各被判 17 / 12 条问题,
|
|
6
|
+
// 全部是**合法写法**:
|
|
7
|
+
// · `description: '第一行…` + 后续行 ` 续行…'` —— YAML 的**多行引号标量**(跨行合法),
|
|
8
|
+
// 逐行判「引号未闭合」→ 假红;
|
|
9
|
+
// · 续行缩进比父级深 → 逐行判「缩进跳级/文件被截断」→ 假红。
|
|
10
|
+
// 误杀的代价在这里同样是反向的:D3 的恢复入口拿它当写盘前闸门 → **一恢复就被自家校验拒掉**。
|
|
11
|
+
//
|
|
12
|
+
// 所以这里只钉**能确证损坏**的形态(全部离线可判定,且对真机补丁 0 误报):
|
|
13
|
+
// ① 行首缩进里有 **tab** —— YAML 明确禁止用 tab 缩进(框架解析器必然报错);
|
|
14
|
+
// ② **顶层空数组占位符 `[]`**(issue #7 事故形态):模板初始化的 `[]` 后面再追加 `- id:` 条目,
|
|
15
|
+
// 同一文档流里「数组结束符 + 后续项」= 非法 YAML → dsh 启动直接崩;
|
|
16
|
+
// ③ 顶层(缩进 0)出现**不是** `- ` 序列项的行(既不是注释/空行/占位符)—— 补丁文件的形状是
|
|
17
|
+
// 「序列项 + 其子键」,顶层键值对说明文件被拼坏/截断;
|
|
18
|
+
// ④ **多行引号标量未闭合**(真正的截断):跨行扫描引号状态,直到文件末尾仍未闭合才判红
|
|
19
|
+
// —— 这一条恰好是②之外最常见的"写坏"形态;
|
|
20
|
+
// ⑤ 块标量(`|` / `>`)整段跳过判据(其内容是散文,不是 YAML 结构)。
|
|
21
|
+
//
|
|
22
|
+
// 分层:L1 domain —— 纯函数、无 IO、永不抛(解析失败是正常结局,调用方按 ok=false 处理)。
|
|
23
|
+
|
|
24
|
+
/** 缩进宽度(空格数;tab 会在 ① 里单独判掉,不参与计数)。 */
|
|
25
|
+
function indentWidth(line) {
|
|
26
|
+
const m = String(line).match(/^ */u)
|
|
27
|
+
return m === null ? 0 : m[0].length
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 行首缩进里有没有 tab(YAML 禁止;框架解析器会直接报错)。 */
|
|
31
|
+
function hasTabIndent(line) {
|
|
32
|
+
return /^[ \t]*\t/u.test(String(line))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 剥掉行尾注释(只认**不在引号里**且 `#` 前有空白或行首的 `#`)。 */
|
|
36
|
+
function stripTrailingComment(line) {
|
|
37
|
+
const text = String(line)
|
|
38
|
+
let quote = null
|
|
39
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
+
const ch = text[i]
|
|
41
|
+
if (quote !== null) {
|
|
42
|
+
if (quote === '"' && ch === '\\') { i += 1; continue }
|
|
43
|
+
if (ch === quote) quote = null
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (ch === "'" || ch === '"') { quote = ch; continue }
|
|
47
|
+
if (ch === '#' && (i === 0 || /\s/u.test(text[i - 1]))) return text.slice(0, i)
|
|
48
|
+
}
|
|
49
|
+
return text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 块标量指示符(`|`、`>`、`|-`、`>+2`…):只有**整个值**就是指示符时才算(`> foo` 是普通标量)。 */
|
|
53
|
+
function blockScalarIndicator(value) {
|
|
54
|
+
const m = String(value).trim().match(/^([|>])([+-]?\d*|\d*[+-]?)$/u)
|
|
55
|
+
return m === null ? null : m[0]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 从一行里取出「值起点之后的引号状态」:
|
|
60
|
+
* · 值不以引号开头 → `{ opens: false, quote: null }`(引号在标量中间不算跨行开始);
|
|
61
|
+
* · 以单/双引号开头 → 逐字符推进(双引号吃 `\\` 转义,单引号吃 `''`),返回是否仍未闭合。
|
|
62
|
+
*/
|
|
63
|
+
function scanValueQuotes(value, initialQuote = null) {
|
|
64
|
+
const text = String(value)
|
|
65
|
+
let quote = initialQuote
|
|
66
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
67
|
+
const ch = text[i]
|
|
68
|
+
if (quote === null) {
|
|
69
|
+
if (ch === "'" || ch === '"') quote = ch
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (quote === '"' && ch === '\\') { i += 1; continue }
|
|
73
|
+
if (ch === quote) {
|
|
74
|
+
if (quote === "'" && text[i + 1] === "'") { i += 1; continue }
|
|
75
|
+
quote = null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { quote }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 结构校验(**纯函数**,永不抛)。返回 `{ ok, problems, summary }`。
|
|
83
|
+
* `summary` 只放"看得懂的事实"(行数 / 顶层项数 / 多行标量数),供报告与面板短句使用。
|
|
84
|
+
*/
|
|
85
|
+
function validatePatchYaml(text) {
|
|
86
|
+
const source = typeof text === 'string' ? text : String(text ?? '')
|
|
87
|
+
const lines = source.replace(/\r\n/gu, '\n').split('\n')
|
|
88
|
+
const problems = []
|
|
89
|
+
let topItems = 0
|
|
90
|
+
let multilineScalars = 0
|
|
91
|
+
let placeholderLines = 0
|
|
92
|
+
let inBlockScalar = false
|
|
93
|
+
let blockIndent = 0
|
|
94
|
+
let openQuote = null
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
97
|
+
const raw = lines[i]
|
|
98
|
+
const lineNo = i + 1
|
|
99
|
+
if (raw.trim() === '') continue
|
|
100
|
+
if (/^\s*#/u.test(raw)) continue
|
|
101
|
+
// ① tab 缩进(无论在哪一层都不合法)
|
|
102
|
+
if (hasTabIndent(raw)) { problems.push(`第 ${lineNo} 行的缩进里有 tab(YAML 禁止用 tab 缩进)`); continue }
|
|
103
|
+
const indent = indentWidth(raw)
|
|
104
|
+
// 多行引号标量的续行:内容不参与结构判定,只继续找闭合
|
|
105
|
+
if (openQuote !== null) {
|
|
106
|
+
multilineScalars += 1
|
|
107
|
+
const st = scanValueQuotes(raw, openQuote)
|
|
108
|
+
openQuote = st.quote
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
// 块标量内容:整段跳过(缩进 ≤ 块首行缩进的非空行 = 块结束)
|
|
112
|
+
if (inBlockScalar) {
|
|
113
|
+
if (indent > blockIndent) continue
|
|
114
|
+
inBlockScalar = false
|
|
115
|
+
}
|
|
116
|
+
const content = stripTrailingComment(raw)
|
|
117
|
+
if (content.trim() === '') continue
|
|
118
|
+
// ② 顶层空数组占位符(issue #7:`[]` 后面再跟条目 = 非法 YAML,启动直接崩)
|
|
119
|
+
if (/^\s*\[\s*\]\s*$/u.test(content)) { placeholderLines += 1; continue }
|
|
120
|
+
// ③ 顶层必须是序列项(补丁文件的形状:`- id:` / `- insert:` + 其子键)
|
|
121
|
+
if (indent === 0) {
|
|
122
|
+
if (!/^-\s/u.test(content) && content.trim() !== '-') {
|
|
123
|
+
problems.push(`第 ${lineNo} 行是顶层键值对(补丁文件顶层必须是 \`- \` 序列项):${content.trim().slice(0, 60)}`)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
topItems += 1
|
|
127
|
+
}
|
|
128
|
+
// ④ / ⑤ 值里的块标量与跨行引号
|
|
129
|
+
const kv = content.match(/^(\s*)(?:-\s+)?[^\s:#][^:]*:\s*(.*)$/u)
|
|
130
|
+
if (kv === null) continue
|
|
131
|
+
const value = kv[2].trim()
|
|
132
|
+
if (blockScalarIndicator(value) !== null) { inBlockScalar = true; blockIndent = indent; continue }
|
|
133
|
+
const st = scanValueQuotes(value, null)
|
|
134
|
+
if (st.quote !== null) openQuote = st.quote
|
|
135
|
+
}
|
|
136
|
+
// ④ 收尾:跨行引号到文件末尾仍未闭合 = 真截断
|
|
137
|
+
if (openQuote !== null) {
|
|
138
|
+
problems.push(`文件末尾仍有未闭合的引号(${openQuote === "'" ? '单引号' : '双引号'})—— 文件被截断或写坏`)
|
|
139
|
+
}
|
|
140
|
+
// ② 收尾:占位符与真实条目共存(真实事故形态)
|
|
141
|
+
if (placeholderLines > 0 && topItems > 0) {
|
|
142
|
+
problems.push(`顶层空数组占位符 \`[]\` 与 ${topItems} 个序列项共存(同一文档流非法,框架解析会崩)—— 写入前必须移除占位符`)
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: problems.length === 0,
|
|
146
|
+
problems,
|
|
147
|
+
summary: { lines: lines.length, topItems, multilineScalars, placeholderLines },
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { blockScalarIndicator, hasTabIndent, indentWidth, scanValueQuotes, stripTrailingComment, validatePatchYaml }
|