@wingsbutterfly/dsh-rtk 0.1.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/README.zh.md +211 -0
  5. package/THIRD_PARTY_NOTICES.md +51 -0
  6. package/docs/assets/how-it-works.svg +47 -0
  7. package/docs/verification.md +279 -0
  8. package/lib/command.d.ts +41 -0
  9. package/lib/command.d.ts.map +1 -0
  10. package/lib/command.js +137 -0
  11. package/lib/command.js.map +1 -0
  12. package/lib/compact/build.d.ts +15 -0
  13. package/lib/compact/build.d.ts.map +1 -0
  14. package/lib/compact/build.js +140 -0
  15. package/lib/compact/build.js.map +1 -0
  16. package/lib/compact/detect.d.ts +22 -0
  17. package/lib/compact/detect.d.ts.map +1 -0
  18. package/lib/compact/detect.js +54 -0
  19. package/lib/compact/detect.js.map +1 -0
  20. package/lib/compact/dsh-result.d.ts +49 -0
  21. package/lib/compact/dsh-result.d.ts.map +1 -0
  22. package/lib/compact/dsh-result.js +84 -0
  23. package/lib/compact/dsh-result.js.map +1 -0
  24. package/lib/compact/git.d.ts +21 -0
  25. package/lib/compact/git.d.ts.map +1 -0
  26. package/lib/compact/git.js +197 -0
  27. package/lib/compact/git.js.map +1 -0
  28. package/lib/compact/index.d.ts +39 -0
  29. package/lib/compact/index.d.ts.map +1 -0
  30. package/lib/compact/index.js +236 -0
  31. package/lib/compact/index.js.map +1 -0
  32. package/lib/compact/linter.d.ts +12 -0
  33. package/lib/compact/linter.d.ts.map +1 -0
  34. package/lib/compact/linter.js +118 -0
  35. package/lib/compact/linter.js.map +1 -0
  36. package/lib/compact/search.d.ts +16 -0
  37. package/lib/compact/search.d.ts.map +1 -0
  38. package/lib/compact/search.js +67 -0
  39. package/lib/compact/search.js.map +1 -0
  40. package/lib/compact/source.d.ts +22 -0
  41. package/lib/compact/source.d.ts.map +1 -0
  42. package/lib/compact/source.js +224 -0
  43. package/lib/compact/source.js.map +1 -0
  44. package/lib/compact/test-output.d.ts +12 -0
  45. package/lib/compact/test-output.d.ts.map +1 -0
  46. package/lib/compact/test-output.js +168 -0
  47. package/lib/compact/test-output.js.map +1 -0
  48. package/lib/compact/text.d.ts +22 -0
  49. package/lib/compact/text.d.ts.map +1 -0
  50. package/lib/compact/text.js +87 -0
  51. package/lib/compact/text.js.map +1 -0
  52. package/lib/config.d.ts +243 -0
  53. package/lib/config.d.ts.map +1 -0
  54. package/lib/config.js +183 -0
  55. package/lib/config.js.map +1 -0
  56. package/lib/index.d.ts +46 -0
  57. package/lib/index.d.ts.map +1 -0
  58. package/lib/index.js +405 -0
  59. package/lib/index.js.map +1 -0
  60. package/lib/metrics.d.ts +35 -0
  61. package/lib/metrics.d.ts.map +1 -0
  62. package/lib/metrics.js +51 -0
  63. package/lib/metrics.js.map +1 -0
  64. package/lib/rtk-executable.d.ts +51 -0
  65. package/lib/rtk-executable.d.ts.map +1 -0
  66. package/lib/rtk-executable.js +75 -0
  67. package/lib/rtk-executable.js.map +1 -0
  68. package/lib/rtk-rewrite.d.ts +88 -0
  69. package/lib/rtk-rewrite.d.ts.map +1 -0
  70. package/lib/rtk-rewrite.js +150 -0
  71. package/lib/rtk-rewrite.js.map +1 -0
  72. package/lib/runtime-guard.d.ts +31 -0
  73. package/lib/runtime-guard.d.ts.map +1 -0
  74. package/lib/runtime-guard.js +32 -0
  75. package/lib/runtime-guard.js.map +1 -0
  76. package/package.json +82 -0
  77. package/scripts/link-dsh.mjs +135 -0
  78. package/src/command.ts +174 -0
  79. package/src/compact/build.ts +154 -0
  80. package/src/compact/detect.ts +54 -0
  81. package/src/compact/dsh-result.ts +99 -0
  82. package/src/compact/git.ts +209 -0
  83. package/src/compact/index.ts +284 -0
  84. package/src/compact/linter.ts +126 -0
  85. package/src/compact/search.ts +73 -0
  86. package/src/compact/source.ts +244 -0
  87. package/src/compact/test-output.ts +184 -0
  88. package/src/compact/text.ts +86 -0
  89. package/src/config.ts +263 -0
  90. package/src/index.ts +431 -0
  91. package/src/metrics.ts +84 -0
  92. package/src/rtk-executable.ts +117 -0
  93. package/src/rtk-rewrite.ts +179 -0
  94. package/src/runtime-guard.ts +44 -0
