@spacek33z/autoauto 0.0.1
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/README.md +197 -0
- package/package.json +51 -0
- package/src/App.tsx +224 -0
- package/src/cli.ts +772 -0
- package/src/components/AgentPanel.tsx +254 -0
- package/src/components/Chat.test.tsx +71 -0
- package/src/components/Chat.tsx +308 -0
- package/src/components/CycleField.tsx +23 -0
- package/src/components/ModelPicker.tsx +97 -0
- package/src/components/PostUpdatePrompt.tsx +46 -0
- package/src/components/ResultsTable.tsx +172 -0
- package/src/components/RunCompletePrompt.tsx +90 -0
- package/src/components/RunSettingsOverlay.tsx +49 -0
- package/src/components/RunsTable.tsx +219 -0
- package/src/components/StatsHeader.tsx +100 -0
- package/src/daemon.ts +264 -0
- package/src/index.tsx +8 -0
- package/src/lib/agent/agent-provider.test.ts +133 -0
- package/src/lib/agent/claude-provider.ts +277 -0
- package/src/lib/agent/codex-provider.ts +413 -0
- package/src/lib/agent/default-providers.ts +10 -0
- package/src/lib/agent/index.ts +32 -0
- package/src/lib/agent/mock-provider.ts +61 -0
- package/src/lib/agent/opencode-provider.ts +424 -0
- package/src/lib/agent/types.ts +73 -0
- package/src/lib/auth.ts +11 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/daemon-callbacks.ts +59 -0
- package/src/lib/daemon-client.ts +16 -0
- package/src/lib/daemon-lifecycle.ts +368 -0
- package/src/lib/daemon-spawn.ts +122 -0
- package/src/lib/daemon-status.ts +189 -0
- package/src/lib/daemon-watcher.ts +192 -0
- package/src/lib/experiment-loop.ts +679 -0
- package/src/lib/experiment.ts +356 -0
- package/src/lib/finalize.test.ts +143 -0
- package/src/lib/finalize.ts +511 -0
- package/src/lib/format.test.ts +32 -0
- package/src/lib/format.ts +44 -0
- package/src/lib/git.ts +176 -0
- package/src/lib/ideas-backlog.test.ts +54 -0
- package/src/lib/ideas-backlog.ts +109 -0
- package/src/lib/measure.ts +472 -0
- package/src/lib/model-options.ts +24 -0
- package/src/lib/programs.ts +247 -0
- package/src/lib/push-stream.ts +48 -0
- package/src/lib/run-context.ts +112 -0
- package/src/lib/run-setup.ts +34 -0
- package/src/lib/run.ts +383 -0
- package/src/lib/syntax-theme.ts +39 -0
- package/src/lib/system-prompts/experiment.ts +77 -0
- package/src/lib/system-prompts/finalize.ts +90 -0
- package/src/lib/system-prompts/index.ts +7 -0
- package/src/lib/system-prompts/setup.ts +516 -0
- package/src/lib/system-prompts/update.ts +188 -0
- package/src/lib/tool-events.ts +99 -0
- package/src/lib/validate-measurement.ts +326 -0
- package/src/lib/worktree.ts +40 -0
- package/src/screens/AuthErrorScreen.tsx +31 -0
- package/src/screens/ExecutionScreen.tsx +851 -0
- package/src/screens/FirstSetupScreen.tsx +168 -0
- package/src/screens/HomeScreen.tsx +406 -0
- package/src/screens/PreRunScreen.tsx +206 -0
- package/src/screens/SettingsScreen.tsx +189 -0
- package/src/screens/SetupScreen.tsx +226 -0
- package/src/tui.tsx +17 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import type { RunState } from "./run.ts"
|
|
3
|
+
import type { ModelSlot } from "./config.ts"
|
|
4
|
+
import { formatRecentResults, parseLastResult, parseLastKeepResult, parseDiscardedShas, parseSecondaryValues } from "./run.ts"
|
|
5
|
+
import {
|
|
6
|
+
getFullSha,
|
|
7
|
+
getRecentLog,
|
|
8
|
+
getLatestCommitMessage,
|
|
9
|
+
getFilesChangedBetween,
|
|
10
|
+
getDiscardedDiffs,
|
|
11
|
+
getDiffStats,
|
|
12
|
+
formatShellError,
|
|
13
|
+
type DiffStats,
|
|
14
|
+
} from "./git.ts"
|
|
15
|
+
import { getProvider, type AgentCost } from "./agent/index.ts"
|
|
16
|
+
import { formatToolEvent } from "./tool-events.ts"
|
|
17
|
+
import {
|
|
18
|
+
parseExperimentNotes,
|
|
19
|
+
readIdeasBacklogSummary,
|
|
20
|
+
type ExperimentNotes,
|
|
21
|
+
} from "./ideas-backlog.ts"
|
|
22
|
+
|
|
23
|
+
// --- Types ---
|
|
24
|
+
|
|
25
|
+
/** Everything the experiment agent needs to know */
|
|
26
|
+
export interface ContextPacket {
|
|
27
|
+
experiment: number
|
|
28
|
+
current_baseline: number
|
|
29
|
+
original_baseline: number
|
|
30
|
+
best_metric: number
|
|
31
|
+
best_experiment: number
|
|
32
|
+
total_keeps: number
|
|
33
|
+
total_discards: number
|
|
34
|
+
metric_field: string
|
|
35
|
+
direction: "lower" | "higher"
|
|
36
|
+
program_md: string
|
|
37
|
+
recent_results: string
|
|
38
|
+
recent_git_log: string
|
|
39
|
+
last_outcome: string
|
|
40
|
+
discarded_diffs: string
|
|
41
|
+
ideas_backlog: string
|
|
42
|
+
secondary_metrics?: Record<string, { direction: "lower" | "higher"; last_kept_value?: number }>
|
|
43
|
+
consecutive_discards: number
|
|
44
|
+
max_consecutive_discards: number
|
|
45
|
+
measurement_diagnostics?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cost and usage data from an agent session. */
|
|
49
|
+
export type ExperimentCost = AgentCost
|
|
50
|
+
|
|
51
|
+
/** Result of running one experiment agent session */
|
|
52
|
+
export type ExperimentOutcome =
|
|
53
|
+
| { type: "committed"; sha: string; description: string; files_changed: string[]; diff_stats: DiffStats; cost?: ExperimentCost; notes?: ExperimentNotes }
|
|
54
|
+
| { type: "no_commit"; cost?: ExperimentCost; notes?: ExperimentNotes }
|
|
55
|
+
| { type: "agent_error"; error: string; cost?: ExperimentCost; notes?: ExperimentNotes }
|
|
56
|
+
|
|
57
|
+
/** Result of checking whether locked files were modified */
|
|
58
|
+
export interface LockViolation {
|
|
59
|
+
violated: boolean
|
|
60
|
+
files: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Context Packet ---
|
|
64
|
+
|
|
65
|
+
/** Assembles the context packet from disk for a single experiment. */
|
|
66
|
+
export async function buildContextPacket(
|
|
67
|
+
cwd: string,
|
|
68
|
+
programDir: string,
|
|
69
|
+
runDir: string,
|
|
70
|
+
state: RunState,
|
|
71
|
+
config: { metric_field: string; direction: "lower" | "higher"; secondary_metrics?: Record<string, { direction: "lower" | "higher" }> },
|
|
72
|
+
options: { ideasBacklogEnabled?: boolean; consecutiveDiscards?: number; maxConsecutiveDiscards?: number; measurementDiagnostics?: string } = {},
|
|
73
|
+
): Promise<ContextPacket> {
|
|
74
|
+
const [programMd, resultsRaw, recentGitLog] = await Promise.all([
|
|
75
|
+
Bun.file(join(programDir, "program.md")).text(),
|
|
76
|
+
Bun.file(join(runDir, "results.tsv")).text(),
|
|
77
|
+
getRecentLog(cwd, 15),
|
|
78
|
+
])
|
|
79
|
+
const ideasBacklog = options.ideasBacklogEnabled === false
|
|
80
|
+
? ""
|
|
81
|
+
: await readIdeasBacklogSummary(runDir)
|
|
82
|
+
|
|
83
|
+
const recentResults = formatRecentResults(resultsRaw, 15)
|
|
84
|
+
|
|
85
|
+
// Build last_outcome from last results.tsv row
|
|
86
|
+
const lastResult = parseLastResult(resultsRaw)
|
|
87
|
+
let lastOutcome = "none yet"
|
|
88
|
+
if (lastResult) {
|
|
89
|
+
switch (lastResult.status) {
|
|
90
|
+
case "keep":
|
|
91
|
+
lastOutcome = `kept: improved to ${lastResult.metric_value} (${lastResult.description})`
|
|
92
|
+
break
|
|
93
|
+
case "discard":
|
|
94
|
+
lastOutcome = `discarded: ${lastResult.metric_value} (${lastResult.description})`
|
|
95
|
+
break
|
|
96
|
+
case "crash":
|
|
97
|
+
lastOutcome = `crashed: ${lastResult.description}`
|
|
98
|
+
break
|
|
99
|
+
case "measurement_failure":
|
|
100
|
+
lastOutcome = `measurement failed: ${lastResult.description}`
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build discarded diffs from recent discarded commits
|
|
106
|
+
const discardedShas = parseDiscardedShas(resultsRaw, 5)
|
|
107
|
+
let discardedDiffs = ""
|
|
108
|
+
if (discardedShas.length > 0) {
|
|
109
|
+
try {
|
|
110
|
+
discardedDiffs = await getDiscardedDiffs(cwd, discardedShas, 2000)
|
|
111
|
+
} catch {
|
|
112
|
+
// Discarded commits may have been garbage-collected — diffs unavailable
|
|
113
|
+
discardedDiffs = ""
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let secondaryMetrics: ContextPacket["secondary_metrics"]
|
|
118
|
+
if (config.secondary_metrics && Object.keys(config.secondary_metrics).length > 0) {
|
|
119
|
+
secondaryMetrics = {}
|
|
120
|
+
const lastKeep = parseLastKeepResult(resultsRaw)
|
|
121
|
+
const lastKeepValues = parseSecondaryValues(lastKeep?.secondary_values)
|
|
122
|
+
|
|
123
|
+
for (const [field, metric] of Object.entries(config.secondary_metrics)) {
|
|
124
|
+
const currentValue = lastKeepValues.secondary_metrics[field]
|
|
125
|
+
secondaryMetrics[field] = {
|
|
126
|
+
direction: metric.direction,
|
|
127
|
+
last_kept_value: typeof currentValue === "number" ? currentValue : undefined,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
experiment: state.experiment_number,
|
|
134
|
+
current_baseline: state.current_baseline,
|
|
135
|
+
original_baseline: state.original_baseline,
|
|
136
|
+
best_metric: state.best_metric,
|
|
137
|
+
best_experiment: state.best_experiment,
|
|
138
|
+
total_keeps: state.total_keeps,
|
|
139
|
+
total_discards: state.total_discards,
|
|
140
|
+
metric_field: config.metric_field,
|
|
141
|
+
direction: config.direction,
|
|
142
|
+
program_md: programMd,
|
|
143
|
+
recent_results: recentResults,
|
|
144
|
+
recent_git_log: recentGitLog,
|
|
145
|
+
last_outcome: lastOutcome,
|
|
146
|
+
discarded_diffs: discardedDiffs,
|
|
147
|
+
ideas_backlog: ideasBacklog,
|
|
148
|
+
secondary_metrics: secondaryMetrics,
|
|
149
|
+
consecutive_discards: options.consecutiveDiscards ?? 0,
|
|
150
|
+
max_consecutive_discards: options.maxConsecutiveDiscards ?? 10,
|
|
151
|
+
measurement_diagnostics: options.measurementDiagnostics,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Returns an escalating diversity directive based on how stuck the loop is. */
|
|
156
|
+
function getExplorationDirective(consecutiveDiscards: number, maxConsecutiveDiscards: number): string {
|
|
157
|
+
if (consecutiveDiscards < 1) return ""
|
|
158
|
+
|
|
159
|
+
// Use proportional thresholds so directives scale with the configured limit
|
|
160
|
+
const ratio = consecutiveDiscards / maxConsecutiveDiscards
|
|
161
|
+
|
|
162
|
+
if (ratio >= 0.7) {
|
|
163
|
+
return `## Exploration Directive — CRITICAL
|
|
164
|
+
${consecutiveDiscards} consecutive experiments discarded. Stagnation is imminent (limit: ${maxConsecutiveDiscards}).
|
|
165
|
+
- You MUST try something radically different from everything in the results history.
|
|
166
|
+
- Profile the code mentally and find the ACTUAL bottleneck — not the assumed one. Question fundamental assumptions.
|
|
167
|
+
- If you genuinely cannot find a promising change — EXIT WITHOUT COMMITTING. A no-commit is better than burning another cycle on a doomed approach.`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (ratio >= 0.5) {
|
|
171
|
+
return `## Exploration Directive
|
|
172
|
+
${consecutiveDiscards} consecutive experiments discarded. You are likely stuck in a local optimum.
|
|
173
|
+
- STOP trying variations of what's been tried. Every recent approach has failed.
|
|
174
|
+
- Try something orthogonal: a completely different part of the codebase within scope, a different algorithmic family, or a simplification that removes code.
|
|
175
|
+
- Re-read the ideas backlog "next" suggestions — pick the LEAST similar to recent attempts.
|
|
176
|
+
- Remember: simplification keeps are free wins and can open up new optimization paths.`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (ratio >= 0.3) {
|
|
180
|
+
return `## Exploration Directive
|
|
181
|
+
${consecutiveDiscards} consecutive experiments discarded. The obvious approaches aren't working.
|
|
182
|
+
- Step back and re-read the hot path from scratch — look for something you've been overlooking.
|
|
183
|
+
- Try an approach from a DIFFERENT category than recent attempts (e.g., if recent tries were algorithmic, try a data-structure change; if recent tries were micro-optimizations, try a structural change).`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return ""
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Formats the context packet as the user message string for the agent. */
|
|
190
|
+
export function buildExperimentPrompt(packet: ContextPacket): string {
|
|
191
|
+
let secondarySection = ""
|
|
192
|
+
if (packet.secondary_metrics && Object.keys(packet.secondary_metrics).length > 0) {
|
|
193
|
+
const lines = Object.entries(packet.secondary_metrics).map(([field, m]) => {
|
|
194
|
+
const val = m.last_kept_value !== undefined ? String(m.last_kept_value) : "unknown"
|
|
195
|
+
return `- ${field}: ${val} (${m.direction} is better, last kept measurement)`
|
|
196
|
+
})
|
|
197
|
+
secondarySection = `
|
|
198
|
+
## Secondary Metrics (advisory — do NOT optimize at the expense of the primary metric)
|
|
199
|
+
${lines.join("\n")}
|
|
200
|
+
`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return `You are experiment ${packet.experiment} of an autoresearch loop.
|
|
204
|
+
|
|
205
|
+
## Current State
|
|
206
|
+
- Baseline ${packet.metric_field}: ${packet.current_baseline} (${packet.direction} is better)
|
|
207
|
+
- Original baseline: ${packet.original_baseline}
|
|
208
|
+
- Best achieved: ${packet.best_metric} (experiment #${packet.best_experiment})
|
|
209
|
+
- Total: ${packet.total_keeps} keeps, ${packet.total_discards} discards
|
|
210
|
+
${secondarySection}
|
|
211
|
+
## Last Outcome
|
|
212
|
+
${packet.last_outcome}
|
|
213
|
+
|
|
214
|
+
## Recent Results
|
|
215
|
+
\`\`\`
|
|
216
|
+
${packet.recent_results}
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
219
|
+
## Recent Git History
|
|
220
|
+
\`\`\`
|
|
221
|
+
${packet.recent_git_log}
|
|
222
|
+
\`\`\`
|
|
223
|
+
|
|
224
|
+
## Recently Discarded Experiments
|
|
225
|
+
${packet.discarded_diffs || "(none yet)"}
|
|
226
|
+
${packet.measurement_diagnostics ? `
|
|
227
|
+
## Measurement Diagnostics
|
|
228
|
+
Detailed diagnostic output from the last measurement run. Use this to identify exactly which audits, tests, or checks are underperforming — do NOT guess from code inspection alone.
|
|
229
|
+
\`\`\`
|
|
230
|
+
${packet.measurement_diagnostics}
|
|
231
|
+
\`\`\`
|
|
232
|
+
` : ""}
|
|
233
|
+
${packet.ideas_backlog ? `
|
|
234
|
+
## Ideas Backlog
|
|
235
|
+
${packet.ideas_backlog}
|
|
236
|
+
` : ""}
|
|
237
|
+
|
|
238
|
+
${getExplorationDirective(packet.consecutive_discards, packet.max_consecutive_discards)}
|
|
239
|
+
|
|
240
|
+
Review the recent results and discarded experiments${packet.ideas_backlog ? ", and ideas backlog" : ""} above. Focus on what was tried, why it failed, and what should be tried next.
|
|
241
|
+
Implement ONE change, validate, and commit. Then stop.`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Lock Violation Detection ---
|
|
245
|
+
|
|
246
|
+
/** Checks if any changed files are in the locked .autoauto/ directory. */
|
|
247
|
+
export function checkLockViolation(filesChanged: string[]): LockViolation {
|
|
248
|
+
const violated = filesChanged.filter((f) => f.startsWith(".autoauto/"))
|
|
249
|
+
return {
|
|
250
|
+
violated: violated.length > 0,
|
|
251
|
+
files: violated,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- Experiment Agent ---
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Spawns a fresh agent session for one experiment.
|
|
259
|
+
* One-shot: push one user message, iterate to result, return outcome.
|
|
260
|
+
*/
|
|
261
|
+
export async function runExperimentAgent(
|
|
262
|
+
cwd: string,
|
|
263
|
+
systemPrompt: string,
|
|
264
|
+
userPrompt: string,
|
|
265
|
+
modelConfig: ModelSlot,
|
|
266
|
+
startSha: string,
|
|
267
|
+
onStreamText?: (text: string) => void,
|
|
268
|
+
onToolStatus?: (status: string) => void,
|
|
269
|
+
signal?: AbortSignal,
|
|
270
|
+
maxTurns = 50,
|
|
271
|
+
): Promise<ExperimentOutcome> {
|
|
272
|
+
const raw = await runExperimentAgentRaw(cwd, systemPrompt, userPrompt, modelConfig, startSha, onStreamText, onToolStatus, signal, maxTurns)
|
|
273
|
+
return { ...raw.outcome, notes: parseExperimentNotes(raw.assistantText) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function runExperimentAgentRaw(
|
|
277
|
+
cwd: string,
|
|
278
|
+
systemPrompt: string,
|
|
279
|
+
userPrompt: string,
|
|
280
|
+
modelConfig: ModelSlot,
|
|
281
|
+
startSha: string,
|
|
282
|
+
onStreamText?: (text: string) => void,
|
|
283
|
+
onToolStatus?: (status: string) => void,
|
|
284
|
+
signal?: AbortSignal,
|
|
285
|
+
maxTurns = 50,
|
|
286
|
+
): Promise<{ outcome: ExperimentOutcome; assistantText: string }> {
|
|
287
|
+
if (signal?.aborted) {
|
|
288
|
+
return { outcome: { type: "agent_error", error: "aborted before start" }, assistantText: "" }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let cost: ExperimentCost | undefined
|
|
292
|
+
let assistantText = ""
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const session = getProvider(modelConfig.provider).runOnce(userPrompt, {
|
|
296
|
+
systemPrompt,
|
|
297
|
+
tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
|
|
298
|
+
allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
|
|
299
|
+
maxTurns,
|
|
300
|
+
cwd,
|
|
301
|
+
model: modelConfig.model,
|
|
302
|
+
effort: modelConfig.provider !== "opencode" ? modelConfig.effort : undefined,
|
|
303
|
+
signal,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
for await (const event of session) {
|
|
307
|
+
if (signal?.aborted) break
|
|
308
|
+
|
|
309
|
+
switch (event.type) {
|
|
310
|
+
case "text_delta":
|
|
311
|
+
onStreamText?.(event.text)
|
|
312
|
+
break
|
|
313
|
+
case "tool_use":
|
|
314
|
+
onToolStatus?.(formatToolEvent(event.tool, event.input ?? {}))
|
|
315
|
+
break
|
|
316
|
+
case "assistant_complete":
|
|
317
|
+
assistantText += `\n${event.text}`
|
|
318
|
+
break
|
|
319
|
+
case "error":
|
|
320
|
+
return { outcome: { type: "agent_error", error: event.error, cost }, assistantText }
|
|
321
|
+
case "result":
|
|
322
|
+
cost = event.cost
|
|
323
|
+
if (!event.success) {
|
|
324
|
+
return { outcome: { type: "agent_error", error: event.error ?? "unknown", cost }, assistantText }
|
|
325
|
+
}
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (err: unknown) {
|
|
330
|
+
if (signal?.aborted) {
|
|
331
|
+
return { outcome: { type: "agent_error", error: "aborted", cost }, assistantText }
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
outcome: { type: "agent_error", error: formatShellError(err), cost },
|
|
335
|
+
assistantText,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check if the agent produced a commit
|
|
340
|
+
const endSha = await getFullSha(cwd)
|
|
341
|
+
|
|
342
|
+
if (endSha === startSha) {
|
|
343
|
+
return { outcome: { type: "no_commit", cost }, assistantText }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const [description, filesChanged, diffStats] = await Promise.all([
|
|
347
|
+
getLatestCommitMessage(cwd),
|
|
348
|
+
getFilesChangedBetween(cwd, startSha, endSha),
|
|
349
|
+
getDiffStats(cwd, startSha, endSha),
|
|
350
|
+
])
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
outcome: { type: "committed", sha: endSha, description, files_changed: filesChanged, diff_stats: diffStats, cost },
|
|
354
|
+
assistantText,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { extractFinalizeGroups, validateGroups } from "./finalize.ts"
|
|
3
|
+
|
|
4
|
+
describe("extractFinalizeGroups", () => {
|
|
5
|
+
test("extracts valid groups", () => {
|
|
6
|
+
const text = `Some review text here.
|
|
7
|
+
<finalize_groups>
|
|
8
|
+
[
|
|
9
|
+
{
|
|
10
|
+
"name": "lazy-load-images",
|
|
11
|
+
"title": "perf(images): lazy-load below-fold images",
|
|
12
|
+
"description": "Added intersection observer",
|
|
13
|
+
"files": ["src/ImageLoader.tsx", "src/lazy.ts"],
|
|
14
|
+
"risk": "low"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "remove-lodash",
|
|
18
|
+
"title": "refactor: remove lodash dependency",
|
|
19
|
+
"description": "Replaced with native methods",
|
|
20
|
+
"files": ["package.json", "src/utils.ts"],
|
|
21
|
+
"risk": "medium"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
</finalize_groups>
|
|
25
|
+
More text after.`
|
|
26
|
+
|
|
27
|
+
const groups = extractFinalizeGroups(text)
|
|
28
|
+
expect(groups).not.toBeNull()
|
|
29
|
+
expect(groups!.length).toBe(2)
|
|
30
|
+
expect(groups![0].name).toBe("lazy-load-images")
|
|
31
|
+
expect(groups![0].files).toEqual(["src/ImageLoader.tsx", "src/lazy.ts"])
|
|
32
|
+
expect(groups![0].risk).toBe("low")
|
|
33
|
+
expect(groups![1].name).toBe("remove-lodash")
|
|
34
|
+
expect(groups![1].risk).toBe("medium")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("returns null when no XML tags present", () => {
|
|
38
|
+
expect(extractFinalizeGroups("just some text without tags")).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("returns null for empty array", () => {
|
|
42
|
+
expect(extractFinalizeGroups("<finalize_groups>[]</finalize_groups>")).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("returns null for malformed JSON", () => {
|
|
46
|
+
expect(extractFinalizeGroups("<finalize_groups>{not json]</finalize_groups>")).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("returns null when name is missing", () => {
|
|
50
|
+
const text = `<finalize_groups>[{"title": "fix", "files": ["a.ts"]}]</finalize_groups>`
|
|
51
|
+
expect(extractFinalizeGroups(text)).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("returns null when files is empty", () => {
|
|
55
|
+
const text = `<finalize_groups>[{"name": "a", "title": "fix", "files": []}]</finalize_groups>`
|
|
56
|
+
expect(extractFinalizeGroups(text)).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("normalizes group names to kebab-case", () => {
|
|
60
|
+
const text = `<finalize_groups>[{"name": "My Cool Feature!", "title": "feat", "files": ["a.ts"]}]</finalize_groups>`
|
|
61
|
+
const groups = extractFinalizeGroups(text)
|
|
62
|
+
expect(groups![0].name).toBe("my-cool-feature")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("defaults risk to low when invalid", () => {
|
|
66
|
+
const text = `<finalize_groups>[{"name": "a", "title": "fix", "files": ["a.ts"], "risk": "extreme"}]</finalize_groups>`
|
|
67
|
+
const groups = extractFinalizeGroups(text)
|
|
68
|
+
expect(groups![0].risk).toBe("low")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("defaults description to empty string when missing", () => {
|
|
72
|
+
const text = `<finalize_groups>[{"name": "a", "title": "fix", "files": ["a.ts"]}]</finalize_groups>`
|
|
73
|
+
const groups = extractFinalizeGroups(text)
|
|
74
|
+
expect(groups![0].description).toBe("")
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("validateGroups", () => {
|
|
79
|
+
test("validates a correct partition", () => {
|
|
80
|
+
const groups = [
|
|
81
|
+
{ name: "a", title: "fix a", description: "", files: ["x.ts", "y.ts"], risk: "low" as const },
|
|
82
|
+
{ name: "b", title: "fix b", description: "", files: ["z.ts"], risk: "low" as const },
|
|
83
|
+
]
|
|
84
|
+
const result = validateGroups(groups, ["x.ts", "y.ts", "z.ts"])
|
|
85
|
+
expect(result.valid).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("rejects overlapping files", () => {
|
|
89
|
+
const groups = [
|
|
90
|
+
{ name: "a", title: "fix", description: "", files: ["x.ts"], risk: "low" as const },
|
|
91
|
+
{ name: "b", title: "fix", description: "", files: ["x.ts"], risk: "low" as const },
|
|
92
|
+
]
|
|
93
|
+
const result = validateGroups(groups, ["x.ts"])
|
|
94
|
+
expect(result.valid).toBe(false)
|
|
95
|
+
if (!result.valid) expect(result.reason).toContain("x.ts")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("rejects when files are unassigned", () => {
|
|
99
|
+
const groups = [
|
|
100
|
+
{ name: "a", title: "fix", description: "", files: ["x.ts"], risk: "low" as const },
|
|
101
|
+
]
|
|
102
|
+
const result = validateGroups(groups, ["x.ts", "y.ts"])
|
|
103
|
+
expect(result.valid).toBe(false)
|
|
104
|
+
if (!result.valid) expect(result.reason).toContain("y.ts")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("strips phantom files silently", () => {
|
|
108
|
+
const groups = [
|
|
109
|
+
{ name: "a", title: "fix", description: "", files: ["x.ts", "phantom.ts"], risk: "low" as const },
|
|
110
|
+
]
|
|
111
|
+
const result = validateGroups(groups, ["x.ts"])
|
|
112
|
+
expect(result.valid).toBe(true)
|
|
113
|
+
if (result.valid) expect(result.groups[0].files).toEqual(["x.ts"])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("removes groups left empty after phantom stripping", () => {
|
|
117
|
+
const groups = [
|
|
118
|
+
{ name: "a", title: "fix", description: "", files: ["x.ts"], risk: "low" as const },
|
|
119
|
+
{ name: "b", title: "fix", description: "", files: ["phantom.ts"], risk: "low" as const },
|
|
120
|
+
]
|
|
121
|
+
const result = validateGroups(groups, ["x.ts"])
|
|
122
|
+
expect(result.valid).toBe(true)
|
|
123
|
+
if (result.valid) expect(result.groups.length).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("rejects all-phantom groups", () => {
|
|
127
|
+
const groups = [
|
|
128
|
+
{ name: "a", title: "fix", description: "", files: ["phantom.ts"], risk: "low" as const },
|
|
129
|
+
]
|
|
130
|
+
const result = validateGroups(groups, ["x.ts"])
|
|
131
|
+
expect(result.valid).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("rejects duplicate group names", () => {
|
|
135
|
+
const groups = [
|
|
136
|
+
{ name: "a", title: "fix", description: "", files: ["x.ts"], risk: "low" as const },
|
|
137
|
+
{ name: "a", title: "fix", description: "", files: ["y.ts"], risk: "low" as const },
|
|
138
|
+
]
|
|
139
|
+
const result = validateGroups(groups, ["x.ts", "y.ts"])
|
|
140
|
+
expect(result.valid).toBe(false)
|
|
141
|
+
if (!result.valid) expect(result.reason).toContain("Duplicate")
|
|
142
|
+
})
|
|
143
|
+
})
|