dsh-vscode-mode 0.14.5 → 0.14.7

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,135 @@
1
+ /** Bounded SVN XML framing; retain statuses, never the entire verbose document. @author ddj 2026年10月09号 */
2
+ import { StringDecoder } from 'node:string_decoder'
3
+ import type { Readable } from 'node:stream'
4
+ import { attrOf } from './svnXml.js'
5
+ import type { SvnChangeEntry } from './shared/svn.js'
6
+
7
+ export interface StatusPipe {
8
+ stdout?: Readable | null
9
+ done: Promise<{ exitCode?: number | null; code?: number | null }>
10
+ terminate?: () => unknown
11
+ collected?: { stderr?: { readFrom: (offset: number) => { text: string } } }
12
+ }
13
+
14
+ /** @public @author ddj 2026年10月09号 @param handle Managed pipe process. @param parse Status semantics. @param cap Entry bound. @param timeout Deadline. @returns EOF-validated statuses. @throws CLI, timeout or framing failure; never retries with inherit. */
15
+ export async function readStatusPipe(handle: StatusPipe, parse: StatusParser, cap: number, timeout: number, signal?: AbortSignal) {
16
+ const reader = new SvnStatusReader(parse, cap)
17
+ /** @private @author ddj 2026年10月09号 Consume raw chunks through stream EOF, not just direct process exit. */
18
+ async function consume(): Promise<void> {
19
+ if (!handle.stdout) throw new Error('SVN 状态管道不可用,未确认正常状态')
20
+ for await (const chunk of handle.stdout) reader.push(chunk)
21
+ }
22
+ let timer: ReturnType<typeof setTimeout> | undefined
23
+ const expired = new Promise<never>((_, reject) => {
24
+ timer = setTimeout(() => reject(new Error('读取 SVN 图标状态超时')), timeout)
25
+ timer.unref?.()
26
+ })
27
+ let abort: (() => void) | undefined
28
+ const cancelled = new Promise<never>((_, reject) => {
29
+ abort = () => reject(signal?.reason ?? new Error('SVN 状态扫描已取消'))
30
+ if (signal?.aborted) abort()
31
+ else signal?.addEventListener('abort', abort, { once: true })
32
+ })
33
+ try {
34
+ const [outcome] = await Promise.race([Promise.all([handle.done, consume()]), expired, cancelled])
35
+ if ((outcome.exitCode ?? outcome.code) !== 0) throw new Error(handle.collected?.stderr?.readFrom(0).text || 'SVN 状态命令失败')
36
+ return reader.finish()
37
+ } catch (error) {
38
+ // Keep managed teardown observable but do not mask the original failure.
39
+ try { await handle.terminate?.() } catch { /* Preserve CLI/stream failure. */ }
40
+ throw error
41
+ } finally {
42
+ if (timer) clearTimeout(timer)
43
+ if (abort) signal?.removeEventListener('abort', abort)
44
+ }
45
+ }
46
+
47
+ type StatusParser = (xml: string, cap: number) => { entries: SvnChangeEntry[]; truncated: boolean }
48
+ const TAG_CAP = 64 * 1024
49
+ const STREAM_CAP = 512 * 1024 * 1024
50
+
51
+ /** @public @author ddj 2026年10月09号 Validates framing through EOF, including entries beyond the retained cap. */
52
+ export class SvnStatusReader {
53
+ private readonly decoder = new StringDecoder('utf8')
54
+ private buffer = ''
55
+ private bytes = 0
56
+ private readonly stack: string[] = []
57
+ private opened = false
58
+ private closed = false
59
+ private entry = ''
60
+ private changelist = ''
61
+ private readonly entries: SvnChangeEntry[] = []
62
+ private truncated = false
63
+
64
+ /** @public @author ddj 2026年10月09号 @param parse Existing status semantics. @param cap Retained entry bound. */
65
+ constructor(private readonly parse: StatusParser, private readonly cap: number) {}
66
+
67
+ /** @public @author ddj 2026年10月09号 @param chunk Raw pipe chunk. @throws Byte/tag limits or invalid framing. */
68
+ push(chunk: Buffer | string): void {
69
+ this.bytes += Buffer.byteLength(chunk)
70
+ if (this.bytes > STREAM_CAP) throw new Error('SVN 状态流超过 512 MiB 安全上限')
71
+ this.feed(typeof chunk === 'string' ? chunk : this.decoder.write(chunk))
72
+ }
73
+
74
+ /** @private @author ddj 2026年10月09号 @param text Decoded chunk. Process each complete tag once; partial tags stay bounded. */
75
+ private feed(text: string): void {
76
+ this.buffer += text
77
+ let cursor = 0
78
+ while (cursor < this.buffer.length) {
79
+ const start = this.buffer.indexOf('<', cursor)
80
+ const end = start < 0 ? -1 : this.buffer.indexOf('>', start)
81
+ const tail = start < 0 ? this.buffer.length : start
82
+ if (!this.stack.length && this.buffer.slice(cursor, tail).trim()) this.invalid()
83
+ if (end < 0) { cursor = tail; break }
84
+ if (end - start > TAG_CAP) this.invalid()
85
+ this.tag(this.buffer.slice(start, end + 1))
86
+ cursor = end + 1
87
+ }
88
+ this.buffer = this.buffer.slice(cursor)
89
+ if (this.buffer.length > TAG_CAP) this.invalid()
90
+ }
91
+
92
+ /** @private @author ddj 2026年10月09号 @param token Complete XML tag. Validate nesting and capture only entry/wc-status attributes. */
93
+ private tag(token: string): void {
94
+ if (token.startsWith('<?xml ') && !this.opened && !this.stack.length) return
95
+ const match = /^<(\/?)([\w-]+)\b([^>]*?)(\/?)>$/.exec(token)
96
+ if (!match || this.closed) this.invalid()
97
+ const [, closing, name, attrs, self] = match!
98
+ if (closing) {
99
+ if (this.stack.pop() !== name) this.invalid()
100
+ if (name === 'entry') this.saveEntry()
101
+ if (name === 'changelist') this.changelist = ''
102
+ if (name === 'status') this.closed = true
103
+ return
104
+ }
105
+ if (!this.stack.length) {
106
+ if (name !== 'status' || this.opened || self) this.invalid()
107
+ this.opened = true
108
+ }
109
+ if (name === 'changelist') this.changelist = attrOf(attrs, 'name')
110
+ if (name === 'entry') this.entry = token.replace(/\/>$/, '>')
111
+ if (name === 'wc-status' && this.entry) this.entry += '<wc-status' + attrs + '/>'
112
+ if (!self) this.stack.push(name)
113
+ else if (name === 'entry') this.saveEntry()
114
+ }
115
+
116
+ /** @private @author ddj 2026年10月09号 Preserve existing property/tree-conflict/path semantics and bounded statuses. */
117
+ private saveEntry(): void {
118
+ const result = this.parse(this.entry + '</entry>', 1)
119
+ this.entry = ''
120
+ for (const entry of result.entries) {
121
+ if (this.entries.length >= this.cap) { this.truncated = true; continue }
122
+ this.entries.push(this.changelist ? { ...entry, changelist: this.changelist } : entry)
123
+ }
124
+ }
125
+
126
+ /** @private @author ddj 2026年10月09号 @throws Invalid/missing/truncated XML; never produces a clean snapshot. */
127
+ private invalid(): never { throw new Error('SVN 状态输出为空或不完整,未确认正常状态') }
128
+
129
+ /** @public @author ddj 2026年10月09号 @returns Confirmed snapshot only after complete stream EOF. @throws Invalid framing. */
130
+ finish(): { entries: SvnChangeEntry[]; truncated: boolean } {
131
+ this.feed(this.decoder.end())
132
+ if (!this.opened || !this.closed || this.stack.length || this.buffer.trim()) this.invalid()
133
+ return { entries: this.entries, truncated: this.truncated }
134
+ }
135
+ }