@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,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
+ }