@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/README.zh.md +211 -0
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/docs/assets/how-it-works.svg +47 -0
- package/docs/verification.md +279 -0
- package/lib/command.d.ts +41 -0
- package/lib/command.d.ts.map +1 -0
- package/lib/command.js +137 -0
- package/lib/command.js.map +1 -0
- package/lib/compact/build.d.ts +15 -0
- package/lib/compact/build.d.ts.map +1 -0
- package/lib/compact/build.js +140 -0
- package/lib/compact/build.js.map +1 -0
- package/lib/compact/detect.d.ts +22 -0
- package/lib/compact/detect.d.ts.map +1 -0
- package/lib/compact/detect.js +54 -0
- package/lib/compact/detect.js.map +1 -0
- package/lib/compact/dsh-result.d.ts +49 -0
- package/lib/compact/dsh-result.d.ts.map +1 -0
- package/lib/compact/dsh-result.js +84 -0
- package/lib/compact/dsh-result.js.map +1 -0
- package/lib/compact/git.d.ts +21 -0
- package/lib/compact/git.d.ts.map +1 -0
- package/lib/compact/git.js +197 -0
- package/lib/compact/git.js.map +1 -0
- package/lib/compact/index.d.ts +39 -0
- package/lib/compact/index.d.ts.map +1 -0
- package/lib/compact/index.js +236 -0
- package/lib/compact/index.js.map +1 -0
- package/lib/compact/linter.d.ts +12 -0
- package/lib/compact/linter.d.ts.map +1 -0
- package/lib/compact/linter.js +118 -0
- package/lib/compact/linter.js.map +1 -0
- package/lib/compact/search.d.ts +16 -0
- package/lib/compact/search.d.ts.map +1 -0
- package/lib/compact/search.js +67 -0
- package/lib/compact/search.js.map +1 -0
- package/lib/compact/source.d.ts +22 -0
- package/lib/compact/source.d.ts.map +1 -0
- package/lib/compact/source.js +224 -0
- package/lib/compact/source.js.map +1 -0
- package/lib/compact/test-output.d.ts +12 -0
- package/lib/compact/test-output.d.ts.map +1 -0
- package/lib/compact/test-output.js +168 -0
- package/lib/compact/test-output.js.map +1 -0
- package/lib/compact/text.d.ts +22 -0
- package/lib/compact/text.d.ts.map +1 -0
- package/lib/compact/text.js +87 -0
- package/lib/compact/text.js.map +1 -0
- package/lib/config.d.ts +243 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +183 -0
- package/lib/config.js.map +1 -0
- package/lib/index.d.ts +46 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +405 -0
- package/lib/index.js.map +1 -0
- package/lib/metrics.d.ts +35 -0
- package/lib/metrics.d.ts.map +1 -0
- package/lib/metrics.js +51 -0
- package/lib/metrics.js.map +1 -0
- package/lib/rtk-executable.d.ts +51 -0
- package/lib/rtk-executable.d.ts.map +1 -0
- package/lib/rtk-executable.js +75 -0
- package/lib/rtk-executable.js.map +1 -0
- package/lib/rtk-rewrite.d.ts +88 -0
- package/lib/rtk-rewrite.d.ts.map +1 -0
- package/lib/rtk-rewrite.js +150 -0
- package/lib/rtk-rewrite.js.map +1 -0
- package/lib/runtime-guard.d.ts +31 -0
- package/lib/runtime-guard.d.ts.map +1 -0
- package/lib/runtime-guard.js +32 -0
- package/lib/runtime-guard.js.map +1 -0
- package/package.json +82 -0
- package/scripts/link-dsh.mjs +135 -0
- package/src/command.ts +174 -0
- package/src/compact/build.ts +154 -0
- package/src/compact/detect.ts +54 -0
- package/src/compact/dsh-result.ts +99 -0
- package/src/compact/git.ts +209 -0
- package/src/compact/index.ts +284 -0
- package/src/compact/linter.ts +126 -0
- package/src/compact/search.ts +73 -0
- package/src/compact/source.ts +244 -0
- package/src/compact/test-output.ts +184 -0
- package/src/compact/text.ts +86 -0
- package/src/config.ts +263 -0
- package/src/index.ts +431 -0
- package/src/metrics.ts +84 -0
- package/src/rtk-executable.ts +117 -0
- package/src/rtk-rewrite.ts +179 -0
- 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
|
+
}
|