@@ -0,0 +1,244 @@
1
+ import type { SourceCodeFilteringLevel } from '../config.js'
2
+
3
+ /** A language whose comment syntax this module knows. */
4
+ export type Language = 'javascript' | 'python' | 'rust' | 'go' | 'java' | 'c' | 'shell' | 'ruby' | 'unknown'
5
+
6
+ const LANGUAGE_EXTENSIONS: Record<string, Language> = {
7
+ '.js': 'javascript',
8
+ '.jsx': 'javascript',
9
+ '.mjs': 'javascript',
10
+ '.cjs': 'javascript',
11
+ '.ts': 'javascript',
12
+ '.tsx': 'javascript',
13
+ '.mts': 'javascript',
14
+ '.cts': 'javascript',
15
+ '.py': 'python',
16
+ '.pyi': 'python',
17
+ '.rs': 'rust',
18
+ '.go': 'go',
19
+ '.java': 'java',
20
+ '.kt': 'java',
21
+ '.c': 'c',
22
+ '.h': 'c',
23
+ '.cc': 'c',
24
+ '.cpp': 'c',
25
+ '.hpp': 'c',
26
+ '.sh': 'shell',
27
+ '.bash': 'shell',
28
+ '.zsh': 'shell',
29
+ '.rb': 'ruby',
30
+ }
31
+
32
+ const LINE_COMMENT: Record<Language, string | undefined> = {
33
+ javascript: '//',
34
+ python: '#',
35
+ rust: '//',
36
+ go: '//',
37
+ java: '//',
38
+ c: '//',
39
+ shell: '#',
40
+ ruby: '#',
41
+ unknown: undefined,
42
+ }
43
+
44
+ const BLOCK_COMMENT: Partial<Record<Language, readonly [string, string]>> = {
45
+ javascript: ['/*', '*/'],
46
+ rust: ['/*', '*/'],
47
+ go: ['/*', '*/'],
48
+ java: ['/*', '*/'],
49
+ c: ['/*', '*/'],
50
+ }
51
+
52
+ /** Signature/declaration lines worth keeping when lines must be dropped. */
53
+ const SIGNATURE_PATTERN = /^(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|def|fn|const|let|var|public|private|protected|static|pub|package|import|from|use|mod)\b/
54
+ const IMPORT_PATTERN = /^(?:import|from|use|require|#include|package|mod)\b/
55
+
56
+ /** Infer a source language from a file extension. */
57
+ export function detectLanguage(filePath: string): Language {
58
+ const lastDot = filePath.lastIndexOf('.')
59
+ if (lastDot === -1) return 'unknown'
60
+ return LANGUAGE_EXTENSIONS[filePath.slice(lastDot).toLowerCase()] ?? 'unknown'
61
+ }
62
+
63
+ /**
64
+ * Keep the most informative `maxLines` lines of a source read.
65
+ *
66
+ * The first half of the budget is kept verbatim, after which only structural
67
+ * lines survive — signatures, imports, and lone braces — with a marker where
68
+ * body lines were dropped. This preserves the shape of the file (what is
69
+ * defined where) rather than an arbitrary prefix, which is what makes the
70
+ * result still usable for orientation.
71
+ *
72
+ * @param content - file text.
73
+ * @param maxLines - line budget.
74
+ * @returns the reduced text.
75
+ */
76
+ export function smartTruncate(content: string, maxLines: number): string {
77
+ const lines = content.split('\n')
78
+ if (lines.length <= maxLines) return content
79
+
80
+ const result: string[] = []
81
+ let keptLines = 0
82
+ let skippedSection = false
83
+
84
+ for (const line of lines) {
85
+ const trimmed = line.trim()
86
+ const isImportant =
87
+ SIGNATURE_PATTERN.test(trimmed) ||
88
+ IMPORT_PATTERN.test(trimmed) ||
89
+ trimmed.startsWith('pub ') ||
90
+ trimmed.startsWith('export ') ||
91
+ trimmed === '}' ||
92
+ trimmed === '{'
93
+
94
+ if (isImportant || keptLines < maxLines / 2) {
95
+ if (skippedSection) {
96
+ result.push(` // ... ${lines.length - keptLines} lines omitted`)
97
+ skippedSection = false
98
+ }
99
+ result.push(line)
100
+ keptLines += 1
101
+ } else {
102
+ skippedSection = true
103
+ }
104
+
105
+ if (keptLines >= maxLines - 1) break
106
+ }
107
+
108
+ if (skippedSection || keptLines < lines.length) {
109
+ result.push(`// ... ${lines.length - keptLines} more lines (total: ${lines.length})`)
110
+ }
111
+
112
+ return result.join('\n')
113
+ }
114
+
115
+ /**
116
+ * Remove non-documentation comments and collapse blank runs.
117
+ *
118
+ * Documentation comments — line docs and block docs alike — are kept: they
119
+ * carry the contract, which is the part a reader most often needs. A line
120
+ * whose comment marker appears inside a string literal is left alone, which is
121
+ * why the check is prefix-anchored rather than a plain substring search.
122
+ */
123
+ function filterMinimal(content: string, language: Language): string {
124
+ const lineComment = LINE_COMMENT[language]
125
+ const block = BLOCK_COMMENT[language]
126
+ const lines = content.split('\n')
127
+ const result: string[] = []
128
+ let inBlockComment = false
129
+ let inDocstring = false
130
+
131
+ for (const line of lines) {
132
+ const trimmed = line.trim()
133
+
134
+ if (inBlockComment) {
135
+ if (block !== undefined && trimmed.includes(block[1])) inBlockComment = false
136
+ continue
137
+ }
138
+ if (inDocstring) {
139
+ result.push(line)
140
+ if (trimmed.endsWith('"""') || trimmed.endsWith("'''")) inDocstring = false
141
+ continue
142
+ }
143
+
144
+ if (language === 'python' && (trimmed.startsWith('"""') || trimmed.startsWith("'''"))) {
145
+ result.push(line)
146
+ const isSingleLine = trimmed.length > 3 && (trimmed.endsWith('"""') || trimmed.endsWith("'''"))
147
+ if (!isSingleLine) inDocstring = true
148
+ continue
149
+ }
150
+
151
+ if (block !== undefined && trimmed.startsWith(block[0]) && !trimmed.includes(block[1], block[0].length)) {
152
+ // A doc block (an extra `*` after the opener) is kept verbatim; any other
153
+ // block comment opens and hides the lines that follow it.
154
+ const afterOpener = trimmed.charAt(block[0].length)
155
+ if (afterOpener === '*') {
156
+ result.push(line)
157
+ } else {
158
+ inBlockComment = true
159
+ }
160
+ continue
161
+ }
162
+
163
+ if (lineComment !== undefined && trimmed.startsWith(lineComment)) {
164
+ // Doc comments stay; ordinary comments go. A doc marker is the comment
165
+ // prefix followed by `/` (`///`) or `!` (`//!`).
166
+ const afterMarker = trimmed.charAt(lineComment.length)
167
+ if (afterMarker === '/' || afterMarker === '!') result.push(line)
168
+ continue
169
+ }
170
+
171
+ result.push(line)
172
+ }
173
+
174
+ const collapsed: string[] = []
175
+ let blankRun = 0
176
+ for (const line of result) {
177
+ if (line.trim() === '') {
178
+ blankRun++
179
+ if (blankRun > 1) continue
180
+ } else {
181
+ blankRun = 0
182
+ }
183
+ collapsed.push(line)
184
+ }
185
+
186
+ return collapsed.join('\n')
187
+ }
188
+
189
+ /**
190
+ * Keep imports, constants, and signatures while replacing bodies with a marker.
191
+ *
192
+ * Deliberately the most lossy level: it answers "what is in this file" without
193
+ * pretending to answer "how does it work".
194
+ */
195
+ function filterAggressive(content: string, language: Language): string {
196
+ const lines = content.split('\n')
197
+ const result: string[] = []
198
+ let bodyDepth = 0
199
+ let skipped = 0
200
+
201
+ for (const line of lines) {
202
+ const trimmed = line.trim()
203
+ const isStructural =
204
+ IMPORT_PATTERN.test(trimmed) ||
205
+ SIGNATURE_PATTERN.test(trimmed) ||
206
+ /^[A-Z_][A-Z0-9_]*\s*=/.test(trimmed) ||
207
+ trimmed === '}' ||
208
+ trimmed === '{' ||
209
+ trimmed === ''
210
+
211
+ if (isStructural) {
212
+ if (skipped > 0) {
213
+ result.push(` // ... ${skipped} implementation lines omitted`)
214
+ skipped = 0
215
+ }
216
+ result.push(line)
217
+ bodyDepth = trimmed.endsWith('{') || trimmed.endsWith(':') ? bodyDepth + 1 : Math.max(0, bodyDepth - 1)
218
+ continue
219
+ }
220
+
221
+ if (bodyDepth > 0) {
222
+ skipped++
223
+ continue
224
+ }
225
+ result.push(line)
226
+ }
227
+
228
+ if (skipped > 0) result.push(` // ... ${skipped} implementation lines omitted`)
229
+ return result.join('\n')
230
+ }
231
+
232
+ /** Apply the configured source-filtering level. */
233
+ export function filterSourceCode(content: string, language: Language, level: SourceCodeFilteringLevel): string {
234
+ switch (level) {
235
+ case 'none':
236
+ return content
237
+ case 'minimal':
238
+ return filterMinimal(content, language)
239
+ case 'aggressive':
240
+ return filterAggressive(content, language)
241
+ default:
242
+ return content
243
+ }
244
+ }
@@ -0,0 +1,184 @@
1
+ import { matchesCommandPatterns } from './detect.js'
2
+
3
+ interface TestSummary {
4
+ passed: number
5
+ failed: number
6
+ skipped: number
7
+ failures: string[]
8
+ }
9
+
10
+ const TEST_COMMAND_PATTERNS = [
11
+ /^npm\s+test\b/,
12
+ /^pnpm\s+test\b/,
13
+ /^yarn\s+test\b/,
14
+ /^bun\s+test\b/,
15
+ /^cargo\s+test\b/,
16
+ /^go\s+test\b/,
17
+ /^pytest\b/,
18
+ /^python\s+-m\s+pytest\b/,
19
+ /^(?:pnpm\s+)?(?:npx\s+)?vitest\b/,
20
+ /^(?:npx\s+)?jest\b/,
21
+ /^mocha\b/,
22
+ /^ava\b/,
23
+ /^tap\b/,
24
+ /^node\s+--test\b/,
25
+ ] as const
26
+
27
+ const TEST_RESULT_PATTERNS = [
28
+ /test result:\s*(\w+)\.\s*(\d+)\s*passed;\s*(\d+)\s*failed;/,
29
+ /#\s*tests\s+(\d+)\s*$/m,
30
+ /(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
31
+ /(\d+)\s*pass(?:,\s*(\d+)\s*fail)?(?:,\s*(\d+)\s*skip)?/i,
32
+ /tests?:\s*(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
33
+ ]
34
+
35
+ const FAILURE_START_PATTERNS = [
36
+ /^FAIL\s+/,
37
+ /^FAILED\s+/,
38
+ /^\s*●\s+/,
39
+ // U+2715 and U+2716 look alike and are both in the wild; `node --test` uses
40
+ // the heavy form, which a pattern written for the light one silently misses.
41
+ /^\s*[✕✖]\s+/,
42
+ /^\s*not ok\s+/,
43
+ /test\s+\w+\s+\.\.\.\s*FAILED/,
44
+ /thread\s+'\w+'\s+panicked/,
45
+ ]
46
+
47
+ const FALLBACK_PASS_PATTERN = /(?:\b(?:ok|PASS)\b|[✓✔])/
48
+ const FALLBACK_FAIL_PATTERN = /(?:\b(?:FAIL|fail)\b|[✗✕✖])/
49
+
50
+ const MAX_FAILURES = 5
51
+ const MAX_FAILURE_LINES = 4
52
+ const BLANKS_TO_CLOSE_BLOCK = 2
53
+
54
+ /**
55
+ * Read `node --test`'s tallies, which it prints on separate lines:
56
+ * `ℹ tests 2` / `ℹ pass 1` / `ℹ fail 1`.
57
+ *
58
+ * These are authoritative — the runner counted them — so they are preferred
59
+ * over every scraped-marker fallback.
60
+ */
61
+ function extractNodeTestStats(output: string): Partial<TestSummary> | undefined {
62
+ const passed = output.match(/^\u2139\s*pass\s+(\d+)/m)
63
+ if (passed === null) return undefined
64
+ return {
65
+ passed: Number.parseInt(passed[1] ?? '0', 10) || 0,
66
+ failed: Number.parseInt(output.match(/^\u2139\s*fail\s+(\d+)/m)?.[1] ?? '0', 10) || 0,
67
+ skipped: Number.parseInt(output.match(/^\u2139\s*skipped\s+(\d+)/m)?.[1] ?? '0', 10) || 0,
68
+ }
69
+ }
70
+
71
+ function extractTestStats(output: string): Partial<TestSummary> {
72
+ const fromNodeTest = extractNodeTestStats(output)
73
+ if (fromNodeTest !== undefined) return fromNodeTest
74
+
75
+ for (const pattern of TEST_RESULT_PATTERNS) {
76
+ const match = output.match(pattern)
77
+ if (!match) continue
78
+ return {
79
+ passed: Number.parseInt(match[1] ?? '0', 10) || 0,
80
+ failed: Number.parseInt(match[2] ?? '0', 10) || 0,
81
+ skipped: Number.parseInt(match[3] ?? '0', 10) || 0,
82
+ }
83
+ }
84
+ return {}
85
+ }
86
+
87
+ /** Whether the command is a test runner this module understands. */
88
+ export function isTestCommand(command: string | undefined | null): boolean {
89
+ return matchesCommandPatterns(command, TEST_COMMAND_PATTERNS)
90
+ }
91
+
92
+ /**
93
+ * Collapse test-runner output to a pass/fail/skip summary plus the first few
94
+ * failure excerpts.
95
+ *
96
+ * When no runner summary line can be parsed, per-line pass/fail markers are
97
+ * counted instead, so an unusual runner still yields a usable shape rather than
98
+ * a bare "0 passed".
99
+ */
100
+ export function aggregateTestOutput(output: string, command: string | undefined | null): string | null {
101
+ if (!isTestCommand(command)) return null
102
+
103
+ const lines = output.split('\n')
104
+ const summary: TestSummary = { passed: 0, failed: 0, skipped: 0, failures: [] }
105
+
106
+ const stats = extractTestStats(output)
107
+ summary.passed = stats.passed ?? 0
108
+ summary.failed = stats.failed ?? 0
109
+ summary.skipped = stats.skipped ?? 0
110
+
111
+ if (summary.passed === 0 && summary.failed === 0) {
112
+ for (const line of lines) {
113
+ if (FALLBACK_PASS_PATTERN.test(line)) summary.passed++
114
+ if (FALLBACK_FAIL_PATTERN.test(line)) summary.failed++
115
+ }
116
+ }
117
+
118
+ if (summary.failed > 0) {
119
+ let inFailure = false
120
+ let currentFailure: string[] = []
121
+ let blankCount = 0
122
+
123
+ for (const line of lines) {
124
+ if (FAILURE_START_PATTERNS.some((pattern) => pattern.test(line))) {
125
+ if (inFailure && currentFailure.length > 0) summary.failures.push(currentFailure.join('\n'))
126
+ inFailure = true
127
+ currentFailure = [line]
128
+ blankCount = 0
129
+ continue
130
+ }
131
+
132
+ if (!inFailure) continue
133
+
134
+ if (line.trim() === '') {
135
+ blankCount++
136
+ if (blankCount >= BLANKS_TO_CLOSE_BLOCK && currentFailure.length > 3) {
137
+ summary.failures.push(currentFailure.join('\n'))
138
+ inFailure = false
139
+ currentFailure = []
140
+ } else {
141
+ currentFailure.push(line)
142
+ }
143
+ continue
144
+ }
145
+
146
+ if (line.match(/^\s/) || line.match(/^-/)) {
147
+ currentFailure.push(line)
148
+ blankCount = 0
149
+ continue
150
+ }
151
+
152
+ summary.failures.push(currentFailure.join('\n'))
153
+ inFailure = false
154
+ currentFailure = []
155
+ }
156
+
157
+ if (inFailure && currentFailure.length > 0) summary.failures.push(currentFailure.join('\n'))
158
+ }
159
+
160
+ const result: string[] = ['Test Results:']
161
+ result.push(` PASS: ${summary.passed} passed`)
162
+ if (summary.failed > 0) result.push(` FAIL: ${summary.failed} failed`)
163
+ if (summary.skipped > 0) result.push(` SKIP: ${summary.skipped} skipped`)
164
+
165
+ if (summary.failed > 0 && summary.failures.length > 0) {
166
+ result.push('\n Failures:')
167
+ for (const failure of summary.failures.slice(0, MAX_FAILURES)) {
168
+ const failureLines = failure.split('\n')
169
+ const firstLine = failureLines[0] ?? ''
170
+ result.push(` - ${firstLine.slice(0, 70)}${firstLine.length > 70 ? '...' : ''}`)
171
+ for (const detailLine of failureLines.slice(1, MAX_FAILURE_LINES)) {
172
+ if (detailLine.trim()) result.push(` ${detailLine.slice(0, 65)}${detailLine.length > 65 ? '...' : ''}`)
173
+ }
174
+ if (failureLines.length > MAX_FAILURE_LINES) {
175
+ result.push(` ... (${failureLines.length - MAX_FAILURE_LINES} more lines)`)
176
+ }
177
+ }
178
+ if (summary.failures.length > MAX_FAILURES) {
179
+ result.push(` ... and ${summary.failures.length - MAX_FAILURES} more failures`)
180
+ }
181
+ }
182
+
183
+ return result.join('\n')
184
+ }
@@ -0,0 +1,86 @@
1
+ /** Text primitives shared by every compaction technique. */
2
+
3
+ const ANSI_CSI = /\u001b\[[0-9;]*[a-zA-Z]/g
4
+ const ANSI_OSC_BEL = /\u001b\][0-9;]*(?:\u0007|\u001b\\)/g
5
+ const ANSI_OSC_ST = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g
6
+
7
+ /**
8
+ * Remove terminal color and formatting sequences.
9
+ *
10
+ * Covered: CSI (`ESC [ … letter`) and OSC (`ESC ] … BEL|ST`). The cheap guard
11
+ * matters because this runs on every text result and most have no escape byte
12
+ * at all.
13
+ */
14
+ export function stripAnsi(text: string): string {
15
+ if (!text.includes('\u001b')) return text
16
+ return text.replace(ANSI_CSI, '').replace(ANSI_OSC_BEL, '').replace(ANSI_OSC_ST, '')
17
+ }
18
+
19
+ /** Keep at most `maxLength` characters, marking elision with a trailing ellipsis. */
20
+ export function truncate(text: string, maxLength: number): string {
21
+ if (text.length <= maxLength) return text
22
+ if (maxLength < 3) return '...'
23
+ return `${text.slice(0, maxLength - 3)}...`
24
+ }
25
+
26
+ /** Number of lines, counting a trailing newline as a terminator rather than a new line. */
27
+ export function countLines(text: string): number {
28
+ if (!text) return 0
29
+ const normalized = text.endsWith('\n') ? text.slice(0, -1) : text
30
+ if (!normalized) return 1
31
+ return normalized.split('\n').length
32
+ }
33
+
34
+ function detectPathSeparator(path: string): '/' | '\\' {
35
+ return path.includes('\\') && !path.includes('/') ? '\\' : '/'
36
+ }
37
+
38
+ function detectPathPrefix(path: string, separator: '/' | '\\'): string {
39
+ if (/^[A-Za-z]:[\\/]/.test(path)) return `${path.slice(0, 2)}${separator}`
40
+ if (path.startsWith('\\\\') || path.startsWith('//')) {
41
+ const parts = path.split(/[\\/]+/).filter((part) => part.length > 0)
42
+ if (parts.length >= 2) return `${separator}${separator}${parts[0]}${separator}${parts[1]}${separator}`
43
+ return `${separator}${separator}`
44
+ }
45
+ if (path.startsWith('/') || path.startsWith('\\')) return separator
46
+ return ''
47
+ }
48
+
49
+ /**
50
+ * Shorten a path to `maxLength` by eliding its middle segments.
51
+ *
52
+ * The last segment survives because it is the part a reader needs; earlier
53
+ * candidates keep one parent directory when the budget allows, which is what
54
+ * disambiguates two files with the same basename.
55
+ */
56
+ export function compactPath(path: string, maxLength: number): string {
57
+ if (path.length <= maxLength) return path
58
+ if (maxLength < 2) return path.slice(0, maxLength)
59
+
60
+ const separator = detectPathSeparator(path)
61
+ const prefix = detectPathPrefix(path, separator)
62
+ const segments = path
63
+ .slice(prefix.length)
64
+ .split(/[\\/]+/)
65
+ .filter((segment) => segment.length > 0)
66
+
67
+ const lastSegment = segments[segments.length - 1] ?? path.slice(-(maxLength - 1))
68
+ const previousSegment = segments[segments.length - 2]
69
+ const tail = [previousSegment, lastSegment].filter((segment): segment is string => segment !== undefined)
70
+ const join = (head: string, parts: string[]): string => {
71
+ const body = parts.join(separator)
72
+ return head ? `${head}${body}` : body
73
+ }
74
+
75
+ const candidates = [
76
+ join(prefix, ['…', ...tail]),
77
+ join('', ['…', ...tail]),
78
+ join('', ['…', lastSegment]),
79
+ `…${path.slice(-(maxLength - 1))}`,
80
+ ]
81
+
82
+ for (const candidate of candidates) {
83
+ if (candidate.length <= maxLength) return candidate
84
+ }
85
+ return `…${lastSegment.slice(-(maxLength - 1))}`
86
+ }