@xmoon76/dsh-pi-tui 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/index.d.mts +29 -1
- package/dist/index.mjs +4849 -789
- package/package.json +25 -21
- package/scripts/bench.mts +261 -0
- package/scripts/naming-gate.mjs +102 -0
- package/scripts/repair-core.mjs +638 -0
- package/scripts/repair-session.mjs +474 -0
- package/scripts/tarball-smoke.mjs +325 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmoon76/dsh-pi-tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The dsh-pi-tui bundle: a TUI surface for DeepSeek Harness profiles (dsh --profile pi-tui)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "XMoon <xmoonox@gmail.com>",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
41
|
"config",
|
|
42
|
+
"scripts",
|
|
42
43
|
"cordis.patch.yml",
|
|
43
44
|
"README.md",
|
|
44
45
|
"LICENSE"
|
|
@@ -60,35 +61,36 @@
|
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
63
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2",
|
|
63
|
-
"@deepseek-ai/dsh-cmdline": "^0.1.0-rc.6",
|
|
64
64
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
65
65
|
"@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.6",
|
|
66
|
-
"@deepseek-ai/dsh-
|
|
67
|
-
"@deepseek-ai/dsh-
|
|
66
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6",
|
|
67
|
+
"@deepseek-ai/dsh-cmdline": "^0.1.0-rc.6",
|
|
68
68
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
|
|
69
|
-
"@deepseek-ai/dsh-
|
|
70
|
-
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
71
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
72
|
-
"@deepseek-ai/dsh-plan-mode": "^0.1.0-rc.6",
|
|
73
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
|
|
74
|
-
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6",
|
|
75
|
-
"@deepseek-ai/dsh-user-questions": "^0.1.0-rc.6",
|
|
76
|
-
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
|
|
69
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
77
70
|
"@deepseek-ai/dsh-goal": "^0.1.0-rc.6",
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@deepseek-ai/dsh-
|
|
71
|
+
"@deepseek-ai/dsh-jobs": "^0.1.0-rc.6",
|
|
72
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
80
73
|
"@deepseek-ai/dsh-llm-retry": "^0.1.0-rc.6",
|
|
81
74
|
"@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.6",
|
|
82
|
-
"@deepseek-ai/dsh-
|
|
75
|
+
"@deepseek-ai/dsh-plan-mode": "^0.1.0-rc.6",
|
|
76
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6",
|
|
77
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
78
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
|
|
83
79
|
"@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
|
|
84
|
-
"@deepseek-ai/dsh-
|
|
80
|
+
"@deepseek-ai/dsh-session-title": "^0.1.0-rc.6",
|
|
81
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
85
82
|
"@deepseek-ai/dsh-shell": "^0.1.0-rc.6",
|
|
86
|
-
"@deepseek-ai/dsh-
|
|
87
|
-
"@deepseek-ai/dsh-
|
|
83
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
84
|
+
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
|
|
85
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.0-rc.6",
|
|
86
|
+
"@deepseek-ai/dsh-tool-workflow": "^0.1.0-rc.6",
|
|
87
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
88
|
+
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6",
|
|
89
|
+
"@deepseek-ai/dsh-user-questions": "^0.1.0-rc.6",
|
|
88
90
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
89
91
|
},
|
|
90
92
|
"devDependencies": {
|
|
91
|
-
"@xmoon76/pi-tui": "^0.84.
|
|
93
|
+
"@xmoon76/pi-tui": "^0.84.3",
|
|
92
94
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2",
|
|
93
95
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
94
96
|
"@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.6",
|
|
@@ -124,8 +126,10 @@
|
|
|
124
126
|
},
|
|
125
127
|
"scripts": {
|
|
126
128
|
"build": "tsdown",
|
|
129
|
+
"build:package": "pnpm --dir ../pi-tui build && pnpm build",
|
|
127
130
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
128
|
-
"test": "node --test test/*.test.ts",
|
|
129
|
-
"clean": "rm -rf dist"
|
|
131
|
+
"test": "node --test test/*.test.ts test/*.test.mjs",
|
|
132
|
+
"clean": "rm -rf dist",
|
|
133
|
+
"smoke": "node scripts/tarball-smoke.mjs"
|
|
130
134
|
}
|
|
131
135
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @xmoon76/dsh-pi-tui/scripts/bench — non-default performance benchmark
|
|
4
|
+
* (run explicitly: `node scripts/bench.mjs`; never part of the test suite).
|
|
5
|
+
*
|
|
6
|
+
* Builds synthetic session logs (markdown, diffs, consecutive reads, tool
|
|
7
|
+
* calls, CJK/emoji) and measures, across widths and themes:
|
|
8
|
+
*
|
|
9
|
+
* - ingest: TranscriptFolder.apply() time per event count;
|
|
10
|
+
* - projection: messages() p50/p95/p99 (the incremental read-grouping);
|
|
11
|
+
* - rebuild: TuiApp.setTranscript cold (full markdown parse) vs warm
|
|
12
|
+
* (the stage-J per-message render cache) p50/p95/p99, including the
|
|
13
|
+
* 20 Hz streaming case (one message's text replaced per frame);
|
|
14
|
+
* - theme switch cost;
|
|
15
|
+
* - heap: growth per warm rebuild and the settled working set.
|
|
16
|
+
*
|
|
17
|
+
* Output is a plain baseline table; run twice to compare. Requires a
|
|
18
|
+
* Node version with native TypeScript stripping (>=22.6 with
|
|
19
|
+
* --experimental-strip-types, or 24+).
|
|
20
|
+
* @module bench
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import xterm from '@xterm/headless'
|
|
24
|
+
import { TuiApp } from '../src/tui-app.ts'
|
|
25
|
+
import { TranscriptFolder } from '../src/transcript.ts'
|
|
26
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
27
|
+
import type { TranscriptMessage } from '../src/transcript.ts'
|
|
28
|
+
|
|
29
|
+
const XtermTerminal = xterm.Terminal
|
|
30
|
+
|
|
31
|
+
/** A minimal headless terminal for TuiApp (render target only). */
|
|
32
|
+
class BenchTerminal {
|
|
33
|
+
private readonly xterm: InstanceType<typeof XtermTerminal>
|
|
34
|
+
constructor(columns: number, rows: number) {
|
|
35
|
+
this.xterm = new XtermTerminal({ cols: columns, rows, disableStdin: true, allowProposedApi: true })
|
|
36
|
+
}
|
|
37
|
+
start(): void {}
|
|
38
|
+
stop(): void {}
|
|
39
|
+
async drainInput(): Promise<void> {}
|
|
40
|
+
write(data: string): void { this.xterm.write(data) }
|
|
41
|
+
get columns(): number { return this.xterm.cols }
|
|
42
|
+
get rows(): number { return this.xterm.rows }
|
|
43
|
+
get kittyProtocolActive(): boolean { return false }
|
|
44
|
+
moveBy(): void {}
|
|
45
|
+
hideCursor(): void {}
|
|
46
|
+
showCursor(): void {}
|
|
47
|
+
clearLine(): void {}
|
|
48
|
+
clearFromCursor(): void {}
|
|
49
|
+
clearScreen(): void {}
|
|
50
|
+
setTitle(): void {}
|
|
51
|
+
setProgress(): void {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- synthetic session content ---------------------------------------------
|
|
55
|
+
|
|
56
|
+
const MARKDOWN_BLOCKS = [
|
|
57
|
+
'## Findings\n\nThe **cache** now hits for unchanged messages, so `markdown` is not re-parsed every frame.\n\n```ts\nconst hit = cached.text === message.text\n```\n\n- one\n- two\n- three',
|
|
58
|
+
'## Steps\n\n1. ingest events\n2. fold the projection\n3. render the tail\n\n> Only the visible window pays the expensive conversions.',
|
|
59
|
+
'## 结论(CJK + emoji)\n\n缓存命中后 🐋🐳 单帧成本不再随完整历史线性增长。`宽度 40/80/160` 与主题切换均按 key 失效。',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const DIFF_BODY = 'diff --git a/src/a.ts b/src/a.ts\nindex 111..222 100644\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,4 @@\n const a = 1\n+const b = 2\n-const old = 3\n // tail'
|
|
63
|
+
|
|
64
|
+
/** Build `turns` turns of events; each turn ≈ 9 events. */
|
|
65
|
+
function buildEvents(turns: number): SessionEvent[] {
|
|
66
|
+
const events: SessionEvent[] = []
|
|
67
|
+
let seq = 0
|
|
68
|
+
for (let turn = 0; turn < turns; turn += 1) {
|
|
69
|
+
events.push({ type: 'turn/start', seq: seq++, time: turn * 1000, data: { turn } } as SessionEvent)
|
|
70
|
+
events.push({
|
|
71
|
+
type: 'user/message', seq: seq++, time: turn * 1000 + 1, data: {
|
|
72
|
+
content: [{ type: 'text', text: `user prompt ${turn} with CJK 你好 and emoji 🐋` }],
|
|
73
|
+
source: { kind: 'user' },
|
|
74
|
+
},
|
|
75
|
+
} as SessionEvent)
|
|
76
|
+
for (let chunk = 0; chunk < 8; chunk += 1) {
|
|
77
|
+
events.push({
|
|
78
|
+
type: 'assistant/chunk', seq: seq++, time: turn * 1000 + 2 + chunk, data: {
|
|
79
|
+
turn, step: 0, index: chunk,
|
|
80
|
+
chunk: { type: 'text-delta', index: chunk, text: MARKDOWN_BLOCKS[turn % 3]!.slice(chunk * 40, chunk * 40 + 40) },
|
|
81
|
+
},
|
|
82
|
+
} as SessionEvent)
|
|
83
|
+
}
|
|
84
|
+
events.push({
|
|
85
|
+
type: 'assistant/message', seq: seq++, time: turn * 1000 + 11, data: {
|
|
86
|
+
turn, step: 0,
|
|
87
|
+
message: { id: `msg-${turn}`, role: 'assistant', content: [{ type: 'text', text: MARKDOWN_BLOCKS[turn % 3]! }], source: { kind: 'assistant' } },
|
|
88
|
+
},
|
|
89
|
+
} as SessionEvent)
|
|
90
|
+
events.push({
|
|
91
|
+
type: 'tool/call', seq: seq++, time: turn * 1000 + 12, data: {
|
|
92
|
+
turn, step: 0, callId: `r${turn}`, name: 'read', arguments: JSON.stringify({ file: `src/file-${turn}.ts` }),
|
|
93
|
+
},
|
|
94
|
+
} as SessionEvent)
|
|
95
|
+
events.push({
|
|
96
|
+
type: 'tool/result', seq: seq++, time: turn * 1000 + 13, data: {
|
|
97
|
+
turn, step: 0, message: {
|
|
98
|
+
id: `m-${turn}`, role: 'user',
|
|
99
|
+
content: [{ type: 'tool-result', toolCallId: `r${turn}`, content: [{ type: 'text', text: DIFF_BODY }] }],
|
|
100
|
+
source: { kind: 'tool', callId: `r${turn}` },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
} as SessionEvent)
|
|
104
|
+
events.push({
|
|
105
|
+
type: 'tool/call', seq: seq++, time: turn * 1000 + 14, data: {
|
|
106
|
+
turn, step: 0, callId: `b${turn}`, name: 'bash', arguments: JSON.stringify({ command: 'ls -la' }),
|
|
107
|
+
},
|
|
108
|
+
} as SessionEvent)
|
|
109
|
+
events.push({
|
|
110
|
+
type: 'tool/result', seq: seq++, time: turn * 1000 + 15, data: {
|
|
111
|
+
turn, step: 0, message: {
|
|
112
|
+
id: `bm-${turn}`, role: 'user',
|
|
113
|
+
content: [{ type: 'tool-result', toolCallId: `b${turn}`, content: [{ type: 'text', text: 'total 8\ndrwxr-xr-x 2 user user 4096 Aug 15 00:00 .\n-rw-r--r-- 1 user user 123 src/a.ts' }] }],
|
|
114
|
+
source: { kind: 'tool', callId: `b${turn}` },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
} as SessionEvent)
|
|
118
|
+
events.push({ type: 'turn/end', seq: seq++, time: turn * 1000 + 16, data: { turn, reason: { kind: 'completed' } } } as SessionEvent)
|
|
119
|
+
}
|
|
120
|
+
return events
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- measurement helpers ----------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function percentile(sorted: number[], p: number): number {
|
|
126
|
+
if (sorted.length === 0) return 0
|
|
127
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
|
|
128
|
+
return sorted[index]!
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stats(samples: number[]): { p50: number; p95: number; p99: number; mean: number } {
|
|
132
|
+
const sorted = [...samples].sort((a, b) => a - b)
|
|
133
|
+
return {
|
|
134
|
+
p50: percentile(sorted, 50),
|
|
135
|
+
p95: percentile(sorted, 95),
|
|
136
|
+
p99: percentile(sorted, 99),
|
|
137
|
+
mean: samples.reduce((sum, value) => sum + value, 0) / Math.max(1, samples.length),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fmt(st: { p50: number; p95: number; p99: number; mean: number }): string {
|
|
142
|
+
return `p50 ${st.p50.toFixed(2)}ms · p95 ${st.p95.toFixed(2)}ms · p99 ${st.p99.toFixed(2)}ms · mean ${st.mean.toFixed(2)}ms`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fmtMs(ms: number): string {
|
|
146
|
+
return `${ms.toFixed(2)}ms`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Run a callback `n` times and return per-call wall times. */
|
|
150
|
+
function timeIt(n: number, run: () => void): number[] {
|
|
151
|
+
const samples: number[] = []
|
|
152
|
+
for (let i = 0; i < n; i += 1) {
|
|
153
|
+
const started = performance.now()
|
|
154
|
+
run()
|
|
155
|
+
samples.push(performance.now() - started)
|
|
156
|
+
}
|
|
157
|
+
return samples
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Iteration counts; BENCH_FAST=1 shrinks them for quick before/after sweeps. */
|
|
161
|
+
const FAST = process.env.BENCH_FAST === '1'
|
|
162
|
+
const PROJ_SAMPLES = FAST ? 50 : 200
|
|
163
|
+
const WARM_SAMPLES = FAST ? 50 : 200
|
|
164
|
+
const STREAM_SAMPLES = FAST ? 50 : 200
|
|
165
|
+
const FULL_SAMPLES = FAST ? 20 : 50
|
|
166
|
+
const HEAP_REBUILDS = FAST ? 300 : 2000
|
|
167
|
+
|
|
168
|
+
// --- the benchmark ----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function main(): void {
|
|
171
|
+
const rows: string[] = []
|
|
172
|
+
const row = (label: string, value: string): void => { rows.push(`${label.padEnd(58)} ${value}`) }
|
|
173
|
+
|
|
174
|
+
row('scenario', 'value')
|
|
175
|
+
|
|
176
|
+
// 1. ingest + projection
|
|
177
|
+
for (const turns of [111, 1111, 5555]) {
|
|
178
|
+
const events = buildEvents(turns)
|
|
179
|
+
const folder = new TranscriptFolder()
|
|
180
|
+
const ingest = timeIt(3, () => folder.apply(events))
|
|
181
|
+
const projection = timeIt(PROJ_SAMPLES, () => folder.messages())
|
|
182
|
+
const st = stats(projection)
|
|
183
|
+
row(`ingest ${events.length} events (${turns} turns)`, fmtMs(ingest[0]!))
|
|
184
|
+
row(` messages() ×200 (${folder.messages().length} messages)`, fmt(st))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 2. rebuild (cold vs warm) across widths, plus the streaming case
|
|
188
|
+
const turns = 555
|
|
189
|
+
const folder = new TranscriptFolder()
|
|
190
|
+
folder.apply(buildEvents(turns))
|
|
191
|
+
const messages = folder.messages()
|
|
192
|
+
for (const width of [40, 80, 160]) {
|
|
193
|
+
// True cold: a fresh app whose FIRST setTranscript parses every message.
|
|
194
|
+
const coldApp = new TuiApp(new BenchTerminal(width, 24) as never, { onSubmit: () => {}, onExit: () => {} })
|
|
195
|
+
coldApp.start()
|
|
196
|
+
const cold = timeIt(1, () => coldApp.setTranscript(messages))
|
|
197
|
+
coldApp.stop()
|
|
198
|
+
const terminal = new BenchTerminal(width, 24)
|
|
199
|
+
const app = new TuiApp(terminal as never, { onSubmit: () => {}, onExit: () => {} })
|
|
200
|
+
app.start()
|
|
201
|
+
app.setTranscript(messages)
|
|
202
|
+
const warm = timeIt(WARM_SAMPLES, () => app.setTranscript(messages))
|
|
203
|
+
row(`rebuild ${messages.length} messages @${width} cols (cold, fresh app)`, fmtMs(cold[0]!))
|
|
204
|
+
row(` same content (warm cache)`, fmt(stats(warm)))
|
|
205
|
+
// 20 Hz streaming: one assistant message's text replaced per frame.
|
|
206
|
+
const streaming = [...messages]
|
|
207
|
+
const target = streaming.findIndex(message => message.kind === 'assistant')
|
|
208
|
+
const warmStream = timeIt(STREAM_SAMPLES, () => {
|
|
209
|
+
const entry = streaming[target]
|
|
210
|
+
if (entry !== undefined && entry.kind === 'assistant') entry.text += ' tail'
|
|
211
|
+
app.setTranscript(streaming)
|
|
212
|
+
})
|
|
213
|
+
row(` streaming 1 message/frame @${width}`, fmt(stats(warmStream)))
|
|
214
|
+
// Theme switch cost (once per switch).
|
|
215
|
+
const theme = timeIt(5, () => app.applyTheme('light'))
|
|
216
|
+
row(` theme dark→light @${width}`, fmtMs(theme[0]!))
|
|
217
|
+
app.stop()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. fullscreen rebuild
|
|
221
|
+
{
|
|
222
|
+
const terminal = new BenchTerminal(120, 24)
|
|
223
|
+
const app = new TuiApp(terminal as never, { onSubmit: () => {}, onExit: () => {} })
|
|
224
|
+
app.start()
|
|
225
|
+
app.setTranscript(messages)
|
|
226
|
+
app.setFullscreen(true)
|
|
227
|
+
const full = timeIt(FULL_SAMPLES, () => app.setTranscript(messages))
|
|
228
|
+
row(`fullscreen rebuild ${messages.length} messages @120`, fmt(stats(full)))
|
|
229
|
+
app.stop()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 4. heap (requires --expose-gc)
|
|
233
|
+
if (globalThis.gc !== undefined) {
|
|
234
|
+
const terminal = new BenchTerminal(120, 24)
|
|
235
|
+
const app = new TuiApp(terminal as never, { onSubmit: () => {}, onExit: () => {} })
|
|
236
|
+
app.start()
|
|
237
|
+
app.setTranscript(messages)
|
|
238
|
+
const gc = globalThis.gc as () => void
|
|
239
|
+
gc()
|
|
240
|
+
const before = process.memoryUsage().heapUsed
|
|
241
|
+
for (let i = 0; i < HEAP_REBUILDS; i += 1) app.setTranscript(messages)
|
|
242
|
+
gc()
|
|
243
|
+
const after = process.memoryUsage().heapUsed
|
|
244
|
+
row(`heap growth per warm rebuild (${HEAP_REBUILDS}×, gc)`, `${((after - before) / 2000).toFixed(1)} B/rebuild`)
|
|
245
|
+
gc()
|
|
246
|
+
const settled = process.memoryUsage().heapUsed
|
|
247
|
+
row(`settled heap for ${messages.length} messages @120`, `${(settled / 1024 / 1024).toFixed(1)} MiB`)
|
|
248
|
+
app.stop()
|
|
249
|
+
} else {
|
|
250
|
+
row('heap (rerun with --expose-gc)', 'skipped')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(rows.join('\n'))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
main()
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error(`bench: ${error instanceof Error ? error.message : String(error)}`)
|
|
260
|
+
process.exit(1)
|
|
261
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @xmoon76/dsh-pi-tui/scripts/naming-gate — CI guard for the naming and
|
|
4
|
+
* packaging decisions (AGENTS.md "Naming (hard rules)"): nothing here may
|
|
5
|
+
* use the `dsh-tui` / `@deepseek-ai/dsh-tui` family, the profile stays
|
|
6
|
+
* `pi-tui`, the fork stays private and bundled, and the bundle is the only
|
|
7
|
+
* published package with its fork in `deps.onlyBundle`. Fails with a clear
|
|
8
|
+
* list when any decision regresses.
|
|
9
|
+
* @module naming-gate
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { join, dirname } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
|
18
|
+
const failures = []
|
|
19
|
+
const checks = []
|
|
20
|
+
|
|
21
|
+
function check(name, ok, detail = '') {
|
|
22
|
+
checks.push(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`)
|
|
23
|
+
if (!ok) failures.push(`${name}${detail === '' ? '' : `: ${detail}`}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJson(path) {
|
|
27
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rootPkg = readJson(join(ROOT, 'package.json'))
|
|
31
|
+
const bundlePkg = readJson(join(ROOT, 'packages', 'dsh-pi-tui', 'package.json'))
|
|
32
|
+
const forkPkg = readJson(join(ROOT, 'packages', 'pi-tui', 'package.json'))
|
|
33
|
+
|
|
34
|
+
// 1. Repository + profile naming: never the reserved `tui` / `dsh-tui` family.
|
|
35
|
+
check('root package name is dsh-pi-tui', rootPkg.name === 'dsh-pi-tui', rootPkg.name)
|
|
36
|
+
check('no @deepseek-ai/dsh-tui anywhere', !['@deepseek-ai/dsh-tui', 'dsh-tui'].some(name =>
|
|
37
|
+
[rootPkg, bundlePkg, forkPkg].some(pkg => pkg.name === name)))
|
|
38
|
+
for (const pkg of [rootPkg, bundlePkg, forkPkg]) {
|
|
39
|
+
const manifest = JSON.stringify(pkg)
|
|
40
|
+
if (manifest.includes('@deepseek-ai/dsh-tui') || manifest.includes('"dsh-tui"')) {
|
|
41
|
+
check(`no dsh-tui references in ${pkg.name}`, false)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// The startup service keeps its stable id (internal Loader ids, fine as-is).
|
|
45
|
+
const patch = readFileSync(join(ROOT, 'packages', 'dsh-pi-tui', 'cordis.patch.yml'), 'utf8')
|
|
46
|
+
check('patch rows keep tui-startup/tui-app ids', patch.includes('id: tui-startup') && patch.includes('id: tui-app'))
|
|
47
|
+
check('startup service keeps TUI_STARTUP_SERVICE',
|
|
48
|
+
readFileSync(join(ROOT, 'packages', 'dsh-pi-tui', 'src', 'startup.ts'), 'utf8').includes("export const TUI_STARTUP_SERVICE = 'tuiStartup'"))
|
|
49
|
+
|
|
50
|
+
// 2. Package roles: the fork is private + bundled; the bundle is public.
|
|
51
|
+
check('fork package is private (@xmoon76/pi-tui)', forkPkg.private === true, `private=${String(forkPkg.private)}`)
|
|
52
|
+
check('bundle package is public (@xmoon76/dsh-pi-tui)', bundlePkg.private !== true, `private=${String(bundlePkg.private)}`)
|
|
53
|
+
check('bundle declares dsh.bundle.patch', bundlePkg.dsh?.bundle?.patch !== undefined, bundlePkg.dsh?.bundle?.patch)
|
|
54
|
+
|
|
55
|
+
// 3. The fork is bundled, never a runtime dependency of the published package.
|
|
56
|
+
const tsdown = readFileSync(join(ROOT, 'packages', 'dsh-pi-tui', 'tsdown.config.ts'), 'utf8')
|
|
57
|
+
check('tsdown deps.onlyBundle includes @xmoon76/pi-tui',
|
|
58
|
+
tsdown.includes("onlyBundle") && tsdown.includes("'@xmoon76/pi-tui'"))
|
|
59
|
+
const runtimeDeps = bundlePkg.dependencies ?? {}
|
|
60
|
+
check('@xmoon76/pi-tui is NOT a runtime dependency of the bundle',
|
|
61
|
+
!(runtimeDeps['@xmoon76/pi-tui'] !== undefined), runtimeDeps['@xmoon76/pi-tui'] ?? 'absent')
|
|
62
|
+
const forkDeps = forkPkg.dependencies ?? {}
|
|
63
|
+
check('the fork does not depend on the bundle', !(forkDeps['@xmoon76/dsh-pi-tui'] !== undefined))
|
|
64
|
+
|
|
65
|
+
// 4. The vendored-version single source of truth stays in the fork manifest.
|
|
66
|
+
check('fork repository.note records the vendored commit (single source of truth)',
|
|
67
|
+
typeof forkPkg.repository?.note === 'string' && forkPkg.repository.note.length > 20,
|
|
68
|
+
forkPkg.repository?.note ?? 'missing')
|
|
69
|
+
|
|
70
|
+
// 5. The tarball smoke + pack gate stay wired (stage G).
|
|
71
|
+
check('bundle prepack gate exists', (bundlePkg.scripts?.prepack ?? '').includes('build:package'))
|
|
72
|
+
check('bundle postpack smoke exists', (bundlePkg.scripts?.postpack ?? '').includes('tarball-smoke'))
|
|
73
|
+
|
|
74
|
+
// 6. The workspace has no stray dist/ or tarballs COMMITTED (dist is
|
|
75
|
+
// gitignored by design; the gate checks git tracking, not the tree).
|
|
76
|
+
for (const pkg of ['pi-tui', 'dsh-pi-tui']) {
|
|
77
|
+
const tracked = spawnSync('git', ['ls-files', `packages/${pkg}/dist`], { cwd: ROOT, encoding: 'utf8' }).stdout.trim()
|
|
78
|
+
check(`packages/${pkg}/dist is not committed`, tracked === '', tracked.split('\n').filter(Boolean).slice(0, 3).join(', '))
|
|
79
|
+
}
|
|
80
|
+
const tgz = spawnSync('git', ['ls-files', '*.tgz'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim()
|
|
81
|
+
check('no tarballs committed', tgz === '', tgz.split('\n').filter(Boolean).slice(0, 3).join(', '))
|
|
82
|
+
|
|
83
|
+
// 7. Release workflows reference the UN-scoped tarball filename: npm/pnpm
|
|
84
|
+
// strip the `@` scope from tgz names (xmoon76-dsh-pi-tui-<v>.tgz), so an
|
|
85
|
+
// `@xmoon76-dsh-pi-tui-*.tgz` glob in a workflow matches nothing and the
|
|
86
|
+
// gate/upload/publish step fails at runtime. Guard the exact mistake the
|
|
87
|
+
// 2026-08-15 pack-gate review found.
|
|
88
|
+
for (const workflow of ['ci.yml', 'release.yml']) {
|
|
89
|
+
const path = join(ROOT, '.github', 'workflows', workflow)
|
|
90
|
+
if (!existsSync(path)) continue // publishing lives inside ci.yml today
|
|
91
|
+
const text = readFileSync(path, 'utf8')
|
|
92
|
+
check(`workflow ${workflow} uses unscoped tarball globs`,
|
|
93
|
+
!text.includes('@xmoon76-dsh-pi-tui-*.tgz') && text.includes('xmoon76-dsh-pi-tui-*.tgz'))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const line of checks) console.log(line)
|
|
97
|
+
if (failures.length > 0) {
|
|
98
|
+
console.error(`\nnaming-gate FAILED (${failures.length}):`)
|
|
99
|
+
for (const failure of failures) console.error(` - ${failure}`)
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
console.log('\nnaming-gate passed')
|