@xmoon76/dsh-pi-tui 0.1.7 → 0.2.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,300 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * unstable-plugin-smoke — verify the PACKED UNSTABLE extension surface end
4
+ * to end with a real third-party-shaped plugin (Phase 3, plan §17):
5
+ *
6
+ * 1. the fixture imports ONLY the public `@xmoon76/dsh-pi-tui/
7
+ * extensions/unstable` (and the stable `extensions` entry for the
8
+ * service type) subpaths — importing `@xmoon76/pi-tui`, `src/tui-app`
9
+ * or any repository-relative internal path FAILS the gate (static
10
+ * imports, dynamic import() AND require() are all scanned);
11
+ * 2. the fixture typechecks against the packed `.d.mts`;
12
+ * 3. the fixture COMPILES to JS and RUNTIME-LOADS against the packed
13
+ * bytes (apply() registers through a mock service without throwing);
14
+ * 4. the fixture exercises the UNSTABLE surface: UNSTABLE_API_LEVEL === 1,
15
+ * raw capture (observe/consume/rewrite), the low-level surface
16
+ * handle and the mount lease;
17
+ * 5. the fixture's module shape (name/inject/apply) matches the Loader;
18
+ * 6. no duplicate dsh runtime in the fixture tree.
19
+ *
20
+ * The probe mirrors the host install with SYMLINKS (no npm install — the
21
+ * gate is offline-safe; the packed package's runtime deps resolve from
22
+ * the repo's node_modules exactly like a real profile).
23
+ *
24
+ * Usage: node scripts/unstable-plugin-smoke.mjs [path-to-tgz]
25
+ * @module unstable-plugin-smoke
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process'
29
+ import {
30
+ existsSync,
31
+ mkdirSync,
32
+ mkdtempSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ realpathSync,
36
+ rmSync,
37
+ statSync,
38
+ lstatSync,
39
+ symlinkSync,
40
+ writeFileSync,
41
+ } from 'node:fs'
42
+ import { tmpdir } from 'node:os'
43
+ import { dirname, join, resolve } from 'node:path'
44
+ import { fileURLToPath } from 'node:url'
45
+
46
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
47
+ const PACKAGE_ROOT = join(SCRIPT_DIR, '..')
48
+ const FIXTURE_ROOT = join(PACKAGE_ROOT, 'test', 'fixtures', 'unstable-plugin')
49
+
50
+ const failures = []
51
+ const checks = []
52
+
53
+ function check(name, ok, detail = '') {
54
+ checks.push(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`)
55
+ if (!ok) failures.push(`${name}${detail === '' ? '' : `: ${detail}`}`)
56
+ }
57
+
58
+ function run(command, args, options = {}) {
59
+ return spawnSync(command, args, { encoding: 'utf8', ...options })
60
+ }
61
+
62
+ function resolveTarball(explicit) {
63
+ if (explicit !== undefined) {
64
+ const absolute = resolve(explicit)
65
+ if (!existsSync(absolute)) throw new Error(`tarball not found: ${explicit}`)
66
+ return absolute
67
+ }
68
+ const dir = PACKAGE_ROOT
69
+ const candidates = readdirSync(dir)
70
+ .filter(name => /xmoon76-dsh-pi-tui-.*\.tgz$/.test(name))
71
+ .map(name => join(dir, name))
72
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
73
+ if (candidates.length === 0) {
74
+ throw new Error(`no xmoon76-dsh-pi-tui-*.tgz in ${dir}; run pnpm pack:release first`)
75
+ }
76
+ return candidates[0]
77
+ }
78
+
79
+ function main() {
80
+ const tarball = resolveTarball(process.argv[2])
81
+ const workDir = mkdtempSync(join(tmpdir(), 'unstable-plugin-'))
82
+ try {
83
+ // 1. Extract the packed package.
84
+ const extractedDir = join(workDir, 'pkg')
85
+ mkdirSync(extractedDir)
86
+ const extract = run('tar', ['-xzf', tarball, '-C', extractedDir])
87
+ check('tarball extracts', extract.status === 0, extract.stderr)
88
+ const pkgDir = join(extractedDir, 'package')
89
+ check('packed package has dist/extension/unstable.mjs', existsSync(join(pkgDir, 'dist', 'extension', 'unstable.mjs')))
90
+ check('packed package has dist/extension/unstable.d.mts', existsSync(join(pkgDir, 'dist', 'extension', 'unstable.d.mts')))
91
+
92
+ // 2. IMPORT BAN (plan §17 CI gate): the fixture's IMPORT STATEMENTS
93
+ // (multi-line blocks), dynamic import() and require() specifiers
94
+ // must never reference private internals.
95
+ const fixtureSrc = readFileSync(join(FIXTURE_ROOT, 'src', 'index.ts'), 'utf8')
96
+ const lines = fixtureSrc.split('\n')
97
+ const importStatements = []
98
+ for (let index = 0; index < lines.length; index++) {
99
+ if (/^\s*import\b/.test(lines[index] ?? '')) {
100
+ let statement = lines[index] ?? ''
101
+ while (!statement.includes(';') && index + 1 < lines.length) {
102
+ index += 1
103
+ statement += lines[index]
104
+ }
105
+ importStatements.push(statement)
106
+ }
107
+ }
108
+ const importLines = importStatements.join('\n')
109
+ const dynamicSpecifiers = [...fixtureSrc.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)]
110
+ .map(match => match[1])
111
+ .concat([...fixtureSrc.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)]
112
+ .map(match => match[1]))
113
+ const banned = [
114
+ // Word-boundary: `@xmoon76/pi-tui` must NOT match
115
+ // `@xmoon76/dsh-pi-tui` (a substring of the public package name).
116
+ ['@xmoon76/pi-tui (the private fork, word-bounded)', /@xmoon76\/pi-tui(?![-a-z])/g],
117
+ ['@moonshot-ai/pi-tui', /@moonshot-ai\/pi-tui/g],
118
+ ['src/tui-app', /src\/tui-app/g],
119
+ ['TuiApp', /\bTuiApp\b/g],
120
+ ['TuiMainScreen', /\bTuiMainScreen\b/g],
121
+ ['TuiAltScreen', /\bTuiAltScreen\b/g],
122
+ ['repository-relative internal path', /\.\.\/\.\.\/src/g],
123
+ ]
124
+ for (const [name, pattern] of banned) {
125
+ const staticMatches = importLines.match(pattern) ?? []
126
+ const dynamicMatches = dynamicSpecifiers.filter(specifier => pattern.test(specifier))
127
+ check(`fixture does not import ${name}`, staticMatches.length === 0 && dynamicMatches.length === 0,
128
+ [...staticMatches, ...dynamicMatches].join(', '))
129
+ }
130
+ // The ONLY @xmoon76/dsh-pi-tui imports are the public subpaths
131
+ // (extensions/unstable + extensions for the service type; cordis is a
132
+ // legitimate peer for the plugin's apply signature).
133
+ const dshImports = importLines.match(/@xmoon76\/dsh-pi-tui[^'"]*/g) ?? []
134
+ check('fixture imports ONLY the public extensions subpaths',
135
+ dshImports.length === 2
136
+ && dshImports.includes('@xmoon76/dsh-pi-tui/extensions/unstable')
137
+ && dshImports.includes('@xmoon76/dsh-pi-tui/extensions'),
138
+ dshImports.join(', '))
139
+
140
+ // 3. UNSTABLE-SURFACE EXERCISE (plan §17 acceptance): the fixture
141
+ // exercises the Phase-3 unstable surface through the public entry.
142
+ const surface = [
143
+ ['UNSTABLE_API_LEVEL', 'tier level constant'],
144
+ ['unstable(', 'facade builder'],
145
+ ['ui.input.captureRaw', 'raw input capture'],
146
+ ["mode: 'observe'", 'observe mode'],
147
+ ["mode: 'capture'", 'capture mode'],
148
+ ["action: 'consume'", 'consume decision'],
149
+ ["action: 'rewrite'", 'rewrite decision'],
150
+ ['ui.surface.handle', 'low-level surface handle'],
151
+ ['mountComponent', 'low-level mount'],
152
+ ]
153
+ for (const [api, label] of surface) {
154
+ check(`fixture exercises ${label} (${api})`, fixtureSrc.includes(api))
155
+ }
156
+
157
+ // 4. The fixture tree: symlink the packed package + the host's
158
+ // node_modules (the offline-safe mirror of a real profile install).
159
+ const fixtureDir = join(workDir, 'fixture')
160
+ mkdirSync(join(fixtureDir, 'node_modules', '@xmoon76'), { recursive: true })
161
+ symlinkSync(pkgDir, join(fixtureDir, 'node_modules', '@xmoon76', 'dsh-pi-tui'), 'dir')
162
+ const hostModules = join(PACKAGE_ROOT, 'node_modules')
163
+ mkdirSync(join(pkgDir, 'node_modules'), { recursive: true })
164
+ for (const entry of readdirSync(hostModules)) {
165
+ const target = join(pkgDir, 'node_modules', entry)
166
+ if (existsSync(target)) continue
167
+ symlinkSync(join(hostModules, entry), target, 'dir')
168
+ }
169
+ mkdirSync(join(fixtureDir, 'node_modules'), { recursive: true })
170
+ for (const entry of readdirSync(hostModules)) {
171
+ const target = join(fixtureDir, 'node_modules', entry)
172
+ if (existsSync(target)) continue
173
+ symlinkSync(join(hostModules, entry), target, 'dir')
174
+ }
175
+ mkdirSync(join(fixtureDir, 'src'), { recursive: true })
176
+ writeFileSync(join(fixtureDir, 'src', 'index.ts'), fixtureSrc)
177
+ writeFileSync(join(fixtureDir, 'tsconfig.json'), JSON.stringify({
178
+ compilerOptions: {
179
+ target: 'ES2022',
180
+ module: 'NodeNext',
181
+ moduleResolution: 'NodeNext',
182
+ strict: true,
183
+ noEmit: true,
184
+ skipLibCheck: true,
185
+ types: [],
186
+ },
187
+ include: ['src'],
188
+ }, null, 2))
189
+ writeFileSync(join(fixtureDir, 'package.json'), JSON.stringify({
190
+ name: 'dsh-pi-unstable-fixture',
191
+ private: true,
192
+ type: 'module',
193
+ }, null, 2))
194
+
195
+ // 5. Typecheck against the packed .d.mts (the repo's tsc — no
196
+ // registry fetch).
197
+ const tsc = run('node', [join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'), '-p', join(fixtureDir, 'tsconfig.json')], { cwd: fixtureDir })
198
+ check('fixture typechecks against the packed .d.mts', tsc.status === 0,
199
+ tsc.status === 0 ? '' : (tsc.stdout + tsc.stderr).split('\n').slice(-6).join(' '))
200
+
201
+ // 6. Module shape.
202
+ const moduleShape = /export const name = ['"][^'"]+['"]/s.test(fixtureSrc)
203
+ && /export const inject = \[/s.test(fixtureSrc)
204
+ && /export function apply\(ctx: Context\)/s.test(fixtureSrc)
205
+ check('fixture module shape (name/inject/apply) matches the Loader', moduleShape)
206
+
207
+ // 7. RUNTIME LOAD: compile the fixture to JS and apply() it with a
208
+ // mock service — the registrations must not throw and must resolve
209
+ // the PACKED unstable module.
210
+ const emitDir = join(fixtureDir, 'dist-out')
211
+ const emit = run('node', [join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
212
+ '-p', join(fixtureDir, 'tsconfig.json'), '--noEmit', 'false', '--outDir', emitDir], { cwd: fixtureDir })
213
+ check('fixture compiles to JS for the runtime probe', emit.status === 0,
214
+ emit.status === 0 ? '' : (emit.stdout + emit.stderr).split('\n').slice(-4).join(' '))
215
+ if (emit.status === 0) {
216
+ writeFileSync(join(fixtureDir, 'runtime-probe.mjs'), `
217
+ const context = {
218
+ get: (name) => name === 'piTuiExtensions' ? mockService() : undefined,
219
+ on: () => {},
220
+ }
221
+ function mockService() {
222
+ const inertLease = {
223
+ id: 'inert', active: false, focused: false,
224
+ focus: () => {}, blur: () => {}, invalidate: () => {},
225
+ close: () => {}, hide: () => {}, show: () => {},
226
+ }
227
+ const inertHandle = {
228
+ surfaceId: 'inert', generation: 0, width: 0, height: 0,
229
+ requestRender: () => {},
230
+ mountComponent: () => inertLease,
231
+ }
232
+ return {
233
+ api: () => ({
234
+ apiVersion: 1,
235
+ hostVersion: 'test',
236
+ capabilities: new Set(['unstable.input.raw', 'unstable.surface.handle']),
237
+ deprecations: new Map(),
238
+ }),
239
+ _unstableCaptureRaw: (spec) => ({ id: spec.id, dispose: () => {} }),
240
+ _unstableSurfaceHandle: () => inertHandle,
241
+ }
242
+ }
243
+ const plugin = await import(${JSON.stringify(join(emitDir, 'index.js').replaceAll('\\', '/'))})
244
+ plugin.apply(context)
245
+ if (plugin.name !== 'dsh-pi-unstable-fixture') throw new Error('bad name: ' + plugin.name)
246
+ console.log('fixture-runtime-ok')
247
+ `)
248
+ const runtime = run(process.execPath, [join(fixtureDir, 'runtime-probe.mjs')], { cwd: fixtureDir })
249
+ check('fixture runtime-loads and apply() registers without throwing',
250
+ runtime.status === 0 && runtime.stdout.includes('fixture-runtime-ok'),
251
+ runtime.status === 0 ? '' : (runtime.stderr || runtime.stdout).split('\n').slice(-8).join(' '))
252
+ }
253
+
254
+ // 8. No duplicate dsh runtime: every @deepseek-ai entry in BOTH the
255
+ // packed package tree AND the fixture tree must be a SYMLINK whose
256
+ // realpath resolves into the host module tree (the packed tarball
257
+ // itself carries no node_modules — the scaffolding symlinks them,
258
+ // exactly like a real profile resolves in-box packages from the
259
+ // dsh installation). A REAL directory is a duplicate runtime copy.
260
+ const probeReal = resolve(workDir)
261
+ const auditTrees = [
262
+ ['packed package', join(pkgDir, 'node_modules', '@deepseek-ai')],
263
+ ['fixture tree', join(fixtureDir, 'node_modules', '@deepseek-ai')],
264
+ ]
265
+ const realDirs = []
266
+ for (const [label, nm] of auditTrees) {
267
+ const entries = existsSync(nm) ? readdirSync(nm) : []
268
+ for (const name of entries) {
269
+ const entry = join(nm, name)
270
+ let isSymlink = false
271
+ try { isSymlink = lstatSync(entry).isSymbolicLink() } catch { /* missing */ }
272
+ if (!isSymlink) {
273
+ realDirs.push(`${label}:${name} (real dir)`)
274
+ continue
275
+ }
276
+ try {
277
+ const target = resolve(realpathSync(entry))
278
+ if (target.startsWith(probeReal)) realDirs.push(`${label}:${name}->${target}`)
279
+ } catch {
280
+ realDirs.push(`${label}:${name} (dangling)`)
281
+ }
282
+ }
283
+ }
284
+ check('no duplicate @deepseek-ai dsh-runtime copy (symlinks only, both trees)',
285
+ realDirs.length === 0,
286
+ realDirs.join(', '))
287
+ } finally {
288
+ if (process.env.UNSTABLE_SMOKE_KEEP !== '1') rmSync(workDir, { recursive: true, force: true })
289
+ }
290
+
291
+ for (const line of checks) console.log(line)
292
+ if (failures.length > 0) {
293
+ console.error(`\nunstable-plugin smoke FAILED (${failures.length}):`)
294
+ for (const failure of failures) console.error(` - ${failure}`)
295
+ process.exit(1)
296
+ }
297
+ console.log('\nunstable-plugin smoke passed')
298
+ }
299
+
300
+ main()
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * vim-plugin-smoke — verify the M10 vim acceptance plugin against the
4
+ * PACKED `@xmoon76/dsh-pi-tui` tarball (plan §15):
5
+ *
6
+ * 1. the vim fixture imports ONLY the public `@xmoon76/dsh-pi-tui/
7
+ * extensions` subpath — importing `@xmoon76/pi-tui`, `src/tui-app`
8
+ * or any repository-relative internal path FAILS the gate (static
9
+ * imports, dynamic import() AND require() are all scanned);
10
+ * 2. the fixture typechecks against the packed `.d.mts`;
11
+ * 3. the fixture COMPILES to JS and RUNTIME-LOADS against the packed
12
+ * bytes (apply() registers through a mock service without throwing);
13
+ * 4. the fixture exercises the EDITOR-EXTENSION SEAM (replacement
14
+ * registration + semantic EditorInputEvent handling + modal state);
15
+ * the remaining public surfaces (commands, themes, settings,
16
+ * autocomplete, keybindings, renderers, overlays, widgets) are each
17
+ * covered by their own dedicated tests — this gate is NOT a
18
+ * Stable-completeness proof;
19
+ * 5. the fixture's module shape (name/inject/apply) matches the Loader;
20
+ * 6. no duplicate dsh runtime in the fixture tree.
21
+ *
22
+ * The probe mirrors the host install with SYMLINKS (no npm install — the
23
+ * gate is offline-safe; the packed package's runtime deps resolve from
24
+ * the repo's node_modules exactly like a real profile).
25
+ *
26
+ * Usage: node scripts/vim-plugin-smoke.mjs [path-to-tgz]
27
+ * @module vim-plugin-smoke
28
+ */
29
+
30
+ import { spawnSync } from 'node:child_process'
31
+ import {
32
+ existsSync,
33
+ mkdirSync,
34
+ mkdtempSync,
35
+ readdirSync,
36
+ readFileSync,
37
+ realpathSync,
38
+ rmSync,
39
+ statSync,
40
+ lstatSync,
41
+ symlinkSync,
42
+ writeFileSync,
43
+ } from 'node:fs'
44
+ import { tmpdir } from 'node:os'
45
+ import { dirname, join, resolve } from 'node:path'
46
+ import { fileURLToPath } from 'node:url'
47
+
48
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
49
+ const PACKAGE_ROOT = join(SCRIPT_DIR, '..')
50
+ const FIXTURE_ROOT = join(PACKAGE_ROOT, 'test', 'fixtures', 'vim-plugin')
51
+
52
+ const failures = []
53
+ const checks = []
54
+
55
+ function check(name, ok, detail = '') {
56
+ checks.push(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`)
57
+ if (!ok) failures.push(`${name}${detail === '' ? '' : `: ${detail}`}`)
58
+ }
59
+
60
+ function run(command, args, options = {}) {
61
+ return spawnSync(command, args, { encoding: 'utf8', ...options })
62
+ }
63
+
64
+ function resolveTarball(explicit) {
65
+ if (explicit !== undefined) {
66
+ const absolute = resolve(explicit)
67
+ if (!existsSync(absolute)) throw new Error(`tarball not found: ${explicit}`)
68
+ return absolute
69
+ }
70
+ const dir = PACKAGE_ROOT
71
+ const candidates = readdirSync(dir)
72
+ .filter(name => /xmoon76-dsh-pi-tui-.*\.tgz$/.test(name))
73
+ .map(name => join(dir, name))
74
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
75
+ if (candidates.length === 0) {
76
+ throw new Error(`no xmoon76-dsh-pi-tui-*.tgz in ${dir}; run pnpm pack:release first`)
77
+ }
78
+ return candidates[0]
79
+ }
80
+
81
+ function main() {
82
+ const tarball = resolveTarball(process.argv[2])
83
+ const workDir = mkdtempSync(join(tmpdir(), 'vim-plugin-'))
84
+ try {
85
+ // 1. Extract the packed package.
86
+ const extractedDir = join(workDir, 'pkg')
87
+ mkdirSync(extractedDir)
88
+ const extract = run('tar', ['-xzf', tarball, '-C', extractedDir])
89
+ check('tarball extracts', extract.status === 0, extract.stderr)
90
+ const pkgDir = join(extractedDir, 'package')
91
+ check('packed package has dist/extensions.mjs', existsSync(join(pkgDir, 'dist', 'extensions.mjs')))
92
+ check('packed package has dist/extensions.d.mts', existsSync(join(pkgDir, 'dist', 'extensions.d.mts')))
93
+
94
+ // 2. IMPORT BAN (plan §15 CI gate): the fixture's IMPORT STATEMENTS
95
+ // (multi-line blocks), dynamic import() and require() specifiers
96
+ // must never reference private internals (prose may name them —
97
+ // the gate is about what the plugin can IMPORT).
98
+ const fixtureSrc = readFileSync(join(FIXTURE_ROOT, 'src', 'index.ts'), 'utf8')
99
+ const lines = fixtureSrc.split('\n')
100
+ const importStatements = []
101
+ for (let index = 0; index < lines.length; index++) {
102
+ if (/^\s*import\b/.test(lines[index] ?? '')) {
103
+ let statement = lines[index] ?? ''
104
+ while (!statement.includes(';') && index + 1 < lines.length) {
105
+ index += 1
106
+ statement += lines[index]
107
+ }
108
+ importStatements.push(statement)
109
+ }
110
+ }
111
+ const importLines = importStatements.join('\n')
112
+ const dynamicSpecifiers = [...fixtureSrc.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)]
113
+ .map(match => match[1])
114
+ .concat([...fixtureSrc.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)]
115
+ .map(match => match[1]))
116
+ const banned = [
117
+ // Word-boundary: `@xmoon76/pi-tui` must NOT match
118
+ // `@xmoon76/dsh-pi-tui` (a substring of the public package name).
119
+ ['@xmoon76/pi-tui (the private fork, word-bounded)', /@xmoon76\/pi-tui(?![-a-z])/g],
120
+ ['@moonshot-ai/pi-tui', /@moonshot-ai\/pi-tui/g],
121
+ ['src/tui-app', /src\/tui-app/g],
122
+ ['TuiApp', /\bTuiApp\b/g],
123
+ ['TuiMainScreen', /\bTuiMainScreen\b/g],
124
+ ['TuiAltScreen', /\bTuiAltScreen\b/g],
125
+ ['repository-relative internal path', /\.\.\/\.\.\/src/g],
126
+ ]
127
+ for (const [name, pattern] of banned) {
128
+ const staticMatches = importLines.match(pattern) ?? []
129
+ const dynamicMatches = dynamicSpecifiers.filter(specifier => pattern.test(specifier))
130
+ check(`fixture does not import ${name}`, staticMatches.length === 0 && dynamicMatches.length === 0,
131
+ [...staticMatches, ...dynamicMatches].join(', '))
132
+ }
133
+ // The ONLY @xmoon76/dsh-pi-tui imports are the extensions subpath
134
+ // (cordis is a legitimate peer for the plugin's apply signature).
135
+ const dshImports = importLines.match(/@xmoon76\/dsh-pi-tui[^'"]*/g) ?? []
136
+ check('fixture imports ONLY the public extensions subpath',
137
+ dshImports.length === 1 && dshImports[0] === '@xmoon76/dsh-pi-tui/extensions',
138
+ dshImports.join(', '))
139
+
140
+ // 3. EDITOR-SEAM SURFACE (plan §15 acceptance): the editor seam
141
+ // exercised through the vim fixture. The checks below validate the
142
+ // editor-extension seam, NOT a Stable-completeness proof: each
143
+ // remaining public surface (commands, themes, settings,
144
+ // autocomplete, keybindings, renderers, overlays, widgets) has its
145
+ // own dedicated tests (plan §8) — this gate must not grow into a
146
+ // FULL-SDK proof.
147
+ const surface = [
148
+ ['registerEditor', 'editor replacement'],
149
+ ['handleInput', 'semantic editor input channel'],
150
+ ['EditorInputEvent', 'host-normalized event shape'],
151
+ ['create:', 'editor create() protocol member'],
152
+ ['dispose:', 'editor dispose() protocol member'],
153
+ ]
154
+ for (const [api, label] of surface) {
155
+ check(`fixture exercises ${label} (${api})`, fixtureSrc.includes(api))
156
+ }
157
+ // The fixture must consume the semantic handleInput(event) channel
158
+ // (EditorInputEvent) — the editor-extension seam. Modal-mode behavior
159
+ // (insert/normal) is NOT part of the Stable contract (plan §7): the
160
+ // fixture keeps its mini modal editor, but CI must not define Vim
161
+ // behavior as a Stable API contract.
162
+ check('fixture never parses raw terminal bytes (no CSI-u / escape matching in the plugin)',
163
+ !/\\x1b\[/.test(fixtureSrc) && !/escape\s*\(/.test(fixtureSrc.replace(/handleInput/g, '')))
164
+
165
+ // 4. The fixture tree: symlink the packed package + the host's
166
+ // node_modules (the offline-safe mirror of a real profile install —
167
+ // the packed tarball itself carries no node_modules).
168
+ const fixtureDir = join(workDir, 'fixture')
169
+ mkdirSync(join(fixtureDir, 'node_modules', '@xmoon76'), { recursive: true })
170
+ symlinkSync(pkgDir, join(fixtureDir, 'node_modules', '@xmoon76', 'dsh-pi-tui'), 'dir')
171
+ const hostModules = join(PACKAGE_ROOT, 'node_modules')
172
+ mkdirSync(join(pkgDir, 'node_modules'), { recursive: true })
173
+ for (const entry of readdirSync(hostModules)) {
174
+ const target = join(pkgDir, 'node_modules', entry)
175
+ if (existsSync(target)) continue
176
+ symlinkSync(join(hostModules, entry), target, 'dir')
177
+ }
178
+ // The fixture ALSO imports @deepseek-ai/cordis directly (the apply
179
+ // Context type) — mirror the host modules into the fixture tree too.
180
+ mkdirSync(join(fixtureDir, 'node_modules'), { recursive: true })
181
+ for (const entry of readdirSync(hostModules)) {
182
+ const target = join(fixtureDir, 'node_modules', entry)
183
+ if (existsSync(target)) continue
184
+ symlinkSync(join(hostModules, entry), target, 'dir')
185
+ }
186
+ mkdirSync(join(fixtureDir, 'src'), { recursive: true })
187
+ writeFileSync(join(fixtureDir, 'src', 'index.ts'), fixtureSrc)
188
+ writeFileSync(join(fixtureDir, 'tsconfig.json'), JSON.stringify({
189
+ compilerOptions: {
190
+ target: 'ES2022',
191
+ module: 'NodeNext',
192
+ moduleResolution: 'NodeNext',
193
+ strict: true,
194
+ noEmit: true,
195
+ skipLibCheck: true,
196
+ types: [],
197
+ },
198
+ include: ['src'],
199
+ }, null, 2))
200
+ writeFileSync(join(fixtureDir, 'package.json'), JSON.stringify({
201
+ name: 'dsh-pi-vim-fixture',
202
+ private: true,
203
+ type: 'module',
204
+ }, null, 2))
205
+
206
+ // 5. Typecheck against the packed .d.mts (the repo's tsc — no
207
+ // registry fetch).
208
+ const tsc = run('node', [join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'), '-p', join(fixtureDir, 'tsconfig.json')], { cwd: fixtureDir })
209
+ check('fixture typechecks against the packed .d.mts', tsc.status === 0,
210
+ tsc.status === 0 ? '' : (tsc.stdout + tsc.stderr).split('\n').slice(-6).join(' '))
211
+
212
+ // 6. Module shape.
213
+ const moduleShape = /export const name = ['"][^'"]+['"]/s.test(fixtureSrc)
214
+ && /export const inject = \[/s.test(fixtureSrc)
215
+ && /export function apply\(ctx: Context\)/s.test(fixtureSrc)
216
+ check('fixture module shape (name/inject/apply) matches the Loader', moduleShape)
217
+
218
+ // 7. RUNTIME LOAD (round-1 finding 2): compile the fixture to JS and
219
+ // apply() it with a mock service — the registrations must not
220
+ // throw and must resolve the PACKED extensions module.
221
+ const emitDir = join(fixtureDir, 'dist-out')
222
+ const emit = run('node', [join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
223
+ '-p', join(fixtureDir, 'tsconfig.json'), '--noEmit', 'false', '--outDir', emitDir], { cwd: fixtureDir })
224
+ check('fixture compiles to JS for the runtime probe', emit.status === 0,
225
+ emit.status === 0 ? '' : (emit.stdout + emit.stderr).split('\n').slice(-4).join(' '))
226
+ if (emit.status === 0) {
227
+ writeFileSync(join(fixtureDir, 'runtime-probe.mjs'), `
228
+ const context = {
229
+ get: (name) => name === 'piTuiExtensions' ? mockService() : undefined,
230
+ on: () => {},
231
+ }
232
+ function mockService() {
233
+ const makeHandle = () => ({ id: 'x', invalidate: () => {}, replace: () => {}, dispose: () => {} })
234
+ return {
235
+ api: () => ({ apiVersion: 1, hostVersion: 'test', capabilities: new Set(['slot.input.widget', 'slot.input.dock.item', 'slot.chrome.header.badge', 'slot.chrome.footer.status']) }),
236
+ register: () => makeHandle(),
237
+ registerCommand: () => makeHandle(),
238
+ registerTheme: () => makeHandle(),
239
+ registerAutocomplete: () => makeHandle(),
240
+ registerSetting: () => makeHandle(),
241
+ registerKeybinding: () => makeHandle(),
242
+ registerMessageRenderer: () => makeHandle(),
243
+ registerToolRenderer: () => makeHandle(),
244
+ registerEditor: () => makeHandle(),
245
+ showOverlay: () => ({ close: () => {}, hide: () => {}, show: () => {} }),
246
+ }
247
+ }
248
+ const plugin = await import(${JSON.stringify(join(emitDir, 'index.js').replaceAll('\\', '/'))})
249
+ plugin.apply(context)
250
+ if (plugin.name !== 'dsh-pi-vim-fixture') throw new Error('bad name: ' + plugin.name)
251
+ console.log('fixture-runtime-ok')
252
+ `)
253
+ const runtime = run(process.execPath, [join(fixtureDir, 'runtime-probe.mjs')], { cwd: fixtureDir })
254
+ check('fixture runtime-loads and apply() registers without throwing',
255
+ runtime.status === 0 && runtime.stdout.includes('fixture-runtime-ok'),
256
+ runtime.status === 0 ? '' : (runtime.stderr || runtime.stdout).split('\n').slice(-8).join(' '))
257
+ }
258
+
259
+ // 8. No duplicate dsh runtime: every @deepseek-ai entry in BOTH the
260
+ // packed package tree AND the fixture tree must be a SYMLINK whose
261
+ // realpath resolves into the host module tree (the packed tarball
262
+ // itself carries no node_modules — the scaffolding symlinks them,
263
+ // exactly like a real profile resolves in-box packages from the
264
+ // dsh installation). A REAL directory is a duplicate runtime copy.
265
+ // The symlinks point into the HOST's modules (pnpm's virtual store
266
+ // under node_modules/.pnpm, itself linking to the pnpm store — a
267
+ // REAL host install). The defect class is a COPY: a real directory,
268
+ // or a symlink resolving back INTO the packed/fixture tree (a
269
+ // self-contained duplicate). A symlink resolving OUTSIDE the probe
270
+ // (host node_modules / pnpm store) is exactly the host resolution a
271
+ // real profile uses.
272
+ const probeReal = resolve(workDir)
273
+ const auditTrees = [
274
+ ['packed package', join(pkgDir, 'node_modules', '@deepseek-ai')],
275
+ ['fixture tree', join(fixtureDir, 'node_modules', '@deepseek-ai')],
276
+ ]
277
+ const realDirs = []
278
+ for (const [label, nm] of auditTrees) {
279
+ const entries = existsSync(nm) ? readdirSync(nm) : []
280
+ for (const name of entries) {
281
+ const entry = join(nm, name)
282
+ let isSymlink = false
283
+ try { isSymlink = lstatSync(entry).isSymbolicLink() } catch { /* missing */ }
284
+ if (!isSymlink) {
285
+ realDirs.push(`${label}:${name} (real dir)`)
286
+ continue
287
+ }
288
+ // A symlink whose target resolves INSIDE the probe is a duplicate
289
+ // copy; a dangling link is a defect. Anything else (host modules,
290
+ // the pnpm store) is the intended host resolution.
291
+ try {
292
+ const target = resolve(realpathSync(entry))
293
+ if (target.startsWith(probeReal)) realDirs.push(`${label}:${name}->${target}`)
294
+ } catch {
295
+ realDirs.push(`${label}:${name} (dangling)`)
296
+ }
297
+ }
298
+ }
299
+ check('no duplicate @deepseek-ai dsh-runtime copy (symlinks only, both trees)',
300
+ realDirs.length === 0,
301
+ realDirs.join(', '))
302
+ } finally {
303
+ if (process.env.VIM_SMOKE_KEEP !== '1') rmSync(workDir, { recursive: true, force: true })
304
+ }
305
+
306
+ for (const line of checks) console.log(line)
307
+ if (failures.length > 0) {
308
+ console.error(`\nvim-plugin smoke FAILED (${failures.length}):`)
309
+ for (const failure of failures) console.error(` - ${failure}`)
310
+ process.exit(1)
311
+ }
312
+ console.log('\nvim-plugin smoke passed')
313
+ }
314
+
315
+ main()