dsh-plugin-upgrade 0.1.3 → 2.0.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.
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * scan-0.1.6.mjs — zero-dependency detector for the DSH `0.1.5-rc.2 →
5
+ * 0.1.6-alpha.2` plugin-adaptation seams.
6
+ *
7
+ * Why this exists: this span's breakage is mostly *silent*. Four examples that
8
+ * motivate the whole catalog:
9
+ * - the `settings.plugin.item` slot was deleted with no alias, and
10
+ * `ctx.slots.inject()` only runs its callback when the declaration exists —
11
+ * so a settings card that still targets it stops mounting without an error,
12
+ * a log line or a failed build (`E3`);
13
+ * - `sessions.open`/`openSubagent`/`clear` were removed from `ISessions`
14
+ * (replaced by `retain`/`using`/`retainInfo`), so navigation code dies at
15
+ * click time with a loud TypeError or a swallowed error string (`E4`);
16
+ * - `SessionListState.current` was deleted, so every reader of "the current
17
+ * session" silently degrades (cast-typed readers pass tsc anyway) (`E3`);
18
+ * - the default model catalog shrank 4 → 2 and uncatalogued ids like
19
+ * `deepseek-v4-flash` route as text-only, so a vision tier that names one
20
+ * throws UNSUPPORTED_CONTENT on the first image request (`E5`).
21
+ * A green local gate is therefore NOT evidence of adaptation: the published
22
+ * type line hides the deletions entirely, and the two remaining breakages are
23
+ * runtime races (`E1`, `E2`) that no typecheck can see.
24
+ *
25
+ * This scanner is the CLOSED catalog for the whole `0.1.5-rc.2 → 0.1.6-alpha.2`
26
+ * corridor. It supersedes nothing and shares nothing: the previous span's
27
+ * package (dsh-plugin-upgrade-015) owns its own twenty seams, and a corridor
28
+ * never widens — this package's five seams have zero overlap with that one.
29
+ *
30
+ * Born hardened (the previous scanner's known defects are absorbed here, not
31
+ * back-patched there):
32
+ * - `SKIP_DIRS` does NOT contain `lib`: a repo with a committed build (like
33
+ * the family's `lib/client.js`) must be scanned, because committed build
34
+ * artifacts ship the old seam to the user.
35
+ * - Every line-level behavior (context filter, file filter, downgrade rule)
36
+ * is a per-seam catalog field applied uniformly by one loop — no seam gets
37
+ * a special-case branch — and the render order derives from the catalog
38
+ * itself.
39
+ *
40
+ * The catalog below is the single source of truth shared by the version card,
41
+ * the packaged skill and this CLI. `test/card.test.mjs` fails when the card and
42
+ * this catalog disagree about the seam ids.
43
+ *
44
+ * Usage:
45
+ * node scan-0.1.6.mjs [--repo <path>] [--json <out.json>] [--seams E1,E3] [--quiet]
46
+ *
47
+ * Exit codes: 0 = no error-severity hit, 1 = at least one error-severity hit,
48
+ * 2 = usage/scan failure.
49
+ *
50
+ * Provenance: every upstream fact behind a seam was re-read from the harness
51
+ * checkout at tag `dsh-v0.1.6-alpha.2` (ddefc45) and recorded with `path:line`
52
+ * in `docs/EVIDENCE.md` and in the version card
53
+ * (`skills/plugin-upgrade/references/v0.1.5-rc.2-to-v0.1.6-alpha.2.md`).
54
+ * This scanner ships so a plugin author can re-measure their own repository; it
55
+ * imports nothing outside Node's standard library and never writes inside the
56
+ * scanned tree.
57
+ */
58
+
59
+ import fs from 'node:fs'
60
+ import path from 'node:path'
61
+
62
+ // Hardened: `lib` is deliberately absent — committed build artifacts must be
63
+ // scanned (a rebuilt `lib/client.js` still carrying the old slot key ships the
64
+ // breakage to every user).
65
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.tmp', 'coverage', '_scratch', '_archive', 'downloads', 'upstream', 'dev'])
66
+ const SCAN_EXT = /\.(ts|tsx|mts|cts|mjs|cjs|js|jsx|json|yml|yaml)$/
67
+
68
+ /**
69
+ * @typedef {object} Seam
70
+ * @property {string} id
71
+ * @property {string} title
72
+ * @property {'error'} severity
73
+ * @property {string} action
74
+ * @property {RegExp|null} test null = structured, detected by a dedicated file-level check
75
+ * @property {(line: string) => boolean} [lineFilter]
76
+ * @property {(lines: string[], i: number) => boolean} [windowFilter]
77
+ * @property {(text: string, file: string) => boolean} [fileCheck]
78
+ * @property {(text: string, file: string) => boolean} [downgradeIf] file-level guard recognition
79
+ */
80
+
81
+ /** @type {Seam[]} */
82
+ export const SEAMS = [
83
+ // ---------------------------------------------------------------- leg C
84
+ // 0.1.5-rc.2 → 0.1.6-alpha.2
85
+ {
86
+ id: 'E1',
87
+ title: 'agent/created 监听器抛错或异步拖延会阻断 agent 创建(A1 串行链)',
88
+ severity: 'error',
89
+ action: '宿主在 agent 创建链上串行派发 `agent/created`:监听器抛错直接阻断创建,异步工作拖延首次消息。把监听器改写成「绝不抛错 + 快速返回」:同步工作包 try/catch,重工作 queueMicrotask/setImmediate 或投递到自己的队列,副作用失败只记日志。',
90
+ test: /agent\/created/,
91
+ // Registration contexts only: `.on(`, `.oneline(`, `addListener(`, listener maps.
92
+ windowFilter: (lines, i) => {
93
+ const win = lines.slice(Math.max(0, i - 2), i + 1).join('\n')
94
+ return /\.on\s*\(|\.oneline\s*\(|addListener\s*\(|listener\s*[:=]\s*\{/.test(win)
95
+ },
96
+ // A file that guards its listener (try/catch, .catch, deferred work) is
97
+ // adapted in spirit: report as a review lead instead of a blocker.
98
+ downgradeIf: text => /try\s*\{|\.catch\s*\(|queueMicrotask|setImmediate|\.push\s*\(/.test(text),
99
+ },
100
+ {
101
+ id: 'E2',
102
+ title: '异步 apply 竞态:首个 await 之后才注册 ⇒ 卸载窗口内 INACTIVE_EFFECT(A02 全类)',
103
+ severity: 'error',
104
+ action: '`export async function apply(ctx)` 里,一切注册(ctx.effect/ctx.on/ctx.provide/ctx.plugin/*.register)必须在第一个 `await` 之前完成;`register()` 的返回值必须交给 `ctx.effect()` 持有。首个 await 之后的注册在卸载窗口内必抛 INACTIVE_EFFECT,旧闭包继续生效。',
105
+ test: null, // structured detector: checkAsyncApply
106
+ },
107
+ {
108
+ id: 'E3',
109
+ title: '被删的槽/状态键:settings.plugin.item 与 SessionListState.current(静默消失)',
110
+ severity: 'error',
111
+ action: '`settings.plugin.item` 已删(新契约 `plugins.item`,keyed→list,条目带 `id`/`order`/`label`、props `{view:\'summary\'|\'page\'}`,不传 priority);`SessionListState.current` 已删——「当前会话」从槽标准 props `sessionId`/`useSessionStatus`/`retainedBy.mainView` 推导(上游 ui-session 是模式),保留 undefined 守卫。两者都是静默失效:inject 回调不执行、cast 结构面 tsc 抓不到。',
112
+ test: /settings\.plugin\.item|(?:list|snapshot|sessions\.list)\.current\b|getSnapshot\s*\(\s*\)\s*\.current/,
113
+ // Slot/session consumption contexts only, never prose about the migration.
114
+ lineFilter: line => /slots\.inject|register\s*\(\s*\{|name\s*:|sessions|snapshot|current|SessionListState|list\b/.test(line),
115
+ },
116
+ {
117
+ id: 'E4',
118
+ title: '被删的客户端 API:sessions.open / openSubagent / clear(点击路径 TypeError/吞错)',
119
+ severity: 'error',
120
+ action: '`ISessions` 删除了 `open`/`openSubagent`/`clear`,替代面是 `retain(id, { source })`(返回 Disposable,面板持有时存下、卸载时 dispose)/`using`/`retainInfo`。跳转代码改走 retain;特性探测(typeof 检查 + 回退)是保留旧 peer 带期间的合法写法。',
121
+ test: /sessions\.(open|openSubagent|clear)\b/,
122
+ // A file that also names the new surface (or probes before calling) is
123
+ // handling the transition on purpose: report as a review lead.
124
+ downgradeIf: text => /sessions\.retain|sessions\.using|typeof\s+\w*\.?\s*sessions|feature[-\s]?detect|hasOwnProperty\(['"]open['"]\)/i.test(text),
125
+ },
126
+ {
127
+ id: 'E5',
128
+ title: '被删的模型字面量:deepseek-v4-flash* / deepseek-v4-vision-exp(未编目 id 透传 text-only)',
129
+ severity: 'error',
130
+ action: '默认模型目录 4→2,`deepseek-v4-flash*` 与 `deepseek-v4-vision-exp` 已不在目录:未编目 id 透传为 text-only 路由,带图请求(vision 档)响亮抛 UNSUPPORTED_CONTENT、cheap 档静默降级。把配置默认值与测试硬编码换成仍在册的模型 id,README 同步。',
131
+ test: /['"](deepseek-v4-flash[\w.-]*|deepseek-v4-vision-exp[\w.-]*)['"]|(?:default|model)\s*:\s*(deepseek-v4-flash[\w.-]*|deepseek-v4-vision-exp[\w.-]*)\s*$/,
132
+ },
133
+ ]
134
+
135
+ /** Seam ids in card order; the version card must name exactly this set. */
136
+ export const SEAM_IDS = SEAMS.map(s => s.id)
137
+
138
+ /** Seams implemented structurally (not by regex), like `E2`. */
139
+ const STRUCTURED = new Set(['E2'])
140
+
141
+ /**
142
+ * Seams that are documented on the card and id-parity checked, but deliberately
143
+ * have no automatic detection. This corridor has none: every seam ships a
144
+ * detector.
145
+ */
146
+ export const CARD_ONLY = SEAMS.filter(s => s.test === null && !STRUCTURED.has(s.id)).map(s => s.id)
147
+
148
+ function* walk(dir, depth = 0) {
149
+ if (depth > 8) return
150
+ let ents
151
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
152
+ for (const e of ents) {
153
+ if (e.isDirectory()) {
154
+ if (SKIP_DIRS.has(e.name)) continue
155
+ yield* walk(path.join(dir, e.name), depth + 1)
156
+ } else if (SCAN_EXT.test(e.name)) {
157
+ yield path.join(dir, e.name)
158
+ }
159
+ }
160
+ }
161
+
162
+ /** 1-based line number of the first line containing `needle`, or 1. */
163
+ function lineOf(text, needle) {
164
+ const lines = text.split(/\r?\n/)
165
+ const i = lines.findIndex(l => l.includes(needle))
166
+ return i < 0 ? 1 : i + 1
167
+ }
168
+
169
+ /**
170
+ * E2 — the async-apply race, structured. Locate `export async function apply`,
171
+ * walk its body with brace counting, find the first `await`, and flag every
172
+ * registration call after it. `register()` return values are flagged regardless
173
+ * of whether they reach `ctx.effect()`: the card instructs the author to hand
174
+ * them to the effect.
175
+ */
176
+ function checkAsyncApply(text, file) {
177
+ const lines = text.split(/\r?\n/)
178
+ const hits = []
179
+ for (let i = 0; i < lines.length; i++) {
180
+ if (!/export\s+async\s+function\s+apply\b/.test(lines[i])) continue
181
+ // First line from the signature onward that carries a brace. The signature
182
+ // line itself may hold a `config = {}` default: counting starts there and
183
+ // only a close to depth 0 on a LATER line ends the body.
184
+ let j = i
185
+ while (j < lines.length && !lines[j].includes('{')) j++
186
+ if (j >= lines.length) continue
187
+ let depth = 0
188
+ let firstAwait = -1
189
+ let bodyLine = 0
190
+ for (let k = j; k < lines.length; k++) {
191
+ const line = lines[k]
192
+ let inString = null
193
+ for (let c = 0; c < line.length; c++) {
194
+ const ch = line[c]
195
+ if (inString) {
196
+ if (ch === inString && line[c - 1] !== '\\') inString = null
197
+ continue
198
+ }
199
+ if (ch === '"' || ch === "'" || ch === '`') { inString = ch; continue }
200
+ if (ch === '{') depth++
201
+ if (ch === '}') { depth--; if (depth === 0 && k > j) { bodyLine = k; break } }
202
+ }
203
+ if (bodyLine) break
204
+ if (firstAwait < 0 && k > j && /\bawait\s+/.test(line)) firstAwait = k
205
+ }
206
+ if (bodyLine === 0 || firstAwait < 0) continue
207
+ for (let k = firstAwait + 1; k <= bodyLine; k++) {
208
+ const line = lines[k]
209
+ if (/^\s*(?:\/\/|\/\*|\*|#)/.test(line.trim())) continue
210
+ if (/ctx\.(effect|on|provide|plugin)\s*\(|\b[a-zA-Z_$][\w.$]*\.register\s*\(/.test(line)) {
211
+ hits.push({
212
+ seam: 'E2', severity: 'error', file, line: k + 1,
213
+ snippet: line.trim().slice(0, 200),
214
+ detail: `registration after the first await (line ${firstAwait + 1}) of an async apply → INACTIVE_EFFECT in the unload window; move it before any await and hand register() results to ctx.effect()`,
215
+ })
216
+ }
217
+ }
218
+ i = bodyLine
219
+ }
220
+ return hits
221
+ }
222
+
223
+ /**
224
+ * Scan one repo.
225
+ * @param {string} repoDir
226
+ * @param {{ seams?: string[] }} [options]
227
+ * @returns {{ repo: string, scannedAt: string, files: number, hits: any[], bySeam: Record<string, number> }}
228
+ */
229
+ export function scanRepo(repoDir, options = {}) {
230
+ const wanted = options.seams && options.seams.length ? new Set(options.seams) : null
231
+ const hits = []
232
+ let files = 0
233
+ for (const file of walk(repoDir)) {
234
+ files++
235
+ let text
236
+ try { text = fs.readFileSync(file, 'utf8') } catch { continue }
237
+ const lines = text.split(/\r?\n/)
238
+ for (const seam of SEAMS) {
239
+ if (wanted && !wanted.has(seam.id)) continue
240
+ if (seam.test === null) continue // structured seam, handled below
241
+ if (STRUCTURED.has(seam.id)) continue
242
+ if (seam.fileCheck && !seam.fileCheck(text, file)) continue
243
+ const downgraded = seam.downgradeIf ? seam.downgradeIf(text) : false
244
+ for (let i = 0; i < lines.length; i++) {
245
+ const trimmed = lines[i].trim()
246
+ // Comment-only lines carry prose, not code: never a seam hit.
247
+ if (/^(?:\/\/|\/\*|\*|#)/.test(trimmed)) continue
248
+ if (!seam.test.test(lines[i])) continue
249
+ if (seam.lineFilter && !seam.lineFilter(lines[i])) continue
250
+ if (seam.windowFilter && !seam.windowFilter(lines, i)) continue
251
+ hits.push({
252
+ seam: seam.id, severity: downgraded ? 'warn' : seam.severity, file, line: i + 1,
253
+ snippet: lines[i].trim().slice(0, 200),
254
+ detail: downgraded ? `${seam.title} (guarded/transitional form — verify it is intentional)` : seam.title,
255
+ })
256
+ }
257
+ }
258
+ if (!wanted || wanted.has('E2')) hits.push(...checkAsyncApply(text, file))
259
+ }
260
+ const bySeam = {}
261
+ for (const h of hits) bySeam[h.seam] = (bySeam[h.seam] || 0) + 1
262
+ return { repo: repoDir, scannedAt: new Date().toISOString(), files, hits, bySeam }
263
+ }
264
+
265
+ /** Human-readable rendering. Order derives from the catalog, never a copy. */
266
+ export function render(report) {
267
+ const L = []
268
+ L.push(`# scan-0.1.6 · ${report.repo}`)
269
+ L.push(`files scanned: ${report.files} · hits: ${report.hits.length}`)
270
+ if (!report.hits.length) {
271
+ L.push('no seam hits — still verify with a real-host smoke AND a real browser assertion for the client half')
272
+ L.push('(this scanner is necessary, not sufficient: the breakage this corridor covers is silent)')
273
+ }
274
+ for (const id of SEAM_IDS) {
275
+ const group = report.hits.filter(h => h.seam === id)
276
+ if (!group.length) continue
277
+ const seam = SEAMS.find(s => s.id === id)
278
+ L.push('')
279
+ L.push(`## ${id} [${seam.severity}] ${seam.title} — ${group.length} hit(s)`)
280
+ L.push(` action: ${seam.action}`)
281
+ for (const h of group.slice(0, 12)) L.push(` ${path.relative(process.cwd(), h.file)}:${h.line} ${h.snippet}`)
282
+ if (group.length > 12) L.push(` ... ${group.length - 12} more`)
283
+ }
284
+ L.push('')
285
+ L.push('every seam in this corridor ships a detector; CARD_ONLY = []')
286
+ return L.join('\n')
287
+ }
288
+
289
+ export function main(argv) {
290
+ const args = { repo: process.cwd(), json: null, seams: null, quiet: false }
291
+ for (let i = 0; i < argv.length; i++) {
292
+ const a = argv[i]
293
+ if (a === '--repo') args.repo = argv[++i]
294
+ else if (a === '--json') args.json = argv[++i]
295
+ else if (a === '--seams') args.seams = String(argv[++i]).split(',').map(s => s.trim()).filter(Boolean)
296
+ else if (a === '--quiet') args.quiet = true
297
+ else if (a === '--help' || a === '-h') { console.log('usage: node scan-0.1.6.mjs [--repo <path>] [--json <out.json>] [--seams E1,E3] [--quiet]'); return 0 }
298
+ else { console.error(`unknown argument: ${a}`); return 2 }
299
+ }
300
+ const repoDir = path.resolve(args.repo)
301
+ if (!fs.existsSync(repoDir)) { console.error(`repo not found: ${repoDir}`); return 2 }
302
+ const report = scanRepo(repoDir, { seams: args.seams })
303
+ if (!args.quiet) console.log(render(report))
304
+ if (args.json) fs.writeFileSync(path.resolve(args.json), JSON.stringify(report, null, 1), 'utf8')
305
+ return report.hits.some(h => h.severity === 'error') ? 1 : 0
306
+ }
307
+
308
+ if (process.argv[1]?.endsWith('scan-0.1.6.mjs')) {
309
+ process.exit(main(process.argv.slice(2)))
310
+ }