@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.
Files changed (67) hide show
  1. package/README.md +197 -0
  2. package/package.json +51 -0
  3. package/src/App.tsx +224 -0
  4. package/src/cli.ts +772 -0
  5. package/src/components/AgentPanel.tsx +254 -0
  6. package/src/components/Chat.test.tsx +71 -0
  7. package/src/components/Chat.tsx +308 -0
  8. package/src/components/CycleField.tsx +23 -0
  9. package/src/components/ModelPicker.tsx +97 -0
  10. package/src/components/PostUpdatePrompt.tsx +46 -0
  11. package/src/components/ResultsTable.tsx +172 -0
  12. package/src/components/RunCompletePrompt.tsx +90 -0
  13. package/src/components/RunSettingsOverlay.tsx +49 -0
  14. package/src/components/RunsTable.tsx +219 -0
  15. package/src/components/StatsHeader.tsx +100 -0
  16. package/src/daemon.ts +264 -0
  17. package/src/index.tsx +8 -0
  18. package/src/lib/agent/agent-provider.test.ts +133 -0
  19. package/src/lib/agent/claude-provider.ts +277 -0
  20. package/src/lib/agent/codex-provider.ts +413 -0
  21. package/src/lib/agent/default-providers.ts +10 -0
  22. package/src/lib/agent/index.ts +32 -0
  23. package/src/lib/agent/mock-provider.ts +61 -0
  24. package/src/lib/agent/opencode-provider.ts +424 -0
  25. package/src/lib/agent/types.ts +73 -0
  26. package/src/lib/auth.ts +11 -0
  27. package/src/lib/config.ts +152 -0
  28. package/src/lib/daemon-callbacks.ts +59 -0
  29. package/src/lib/daemon-client.ts +16 -0
  30. package/src/lib/daemon-lifecycle.ts +368 -0
  31. package/src/lib/daemon-spawn.ts +122 -0
  32. package/src/lib/daemon-status.ts +189 -0
  33. package/src/lib/daemon-watcher.ts +192 -0
  34. package/src/lib/experiment-loop.ts +679 -0
  35. package/src/lib/experiment.ts +356 -0
  36. package/src/lib/finalize.test.ts +143 -0
  37. package/src/lib/finalize.ts +511 -0
  38. package/src/lib/format.test.ts +32 -0
  39. package/src/lib/format.ts +44 -0
  40. package/src/lib/git.ts +176 -0
  41. package/src/lib/ideas-backlog.test.ts +54 -0
  42. package/src/lib/ideas-backlog.ts +109 -0
  43. package/src/lib/measure.ts +472 -0
  44. package/src/lib/model-options.ts +24 -0
  45. package/src/lib/programs.ts +247 -0
  46. package/src/lib/push-stream.ts +48 -0
  47. package/src/lib/run-context.ts +112 -0
  48. package/src/lib/run-setup.ts +34 -0
  49. package/src/lib/run.ts +383 -0
  50. package/src/lib/syntax-theme.ts +39 -0
  51. package/src/lib/system-prompts/experiment.ts +77 -0
  52. package/src/lib/system-prompts/finalize.ts +90 -0
  53. package/src/lib/system-prompts/index.ts +7 -0
  54. package/src/lib/system-prompts/setup.ts +516 -0
  55. package/src/lib/system-prompts/update.ts +188 -0
  56. package/src/lib/tool-events.ts +99 -0
  57. package/src/lib/validate-measurement.ts +326 -0
  58. package/src/lib/worktree.ts +40 -0
  59. package/src/screens/AuthErrorScreen.tsx +31 -0
  60. package/src/screens/ExecutionScreen.tsx +851 -0
  61. package/src/screens/FirstSetupScreen.tsx +168 -0
  62. package/src/screens/HomeScreen.tsx +406 -0
  63. package/src/screens/PreRunScreen.tsx +206 -0
  64. package/src/screens/SettingsScreen.tsx +189 -0
  65. package/src/screens/SetupScreen.tsx +226 -0
  66. package/src/tui.tsx +17 -0
  67. 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
+ })