@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,284 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { OutputCompactionConfig, RtkConfig } from '../config.js'
|
|
5
|
+
import { aggregateLinterOutput } from './linter.js'
|
|
6
|
+
import { filterBuildOutput } from './build.js'
|
|
7
|
+
import { aggregateTestOutput } from './test-output.js'
|
|
8
|
+
import { compactGitOutput } from './git.js'
|
|
9
|
+
import { groupSearchResults } from './search.js'
|
|
10
|
+
import { detectLanguage, filterSourceCode, smartTruncate } from './source.js'
|
|
11
|
+
import { NO_OUTPUT_PLACEHOLDER, parseBashResult, renderBashResult } from './dsh-result.js'
|
|
12
|
+
import { countLines, stripAnsi, truncate } from './text.js'
|
|
13
|
+
|
|
14
|
+
/** Techniques whose effect cannot be undone by re-reading a smaller range. */
|
|
15
|
+
const LOSSY_TECHNIQUES = ['build', 'test', 'git', 'linter', 'search', 'truncate', 'smart-truncate', 'source:'] as const
|
|
16
|
+
|
|
17
|
+
/** Reads at or below this many lines are always left exact. */
|
|
18
|
+
const READ_EXACT_LINE_THRESHOLD = 80
|
|
19
|
+
|
|
20
|
+
/** Banner prefixed to a `read` result that lost information. */
|
|
21
|
+
export const READ_COMPACTION_BANNER_PREFIX = '[rtk compacted output:'
|
|
22
|
+
|
|
23
|
+
/** The tool-result projection compaction reads and writes. */
|
|
24
|
+
export interface CompactionInput {
|
|
25
|
+
toolName: string
|
|
26
|
+
args: unknown
|
|
27
|
+
content: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What one compaction pass did, for the session metrics. */
|
|
31
|
+
export interface CompactionMetadata {
|
|
32
|
+
applied: boolean
|
|
33
|
+
techniques: string[]
|
|
34
|
+
truncated: boolean
|
|
35
|
+
originalCharCount: number
|
|
36
|
+
compactedCharCount: number
|
|
37
|
+
originalLineCount: number
|
|
38
|
+
compactedLineCount: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The pipeline's verdict for one tool result. */
|
|
42
|
+
export interface CompactionOutcome {
|
|
43
|
+
changed: boolean
|
|
44
|
+
content?: unknown[]
|
|
45
|
+
techniques: string[]
|
|
46
|
+
metadata?: CompactionMetadata
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface TextTransform {
|
|
50
|
+
text: string
|
|
51
|
+
techniques: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
55
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asArray(value: unknown): unknown[] {
|
|
59
|
+
return Array.isArray(value) ? value : []
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTextBlock(block: unknown): block is { type: 'text'; text: string } {
|
|
63
|
+
const record = asRecord(block)
|
|
64
|
+
return record.type === 'text' && typeof record.text === 'string'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasLossyTechnique(techniques: readonly string[]): boolean {
|
|
68
|
+
return techniques.some((technique) =>
|
|
69
|
+
LOSSY_TECHNIQUES.some((prefix) => (prefix.endsWith(':') ? technique.startsWith(prefix) : technique === prefix)),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Apply one nullable technique, recording it only when it changed the text. */
|
|
74
|
+
function applyNullable(state: TextTransform, result: string | null, technique: string): void {
|
|
75
|
+
if (result === null || result === state.text) return
|
|
76
|
+
state.text = result
|
|
77
|
+
state.techniques.push(technique)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyAnsi(state: TextTransform, compaction: OutputCompactionConfig): void {
|
|
81
|
+
if (!compaction.stripAnsi) return
|
|
82
|
+
const stripped = stripAnsi(state.text)
|
|
83
|
+
if (stripped !== state.text) {
|
|
84
|
+
state.text = stripped
|
|
85
|
+
state.techniques.push('ansi')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compact a bash result without disturbing its status markers.
|
|
91
|
+
*
|
|
92
|
+
* stdout is summarized by whichever technique recognizes the command; stderr is
|
|
93
|
+
* only stripped of escape codes, because an error message's exact wording is
|
|
94
|
+
* what the reader acts on. The trailing markers are re-attached verbatim, and
|
|
95
|
+
* a final budget check truncates the bodies while never cutting a marker.
|
|
96
|
+
*/
|
|
97
|
+
function compactBashText(text: string, command: string | undefined, compaction: OutputCompactionConfig): TextTransform {
|
|
98
|
+
const parts = parseBashResult(text)
|
|
99
|
+
if (parts.empty) return { text, techniques: [] }
|
|
100
|
+
|
|
101
|
+
const state: TextTransform = { text: parts.stdout, techniques: [] }
|
|
102
|
+
applyAnsi(state, compaction)
|
|
103
|
+
|
|
104
|
+
if (compaction.filterBuildOutput) applyNullable(state, filterBuildOutput(state.text, command), 'build')
|
|
105
|
+
if (compaction.aggregateTestOutput) applyNullable(state, aggregateTestOutput(state.text, command), 'test')
|
|
106
|
+
if (compaction.compactGitOutput) applyNullable(state, compactGitOutput(state.text, command), 'git')
|
|
107
|
+
if (compaction.aggregateLinterOutput) applyNullable(state, aggregateLinterOutput(state.text, command), 'linter')
|
|
108
|
+
|
|
109
|
+
let stdout = state.text
|
|
110
|
+
let stderr = parts.stderr
|
|
111
|
+
if (stderr !== undefined && compaction.stripAnsi) stderr = stripAnsi(stderr)
|
|
112
|
+
|
|
113
|
+
const render = (): string =>
|
|
114
|
+
renderBashResult({
|
|
115
|
+
empty: false,
|
|
116
|
+
stdout,
|
|
117
|
+
...(stderr === undefined ? {} : { stderr }),
|
|
118
|
+
markers: parts.markers,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
let rendered = render()
|
|
122
|
+
if (compaction.truncate.enabled && rendered.length > compaction.truncate.maxChars) {
|
|
123
|
+
const markerOverhead = renderBashResult({ empty: false, stdout: '', markers: parts.markers }).length
|
|
124
|
+
const budget = Math.max(1, compaction.truncate.maxChars - markerOverhead - 8)
|
|
125
|
+
// stderr keeps the smaller share: it is usually short, and when it is not,
|
|
126
|
+
// its head is still the diagnostic while its tail is usually a stack dump.
|
|
127
|
+
const stderrBudget = stderr === undefined ? 0 : Math.min(stderr.length, Math.floor(budget * 0.4))
|
|
128
|
+
const stdoutBudget = Math.max(1, budget - stderrBudget)
|
|
129
|
+
if (stdout.length > stdoutBudget) stdout = truncate(stdout, stdoutBudget)
|
|
130
|
+
if (stderr !== undefined && stderr.length > stderrBudget) stderr = truncate(stderr, stderrBudget)
|
|
131
|
+
rendered = render()
|
|
132
|
+
state.techniques.push('truncate')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Compaction must never make a result bigger than the text it replaced.
|
|
136
|
+
if (rendered.length >= text.length) return { text, techniques: [] }
|
|
137
|
+
return { text: rendered, techniques: state.techniques }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a `read` result must stay byte-exact.
|
|
142
|
+
*
|
|
143
|
+
* Three cases: the caller asked for a narrow range (the tool already bounded
|
|
144
|
+
* it, and the caller will edit against what it sees), the file is short enough
|
|
145
|
+
* that filtering saves nothing worth the risk, or the path lives under a skill
|
|
146
|
+
* directory — skills are instructions the agent follows verbatim.
|
|
147
|
+
*/
|
|
148
|
+
function shouldPreserveExactRead(text: string, args: Record<string, unknown>, compaction: OutputCompactionConfig): boolean {
|
|
149
|
+
if (!compaction.readCompaction.enabled) return true
|
|
150
|
+
if (args.offset !== undefined || args.limit !== undefined) return true
|
|
151
|
+
if (countLines(text) <= READ_EXACT_LINE_THRESHOLD) return true
|
|
152
|
+
|
|
153
|
+
if (compaction.preserveExactSkillReads && typeof args.path === 'string') {
|
|
154
|
+
const target = resolve(args.path)
|
|
155
|
+
const roots = [resolve(homedir(), '.agents', 'skills'), resolve(homedir(), '.dsh', 'skills')]
|
|
156
|
+
let cursor = process.cwd()
|
|
157
|
+
for (;;) {
|
|
158
|
+
roots.push(resolve(cursor, '.agents', 'skills'))
|
|
159
|
+
const parent = resolve(cursor, '..')
|
|
160
|
+
if (parent === cursor) break
|
|
161
|
+
cursor = parent
|
|
162
|
+
}
|
|
163
|
+
if (roots.some((root) => target === root || target.startsWith(`${root}/`))) return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function compactReadText(
|
|
170
|
+
text: string,
|
|
171
|
+
args: Record<string, unknown>,
|
|
172
|
+
compaction: OutputCompactionConfig,
|
|
173
|
+
): TextTransform {
|
|
174
|
+
if (shouldPreserveExactRead(text, args, compaction)) return { text, techniques: [] }
|
|
175
|
+
|
|
176
|
+
const state: TextTransform = { text, techniques: [] }
|
|
177
|
+
applyAnsi(state, compaction)
|
|
178
|
+
|
|
179
|
+
const filePath = typeof args.path === 'string' ? args.path : ''
|
|
180
|
+
const language = detectLanguage(filePath)
|
|
181
|
+
|
|
182
|
+
if (compaction.sourceCodeFilteringEnabled && compaction.sourceCodeFiltering !== 'none') {
|
|
183
|
+
applyNullable(state, filterSourceCode(state.text, language, compaction.sourceCodeFiltering), `source:${compaction.sourceCodeFiltering}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (compaction.smartTruncate.enabled && countLines(state.text) > compaction.smartTruncate.maxLines) {
|
|
187
|
+
applyNullable(state, smartTruncate(state.text, compaction.smartTruncate.maxLines), 'smart-truncate')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (compaction.truncate.enabled && state.text.length > compaction.truncate.maxChars) {
|
|
191
|
+
applyNullable(state, truncate(state.text, compaction.truncate.maxChars), 'truncate')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (state.techniques.length === 0) return { text, techniques: [] }
|
|
195
|
+
|
|
196
|
+
// The banner is part of the returned result, so the size guard must weigh it.
|
|
197
|
+
// Filtering one comment line out of a short file saves less than the banner
|
|
198
|
+
// costs, and without this the result would come back longer than the text it
|
|
199
|
+
// replaced — the exact invariant the guard exists to hold.
|
|
200
|
+
const banner = `${READ_COMPACTION_BANNER_PREFIX} ${state.techniques.join(', ')}]`
|
|
201
|
+
const rendered = `${banner}\n${state.text}`
|
|
202
|
+
if (rendered.length >= text.length) return { text, techniques: [] }
|
|
203
|
+
return { text: rendered, techniques: state.techniques }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function compactGrepText(text: string, compaction: OutputCompactionConfig): TextTransform {
|
|
207
|
+
const state: TextTransform = { text, techniques: [] }
|
|
208
|
+
applyAnsi(state, compaction)
|
|
209
|
+
|
|
210
|
+
if (compaction.groupSearchOutput) applyNullable(state, groupSearchResults(state.text), 'search')
|
|
211
|
+
|
|
212
|
+
if (compaction.truncate.enabled && state.text.length > compaction.truncate.maxChars) {
|
|
213
|
+
applyNullable(state, truncate(state.text, compaction.truncate.maxChars), 'truncate')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (state.text.length >= text.length) return { text, techniques: [] }
|
|
217
|
+
return state
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run every enabled technique over one tool result.
|
|
222
|
+
*
|
|
223
|
+
* Returns `changed: false` whenever the result would not actually shrink, so a
|
|
224
|
+
* caller can treat "no outcome" and "no improvement" identically and the model
|
|
225
|
+
* never pays for a rewrite that saved nothing.
|
|
226
|
+
*
|
|
227
|
+
* @param input - the tool name, its parsed arguments, and the content blocks.
|
|
228
|
+
* @param config - the resolved plugin configuration.
|
|
229
|
+
* @returns the replacement content and the techniques that fired.
|
|
230
|
+
*/
|
|
231
|
+
export function compactToolResult(input: CompactionInput, config: RtkConfig): CompactionOutcome {
|
|
232
|
+
const compaction = config.outputCompaction
|
|
233
|
+
if (!config.enabled || !compaction.enabled) return { changed: false, techniques: [] }
|
|
234
|
+
if (!config.compactedTools.includes(input.toolName)) return { changed: false, techniques: [] }
|
|
235
|
+
|
|
236
|
+
const sourceContent = asArray(input.content)
|
|
237
|
+
if (sourceContent.length === 0) return { changed: false, techniques: [] }
|
|
238
|
+
|
|
239
|
+
const args = asRecord(input.args)
|
|
240
|
+
const command = typeof args.command === 'string' ? args.command : undefined
|
|
241
|
+
const techniques = new Set<string>()
|
|
242
|
+
const originalChunks: string[] = []
|
|
243
|
+
const compactedChunks: string[] = []
|
|
244
|
+
let changed = false
|
|
245
|
+
|
|
246
|
+
const nextContent = sourceContent.map((block) => {
|
|
247
|
+
if (!isTextBlock(block)) return block
|
|
248
|
+
|
|
249
|
+
let transformed: TextTransform = { text: block.text, techniques: [] }
|
|
250
|
+
if (input.toolName === 'bash') transformed = compactBashText(block.text, command, compaction)
|
|
251
|
+
else if (input.toolName === 'read') transformed = compactReadText(block.text, args, compaction)
|
|
252
|
+
else if (input.toolName === 'grep') transformed = compactGrepText(block.text, compaction)
|
|
253
|
+
|
|
254
|
+
for (const technique of transformed.techniques) techniques.add(technique)
|
|
255
|
+
originalChunks.push(block.text)
|
|
256
|
+
compactedChunks.push(transformed.text)
|
|
257
|
+
|
|
258
|
+
if (transformed.text === block.text) return block
|
|
259
|
+
changed = true
|
|
260
|
+
return { ...(block as Record<string, unknown>), text: transformed.text }
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
if (!changed) return { changed: false, techniques: [] }
|
|
264
|
+
if (originalChunks.join('\n') === NO_OUTPUT_PLACEHOLDER) return { changed: false, techniques: [] }
|
|
265
|
+
|
|
266
|
+
const originalText = originalChunks.join('\n')
|
|
267
|
+
const compactedText = compactedChunks.join('\n')
|
|
268
|
+
const applied = Array.from(techniques)
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
changed: true,
|
|
272
|
+
content: nextContent,
|
|
273
|
+
techniques: applied,
|
|
274
|
+
metadata: {
|
|
275
|
+
applied: true,
|
|
276
|
+
techniques: applied,
|
|
277
|
+
truncated: hasLossyTechnique(applied),
|
|
278
|
+
originalCharCount: originalText.length,
|
|
279
|
+
compactedCharCount: compactedText.length,
|
|
280
|
+
originalLineCount: countLines(originalText),
|
|
281
|
+
compactedLineCount: countLines(compactedText),
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { matchesCommandPatterns, normalizeCommandForDetection } from './detect.js'
|
|
2
|
+
import { compactPath } from './text.js'
|
|
3
|
+
|
|
4
|
+
const LINTER_COMMAND_PATTERNS = [
|
|
5
|
+
/^(?:pnpm\s+)?(?:npx\s+)?eslint\b/,
|
|
6
|
+
/^(?:npx\s+)?prettier\b/,
|
|
7
|
+
/^ruff\b/,
|
|
8
|
+
/^pylint\b/,
|
|
9
|
+
/^mypy\b/,
|
|
10
|
+
/^flake8\b/,
|
|
11
|
+
/^black\b/,
|
|
12
|
+
/^cargo\s+clippy\b/,
|
|
13
|
+
/^golangci-lint\b/,
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
interface Issue {
|
|
17
|
+
severity: 'ERROR' | 'WARNING'
|
|
18
|
+
rule: string
|
|
19
|
+
file: string
|
|
20
|
+
line?: number
|
|
21
|
+
message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_RULES = 10
|
|
25
|
+
const MAX_FILES = 10
|
|
26
|
+
const MAX_RULES_PER_FILE = 3
|
|
27
|
+
|
|
28
|
+
/** Whether the command is a linter this module understands. */
|
|
29
|
+
export function isLinterCommand(command: string | undefined | null): boolean {
|
|
30
|
+
return matchesCommandPatterns(command, LINTER_COMMAND_PATTERNS)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseIssueLine(line: string): Issue | null {
|
|
34
|
+
const fileLineMatch = line.match(/^(.+):(\d+):(\d+):\s*(.+)$/)
|
|
35
|
+
if (fileLineMatch) {
|
|
36
|
+
const lineNumber = Number.parseInt(fileLineMatch[2] ?? '0', 10)
|
|
37
|
+
const content = fileLineMatch[4] ?? line
|
|
38
|
+
return {
|
|
39
|
+
severity: /warning/i.test(content) ? 'WARNING' : 'ERROR',
|
|
40
|
+
rule: content.match(/\[(.+?)\]$/)?.[1] ?? 'unknown',
|
|
41
|
+
file: fileLineMatch[1] ?? 'unknown',
|
|
42
|
+
...(Number.isNaN(lineNumber) ? {} : { line: lineNumber }),
|
|
43
|
+
message: content,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const rustMatch = line.match(/^(error|warning):\s*(.+?)\s+at\s+(.+):(\d+):(\d+)$/)
|
|
48
|
+
if (rustMatch) {
|
|
49
|
+
const lineNumber = Number.parseInt(rustMatch[4] ?? '0', 10)
|
|
50
|
+
return {
|
|
51
|
+
severity: (rustMatch[1]?.toUpperCase() ?? 'ERROR') as 'ERROR' | 'WARNING',
|
|
52
|
+
rule: 'unknown',
|
|
53
|
+
file: rustMatch[3] ?? 'unknown',
|
|
54
|
+
...(Number.isNaN(lineNumber) ? {} : { line: lineNumber }),
|
|
55
|
+
message: rustMatch[2] ?? line,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function detectLinterType(command: string | undefined | null): string {
|
|
63
|
+
const normalized = normalizeCommandForDetection(command)
|
|
64
|
+
if (!normalized) return 'Linter'
|
|
65
|
+
if (/(?:^|\s)eslint\b/.test(normalized)) return 'ESLint'
|
|
66
|
+
if (/^ruff\b/.test(normalized)) return 'Ruff'
|
|
67
|
+
if (/^pylint\b/.test(normalized)) return 'Pylint'
|
|
68
|
+
if (/^mypy\b/.test(normalized)) return 'MyPy'
|
|
69
|
+
if (/^flake8\b/.test(normalized)) return 'Flake8'
|
|
70
|
+
if (/clippy\b/.test(normalized)) return 'Clippy'
|
|
71
|
+
if (/^golangci-lint\b/.test(normalized)) return 'GolangCI-Lint'
|
|
72
|
+
if (/prettier\b/.test(normalized)) return 'Prettier'
|
|
73
|
+
return 'Linter'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reduce linter output to counts plus the rules and files that dominate.
|
|
78
|
+
*
|
|
79
|
+
* The full issue list is usually thousands of near-identical lines; the
|
|
80
|
+
* distribution is what a reader acts on. Individual messages are dropped in
|
|
81
|
+
* favor of counts, which is lossy by design — the reader re-runs on the file
|
|
82
|
+
* they choose to fix.
|
|
83
|
+
*/
|
|
84
|
+
export function aggregateLinterOutput(output: string, command: string | undefined | null): string | null {
|
|
85
|
+
if (!isLinterCommand(command)) return null
|
|
86
|
+
|
|
87
|
+
const linterType = detectLinterType(command)
|
|
88
|
+
const issues: Issue[] = []
|
|
89
|
+
for (const line of output.split('\n')) {
|
|
90
|
+
const issue = parseIssueLine(line)
|
|
91
|
+
if (issue) issues.push(issue)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (issues.length === 0) return `[OK] ${linterType}: No issues found`
|
|
95
|
+
|
|
96
|
+
const errors = issues.filter((issue) => issue.severity === 'ERROR').length
|
|
97
|
+
const warnings = issues.filter((issue) => issue.severity === 'WARNING').length
|
|
98
|
+
|
|
99
|
+
const byRule = new Map<string, number>()
|
|
100
|
+
const byFile = new Map<string, Issue[]>()
|
|
101
|
+
for (const issue of issues) {
|
|
102
|
+
byRule.set(issue.rule, (byRule.get(issue.rule) ?? 0) + 1)
|
|
103
|
+
const existing = byFile.get(issue.file) ?? []
|
|
104
|
+
existing.push(issue)
|
|
105
|
+
byFile.set(issue.file, existing)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let result = `${linterType}: ${errors} errors, ${warnings} warnings in ${byFile.size} files\n`
|
|
109
|
+
result += '═══════════════════════════════════════\n'
|
|
110
|
+
result += 'Top rules:\n'
|
|
111
|
+
for (const [rule, count] of Array.from(byRule.entries()).sort((left, right) => right[1] - left[1]).slice(0, MAX_RULES)) {
|
|
112
|
+
result += ` ${rule} (${count}x)\n`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
result += '\nTop files:\n'
|
|
116
|
+
const sortedFiles = Array.from(byFile.entries()).sort((left, right) => right[1].length - left[1].length).slice(0, MAX_FILES)
|
|
117
|
+
for (const [file, fileIssues] of sortedFiles) {
|
|
118
|
+
result += ` ${compactPath(file, 40)} (${fileIssues.length} issues)\n`
|
|
119
|
+
const fileRules = new Map<string, number>()
|
|
120
|
+
for (const issue of fileIssues) fileRules.set(issue.rule, (fileRules.get(issue.rule) ?? 0) + 1)
|
|
121
|
+
const topRules = Array.from(fileRules.entries()).sort((left, right) => right[1] - left[1]).slice(0, MAX_RULES_PER_FILE)
|
|
122
|
+
for (const [rule, count] of topRules) result += ` ${rule} (${count})\n`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { compactPath } from './text.js'
|
|
2
|
+
|
|
3
|
+
interface SearchResult {
|
|
4
|
+
file: string
|
|
5
|
+
lineNumber: string
|
|
6
|
+
content: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Minimum share of non-blank lines that must parse as `file:line:content`. */
|
|
10
|
+
const MIN_MATCH_RATIO = 0.6
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Group `grep`-style matches by file.
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` when the output is not recognizably a match list. The format
|
|
16
|
+
* check is what keeps this safe across search tools: ripgrep, GNU grep, and the
|
|
17
|
+
* harness's own `grep` all print `path:line:text`, but a command whose name
|
|
18
|
+
* merely resembles a search may print anything at all, and summarizing that
|
|
19
|
+
* would silently discard real output. Erring toward `null` leaves the text
|
|
20
|
+
* untouched, which is always correct.
|
|
21
|
+
*
|
|
22
|
+
* @param output - raw search output.
|
|
23
|
+
* @param maxResults - cap on rendered match lines across all files.
|
|
24
|
+
* @returns the grouped summary, or `null` when the shape does not match.
|
|
25
|
+
*/
|
|
26
|
+
export function groupSearchResults(output: string, maxResults = 50): string | null {
|
|
27
|
+
const lines = output.split('\n').filter((line) => line.trim().length > 0)
|
|
28
|
+
if (lines.length === 0) return null
|
|
29
|
+
|
|
30
|
+
const results: SearchResult[] = []
|
|
31
|
+
let parseable = 0
|
|
32
|
+
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const match = line.match(/^(.+?):(\d+)?:(.*)$/)
|
|
35
|
+
if (!match) continue
|
|
36
|
+
parseable++
|
|
37
|
+
results.push({
|
|
38
|
+
file: match[1] ?? 'unknown',
|
|
39
|
+
lineNumber: match[2] ?? '?',
|
|
40
|
+
content: match[3] ?? '',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (results.length === 0) return null
|
|
45
|
+
if (parseable / lines.length < MIN_MATCH_RATIO) return null
|
|
46
|
+
|
|
47
|
+
const byFile = new Map<string, SearchResult[]>()
|
|
48
|
+
for (const result of results) {
|
|
49
|
+
const existing = byFile.get(result.file) ?? []
|
|
50
|
+
existing.push(result)
|
|
51
|
+
byFile.set(result.file, existing)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let outputText = `${results.length} matches in ${byFile.size} files:\n\n`
|
|
55
|
+
const sortedFiles = Array.from(byFile.entries()).sort((left, right) => left[0].localeCompare(right[0]))
|
|
56
|
+
|
|
57
|
+
let shown = 0
|
|
58
|
+
for (const [file, matches] of sortedFiles) {
|
|
59
|
+
if (shown >= maxResults) break
|
|
60
|
+
outputText += `> ${compactPath(file, 50)} (${matches.length} matches):\n`
|
|
61
|
+
for (const match of matches.slice(0, 10)) {
|
|
62
|
+
let cleaned = match.content.trim()
|
|
63
|
+
if (cleaned.length > 70) cleaned = `${cleaned.slice(0, 67)}...`
|
|
64
|
+
outputText += ` ${match.lineNumber}: ${cleaned}\n`
|
|
65
|
+
shown++
|
|
66
|
+
}
|
|
67
|
+
if (matches.length > 10) outputText += ` +${matches.length - 10} more\n`
|
|
68
|
+
outputText += '\n'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (results.length > shown) outputText += `... +${results.length - shown} more\n`
|
|
72
|
+
return outputText
|
|
73
|
+
}
|