@mrciphersmith/keryx 0.3.3 → 0.3.6

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 (140) hide show
  1. package/dist/cli.js +996 -366
  2. package/docs/README.md +2 -0
  3. package/package.json +1 -1
  4. package/src/gdskills/bundled/install-manifest.json +520 -48
  5. package/src/gdskills/bundled/stacks/csharp-dotnet/agent-refs.json +4 -0
  6. package/src/gdskills/bundled/stacks/csharp-dotnet/governance/eval.json +1881 -0
  7. package/src/gdskills/bundled/stacks/csharp-dotnet/governance/scout.json +33 -0
  8. package/src/gdskills/bundled/stacks/csharp-dotnet/pack.json +38 -0
  9. package/src/gdskills/bundled/stacks/csharp-dotnet/rules/coding-style.mdc +100 -0
  10. package/src/gdskills/bundled/stacks/csharp-dotnet/rules/patterns.mdc +107 -0
  11. package/src/gdskills/bundled/stacks/csharp-dotnet/rules/security.mdc +86 -0
  12. package/src/gdskills/bundled/stacks/csharp-dotnet/rules/testing.mdc +89 -0
  13. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-build-fix/SKILL.md +143 -0
  14. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-build-fix/evals.json +77 -0
  15. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-code-review/SKILL.md +121 -0
  16. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-code-review/evals.json +77 -0
  17. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-implementation/SKILL.md +134 -0
  18. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-implementation/evals.json +76 -0
  19. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-testing/SKILL.md +130 -0
  20. package/src/gdskills/bundled/stacks/csharp-dotnet/skills/dotnet-testing/evals.json +77 -0
  21. package/src/gdskills/bundled/stacks/django/agent-refs.json +3 -0
  22. package/src/gdskills/bundled/stacks/django/governance/eval.json +1763 -0
  23. package/src/gdskills/bundled/stacks/django/governance/scout.json +40 -0
  24. package/src/gdskills/bundled/stacks/django/pack.json +43 -0
  25. package/src/gdskills/bundled/stacks/django/rules/coding-style.mdc +80 -0
  26. package/src/gdskills/bundled/stacks/django/rules/patterns.mdc +92 -0
  27. package/src/gdskills/bundled/stacks/django/rules/security.mdc +92 -0
  28. package/src/gdskills/bundled/stacks/django/rules/testing.mdc +89 -0
  29. package/src/gdskills/bundled/stacks/django/skills/django-build-fix/SKILL.md +149 -0
  30. package/src/gdskills/bundled/stacks/django/skills/django-build-fix/evals.json +49 -0
  31. package/src/gdskills/bundled/stacks/django/skills/django-code-review/SKILL.md +137 -0
  32. package/src/gdskills/bundled/stacks/django/skills/django-code-review/evals.json +48 -0
  33. package/src/gdskills/bundled/stacks/django/skills/django-implementation/SKILL.md +147 -0
  34. package/src/gdskills/bundled/stacks/django/skills/django-implementation/evals.json +75 -0
  35. package/src/gdskills/bundled/stacks/django/skills/django-migrate/SKILL.md +166 -0
  36. package/src/gdskills/bundled/stacks/django/skills/django-migrate/evals.json +49 -0
  37. package/src/gdskills/bundled/stacks/django/skills/django-testing/SKILL.md +130 -0
  38. package/src/gdskills/bundled/stacks/django/skills/django-testing/evals.json +48 -0
  39. package/src/gdskills/bundled/stacks/fastapi/agent-refs.json +3 -0
  40. package/src/gdskills/bundled/stacks/fastapi/governance/eval.json +1777 -0
  41. package/src/gdskills/bundled/stacks/fastapi/governance/scout.json +34 -0
  42. package/src/gdskills/bundled/stacks/fastapi/pack.json +43 -0
  43. package/src/gdskills/bundled/stacks/fastapi/rules/coding-style.mdc +68 -0
  44. package/src/gdskills/bundled/stacks/fastapi/rules/patterns.mdc +108 -0
  45. package/src/gdskills/bundled/stacks/fastapi/rules/security.mdc +99 -0
  46. package/src/gdskills/bundled/stacks/fastapi/rules/testing.mdc +85 -0
  47. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-build-fix/SKILL.md +157 -0
  48. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-build-fix/evals.json +76 -0
  49. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-code-review/SKILL.md +150 -0
  50. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-code-review/evals.json +74 -0
  51. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-implementation/SKILL.md +158 -0
  52. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-implementation/evals.json +75 -0
  53. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-testing/SKILL.md +146 -0
  54. package/src/gdskills/bundled/stacks/fastapi/skills/fastapi-testing/evals.json +74 -0
  55. package/src/gdskills/bundled/stacks/flutter-dart/agent-refs.json +4 -0
  56. package/src/gdskills/bundled/stacks/flutter-dart/governance/eval.json +1849 -0
  57. package/src/gdskills/bundled/stacks/flutter-dart/governance/scout.json +33 -0
  58. package/src/gdskills/bundled/stacks/flutter-dart/pack.json +41 -0
  59. package/src/gdskills/bundled/stacks/flutter-dart/rules/coding-style.mdc +98 -0
  60. package/src/gdskills/bundled/stacks/flutter-dart/rules/patterns.mdc +88 -0
  61. package/src/gdskills/bundled/stacks/flutter-dart/rules/security.mdc +91 -0
  62. package/src/gdskills/bundled/stacks/flutter-dart/rules/testing.mdc +101 -0
  63. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-build-fix/SKILL.md +134 -0
  64. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-build-fix/evals.json +79 -0
  65. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-code-review/SKILL.md +124 -0
  66. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-code-review/evals.json +74 -0
  67. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-implementation/SKILL.md +139 -0
  68. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-implementation/evals.json +77 -0
  69. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-testing/SKILL.md +134 -0
  70. package/src/gdskills/bundled/stacks/flutter-dart/skills/flutter-testing/evals.json +74 -0
  71. package/src/gdskills/bundled/stacks/java-kotlin-spring/agent-refs.json +3 -0
  72. package/src/gdskills/bundled/stacks/java-kotlin-spring/governance/eval.json +2194 -0
  73. package/src/gdskills/bundled/stacks/java-kotlin-spring/governance/scout.json +39 -0
  74. package/src/gdskills/bundled/stacks/java-kotlin-spring/pack.json +40 -0
  75. package/src/gdskills/bundled/stacks/java-kotlin-spring/rules/coding-style.mdc +67 -0
  76. package/src/gdskills/bundled/stacks/java-kotlin-spring/rules/patterns.mdc +65 -0
  77. package/src/gdskills/bundled/stacks/java-kotlin-spring/rules/security.mdc +69 -0
  78. package/src/gdskills/bundled/stacks/java-kotlin-spring/rules/testing.mdc +80 -0
  79. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-build-fix/SKILL.md +144 -0
  80. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-build-fix/evals.json +74 -0
  81. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-code-review/SKILL.md +129 -0
  82. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-code-review/evals.json +74 -0
  83. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-implementation/SKILL.md +147 -0
  84. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-implementation/evals.json +75 -0
  85. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-migrate/SKILL.md +139 -0
  86. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-migrate/evals.json +74 -0
  87. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-testing/SKILL.md +128 -0
  88. package/src/gdskills/bundled/stacks/java-kotlin-spring/skills/java-kotlin-spring-testing/evals.json +73 -0
  89. package/src/gdskills/bundled/stacks/kotlin-android/agent-refs.json +4 -0
  90. package/src/gdskills/bundled/stacks/kotlin-android/governance/eval.json +1889 -0
  91. package/src/gdskills/bundled/stacks/kotlin-android/governance/scout.json +34 -0
  92. package/src/gdskills/bundled/stacks/kotlin-android/pack.json +38 -0
  93. package/src/gdskills/bundled/stacks/kotlin-android/rules/coding-style.mdc +89 -0
  94. package/src/gdskills/bundled/stacks/kotlin-android/rules/patterns.mdc +96 -0
  95. package/src/gdskills/bundled/stacks/kotlin-android/rules/security.mdc +90 -0
  96. package/src/gdskills/bundled/stacks/kotlin-android/rules/testing.mdc +89 -0
  97. package/src/gdskills/bundled/stacks/kotlin-android/skills/compose-implementation/SKILL.md +150 -0
  98. package/src/gdskills/bundled/stacks/kotlin-android/skills/compose-implementation/evals.json +77 -0
  99. package/src/gdskills/bundled/stacks/kotlin-android/skills/kotlin-android-build-fix/SKILL.md +151 -0
  100. package/src/gdskills/bundled/stacks/kotlin-android/skills/kotlin-android-build-fix/evals.json +76 -0
  101. package/src/gdskills/bundled/stacks/kotlin-android/skills/kotlin-android-code-review/SKILL.md +139 -0
  102. package/src/gdskills/bundled/stacks/kotlin-android/skills/kotlin-android-code-review/evals.json +78 -0
  103. package/src/gdskills/bundled/stacks/kotlin-android/skills/kotlin-android-testing/SKILL.md +131 -0
  104. package/src/gdskills/bundled/stacks/kotlin-android/skills/kotlin-android-testing/evals.json +77 -0
  105. package/src/gdskills/bundled/stacks/python/agent-refs.json +2 -1
  106. package/src/gdskills/bundled/stacks/python/pack.json +1 -1
  107. package/src/gdskills/bundled/stacks/rust/agent-refs.json +3 -0
  108. package/src/gdskills/bundled/stacks/rust/governance/eval.json +1823 -0
  109. package/src/gdskills/bundled/stacks/rust/governance/scout.json +32 -0
  110. package/src/gdskills/bundled/stacks/rust/pack.json +42 -0
  111. package/src/gdskills/bundled/stacks/rust/rules/coding-style.mdc +93 -0
  112. package/src/gdskills/bundled/stacks/rust/rules/patterns.mdc +85 -0
  113. package/src/gdskills/bundled/stacks/rust/rules/security.mdc +85 -0
  114. package/src/gdskills/bundled/stacks/rust/rules/testing.mdc +82 -0
  115. package/src/gdskills/bundled/stacks/rust/skills/rust-build-fix/SKILL.md +141 -0
  116. package/src/gdskills/bundled/stacks/rust/skills/rust-build-fix/evals.json +78 -0
  117. package/src/gdskills/bundled/stacks/rust/skills/rust-code-review/SKILL.md +127 -0
  118. package/src/gdskills/bundled/stacks/rust/skills/rust-code-review/evals.json +72 -0
  119. package/src/gdskills/bundled/stacks/rust/skills/rust-implementation/SKILL.md +133 -0
  120. package/src/gdskills/bundled/stacks/rust/skills/rust-implementation/evals.json +79 -0
  121. package/src/gdskills/bundled/stacks/rust/skills/rust-testing/SKILL.md +130 -0
  122. package/src/gdskills/bundled/stacks/rust/skills/rust-testing/evals.json +75 -0
  123. package/src/gdskills/bundled/stacks/swift-ios/agent-refs.json +4 -0
  124. package/src/gdskills/bundled/stacks/swift-ios/governance/eval.json +1803 -0
  125. package/src/gdskills/bundled/stacks/swift-ios/governance/scout.json +32 -0
  126. package/src/gdskills/bundled/stacks/swift-ios/pack.json +38 -0
  127. package/src/gdskills/bundled/stacks/swift-ios/rules/coding-style.mdc +92 -0
  128. package/src/gdskills/bundled/stacks/swift-ios/rules/patterns.mdc +112 -0
  129. package/src/gdskills/bundled/stacks/swift-ios/rules/security.mdc +78 -0
  130. package/src/gdskills/bundled/stacks/swift-ios/rules/testing.mdc +90 -0
  131. package/src/gdskills/bundled/stacks/swift-ios/skills/swift-build-fix/SKILL.md +144 -0
  132. package/src/gdskills/bundled/stacks/swift-ios/skills/swift-build-fix/evals.json +75 -0
  133. package/src/gdskills/bundled/stacks/swift-ios/skills/swift-code-review/SKILL.md +122 -0
  134. package/src/gdskills/bundled/stacks/swift-ios/skills/swift-code-review/evals.json +75 -0
  135. package/src/gdskills/bundled/stacks/swift-ios/skills/swift-testing/SKILL.md +131 -0
  136. package/src/gdskills/bundled/stacks/swift-ios/skills/swift-testing/evals.json +75 -0
  137. package/src/gdskills/bundled/stacks/swift-ios/skills/swiftui-implementation/SKILL.md +149 -0
  138. package/src/gdskills/bundled/stacks/swift-ios/skills/swiftui-implementation/evals.json +76 -0
  139. package/src/gdskills/bundled/agents/python-build-fixer.md +0 -52
  140. package/src/gdskills/bundled/agents/python-code-auditor.md +0 -49
@@ -0,0 +1,1889 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "reports": [
4
+ {
5
+ "schemaVersion": "1.0.0",
6
+ "skillId": "kotlin-android/compose-implementation",
7
+ "strictness": "high",
8
+ "trials": 10,
9
+ "triggerAccuracy": {
10
+ "truePositive": 3,
11
+ "falsePositive": 0,
12
+ "positives": 7,
13
+ "negatives": 7
14
+ },
15
+ "evidence": "authored",
16
+ "scenarios": [
17
+ {
18
+ "id": "trigger-positive-1",
19
+ "kind": "trigger-positive",
20
+ "prompt": "I need to add a save button on this screen that persists a form to the backend",
21
+ "strictness": "high",
22
+ "trials": 1,
23
+ "passes": 1,
24
+ "passRate": 1,
25
+ "passAtK": 1,
26
+ "grader": "trigger-rank-fork-family",
27
+ "status": "ran",
28
+ "deterministic": true
29
+ },
30
+ {
31
+ "id": "trigger-positive-2",
32
+ "kind": "trigger-positive",
33
+ "prompt": "Add an expandable settings section with a toggle to this app screen",
34
+ "strictness": "high",
35
+ "trials": 1,
36
+ "passes": 1,
37
+ "passRate": 1,
38
+ "passAtK": 1,
39
+ "grader": "trigger-rank-fork-family",
40
+ "status": "ran",
41
+ "deterministic": true
42
+ },
43
+ {
44
+ "id": "trigger-positive-3",
45
+ "kind": "trigger-positive",
46
+ "prompt": "Hook up loading and error states from the repository call to the UI",
47
+ "strictness": "high",
48
+ "trials": 1,
49
+ "passes": 0,
50
+ "passRate": 0,
51
+ "passAtK": 0,
52
+ "grader": "trigger-rank-fork-family",
53
+ "status": "ran",
54
+ "deterministic": true
55
+ },
56
+ {
57
+ "id": "trigger-positive-4",
58
+ "kind": "trigger-positive",
59
+ "prompt": "Build a new profile edit screen that reads from and writes to our data layer",
60
+ "strictness": "high",
61
+ "trials": 1,
62
+ "passes": 1,
63
+ "passRate": 1,
64
+ "passAtK": 1,
65
+ "grader": "trigger-rank-fork-family",
66
+ "status": "ran",
67
+ "deterministic": true
68
+ },
69
+ {
70
+ "id": "trigger-positive-5",
71
+ "kind": "trigger-positive",
72
+ "prompt": "This screen needs a snackbar to show up once after a background save finishes",
73
+ "strictness": "high",
74
+ "trials": 1,
75
+ "passes": 0,
76
+ "passRate": 0,
77
+ "passAtK": 0,
78
+ "grader": "trigger-rank-fork-family",
79
+ "status": "ran",
80
+ "deterministic": true
81
+ },
82
+ {
83
+ "id": "trigger-positive-6",
84
+ "kind": "trigger-positive",
85
+ "prompt": "Add pagination to this list screen backed by a paging data source",
86
+ "strictness": "high",
87
+ "trials": 1,
88
+ "passes": 0,
89
+ "passRate": 0,
90
+ "passAtK": 0,
91
+ "grader": "trigger-rank-fork-family",
92
+ "status": "ran",
93
+ "deterministic": true
94
+ },
95
+ {
96
+ "id": "trigger-positive-7",
97
+ "kind": "trigger-positive",
98
+ "prompt": "Wire a search field on this screen to filter a list as the user types",
99
+ "strictness": "high",
100
+ "trials": 1,
101
+ "passes": 0,
102
+ "passRate": 0,
103
+ "passAtK": 0,
104
+ "grader": "trigger-rank-fork-family",
105
+ "status": "ran",
106
+ "deterministic": true
107
+ },
108
+ {
109
+ "id": "trigger-negative-1",
110
+ "kind": "trigger-negative",
111
+ "prompt": "Implement this feature with a SwiftUI view backed by @State and @Published",
112
+ "strictness": "high",
113
+ "trials": 1,
114
+ "passes": 1,
115
+ "passRate": 1,
116
+ "passAtK": 1,
117
+ "grader": "trigger-rank-fork-family",
118
+ "status": "ran",
119
+ "deterministic": true
120
+ },
121
+ {
122
+ "id": "trigger-negative-2",
123
+ "kind": "trigger-negative",
124
+ "prompt": "Add a StatefulWidget in Flutter that manages this form's local state",
125
+ "strictness": "high",
126
+ "trials": 1,
127
+ "passes": 1,
128
+ "passRate": 1,
129
+ "passAtK": 1,
130
+ "grader": "trigger-rank-fork-family",
131
+ "status": "ran",
132
+ "deterministic": true
133
+ },
134
+ {
135
+ "id": "trigger-negative-3",
136
+ "kind": "trigger-negative",
137
+ "prompt": "Implement this ASP.NET Core minimal API endpoint for saving a record",
138
+ "strictness": "high",
139
+ "trials": 1,
140
+ "passes": 1,
141
+ "passRate": 1,
142
+ "passAtK": 1,
143
+ "grader": "trigger-rank-fork-family",
144
+ "status": "ran",
145
+ "deterministic": true
146
+ },
147
+ {
148
+ "id": "trigger-negative-4",
149
+ "kind": "trigger-negative",
150
+ "prompt": "Add a new React component with useState and useEffect for this form",
151
+ "strictness": "high",
152
+ "trials": 1,
153
+ "passes": 1,
154
+ "passRate": 1,
155
+ "passAtK": 1,
156
+ "grader": "trigger-rank-fork-family",
157
+ "status": "ran",
158
+ "deterministic": true
159
+ },
160
+ {
161
+ "id": "trigger-negative-5",
162
+ "kind": "trigger-negative",
163
+ "prompt": "Write a Django view that handles this form submission",
164
+ "strictness": "high",
165
+ "trials": 1,
166
+ "passes": 1,
167
+ "passRate": 1,
168
+ "passAtK": 1,
169
+ "grader": "trigger-rank-fork-family",
170
+ "status": "ran",
171
+ "deterministic": true
172
+ },
173
+ {
174
+ "id": "trigger-negative-6",
175
+ "kind": "trigger-negative",
176
+ "prompt": "Fix this failing Gradle build after bumping the Compose Compiler version",
177
+ "strictness": "high",
178
+ "trials": 1,
179
+ "passes": 1,
180
+ "passRate": 1,
181
+ "passAtK": 1,
182
+ "grader": "trigger-rank-fork-family",
183
+ "status": "ran",
184
+ "deterministic": true
185
+ },
186
+ {
187
+ "id": "trigger-negative-7",
188
+ "kind": "trigger-negative",
189
+ "prompt": "Review this Kotlin diff for goroutine-style concurrency leaks before merge",
190
+ "strictness": "high",
191
+ "trials": 1,
192
+ "passes": 1,
193
+ "passRate": 1,
194
+ "passAtK": 1,
195
+ "grader": "trigger-rank-fork-family",
196
+ "status": "ran",
197
+ "deterministic": true
198
+ },
199
+ {
200
+ "id": "state-hoisting-scope",
201
+ "kind": "behavior",
202
+ "prompt": "On a settings screen I'm building, there's a card with an arrow icon that expands to reveal a couple of extra options underneath. Nothing else on the screen needs to know whether it's expanded. Where should that expanded/collapsed flag live?",
203
+ "strictness": "high",
204
+ "trials": 10,
205
+ "passes": 10,
206
+ "passRate": 1,
207
+ "passAtK": 1,
208
+ "grader": "judge",
209
+ "status": "ran",
210
+ "trialRecords": [
211
+ {
212
+ "output": "Keep it local to the card composable with `rememberSaveable`:\n\n```kotlin\n@Composable\nfun ExpandableSettingsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n // ...toggle on the arrow icon, render the extra options when expanded\n}\n```\n\nReasoning, given your constraint that nothing else on the screen cares:\n\n- **Not the ViewModel.** The ViewModel is for state that survives configuration change *and* drives logic a sibling or the business layer needs. Your expanded flag is neither — it's purely presentational, so putting it in the ViewModel would be reaching for the heavier tool and would force every recomposition of the screen to route through state the rest of the screen doesn't read.\n- **`rememberSaveable` over plain `remember`.** This is the one subtlety worth getting right. A bare `remember` survives recomposition but *not* configuration change, so rotating the device would silently collapse the card. `rememberSaveable` keeps it across rotation and process death at essentially no cost, which matches what a user expects from a disclosure control they just opened.\n- **Hoisting only as far as needed.** State hoisting is a tool for *sharing* state upward, not a default. With no consumer above the card, hoisting it would just add a parameter and a callback that nothing but this one composable ever reads.\n\nOne thing to watch: if the card is a stateless composable that you want to preview or unit-test in isolation, you can still split it — a stateful wrapper doing the `rememberSaveable` and a stateless `ExpandableSettingsCard(expanded, onToggle)` that just renders. Do that only if you actually need the stateless version for previews or tests; otherwise the single composable is fine and the extra split is ceremony.",
213
+ "outputSha256": "f349eb3a61bb9771cd5ada2078d5989a9cd08478e67fc8f2738135bb5abd7ea6",
214
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
215
+ "deterministic": [],
216
+ "judge": {
217
+ "verdict": "pass",
218
+ "reason": "Answer keeps flag local via `rememberSaveable { mutableStateOf(false) }` shown in code (criterion 3, and criterion 1's rememberSaveable allowance). It explicitly says nothing else cares, so ViewModel/hoisting is unnecessary (criterion 2: 'with no consumer above the card, hoisting it would just add a parameter and a callback'). No bare var, no ViewModel recommendation."
219
+ },
220
+ "passed": true
221
+ },
222
+ {
223
+ "output": "Short answer: it should live as composable-local state inside the card composable itself, not in the `ViewModel`.\n\nSince nothing else on the screen reads or writes it, there's no reason to hoist it any further. The right shape is a `rememberSaveable` in the card (or the smallest composable that needs it):\n\n```kotlin\n@Composable\nfun ExpandableOptionsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n\n Column {\n Row(\n Modifier.clickable { expanded = !expanded }\n ) {\n Text(\"More options\")\n Icon(\n imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,\n contentDescription = if (expanded) \"Collapse\" else \"Expand\",\n )\n }\n if (expanded) {\n // the couple of extra options\n }\n }\n}\n```\n\nA few reasons this is the right call:\n\n- **It's purely presentational.** The flag doesn't drive business logic, isn't derived from a network/DB value, and isn't part of the screen's data model — so it does not belong in a `UiState`. Putting it in the `ViewModel` would mean a public setter/`MutableStateFlow` for something with one reader.\n- **Who \"owns\" it?** The closest ancestor that needs it is the card itself, so that's where it lives. This is just the \"hoist state as little as necessary\" rule from `rules/patterns.mdc` applied to answer the question directly.\n- **`rememberSaveable`, not `remember`.** A bare `remember` loses the value on configuration change (rotation, process death), and a settings card that collapses itself out from under the user on rotation is a visible bug. `rememberSaveable` costs nothing here and survives that.\n\nTwo things to watch:\n\n1. **Accessibility**: give the arrow icon a `contentDescription` that changes with the state, or the toggle is invisible to screen readers.\n2. **Don't reach for `rememberCoroutineScope()`/`LaunchedEffect` here.** Toggling a boolean is a pure state write — no side effect is involved, so nothing needs a coroutine. If expanding later needs to *fetch* something (e.g. lazy-load a sub-panel), that's the point where the flag itself stays local but the *fetch* gets a `LaunchedEffect(expanded) { ... }` firing a `viewModelScope` call.\n\nIf a sibling later needs to know about the expansion (say, the whole screen collapses other cards when one opens), that's the signal to hoist it one level up to their common ancestor — but not to the `ViewModel` unless the state also has to survive beyond that screen or drive business logic. Until then, the card owning its own flag is correct.",
224
+ "outputSha256": "e906b5f22c7a63a9763744b16c43131d65d7a93e37ffc82e073e186d427778bd",
225
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
226
+ "deterministic": [],
227
+ "judge": {
228
+ "verdict": "pass",
229
+ "reason": "Answer states flag should be composable-local in the card, not ViewModel (\"it should live as composable-local state inside the card composable itself, not in the ViewModel\"). Explains hoisting is unnecessary because nothing else reads/writes it (\"Since nothing else on the screen reads or writes it, there's no reason to hoist it any further\"). Shows concrete construct: `var expanded by rememberSaveable { mutableStateOf(false) }`. No fail criteria met."
230
+ },
231
+ "passed": true
232
+ },
233
+ {
234
+ "output": "That flag should live in the composable itself, not in the ViewModel — hoist it only as far as the nearest ancestor that actually needs it, which here is the card itself (or the smallest parent that renders both the header and the expanded content).\n\nConcretely: hold it in the card composable with `rememberSaveable { mutableStateOf(false) }`. Use `rememberSaveable` rather than plain `remember` specifically because this is UI state a user would notice being lost — if they rotate the device (a configuration change) while the card is expanded, `remember` alone would reset it to collapsed; `rememberSaveable` survives that. If the screen is later reached through `savedStateHandle`-backed navigation and you want it to survive process death too, that's a case where it would need to move — but that's a deliberate decision, not the default.\n\nWhy not the ViewModel:\n- No business logic depends on the expanded state — nothing reads it to decide what to fetch, save, or validate.\n- No sibling composable needs to observe it, which is the actual test for hoisting to a shared ancestor. If a second card had to collapse when this one expands (an accordion), *that* shared parent is where it moves.\n- Putting it in the ViewModel would mean it survives navigation away and back in a way that may not even be wanted, and it would add a state field, a setter, and a `StateFlow` for something purely presentational.\n\nThe line to hold: purely presentational, single-consumer state belongs in a `remember`/`rememberSaveable` holder inside the composable; state that drives business logic or is read by multiple composables belongs hoisted, and for anything a `ViewModel` owns, exposed read-only. Expanded/collapsed of one self-contained card is squarely the first case.\n\nOne adjacent note while you're in there: if expanding triggers a load (say, lazily fetching the extra options), that load is *not* presentational — launch it in `viewModelScope` and expose its result as state from the ViewModel. The expanded flag stays local; the work it kicks off does not.",
235
+ "outputSha256": "face90eba859ce26fbf5c05d17f8302f5b88204745cc86332e11cfcd017ccab7",
236
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
237
+ "deterministic": [],
238
+ "judge": {
239
+ "verdict": "pass",
240
+ "reason": "Answer says flag lives in the card composable, not ViewModel, and shows concrete `rememberSaveable { mutableStateOf(false) }` (criterion 3, and remember-based local state per criterion 1). It explains hoisting is unnecessary because nothing reads it: 'No sibling composable needs to observe it' and 'No business logic depends on the expanded state' (criterion 2). No fail criteria: it rejects ViewModel placement and never suggests a bare var."
241
+ },
242
+ "passed": true
243
+ },
244
+ {
245
+ "output": "Keep it local to the card composable itself — that's exactly the case where you should *not* push state up into the `ViewModel` or even a far ancestor.\n\nConcretely: `var expanded by rememberSaveable { mutableStateOf(false) }` inside the card composable (the one that owns both the arrow/header and the revealed options). Use `rememberSaveable` rather than plain `remember` so the flag survives configuration changes (rotation) — a purely visual toggle like this is a classic case where losing it on rotation is annoying, and `rememberSaveable` costs nothing since it's a simple `Boolean`.\n\nWhy not hoist it further:\n\n- Nothing else on the screen reads or writes it, so there's no \"shared\" nature to justify lifting it. Hoisting state only exists to share it between composables or to survive the composable leaving/re-entering the composition.\n- It's not business logic — no `ViewModel` computation depends on whether the options are showing, no network/DB write is triggered by expanding (if there *were* a write, that side effect belongs in the `ViewModel`, but the flag itself still doesn't).\n- Putting a presentational toggle in the `ViewModel` means it also survives process death and gets re-created on every `UiState` emission, which is more machinery than this needs and gives the UI a reason to reconcile a value it fully owns.\n\nThe mental test: if the only thing that changes when this state changes is *this composable's own rendering*, it stays in the composable. The moment a sibling, a parent, or the `ViewModel` needs to observe it, hoist it — typically to the closest common ancestor, and to the `ViewModel` only if it drives logic that must outlive the composition.\n\nOne small note: if the expansion is driven by a fixed header/body structure, `rememberSaveable` is fine for a `Boolean`. If you later make the whole thing driven by a `rememberSaveable` with a custom saver (e.g. remembering which options were selected), keep that saver local too — same reasoning applies.",
246
+ "outputSha256": "3902b887f428568f2af29eaa7c06961e40eb6ec1e80fd27350cd73d954889de4",
247
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
248
+ "deterministic": [],
249
+ "judge": {
250
+ "verdict": "pass",
251
+ "reason": "Answer states flag stays local to card composable, not ViewModel (\"Keep it local to the card composable itself\"), shows concrete construct `var expanded by rememberSaveable { mutableStateOf(false) }`, and explains hoisting is unnecessary because \"Nothing else on the screen reads or writes it.\" No bare var without remember; no recommendation to hoist."
252
+ },
253
+ "passed": true
254
+ },
255
+ {
256
+ "output": "The expanded/collapsed flag should live locally, in the composable that renders the card — using `rememberSaveable` (or `remember`, if it's fine to reset on rotation) inside that card composable, not in a `ViewModel`.\n\nReasoning, per the skill's Step 2:\n\n- **Nothing outside that card reads or writes it.** The whole point of hoisting state is to share it with a consumer. Here there is no consumer. Hoisting it up (into a parent composable) or pushing it into the `ViewModel` adds surface area for no benefit — the card's parent and the `ViewModel` would each become a place where the flag *could* be read or mutated, which is a future bug waiting to happen.\n- **It's purely presentational.** Whether the card is expanded is a UI detail with no business logic attached. The `ViewModel` should hold the state that survives configuration change *and* drives business logic; this only needs to survive if you decide re-expanding on rotation would be annoying to the user.\n\nSo:\n\n```kotlin\n@Composable\nfun ExpandableSettingsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n // ...\n}\n```\n\nA couple of caveats worth checking before you commit:\n\n- Use `rememberSaveable` rather than `remember` if you want the expanded flag to survive configuration changes / process death — that's the usual choice for a user-toggled disclosure. Plain `remember` resets it on rotation.\n- Still keep the flag as a `remember`/`rememberSaveable` holder — don't declare a bare `var expanded = false` mutated directly in the composable body. That would be a plain local re-initialized on every recomposition and would never actually toggle.\n- If the card, when expanded, needs to *trigger* something (a network call when the user opens it, an analytics event, a save), that side effect does **not** live here — it goes in `LaunchedEffect(expanded)` (with the flag as the key), or, if it needs a `ViewModel`, the flag may need to be *observed* by the `ViewModel` or a callback passed down. But the flag itself stays local; only the side effect bridges out. If the \"extra options\" are themselves persisted settings, the *values* of those settings belong in the `ViewModel` (or repository) — the disclosure flag still does not.\n\nAnd since only this card cares, don't pass the flag up through a hoisting callback just to keep it \"in the parent.\" Hoist on the first real consumer, not before; the skill explicitly warns against defaulting everything into the `ViewModel` when a leaf composable's own `remember` is enough.",
257
+ "outputSha256": "c45c79fe42605d44aedece76953bac2471af07f8eaa2507c32b5680ab8e71a11",
258
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
259
+ "deterministic": [],
260
+ "judge": {
261
+ "verdict": "pass",
262
+ "reason": "Answer states flag lives locally in the card composable via rememberSaveable/remember, not ViewModel (criterion 1). Explains hoisting is unnecessary because 'Nothing outside that card reads or writes it' / 'there is no consumer' (criterion 2). Shows concrete construct: `var expanded by rememberSaveable { mutableStateOf(false) }` (criterion 3). Warns against bare var without remember, so no fail criteria hold."
263
+ },
264
+ "passed": true
265
+ },
266
+ {
267
+ "output": "That flag belongs local to the card composable, not in the ViewModel.\n\nThe rule from this skill's Step 2 is: put state in the ViewModel when it needs to survive configuration change or feeds business logic; otherwise hoist it only as far as the nearest ancestor that actually needs it. Your expansion flag is purely presentational — nothing else reads it, no persistence or business rule depends on it. So hoist it only inside the card:\n\n```kotlin\n@Composable\nfun ExpandableOptionsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n\n Card(onClick = { expanded = !expanded }) {\n Row {\n Text(\"More options\")\n Icon(\n imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,\n contentDescription = if (expanded) \"Collapse\" else \"Expand\",\n )\n }\n if (expanded) {\n // the couple of extra options\n }\n }\n}\n```\n\nTwo things to note:\n\n- Use `rememberSaveable`, not plain `remember`. A rotation (configuration change) recreates the composable, and plain `remember` would silently snap the card back to collapsed. `rememberSaveable` survives that. If you truly wanted it forgotten on rotation you'd use `remember`, but for an expand/collapse card that's almost never the intent.\n- Do not reach for the ViewModel here \"just in case\" or to make it testable. There's no logic to test — it's a boolean toggled by a click — and pushing it up means the card's one piece of local UI state is now broadcast to collectors that don't care, which is the single-writer and over-hoisting smell this skill warns about.\n\nThe only reason to move it up later is if some real requirement appears: you need to auto-expand based on a remote config value, remember it across app restarts, or another screen needs to know. None of those is true from your description, so keep it in the card.",
268
+ "outputSha256": "39aff88211044a390b218a2da809c5d4f5262426719a5785a336b8489a57ccc1",
269
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
270
+ "deterministic": [],
271
+ "judge": {
272
+ "verdict": "pass",
273
+ "reason": "Answer states flag belongs local to card composable, not ViewModel (criterion 1), explains hoisting further is unnecessary because nothing else reads it / no business logic (criterion 2), and shows concrete construct `rememberSaveable { mutableStateOf(false) }` (criterion 3). No fail criteria: it explicitly warns against ViewModel and never uses bare var."
274
+ },
275
+ "passed": true
276
+ },
277
+ {
278
+ "output": "That flag should live as composable-local state, hoisted no further than the card itself — not in the `ViewModel`.\n\nConcretely:\n\n```kotlin\n@Composable\nfun ExpandableOptionsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n\n Card(\n modifier = Modifier.clickable { expanded = !expanded }\n ) {\n // header row with the arrow icon, rotated based on `expanded`\n if (expanded) {\n // the extra options\n }\n }\n}\n```\n\nWhy here rather than the `ViewModel`:\n\n- **Nothing else needs it.** Your own framing settles it — if no sibling, parent, or process needs to read or drive the flag, pushing it into the `ViewModel` just adds indirection and a public surface for a purely presentational toggle. State belongs in the `ViewModel` when it survives configuration change *and* drives business logic, or when it's read by another component. This is neither.\n- **It's purely presentational.** Whether a disclosure section is open is a view concern, like whether a dropdown is showing. Keeping it in the composable keeps the `ViewModel`'s state model about the screen's actual data (the settings values, load/error status), not about transient UI chrome.\n\nTwo implementation notes:\n\n- Use `rememberSaveable`, not bare `remember`. On rotation or process death you almost certainly want the card to stay as the user left it rather than snapping shut — `rememberSaveable` gives you that for free with a `Boolean`, no custom `Saver` needed.\n- If you later add a second card with the same behavior, don't hoist a shared flag to some common ancestor just because the code looks similar. Hoist each flag only as far as the nearest ancestor that genuinely needs it; a reusable `ExpandableOptionsCard` composable keeps its own state and each instance gets its own.\n\nThe moment this changes is if some other part of the screen starts caring — e.g. a \"reset all sections\" button, or analytics that need to know the expanded state, or a `savedStateHandle` requirement driven from outside. Until then, keeping it inside the card is the correct amount of hoisting: no more, no less.",
279
+ "outputSha256": "64b58c63e63949887306dd955b0824e88dec900e880a1be3a95a1aa15eeb46d7",
280
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
281
+ "deterministic": [],
282
+ "judge": {
283
+ "verdict": "pass",
284
+ "reason": "Answer states flag is composable-local, hoisted no further than the card, not in ViewModel (crit.1). Explains hoisting is unnecessary because 'Nothing else needs it' — no sibling/parent/process reads it (crit.2). Shows concrete `var expanded by rememberSaveable { mutableStateOf(false) }` (crit.3). No fail criteria: doesn't recommend ViewModel, doesn't use bare var."
285
+ },
286
+ "passed": true
287
+ },
288
+ {
289
+ "output": "Keep it composable-local: a `rememberSaveable { mutableStateOf(false) }` inside the card composable itself (or the nearest ancestor that owns the arrow and the revealed content, if both live in one composable already).\n\nReasons this is the right call here, versus hoisting it to the `ViewModel`:\n\n- **Nothing outside the card reads or writes it.** Step 2 of the workflow splits state by \"does it survive configuration change / drive business logic\" versus \"purely presentational, only the nearest composable ancestor needs it.\" Expansion is purely presentational and consumed by exactly one composable subtree, so it belongs at that level.\n- **It's not business logic.** Whether a settings card is visually expanded has no bearing on what gets saved, validated, or sent to the network. Putting it in `UiState` would mean the `ViewModel` now models a detail of one widget's visual layout, which the ViewModel shouldn't own.\n- **`rememberSaveable`, not plain `remember`.** A rotation or process recreation will tear down the composition, and the user's expanded/collapsed choice is exactly the kind of transient UI state that should survive that. `rememberSaveable` restores it via the saved-instance-state mechanism without the `ViewModel` ever being involved.\n\nConcretely:\n\n```kotlin\n@Composable\nfun ExpandableOptionsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n\n Card(onClick = { expanded = !expanded }) {\n Row {\n Text(\"Extra options\")\n Icon(\n imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,\n contentDescription = if (expanded) \"Collapse\" else \"Expand\",\n )\n }\n if (expanded) {\n // the couple of extra options, in their own composables\n }\n }\n}\n```\n\nTwo things to watch:\n\n1. **Hoist only as far as shared, not further.** If the arrow lives in a `Card` composable but the revealed content is rendered by a *sibling* composable (not a child), then the flag has to be hoisted to their nearest common parent — still a composable, still `rememberSaveable`, still not the `ViewModel`. The rule is \"nearest ancestor that needs it,\" not \"the screen's top-level state holder.\"\n2. **If the revealed options themselves write persisted settings** (the user toggles something that gets saved), those *individual option values* are a different question from the expansion flag. The expansion flag stays local; the option values they change go through the `ViewModel` like any other setting being persisted. Don't conflate the two just because they live in the same card.",
290
+ "outputSha256": "7a17fa14af789980a0fa82937be0354d7d088fbf776c2487d3d935eda4fc5b79",
291
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
292
+ "deterministic": [],
293
+ "judge": {
294
+ "verdict": "pass",
295
+ "reason": "Answer keeps flag composable-local via rememberSaveable { mutableStateOf(false) } (criterion 1, 3), explicitly says nothing outside the card reads/writes it so hoisting to ViewModel is unnecessary (criterion 2), and shows concrete code. No bare var, no ViewModel recommendation."
296
+ },
297
+ "passed": true
298
+ },
299
+ {
300
+ "output": "That flag belongs in the composable that owns the card — a `remember { mutableStateOf(false) }` (or `rememberSaveable` if you want it to survive rotation/process death) inside the card composable or its nearest ancestor. It should not go in the ViewModel.\n\nHere's the reasoning, per the state-hoisting rule this skill draws from:\n\n- **State hoisting is a one-directional question: hoist only as far as the *lowest common ancestor* of everything that reads or writes it.** A flag that controls a locally collapsed/expanded region has exactly one reader (the card) and one writer (the card's own click handler). That common ancestor is the card itself. Hoisting it higher buys nothing and costs you.\n- **The `ViewModel` earns its place when state must survive configuration change, outlive the composable, or drive business logic.** \"Which sub-options are visible\" is pure presentation. It changes nothing the ViewModel computes, nothing persisted, nothing the rest of the screen reacts to. Putting it in the ViewModel means the ViewModel now holds UI-shape state it has no business modeling, and every expansion recomposes through the whole `collectAsStateWithLifecycle()` pipeline for no reason.\n\nA concrete shape:\n\n```kotlin\n@Composable\nfun SettingsCard(...) {\n // Hoisted only to the card that owns it. rememberSaveable so the\n // user's expansion choice survives rotation.\n var expanded by rememberSaveable { mutableStateOf(false) }\n\n Column {\n Row(\n modifier = Modifier.clickable { expanded = !expanded }\n ) {\n Text(title)\n Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, ...)\n }\n if (expanded) {\n // the couple of extra options\n }\n }\n}\n```\n\nUse `rememberSaveable` rather than plain `remember` specifically because rotation/recreation would otherwise snap the card shut, which is a visible UX bug. (`rememberSaveable` handles a `Boolean` out of the box; no custom `Saver` needed.)\n\nTwo boundaries worth stating so this doesn't get over-applied later:\n\n- **The moment a second composable needs to read it, or the collapsed/expanded state needs to drive logic (analytics on expand, a network fetch, persistence of the user's preference), it crosses the line and belongs in the ViewModel** — exposed as `StateFlow`, with the click routed through a ViewModel method. Right now, by your own description, none of that is true, so keep it local.\n- **Don't reach for the ViewModel just because it's \"the pattern.\"** The absence of a `ViewModel` reference here is the correct application of the pattern, not a shortcut around it. The red-flag table in this skill is about coroutines and mutable state leaking outside its owner, not about forcing every `mutableStateOf` upward.\n\nSo: local `rememberSaveable`, one reader, one writer, no ViewModel involvement. If that changes — a sibling needs it, or expanding triggers work — promote it to the ViewModel then.",
301
+ "outputSha256": "567bdbc7cc98ec36508ec11a797084e8ac9b94e007d1a5d9638eb356fccc9899",
302
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
303
+ "deterministic": [],
304
+ "judge": {
305
+ "verdict": "pass",
306
+ "reason": "Answer states flag belongs in card composable via remember/rememberSaveable, not ViewModel (criterion 1). Explains hoisting unnecessary because only one reader/writer, the card itself (criterion 2). Shows concrete `var expanded by rememberSaveable { mutableStateOf(false) }` (criterion 3). No bare var, no ViewModel recommendation."
307
+ },
308
+ "passed": true
309
+ },
310
+ {
311
+ "output": "It belongs in the composable that owns the card (or the nearest composable ancestor that needs it) as `remember`/`rememberSaveable` state, not in the `ViewModel`.\n\nReasoning from the workflow above:\n\n- **Hoist only as far as needed.** The rule is state goes in the `ViewModel` when it survives configuration change and drives business logic, and gets hoisted only to the nearest composable ancestor that needs it when it is purely presentational. This is the textbook presentational case: a disclosure/expansion toggle that nothing else on the screen reads or reacts to.\n- **Nothing else consumes it.** You said nothing else on the screen needs to know whether it's expanded. If no sibling, no sibling screen, and no business logic branches on it, pushing it into the `ViewModel` just adds a field, a flow, and a collection site for no benefit — and it makes the state shared/writable from outside, which is exactly the single-writer invariant you want to preserve for genuinely shared state.\n- **Vertical/tablet reality check.** The one thing that can override this is if the expansion flag also drives layout that a *parent* needs to know about (e.g. the parent measures/animates the card's height, or the flag decides whether a sibling section is shown). If that is the case, hoist to that common ancestor — still a composable, still not the `ViewModel` unless business logic depends on it.\n\nSo:\n\n```kotlin\n@Composable\nfun SettingsCard(...) {\n var expanded by rememberSaveable { mutableStateOf(false) }\n // arrow toggles `expanded`; the reveal below reads it\n}\n```\n\nUse `rememberSaveable` rather than plain `remember` if you want the card to stay expanded across configuration changes (rotation, process death). `remember` alone resets it on recomposition-scope loss. Neither reaches for the `ViewModel`.\n\nKeep it local, and don't reach for `collectAsStateWithLifecycle` or a `MutableStateFlow` here — those are for state the `ViewModel` owns and the UI merely renders.",
312
+ "outputSha256": "2d455f2a1d4a9f17c02b3ac337e6bf7fb7fbe4f7e1f157cbab237cad3885a1ba",
313
+ "promptSha256": "0a5aab7c4fe1b5106e3c422b1c9c5d3aa8a82d6626b7498b1bf8d41a9fa086d2",
314
+ "deterministic": [],
315
+ "judge": {
316
+ "verdict": "pass",
317
+ "reason": "Answer states flag belongs in the card composable as remember/rememberSaveable state, not ViewModel (criterion 1). Explains hoisting further is unnecessary because 'nothing else on the screen reads or reacts to it' (criterion 2). Shows concrete construct: `var expanded by rememberSaveable { mutableStateOf(false) }` (criterion 3). No fail criteria: it warns against ViewModel hoisting and never uses a bare var."
318
+ },
319
+ "passed": true
320
+ }
321
+ ]
322
+ },
323
+ {
324
+ "id": "viewmodel-save-coroutine-scope",
325
+ "kind": "behavior",
326
+ "prompt": "I'm wiring up a save button in this ViewModel. Clicking it should call a suspend function on the repository to persist some data. How should I launch that call?",
327
+ "strictness": "high",
328
+ "trials": 10,
329
+ "passes": 10,
330
+ "passRate": 1,
331
+ "passAtK": 1,
332
+ "grader": "regex+judge",
333
+ "status": "ran",
334
+ "trialRecords": [
335
+ {
336
+ "output": "Launch it from the ViewModel's own `viewModelScope`, inside a function that the UI calls from the button's `onClick`. Do not launch it from the composable with a raw `GlobalScope` or an ad-hoc `CoroutineScope`.\n\nSketch:\n\n```kotlin\nclass ProfileViewModel(\n private val repository: ProfileRepository,\n) : ViewModel() {\n\n fun onSaveClicked() {\n viewModelScope.launch {\n _state.update { it.copy(saving = true) }\n val result = repository.saveProfile(currentProfile())\n _state.update { it.copy(saving = false) }\n result.onFailure { /* surface an error into UiState */ }\n }\n }\n}\n```\n\nWhy `viewModelScope`:\n\n- It is canceled automatically when the `ViewModel` is cleared, so the save coroutine (and any state it would have updated) dies with the screen instead of leaking or updating a gone UI.\n- `GlobalScope` is never the answer here: nothing cancels it, so it can outlive the screen and mutate state or trigger navigation after the user has left.\n\nThe composable side is trivial — just call the function:\n\n```kotlin\nButton(onClick = viewModel::onSaveClicked) { Text(\"Save\") }\n```\n\nDo not put a `suspend` call or a `launch` directly in the composable body. If the composable needs to observe the result (loading spinner, error message), expose it through the existing `StateFlow<UiState>` and collect with `collectAsStateWithLifecycle()`. If you instead need a *one-shot* event (navigate away, show a snackbar exactly once), model it as a `Channel`/`SharedFlow` on the ViewModel and consume it in a `LaunchedEffect` keyed to that flow — not by firing the effect directly in the composable body.\n\nTwo adjacent points that come up with this pattern:\n\n- Keep the save logic in the ViewModel and keep `currentProfile()` / state reads there too, rather than passing a snapshot from the composable — the ViewModel owns the source of truth across recomposition and configuration change.\n- If the save needs an intermediate \"unsaved changes\" flag that must survive rotation, that flag belongs in the ViewModel state as well, not in a `remember` inside the composable.\n\nIf the save call involves multiple concurrent repository suspend calls, wrap them in `coroutineScope { }` with `async`/`await` inside that `viewModelScope.launch`, rather than firing several untracked `launch`es you can't await or cancel as a unit.",
337
+ "outputSha256": "3b12dcb3431e8a5fa83d96862e0846f8620d1f4bf83a929a9a1d0371474eca88",
338
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
339
+ "deterministic": [
340
+ true
341
+ ],
342
+ "judge": {
343
+ "verdict": "pass",
344
+ "reason": "Answer names `viewModelScope.launch { ... }` with `repository.saveProfile(...)` inside (code sketch in onSaveClicked). It explains the lifetime tie: 'canceled automatically when the ViewModel is cleared, so the save coroutine ... dies with the screen', and warns GlobalScope 'is never the answer here: nothing cancels it'. No fail criteria: GlobalScope only warned against; composable just calls viewModel::onSaveClicked."
345
+ },
346
+ "passed": true
347
+ },
348
+ {
349
+ "output": "Launch it in `viewModelScope`, from a `ViewModel` function the button triggers — never directly from the composable, never `GlobalScope`.\n\n```kotlin\nclass ProfileViewModel(\n private val repository: ProfileRepository,\n) : ViewModel() {\n\n private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Idle)\n val state: StateFlow<ProfileUiState> = _state.asStateFlow()\n\n fun onSaveClicked() {\n viewModelScope.launch {\n _state.value = ProfileUiState.Saving\n runCatching { repository.saveProfile() }\n .onSuccess { _state.value = ProfileUiState.Saved }\n .onFailure { _state.value = ProfileUiState.Error(it.message) }\n }\n }\n}\n```\n\nWhy `viewModelScope` and not the alternatives:\n\n- It's tied to the `ViewModel`'s lifetime, so the save is automatically cancelled if the screen is destroyed while it's in flight — no work continues against a screen that's already gone.\n- It survives configuration changes (rotation), which matters if you want the save to keep running and the result to land in state the new composition can observe.\n- `GlobalScope` has none of those guarantees — nothing cancels it, so a late-completing save can write to state or trigger navigation for a screen that no longer exists.\n\nWire it up from the composable like this:\n\n```kotlin\n@Composable\nfun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {\n val state by viewModel.state.collectAsStateWithLifecycle()\n\n Button(onClick = viewModel::onSaveClicked) {\n Text(\"Save\")\n }\n}\n```\n\nThe button calls a plain `ViewModel` function; the `ViewModel` decides the scope. Don't pass a suspend lambda into the composable and launch it there — the composable body doesn't know when it's being recomposed, and it has no scope with the right lifetime.\n\nA couple of details worth getting right while you're in here:\n\n- **Keep the mutable flow private.** Expose `state` as `StateFlow`, not the `MutableStateFlow` — otherwise any collector can push values into it and you lose the single-writer invariant.\n- **Use a single sealed `UiState`** (Idle/Saving/Saved/Error) rather than a scatter of `isSaving: Boolean`, `error: String?` fields the UI has to reconcile.\n- **If the save needs to survive the button's composable leaving** (e.g. fire-and-continue while navigating away), `viewModelScope` already handles that; only reach for a longer-lived injected scope if the work must outlive the ViewModel itself, which is a deliberate, rarer choice.\n- **Don't use `!!`** on anything nullable in the save path — safe calls / `requireNotNull(x) { \"message\" }` instead.\n\nIf the repository call is the only thing happening, a single `viewModelScope.launch { ... }` is enough. If you later find yourself firing several independent suspend calls for one save, wrap them in `coroutineScope { }` with `async`/`await` so they're tracked and structured-cancelled together, rather than launching untracked siblings.",
350
+ "outputSha256": "5d5e88363b09ae6abade78b1483a4b00c0f06697512d84e813173f58ac475714",
351
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
352
+ "deterministic": [
353
+ true
354
+ ],
355
+ "judge": {
356
+ "verdict": "pass",
357
+ "reason": "Answer names `viewModelScope.launch { ... }` with `repository.saveProfile()` inside (criterion 1) and explains it's tied to the ViewModel's lifetime, auto-cancelled when the screen is destroyed, while `GlobalScope` has no such guarantee and can write to state for a gone screen (criterion 2). It explicitly warns against GlobalScope and launching from the composable, so no fail criteria hold."
358
+ },
359
+ "passed": true
360
+ },
361
+ {
362
+ "output": "Launch it in `viewModelScope`, from a function on the ViewModel that the button's click handler calls — not from the composable itself.\n\n```kotlin\nfun onSaveClicked() {\n viewModelScope.launch {\n _uiState.update { it.copy(saveStatus = SaveStatus.Saving) }\n val result = repository.save(formData)\n _uiState.update {\n when (result) {\n is SaveResult.Success -> it.copy(saveStatus = SaveStatus.Saved)\n is SaveResult.Error -> it.copy(saveStatus = SaveStatus.Error(result.message))\n }\n }\n }\n}\n```\n\nThe composable just wires the button to the ViewModel function:\n\n```kotlin\nButton(onClick = viewModel::onSaveClicked) { Text(\"Save\") }\n```\n\nWhy `viewModelScope` and not something else here:\n\n- It's tied to the ViewModel's lifetime, so the save is cancelled automatically if the ViewModel is cleared — no leaked coroutine updating state on a screen that's gone.\n- Never `GlobalScope` for this; there would be nothing to cancel it and it could touch UI state after teardown.\n- `lifecycleScope` or `rememberCoroutineScope()` would be wrong here too, because the work is business logic owned by the ViewModel and needs to survive configuration change (rotation mid-save). A composable scope dies on recomposition/rotation and would drop the in-flight save.\n\nA few things to get right alongside it:\n\n- If the button click can fire while the save is already running, guard against double-submits — either check `if (saveStatus is SaveStatus.Saving) return` at the top of `onSaveClicked`, or cancel/disable the button via the state. Concurrent saves racing on the same data is a common bug.\n- If you need to report a one-off outcome (a snackbar, a navigation event) rather than state, expose that separately — e.g. a `SharedFlow`/`Channel` of events collected with `LaunchedEffect` in the composable — instead of a state field the UI has to reset.\n- If the save should be robust to the user navigating away mid-flight, that's a different decision: it means the work belongs in a longer-lived scope (e.g. a `WorkManager` job or an app-scoped coroutine), not `viewModelScope`. For a plain \"click save, persist, show result\" flow, `viewModelScope` is the right call.\n\nKeep the state exposed as `StateFlow` (`private val _uiState = MutableStateFlow(...)`, public `val uiState = _uiState.asStateFlow()`) and collect it in the composable with `collectAsStateWithLifecycle()`.",
363
+ "outputSha256": "5dfa8debd02e765038622ee6100f085c4e6096bd1bdd44b77de2fa90179fb06e",
364
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
365
+ "deterministic": [
366
+ true
367
+ ],
368
+ "judge": {
369
+ "verdict": "pass",
370
+ "reason": "Answer names `viewModelScope.launch { ... }` with `repository.save(formData)` inside (criterion 1) and explains it's tied to the ViewModel's lifetime, cancelled when cleared, unlike GlobalScope which has nothing to cancel and could touch UI state after teardown (criterion 2). It warns against GlobalScope and rememberCoroutineScope rather than recommending them, so no fail criteria hold."
371
+ },
372
+ "passed": true
373
+ },
374
+ {
375
+ "output": "Use `viewModelScope` — launch the coroutine from inside a `ViewModel` function, not from the composable's click handler.\n\n```kotlin\n// ProfileViewModel.kt\nfun onSaveClicked() {\n viewModelScope.launch {\n _uiState.update { it.copy(isSaving = true, error = null) }\n try {\n repository.saveProfile(currentProfile)\n _uiState.update { it.copy(isSaving = false, saveSucceeded = true) }\n } catch (e: IOException) {\n _uiState.update { it.copy(isSaving = false, error = \"Couldn't save. Try again.\") }\n }\n }\n}\n```\n\nThe composable just calls the function — it never sees the coroutine scope:\n\n```kotlin\n// ProfileScreen.kt\nButton(\n onClick = { viewModel.onSaveClicked() },\n enabled = uiState !is ProfileUiState.Saving,\n) {\n Text(\"Save\")\n}\n```\n\nA few things worth being deliberate about:\n\n**Why not `rememberCoroutineScope()` in the composable.** A scope tied to the composable is cancelled when that composable leaves composition — for a save that should survive a rotation, a back-navigation-and-return, or any recomposition churn, that's the wrong lifetime. `viewModelScope` lives as long as the `ViewModel`, which is the lifetime that actually matches \"the user asked to persist this.\"\n\n**Why not `GlobalScope`.** It's never cancelled, so a save fired right before the user leaves the screen still runs and can still try to touch UI state or navigate on a dead screen. Rule it out on sight.\n\n**Don't launch it from `LaunchedEffect` triggered by a \"save requested\" flag.** People reach for this when they want to model the click as a state change. It works, but it makes the restart key subtle and easy to get wrong (it re-fires if the key recomposes back to `true`). Calling a `ViewModel` function from `onClick` and launching there is the straightforward path — reserve `LaunchedEffect` for effects that are genuinely driven by entering a composable (a load-on-first-display, an animation, a one-shot snackbar).\n\n**If the save is genuinely multi-step or cancellable**, consider tracking the job so a second tap while one is in flight doesn't start a duplicate:\n\n```kotlin\nprivate var saveJob: Job? = null\n\nfun onSaveClicked() {\n if (saveJob?.isActive == true) return // or cancel-and-restart, your call\n saveJob = viewModelScope.launch { ... }\n}\n```\n\nOr just gate the button on `isSaving` as above, which is usually simpler and gives the user visible feedback.\n\n**Expose the result as read-only state, not the coroutine.** The click handler does not return anything and does not suspend. It updates `_state`, and the UI observes the read-only view:\n\n```kotlin\nprivate val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Content(...))\nval uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()\n```\n\nIn Compose that's `collectAsStateWithLifecycle()`.\n\nOne naming caveat: I've written `repository.saveProfile(...)` as a suspend function. If your repository actually exposes a `Flow` or a callback-based API instead, the shape changes — a `Flow` would be collected in `viewModelScope` with `collect { }` (and typed for cancellation), a callback would be wrapped with `suspendCancellableCoroutine`. If that's the case here, tell me what the repository actually exposes and I'll adjust.",
376
+ "outputSha256": "274f7de29e1f9478253d65de52a191667a6d3dc11dbb999478de14a9bafeeb64",
377
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
378
+ "deterministic": [
379
+ true
380
+ ],
381
+ "judge": {
382
+ "verdict": "pass",
383
+ "reason": "Answer shows `viewModelScope.launch { ... }` with `repository.saveProfile(...)` inside (ProfileViewModel.onSaveClicked). It explains the lifetime rationale: viewModelScope lives as long as the ViewModel, and explicitly warns against GlobalScope (never cancelled, can touch dead screen) and rememberCoroutineScope (cancelled on leaving composition). No fail criteria met."
384
+ },
385
+ "passed": true
386
+ },
387
+ {
388
+ "output": "Launch it in `viewModelScope`, and call it from the click handler in a way that never puts the suspend call in the composable body.\n\nThe right shape:\n\n```kotlin\nclass EditProfileViewModel(\n private val repository: ProfileRepository,\n) : ViewModel() {\n\n private val _state = MutableStateFlow<EditProfileUiState>(EditProfileUiState.Content())\n val state: StateFlow<EditProfileUiState> = _state.asStateFlow()\n\n fun onSaveClicked() {\n viewModelScope.launch {\n _state.update { it.copy(isSaving = true) }\n try {\n repository.save(data)\n _state.update { it.copy(isSaving = false, saved = true) }\n } catch (e: Exception) {\n _state.update { it.copy(isSaving = false, error = e.message) }\n }\n }\n }\n}\n```\n\nAnd in the composable, the button just calls the ViewModel method — no coroutine runs in the composable at all:\n\n```kotlin\nButton(\n onClick = { viewModel.onSaveClicked() },\n enabled = !uiState.isSaving,\n) {\n Text(\"Save\")\n}\n```\n\nKey points:\n\n- **`viewModelScope` is the scope.** It's tied to the `ViewModel`'s lifetime, so if the user leaves the screen the in-flight save is cancelled instead of leaking or updating a dead UI. Never use `GlobalScope` here — nothing would cancel it, and it could push state or navigate on a screen that's already gone.\n- **The suspend call lives in the ViewModel, not the composable body.** A composable body re-runs on every recomposition, so a bare suspend call there can fire many times or never fire at the wrong moment. If for some reason the launch genuinely has to originate in the composable (no ViewModel in the picture), it belongs in a `LaunchedEffect(key)` — but for a button-driven persist action, the ViewModel method is the right home.\n- **Don't expose the mutable flow.** `_state` stays private; the UI collects `state` via `collectAsStateWithLifecycle()`.\n- **Model `isSaving`/`error`/`saved` as part of a single `UiState`**, not as loose booleans the UI has to reconcile, so the button's enabled state and any progress indicator read from one source of truth.\n- If the save needs more than one suspend call concurrently, wrap them in `coroutineScope { }` with `async`/`await` rather than firing independent `launch` calls, so failures propagate together.\n\nOne thing worth checking in your codebase first: if this feature already has a `UiState` type and a naming convention for the save action (e.g. `save()` vs `onSaveClicked()`), match it rather than introducing a new one.",
389
+ "outputSha256": "df85555c7efbb4ba68a5086d9b4922a4b000fbb346d79e886b6484fe6408ea26",
390
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
391
+ "deterministic": [
392
+ true
393
+ ],
394
+ "judge": {
395
+ "verdict": "pass",
396
+ "reason": "Answer shows `viewModelScope.launch { ... repository.save(data) ... }` inside onSaveClicked (criterion 1). It explains viewModelScope is tied to the ViewModel's lifetime, cancelling the in-flight save when the user leaves the screen, and warns against GlobalScope since nothing would cancel it (criterion 2). No fail criteria: GlobalScope only mentioned to warn against it; composable only calls the ViewModel method."
397
+ },
398
+ "passed": true
399
+ },
400
+ {
401
+ "output": "Launch it in the ViewModel's own `viewModelScope`:\n\n```kotlin\nfun onSaveClicked() {\n viewModelScope.launch {\n _uiState.update { it.copy(isSaving = true) }\n try {\n repository.saveData(payload)\n _uiState.update { it.copy(isSaving = false, saved = true) }\n } catch (e: Exception) {\n _uiState.update { it.copy(isSaving = false, error = e.message) }\n }\n }\n}\n```\n\nWhy `viewModelScope` and not something else:\n\n- It's tied to the ViewModel's lifetime, so the coroutine is cancelled automatically when the ViewModel is cleared (screen gone, process out of scope). If the user navigates away mid-save, the coroutine stops instead of continuing to write state onto a dead screen.\n- `lifecycleScope` from an Activity/Fragment is tempting here, but that belongs to the *view*, not this ViewModel — don't reach for it when the work is ViewModel-owned. `lifecycleScope`/`rememberCoroutineScope()` are for UI-only work with no ViewModel involved (an animation, a one-off scroll).\n- Never `GlobalScope` — nothing cancels it, and it can still update state or fire navigation against a screen that no longer exists.\n\nSo: don't launch it from the composable's body — have the button call `viewModel.onSaveClicked()`, and let the ViewModel launch in `viewModelScope`. Keep the button's `onClick` a plain synchronous call.\n\nTwo things to make sure you wire alongside it:\n\n- Expose the save state as read-only. Keep `private val _uiState = MutableStateFlow(...)` and expose `val uiState = _uiState.asStateFlow()`. Don't hand the UI the mutable type — it breaks the single-writer invariant. Collect it in Compose with `collectAsStateWithLifecycle()`.\n- If the save can be triggered concurrently (double-tap, two sources), don't let two overlapping writes race. Either guard with the `isSaving` flag, or track a `Job?` and cancel/replace, or use a `Mutex`. A bare `launch` per click gives you N concurrent saves.\n\nFor anything that isn't just this one call — the state modeling (`Loading`/`Content`/`Error` as a sealed hierarchy rather than loose booleans), hoisting decisions, and the `LaunchedEffect` rules for any UI side effects the save triggers — follow the full workflow in the `compose-implementation` skill and the project's `rules/coding-style.mdc` / `rules/patterns.mdc` before writing the rest of the screen. And run `./gradlew assembleDebug testDebugUnitTest lint` before calling it done.",
402
+ "outputSha256": "937ffcb679d63b7f3b7a0a7cdab1b5d31a2cf6131898b5e529c37edf5629d565",
403
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
404
+ "deterministic": [
405
+ true
406
+ ],
407
+ "judge": {
408
+ "verdict": "pass",
409
+ "reason": "Answer shows `viewModelScope.launch { ... }` with `repository.saveData(payload)` inside (criterion 1). It explains the lifetime tie: 'tied to the ViewModel's lifetime, so the coroutine is cancelled automatically when the ViewModel is cleared' and contrasts with GlobalScope which 'nothing cancels' (criterion 2). It explicitly warns against GlobalScope and rememberCoroutineScope, so no fail criteria hold."
410
+ },
411
+ "passed": true
412
+ },
413
+ {
414
+ "output": "Launch it in `viewModelScope`. That scope is tied to the ViewModel's lifetime, so it's automatically cancelled when the ViewModel is cleared — the coroutine won't outlive the screen that owns it.\n\nRoughly:\n\n```kotlin\nclass ProfileViewModel(\n private val repository: ProfileRepository,\n) : ViewModel() {\n\n private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Idle)\n val state: StateFlow<ProfileUiState> = _state.asStateFlow()\n\n fun onSaveClicked(profile: Profile) {\n viewModelScope.launch {\n _state.value = ProfileUiState.Saving\n val result = runCatching { repository.save(profile) }\n _state.value = result.fold(\n onSuccess = { ProfileUiState.Saved },\n onFailure = { ProfileUiState.Error(it.message ?: \"Save failed\") },\n )\n }\n }\n}\n```\n\nKey points:\n\n- `viewModelScope` is the right choice specifically because the save is driven by the ViewModel. Don't use `GlobalScope` — nothing would cancel it when the ViewModel is destroyed, and it could try to update state on a screen that's gone.\n- If the save is triggered from a composable, don't call a suspend function directly in a composable body. Have the composable call a plain `onSaveClicked(...)` on the ViewModel, and let the ViewModel launch inside `viewModelScope`. The composable body can re-run on every recomposition, so anything suspend must live inside `LaunchedEffect`/a ViewModel method, never as a bare statement.\n- If you need to observe the outcome in the UI, expose that through the ViewModel's `StateFlow` (collected with `collectAsStateWithLifecycle()`) rather than trying to await the `launch` yourself.\n- If later you need two independent repository calls to run concurrently, wrap them in a `coroutineScope { async { ... } await() }` instead of firing separate untracked `launch` calls.",
415
+ "outputSha256": "b62738ed06512fea38f8e0d4531e3e02dbd8ae9e057fd074338467d3b2e0ead1",
416
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
417
+ "deterministic": [
418
+ true
419
+ ],
420
+ "judge": {
421
+ "verdict": "pass",
422
+ "reason": "Answer names `viewModelScope.launch { ... }` with `repository.save(profile)` inside (code block in onSaveClicked), and explains the lifetime tie: 'tied to the ViewModel's lifetime, so it's automatically cancelled when the ViewModel is cleared.' It warns against GlobalScope rather than recommending it, and routes composable triggers through the ViewModel, not rememberCoroutineScope."
423
+ },
424
+ "passed": true
425
+ },
426
+ {
427
+ "output": "Launch it in `viewModelScope`, and if you want the click to be a one-shot event rather than a new function that the UI calls directly, expose the trigger as a function on the ViewModel that itself starts the coroutine:\n\n```kotlin\nfun onSaveClicked() {\n viewModelScope.launch {\n _uiState.update { it.copy(isSaving = true) }\n runCatching { repository.save(data) }\n .onSuccess { _uiState.update { s -> s.copy(isSaving = false) } }\n .onFailure { e -> _uiState.update { s -> s.copy(isSaving = false, error = e.message) } }\n }\n}\n\nfun onSaveClicked() { ... }\n```\n\nKey points for this specific case:\n\n- **Scope**: `viewModelScope`, not `GlobalScope` and not `lifecycleScope`. The save is business work tied to the ViewModel's lifetime; `viewModelScope` cancels it automatically when the ViewModel is cleared (screen finished, config change where the VM survives, etc.), so a slow save can't outlive the screen and mutate state that's already gone.\n- **The click handler itself is not a coroutine.** Your composable calls `viewModel.onSaveClicked()` from the button's `onClick` — a plain function that internally does `viewModelScope.launch { ... }`. Do not make the composable call a `suspend` function directly, and do not put the `launch` in the composable.\n- **Don't** wrap it in `rememberCoroutineScope()` in the composable. That scope is tied to the composition, which is wrong for a persistence operation — it would be cancelled when the composable leaves composition, potentially mid-write.\n- If you need the result back in the UI as an event (e.g. show a snackbar once), expose a `SharedFlow`/`Channel` of events from the ViewModel and collect it with `LaunchedEffect`, rather than having `onSaveClicked()` return a value.\n- If the button can be tapped repeatedly while a save is in flight, keep an `isSaving` flag in your `UiState` and either disable the button or drop the second click inside `onSaveClicked()`.\n\nSo: `viewModelScope.launch { repository.save(data) }`, triggered from a plain ViewModel function that the button's `onClick` calls.",
428
+ "outputSha256": "ce3e3f99be554827df09afb9cbf3871ef05b3968853e82cf83b6ad41ff96438d",
429
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
430
+ "deterministic": [
431
+ true
432
+ ],
433
+ "judge": {
434
+ "verdict": "pass",
435
+ "reason": "Answer names `viewModelScope.launch { repository.save(data) }` as the concrete mechanism (criterion 1) and explains it cancels automatically when the ViewModel is cleared so a slow save can't outlive the screen and mutate gone state, contrasting with GlobalScope/lifecycleScope (criterion 2). It explicitly warns against GlobalScope and rememberCoroutineScope, so no fail criterion holds."
436
+ },
437
+ "passed": true
438
+ },
439
+ {
440
+ "output": "Launch it in `viewModelScope`, from a plain public (non-suspend) function on the ViewModel that the UI calls. Not `GlobalScope`, not `lifecycleScope` from inside the ViewModel, and not by making the click handler itself suspend.\n\nShape it like this:\n\n```kotlin\nclass ProfileViewModel(\n private val repository: ProfileRepository,\n) : ViewModel() {\n\n private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Content(...))\n val state: StateFlow<ProfileUiState> = _state.asStateFlow()\n\n fun onSaveClicked() {\n viewModelScope.launch {\n _state.update { it.copy(saving = true) }\n try {\n repository.saveProfile(...)\n _state.update { it.copy(saving = false) }\n } catch (e: IOException) {\n _state.update { it.copy(saving = false, error = \"Could not save\") }\n }\n }\n }\n}\n```\n\nWhy `viewModelScope` specifically:\n\n- It's cancelled automatically in `onCleared()`, so a save in flight doesn't outlive the ViewModel and try to push state into a screen that's gone.\n- It's the scope the ViewModel logically owns; the save is ViewModel-driven work (it updates exposed state), not a UI-only side effect, which is the case where you'd reach for `lifecycleScope`/`rememberCoroutineScope()` instead.\n- `GlobalScope` would leave the coroutine running past ViewModel destruction with no cancellation path — a classic leak and a source of \"why did this navigate after I left the screen\" bugs.\n\nA few things that go with it:\n\n- **Keep the click handler non-suspend.** Composables can't call suspend functions directly outside a `LaunchedEffect`/`rememberCoroutineScope()` anyway, and for a persistent action like save, doing it in the ViewModel is what keeps the state-survives-rotation behavior correct.\n- **Guard against double-taps if the save isn't idempotent.** Either gate on a `saving` flag in the UI state and disable the button, or keep a `Job` reference and skip if `isActive`:\n\n```kotlin\nprivate var saveJob: Job? = null\n\nfun onSaveClicked() {\n if (saveJob?.isActive == true) return\n saveJob = viewModelScope.launch { /* ... */ }\n}\n```\n\n- **Don't let repository exceptions escape `launch` unhandled** — catch them and fold them into the `UiState`, otherwise an uncaught exception propagates to the scope's handler and can crash. If you have a `CoroutineExceptionHandler` wired in, that's a safety net, not a substitute for modeling the error in state.\n- **If you're firing more than one concurrent suspend call** (e.g. save plus a refresh), wrap them in `coroutineScope { val a = async { ... }; val b = async { ... }; a.await(); b.await() }` rather than two untracked `launch`es you can't await together.\n- **Collect the resulting state in Compose** with `collectAsStateWithLifecycle()` so collection stops when the UI goes to the background.\n\nOne thing worth checking in Step 2 of the workflow before you commit to this: confirm `saveProfile` is actually a `suspend` function and not a blocking/`Flow`-returning API. If the repository exposes a `Flow<Result>` instead, the natural fit is `flatMapLatest`/`stateIn` in the ViewModel rather than a one-shot `launch` around a suspend call.",
441
+ "outputSha256": "c13dcc72ecd47cf830cd3b6afaf95c6b61a01fb4f40bd4971f5d3983c2e3773b",
442
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
443
+ "deterministic": [
444
+ true
445
+ ],
446
+ "judge": {
447
+ "verdict": "pass",
448
+ "reason": "Answer names viewModelScope.launch with repository.saveProfile(...) inside it (code block in onSaveClicked). Explains lifetime: 'cancelled automatically in onCleared(), so a save in flight doesn't outlive the ViewModel' and GlobalScope 'would leave the coroutine running past ViewModel destruction with no cancellation path.' No fail criteria: GlobalScope only warned against; scope is ViewModel-owned, not rememberCoroutineScope."
449
+ },
450
+ "passed": true
451
+ },
452
+ {
453
+ "output": "Launch it in `viewModelScope`, never `GlobalScope`.\n\nIn the `ViewModel`:\n\n```kotlin\nfun onSaveClicked() {\n viewModelScope.launch {\n // update UiState to a \"saving\" state first, if the UI needs it\n val result = repository.save(data)\n // update _state with the result (success/error)\n }\n}\n```\n\nA few things worth pinning down while you wire this up, since the skill's rules cover each:\n\n**Scope choice.** `viewModelScope` is tied to the ViewModel's lifetime — it cancels automatically when the ViewModel is cleared (screen finished, nav back). `GlobalScope` cancels nothing, so a save that's mid-flight when the user leaves the screen can still fire a state update or navigation event against a screen that's gone. `lifecycleScope`/`rememberCoroutineScope()` would only be right here if there were no ViewModel driving the button — there is, so `viewModelScope` it is.\n\n**State exposure.** Don't let the caller hand you a value (e.g. don't expose `onSave(scope: CoroutineScope)` from the UI). Keep the mutable state private:\n\n```kotlin\nprivate val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Idle)\nval state: StateFlow<ProfileUiState> = _state.asStateFlow()\n```\n\nThe UI collects it with `collectAsStateWithLifecycle()`, not a raw `collect`.\n\n**Button wiring in Compose.** Keep the composable dumb — it calls the ViewModel method, it does not launch the coroutine itself:\n\n```kotlin\nButton(onClick = viewModel::onSaveClicked) { Text(\"Save\") }\n```\n\nIf the button needs a one-off side effect (navigation on success, a snackbar), that belongs in a `LaunchedEffect(key)` observing the state, not in the click handler and not as a bare statement in the composable body.\n\n**Concurrency.** If the save actually fans out to several suspend calls that should run together, wrap them in `coroutineScope { }` with `async`/`await` so the parent coroutine waits for all of them and a failure cancels the siblings — don't fire untracked parallel `launch`es.\n\n**While you're in there**, per the red-flags list: avoid `!!` on any nullable repository result or saved-state lookup — use `?.`/`?:` or `requireNotNull(x) { \"...\" }`. And if you're adding a \"saving in progress\" flag, fold it into your `UiState` sealed hierarchy (`Loading`/`Content`/`Error`) rather than adding an independent boolean the UI has to reconcile.\n\nBefore you report it done: `./gradlew assembleDebug`, `./gradlew testDebugUnitTest`, and `./gradlew lint` should all pass, and every new launch should be on `viewModelScope`.",
454
+ "outputSha256": "0fb6310b74bdb17603c51d0b89f394cf570fc9a30d75e6531eaf1b13d73f4d29",
455
+ "promptSha256": "94fef52076d4f764f0ec2e16984aaad2a55a165ed53b89bf9109a4efc3fc69d4",
456
+ "deterministic": [
457
+ true
458
+ ],
459
+ "judge": {
460
+ "verdict": "pass",
461
+ "reason": "Answer names `viewModelScope.launch { ... }` with `repository.save(data)` inside (criterion 1) and explains it is tied to the ViewModel's lifetime, cancelling when cleared, while GlobalScope cancels nothing and can fire state updates against a gone screen (criterion 2). It explicitly rejects GlobalScope and rememberCoroutineScope, so no fail criteria hold."
462
+ },
463
+ "passed": true
464
+ }
465
+ ]
466
+ }
467
+ ],
468
+ "verdict": "fail",
469
+ "scope": "bundled",
470
+ "skillDigest": "c824d23ddf4f096ec9657031cb77534484786d16c85148e726ed23d3d1de18b5",
471
+ "catalogDigest": "97f9af01aafac82ae21a63c6af2a2f24fcfe067dc32a7cfdcde9a69a91fa9aae",
472
+ "judgePromptVersion": "2026-09-25.1",
473
+ "runner": "deepseek",
474
+ "model": "deepseek-chat",
475
+ "runnerPromptVersion": "2026-09-25.1",
476
+ "recordedAt": "2026-09-25T18:27:50.413Z",
477
+ "judge": "deepseek",
478
+ "judgeModel": "deepseek-chat"
479
+ },
480
+ {
481
+ "schemaVersion": "1.0.0",
482
+ "skillId": "kotlin-android/kotlin-android-testing",
483
+ "strictness": "high",
484
+ "trials": 10,
485
+ "triggerAccuracy": {
486
+ "truePositive": 3,
487
+ "falsePositive": 0,
488
+ "positives": 7,
489
+ "negatives": 7
490
+ },
491
+ "evidence": "authored",
492
+ "scenarios": [
493
+ {
494
+ "id": "trigger-positive-1",
495
+ "kind": "trigger-positive",
496
+ "prompt": "This ViewModel's loadProfile function needs test coverage before we merge",
497
+ "strictness": "high",
498
+ "trials": 1,
499
+ "passes": 0,
500
+ "passRate": 0,
501
+ "passAtK": 0,
502
+ "grader": "trigger-rank-fork-family",
503
+ "status": "ran",
504
+ "deterministic": true
505
+ },
506
+ {
507
+ "id": "trigger-positive-2",
508
+ "kind": "trigger-positive",
509
+ "prompt": "Add a test that clicking the retry button actually triggers a refetch",
510
+ "strictness": "high",
511
+ "trials": 1,
512
+ "passes": 0,
513
+ "passRate": 0,
514
+ "passAtK": 0,
515
+ "grader": "trigger-rank-fork-family",
516
+ "status": "ran",
517
+ "deterministic": true
518
+ },
519
+ {
520
+ "id": "trigger-positive-3",
521
+ "kind": "trigger-positive",
522
+ "prompt": "The repository call in this suspend function isn't covered by any test",
523
+ "strictness": "high",
524
+ "trials": 1,
525
+ "passes": 1,
526
+ "passRate": 1,
527
+ "passAtK": 1,
528
+ "grader": "trigger-rank-fork-family",
529
+ "status": "ran",
530
+ "deterministic": true
531
+ },
532
+ {
533
+ "id": "trigger-positive-4",
534
+ "kind": "trigger-positive",
535
+ "prompt": "Write a test for the error state shown when the network call fails",
536
+ "strictness": "high",
537
+ "trials": 1,
538
+ "passes": 0,
539
+ "passRate": 0,
540
+ "passAtK": 0,
541
+ "grader": "trigger-rank-fork-family",
542
+ "status": "ran",
543
+ "deterministic": true
544
+ },
545
+ {
546
+ "id": "trigger-positive-5",
547
+ "kind": "trigger-positive",
548
+ "prompt": "This screen's test keeps waiting forever, can you fix it",
549
+ "strictness": "high",
550
+ "trials": 1,
551
+ "passes": 1,
552
+ "passRate": 1,
553
+ "passAtK": 1,
554
+ "grader": "trigger-rank-fork-family",
555
+ "status": "ran",
556
+ "deterministic": true
557
+ },
558
+ {
559
+ "id": "trigger-positive-6",
560
+ "kind": "trigger-positive",
561
+ "prompt": "We need a test proving the save button disables itself while saving",
562
+ "strictness": "high",
563
+ "trials": 1,
564
+ "passes": 0,
565
+ "passRate": 0,
566
+ "passAtK": 0,
567
+ "grader": "trigger-rank-fork-family",
568
+ "status": "ran",
569
+ "deterministic": true
570
+ },
571
+ {
572
+ "id": "trigger-positive-7",
573
+ "kind": "trigger-positive",
574
+ "prompt": "Cover this list-filtering logic in the ViewModel with unit tests",
575
+ "strictness": "high",
576
+ "trials": 1,
577
+ "passes": 1,
578
+ "passRate": 1,
579
+ "passAtK": 1,
580
+ "grader": "trigger-rank-fork-family",
581
+ "status": "ran",
582
+ "deterministic": true
583
+ },
584
+ {
585
+ "id": "trigger-negative-1",
586
+ "kind": "trigger-negative",
587
+ "prompt": "Write an XCTest for this SwiftUI view model's published property",
588
+ "strictness": "high",
589
+ "trials": 1,
590
+ "passes": 1,
591
+ "passRate": 1,
592
+ "passAtK": 1,
593
+ "grader": "trigger-rank-fork-family",
594
+ "status": "ran",
595
+ "deterministic": true
596
+ },
597
+ {
598
+ "id": "trigger-negative-2",
599
+ "kind": "trigger-negative",
600
+ "prompt": "Add a widget test for this Flutter StatefulWidget's local state",
601
+ "strictness": "high",
602
+ "trials": 1,
603
+ "passes": 1,
604
+ "passRate": 1,
605
+ "passAtK": 1,
606
+ "grader": "trigger-rank-fork-family",
607
+ "status": "ran",
608
+ "deterministic": true
609
+ },
610
+ {
611
+ "id": "trigger-negative-3",
612
+ "kind": "trigger-negative",
613
+ "prompt": "Write an xUnit test for this ASP.NET Core controller action",
614
+ "strictness": "high",
615
+ "trials": 1,
616
+ "passes": 1,
617
+ "passRate": 1,
618
+ "passAtK": 1,
619
+ "grader": "trigger-rank-fork-family",
620
+ "status": "ran",
621
+ "deterministic": true
622
+ },
623
+ {
624
+ "id": "trigger-negative-4",
625
+ "kind": "trigger-negative",
626
+ "prompt": "Add a React Testing Library test for this component's click handler",
627
+ "strictness": "high",
628
+ "trials": 1,
629
+ "passes": 1,
630
+ "passRate": 1,
631
+ "passAtK": 1,
632
+ "grader": "trigger-rank-fork-family",
633
+ "status": "ran",
634
+ "deterministic": true
635
+ },
636
+ {
637
+ "id": "trigger-negative-5",
638
+ "kind": "trigger-negative",
639
+ "prompt": "Write a pytest case for this FastAPI endpoint's validation logic",
640
+ "strictness": "high",
641
+ "trials": 1,
642
+ "passes": 1,
643
+ "passRate": 1,
644
+ "passAtK": 1,
645
+ "grader": "trigger-rank-fork-family",
646
+ "status": "ran",
647
+ "deterministic": true
648
+ },
649
+ {
650
+ "id": "trigger-negative-6",
651
+ "kind": "trigger-negative",
652
+ "prompt": "Review this Kotlin diff for exported manifest components before merge",
653
+ "strictness": "high",
654
+ "trials": 1,
655
+ "passes": 1,
656
+ "passRate": 1,
657
+ "passAtK": 1,
658
+ "grader": "trigger-rank-fork-family",
659
+ "status": "ran",
660
+ "deterministic": true
661
+ },
662
+ {
663
+ "id": "trigger-negative-7",
664
+ "kind": "trigger-negative",
665
+ "prompt": "Fix the failing Gradle build after this dependency version bump",
666
+ "strictness": "high",
667
+ "trials": 1,
668
+ "passes": 1,
669
+ "passRate": 1,
670
+ "passAtK": 1,
671
+ "grader": "trigger-rank-fork-family",
672
+ "status": "ran",
673
+ "deterministic": true
674
+ },
675
+ {
676
+ "id": "mock-repository-boundary",
677
+ "kind": "behavior",
678
+ "prompt": "I have a ViewModel with a `loadProfile()` function that calls `profileRepository.fetch()`, a suspend function declared on a `ProfileRepository` interface. I want to unit test what happens when that call fails. How should I set up the mock?",
679
+ "strictness": "high",
680
+ "trials": 10,
681
+ "passes": 10,
682
+ "passRate": 1,
683
+ "passAtK": 1,
684
+ "grader": "judge",
685
+ "status": "ran",
686
+ "trialRecords": [
687
+ {
688
+ "output": "Set the mock up on the `ProfileRepository` interface itself, not on the ViewModel. The ViewModel should receive the mock through its constructor (or whatever dependency-injection path it already uses), exactly as it would receive the real repository in production.\n\nWith MockK, the failure case looks like this:\n\n```kotlin\nprivate val repo = mockk<ProfileRepository>()\n\n@Test\nfun `loadProfile exposes error state when repository fails`() = runTest {\n coEvery { repo.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(repo)\n viewModel.loadProfile()\n advanceUntilIdle()\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n}\n```\n\nKey points:\n\n**Use `coEvery`, not `every`.** `fetch()` is a suspend function, so the coroutine-aware `coEvery { repo.fetch() } throws ...` is required. Plain `every` will not compile or will throw at runtime for a suspend call.\n\n**Mock the interface, not the concrete class.** `mockk<ProfileRepository>()` targets the abstraction the ViewModel actually depends on. Do not mock the ViewModel's own methods, and do not reach for a `relaxed = true` mock just to silence unstubbed calls — an unstubbed call in this test is a signal that the test is exercising more of the ViewModel than intended, not something to paper over.\n\n**Throwing vs. returning a failure type.** This is the important design question. Check what the `ProfileRepository` contract actually says a failure looks like:\n\n- If `fetch()` is declared to throw (e.g. `suspend fun fetch(): Profile` that propagates exceptions), then `coEvery { repo.fetch() } throws SomeException()` is correct, and the test asserts the ViewModel's resulting error state.\n- If the repository returns a sealed `Result`/`Either`-style type and never throws to the caller, then the right setup is `coEvery { repo.fetch() } returns Result.failure(SomeException())` (or your domain failure type), and the test asserts the ViewModel maps that failure into its error state.\n\nGetting this wrong is the most common mistake here: throwing from the mock when the real repository would return a failure value tests a code path that can't happen in production, or vice versa. Match the mock's failure representation to the interface's actual contract.\n\n**Wrap in `runTest` and advance the dispatcher.** Install a `TestDispatcher` on `Dispatchers.Main` (via a shared `MainDispatcherRule` or `Dispatchers.setMain` in `@Before`) so `viewModelScope` runs on the test dispatcher, then call `advanceUntilIdle()` after `loadProfile()` before asserting. Do not use `Thread.sleep` or a fixed `delay` to wait for the failure to propagate.\n\n**Verify the interaction if the ViewModel does more than read.** If the failure path is supposed to, say, record something or fall back to a cached value, add a `coVerify { repo.someOtherCall() }` or assert the fallback state rather than just checking \"it didn't throw.\"\n\nSo the checklist for your specific case: mock `ProfileRepository`, `coEvery { repo.fetch() } throws ...` or `returns Result.failure(...)` depending on the interface contract, construct the ViewModel with that mock, call `loadProfile()`, advance the test dispatcher, assert the ViewModel's error state — and only touch test files, never the ViewModel or repository source.",
689
+ "outputSha256": "2a59b12c871938d906c070315251e19ff6282b7e09b9686196302a39b7b774e5",
690
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
691
+ "deterministic": [],
692
+ "judge": {
693
+ "verdict": "pass",
694
+ "reason": "Answer mocks the ProfileRepository interface (mockk<ProfileRepository>(), injected via constructor), uses coroutine-aware coEvery { repo.fetch() } throws IOException(...) (explicitly warns against plain every), and exercises the real loadProfile() with the mock injected, asserting ProfileUiState.Error. No ViewModel internals stubbed; no non-coroutine every on the suspend call."
695
+ },
696
+ "passed": true
697
+ },
698
+ {
699
+ "output": "Set up the mock at the `ProfileRepository` interface boundary (the dependency `ProfileViewModel` actually holds), and make it throw from the suspend `fetch()` call. With MockK that means `coEvery` (not `every`) because `fetch()` is suspend:\n\n```kotlin\nval profileRepository = mockk<ProfileRepository>()\ncoEvery { profileRepository.fetch() } throws IOException(\"network down\")\n\nval viewModel = ProfileViewModel(profileRepository)\n```\n\nKey points:\n\n- **Mock the interface, not the ViewModel's internals.** `ProfileRepository` is the seam; stubbing anything inside `ProfileViewModel` would test a mocked-out internal instead of real error-handling behavior.\n- **Use `coEvery`, not `every`.** `fetch()` is a suspend function, so plain `every` won't wire up correctly.\n- **If you also need a success path in the same suite**, add `coEvery { profileRepository.fetch() } returns fakeProfile`—but for the failure case, `throws` is what puts the ViewModel on its error branch.\n- **Pick the exception type the ViewModel actually catches.** If it maps a domain error (e.g. a sealed `Result.Failure` or a custom `ProfileError`) rather than letting `IOException` propagate, throw whatever the repository contract says it can throw, and assert on the resulting state (e.g. an error field/`UiState.Error`) rather than on the exception itself.\n\nThen drive the call under `runTest` with a `TestDispatcher` installed as `Dispatchers.Main` (via your project's dispatcher rule) so `viewModelScope` resolves to it, and assert the error state:\n\n```kotlin\n@Test\nfun `loadProfile failure sets error state`() = runTest {\n val profileRepository = mockk<ProfileRepository>()\n coEvery { profileRepository.fetch() } throws IOException(\"network down\")\n val viewModel = ProfileViewModel(profileRepository)\n\n viewModel.loadProfile()\n advanceUntilIdle() // or runCurrent() — never Thread.sleep/delay\n\n assertEquals(UiState.Error, viewModel.uiState.value)\n coVerify { profileRepository.fetch() }\n}\n```\n\nDon't add a fixed `delay`/`Thread.sleep` to \"let the coroutine finish\"—`advanceUntilIdle()` (or `runCurrent()`) makes that wait deterministic and instant. And check a neighboring test file for whether the project standardizes on MockK vs Mockito and whether it has a shared `MainDispatcherRule`; match that rather than introducing your own setup.",
700
+ "outputSha256": "57517fd2369b0f26737595f8c58027e081f3a8e2905faf811b166b3a6cbd222d",
701
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
702
+ "deterministic": [],
703
+ "judge": {
704
+ "verdict": "pass",
705
+ "reason": "Mocks ProfileRepository interface (mockk<ProfileRepository>), not ViewModel internals; uses coroutine-aware coEvery { profileRepository.fetch() } throws IOException; injects mock into ProfileViewModel and drives real loadProfile() asserting UiState.Error. No fail criteria met."
706
+ },
707
+ "passed": true
708
+ },
709
+ {
710
+ "output": "Mock `ProfileRepository` itself — that's the interface boundary the ViewModel depends on — and have `fetch()` throw from the mock, rather than mocking anything internal to the ViewModel.\n\nIn MockK, for a suspend function, the relevant stubbing is `coEvery`:\n\n```kotlin\nval repository = mockk<ProfileRepository>()\ncoEvery { repository.fetch() } throws IOException(\"network down\")\n\nval viewModel = ProfileViewModel(repository)\n```\n\nThen construct the ViewModel with that mock and drive `loadProfile()`. A few things to get right:\n\n**Install the test dispatcher on `Dispatchers.Main` first.** `loadProfile()` almost certainly launches in `viewModelScope`, which resolves to `Dispatchers.Main`. If a test dispatcher isn't installed, that either throws or doesn't run. Do it via a shared rule or in `@Before`/`@After`:\n\n```kotlin\n@Before fun setUp() {\n Dispatchers.setMain(StandardTestDispatcher())\n}\n@After fun tearDown() {\n Dispatchers.resetMain()\n}\n```\n\n**Wrap the body in `runTest` and advance explicitly.** With `StandardTestDispatcher` nothing runs until you advance, so call `loadProfile()` and then `advanceUntilIdle()` before asserting:\n\n```kotlin\n@Test\nfun `loadProfile surfaces error state when repository fails`() = runTest {\n val repository = mockk<ProfileRepository>()\n coEvery { repository.fetch() } throws IOException(\"network down\")\n val viewModel = ProfileViewModel(repository)\n\n viewModel.loadProfile()\n advanceUntilIdle()\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n}\n```\n\nUse the same `StandardTestDispatcher` instance for both `setMain` and the `runTest` scheduler (sharing a scheduler via a rule is the common pattern) so `advanceUntilIdle()` actually drives the Main dispatcher's coroutines.\n\n**Two decisions worth making deliberately:**\n\n- *What kind of failure.* If the ViewModel is expected to catch `IOException` specifically and map it to an error state, throw that. If it's expected to catch any `Throwable`/`Exception`, you can throw something generic — but throwing the exact type the ViewModel's `catch` targets is what actually exercises the intended branch.\n- *Whether the error is thrown or returned.* If `fetch()` is a `Result`-returning suspend function rather than one that throws, stub `coEvery { repository.fetch() } returns Result.failure(...)` instead. Match the real signature — read it from the interface rather than guessing.\n\n**Also cover the happy path in the same file** so the failure test isn't passing for the wrong reason (e.g. an error state that's just the initial state). A `coEvery { repository.fetch() } returns profile` case asserting the success state confirms `loadProfile()` actually transitions state on success and that the error-state assertion is meaningful. And if you want to assert the interaction, `coVerify { repository.fetch() }` (or `coVerify(exactly = 1)`) confirms it was actually called.\n\nNote the one thing not to do: don't stub or verify anything private on `ProfileViewModel` itself. `fetch()` is the seam; the repository is the only thing that should be mocked here.",
711
+ "outputSha256": "b8b8aeff140408849b9592c209cc345cb18d8c05652c98910f1ba08baddfc7d3",
712
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
713
+ "deterministic": [],
714
+ "judge": {
715
+ "verdict": "pass",
716
+ "reason": "Mocks ProfileRepository interface (mockk<ProfileRepository>()), uses coroutine-aware coEvery { repository.fetch() } throws IOException, and injects mock into ViewModel then drives real loadProfile() asserting ProfileUiState.Error. Explicitly warns against stubbing private ViewModel internals. No fail criteria met."
717
+ },
718
+ "passed": true
719
+ },
720
+ {
721
+ "output": "Set up MockK on the `ProfileRepository` interface — that's the boundary you mock, not the ViewModel itself.\n\nGiven a ViewModel roughly like:\n\n```kotlin\nclass ProfileViewModel(\n private val profileRepository: ProfileRepository,\n) : ViewModel() {\n val uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)\n\n fun loadProfile() {\n viewModelScope.launch {\n uiState.value = ProfileUiState.Loading\n try {\n val profile = profileRepository.fetch()\n uiState.value = ProfileUiState.Success(profile)\n } catch (e: Exception) {\n uiState.value = ProfileUiState.Error\n }\n }\n }\n}\n```\n\nThe mock setup for the failure path looks like this:\n\n```kotlin\nprivate val profileRepository = mockk<ProfileRepository>()\n\n@Test\nfun `loadProfile - when repository fails - emits error state`() = runTest {\n coEvery { profileRepository.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(profileRepository)\n viewModel.loadProfile()\n advanceUntilIdle()\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n}\n```\n\nA few points that matter here:\n\nUse `coEvery` (not `every`) because `fetch()` is a suspend function. Using `every` for a suspend function will either fail to compile or produce a mock that misbehaves at the call site.\n\nMatch the failure type to whatever the ViewModel actually catches. If the repository contract says it throws `IOException` on network failure, throw `IOException`. If the repository is expected to translate errors into a domain type (e.g. a `Result.Failure` or a sealed `ProfileError`), then `returns Result.failure(...)` instead of `throws ...` and assert on that. Mocking the wrong failure shape tests a path that never runs in production.\n\nInstall a test dispatcher for `Dispatchers.Main` so `viewModelScope` resolves to it — `viewModelScope` is hardcoded to `Dispatchers.Main.immediate`, and `runTest` alone will not redirect it:\n\n```kotlin\n@get:Rule\nval mainDispatcherRule = MainDispatcherRule()\n```\n\nor manually:\n\n```kotlin\n@Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) }\n@After fun tearDown() { Dispatchers.resetMain() }\n```\n\nThen `advanceUntilIdle()` (or `runCurrent()` if you only need the state after the suspension point) drives the coroutine to completion deterministically — no real delay, no `Thread.sleep`.\n\nIf you want to verify the call actually happened, add `coVerify { profileRepository.fetch() }` alongside the state assertion. And mock the interface, not any concrete implementation, so the test doesn't couple to internals of a real repository.",
722
+ "outputSha256": "88b351ffdcf8558b0cae72fa948c87050bd5d6bb33a19dc7b77c0fcdc9e5e050",
723
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
724
+ "deterministic": [],
725
+ "judge": {
726
+ "verdict": "pass",
727
+ "reason": "Mocks the ProfileRepository interface via mockk<ProfileRepository>() (not ViewModel internals). Uses coroutine-aware coEvery { profileRepository.fetch() } throws IOException(...). Injects the mock into ProfileViewModel, calls real loadProfile(), advances coroutines, and asserts ProfileUiState.Error. No fail criteria triggered."
728
+ },
729
+ "passed": true
730
+ },
731
+ {
732
+ "output": "Set up the mock at the `ProfileRepository` interface boundary with MockK's coroutine stubbing, not on the ViewModel itself. Since `fetch()` is a suspend function, you need `coEvery`/`coVerify`, not `every`/`verify`:\n\n```kotlin\nclass ProfileViewModelTest {\n\n @get:Rule\n val mainDispatcherRule = MainDispatcherRule() // installs StandardTestDispatcher as Dispatchers.Main\n\n private lateinit var repository: ProfileRepository\n\n @Before\n fun setUp() {\n repository = mockk()\n }\n\n @Test\n fun `loadProfile surfaces error state when repository fails`() = runTest {\n coEvery { repository.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(repository)\n viewModel.loadProfile()\n advanceUntilIdle() // let viewModelScope's coroutine actually run\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n }\n}\n```\n\nKey points:\n\n- **Mock the interface the ViewModel depends on** (`ProfileRepository`), never a private/internal method of the ViewModel. The ViewModel should receive the mock via constructor injection so it's substitutable.\n- **Use `coEvery { ... } throws ...`** for the suspend call. `every { repository.fetch() }` won't compile for a suspend function.\n- **When does it throw vs. return a failure?** If `fetch()` returns a `Result`/sealed type rather than throwing, stub the failure value instead: `coEvery { repository.fetch() } returns Result.failure(IOException(...))`. Match whichever contract the interface actually declares — read the interface signature before choosing.\n- **Install a `TestDispatcher` as `Dispatchers.Main`** (via the shared `MainDispatcherRule`, or `Dispatchers.setMain(...)`/`resetMain()` in `@Before`/`@After`) so `viewModelScope` resolves onto it. Then the `advanceUntilIdle()` call deterministically runs the coroutine — no `delay`/`Thread.sleep` to \"give it time.\"\n- **Assert both the observable state and, where relevant, the interaction**: check `viewModel.uiState.value` became the error state, and optionally `coVerify(exactly = 1) { repository.fetch() }` to confirm the call actually happened.\n\nOne thing to verify in Step 1 first: check whether the repo's existing tests standardize on a `MainDispatcherRule` or manual set/reset — match that convention rather than introducing your own. If the ViewModel catches the exception and wraps it (e.g. into a domain error type), assert on that wrapped type, not on the raw `IOException` leaking through — a test that only checks \"it didn't crash\" or catches a raw exception that should have been translated is a red flag, not coverage.",
733
+ "outputSha256": "4ead4ae4ddf5950f7951e3b2b0fbe2c0df8ff87ecbceec8f5be0a710b71d1bcb",
734
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
735
+ "deterministic": [],
736
+ "judge": {
737
+ "verdict": "pass",
738
+ "reason": "Answer mocks ProfileRepository interface via mockk() and constructor injection (not ViewModel internals), uses coEvery { repository.fetch() } throws IOException (coroutine-aware), and exercises real loadProfile() with advanceUntilIdle then asserts viewModel.uiState.value == Error. No fail criteria met."
739
+ },
740
+ "passed": true
741
+ },
742
+ {
743
+ "output": "Mock the `ProfileRepository` **interface** — the seam where `ProfileRepositoryImpl` (real network/DB) plugs in — not anything inside the ViewModel. With MockK:\n\n```kotlin\nclass ProfileViewModelTest {\n\n private val repository = mockk<ProfileRepository>()\n\n @Test\n fun `loadProfile sets error state when fetch fails`() = runTest {\n // 1. Stub the suspend call on the interface to throw.\n coEvery { repository.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(repository)\n\n // 2. Drive the coroutine that loadProfile launches.\n viewModel.loadProfile()\n advanceUntilIdle()\n\n // 3. Assert the observable outcome, not just \"it didn't crash\".\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n }\n}\n```\n\nKey points:\n\n1. **`coEvery`, not `every`.** `fetch()` is a suspend function, so the stubbing builder must be the coroutine-aware one; `every { repository.fetch() }` won't compile/won't work as intended.\n\n2. **Stub the throw via `throws`, not a manual try/catch in the test.** This exercises the ViewModel's own error handling — if it swallows the exception and sets an `Error` state, the test proves that. If it lets it propagate and crashes, you want the test to surface that.\n\n3. **`runTest` + `advanceUntilIdle()`** so the coroutine launched by `loadProfile()` (in `viewModelScope`) actually runs against the test dispatcher. Also make sure `Dispatchers.Main` is installed with your shared `MainDispatcherRule`/`StandardTestDispatcher` in setup — otherwise `viewModelScope` runs on the real main dispatcher and `advanceUntilIdle()` controls nothing. No `Thread.sleep`.\n\n4. **Assert the state, not the call.** `viewModel.uiState.value == Error` is the real contract. Optionally add `coVerify { repository.fetch() }` to confirm the ViewModel actually attempted the call before failing, but the state assertion is what matters.\n\n5. If `fetch()` returns a `Result`/sealed type rather than throwing (some repos model failure that way), match that instead: `coEvery { repository.fetch() } returns Result.failure(IOException(...))` — same principle, mock the interface's real failure shape.\n\nTwo things to avoid: don't mock `ProfileViewModel`'s own methods to force the failure (that tests a fake, not real behavior), and don't loosen the assertion to \"it didn't throw\" — that covers nothing about what state resulted.",
744
+ "outputSha256": "3692f4e60cecbcb578d038a987225bed4d666f2cbe919dfd093075aa0f595590",
745
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
746
+ "deterministic": [],
747
+ "judge": {
748
+ "verdict": "pass",
749
+ "reason": "Mocks ProfileRepository interface via mockk<ProfileRepository>() (not ViewModel internals); uses coroutine-aware coEvery { repository.fetch() } throws IOException; injects mock into ProfileViewModel and drives real loadProfile() with runTest/advanceUntilIdle, asserting uiState.value == Error. Explicitly warns against mocking ViewModel's own methods and against plain every."
750
+ },
751
+ "passed": true
752
+ },
753
+ {
754
+ "output": "Mock at the `ProfileRepository` interface boundary, not the ViewModel itself. The standard setup:\n\n```kotlin\nclass ProfileViewModelTest {\n\n @get:Rule\n val mainDispatcherRule = MainDispatcherRule() // or setMain/resetMain in @Before/@After\n\n private lateinit var profileRepository: ProfileRepository\n private lateinit var viewModel: ProfileViewModel\n\n @Before\n fun setUp() {\n profileRepository = mockk()\n viewModel = ProfileViewModel(profileRepository)\n }\n}\n```\n\nThen, in the failure test itself:\n\n```kotlin\n@Test\nfun `loadProfile failure surfaces error state`() = runTest {\n coEvery { profileRepository.fetch() } throws IOException(\"network down\")\n\n viewModel.loadProfile()\n advanceUntilIdle()\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n}\n```\n\nPoints that matter here:\n\n- The failing call must be stubbed with `coEvery { ... } throws ...` — because `fetch()` is a `suspend` function. Plain `every { }` will not compile against a suspend function. If your repository returns a `Result`-like wrapper instead of throwing, use `coEvery { profileRepository.fetch() } returns Result.failure(IOException(\"network down\"))` and assert on the resulting state the same way.\n- Install a `TestDispatcher` as `Dispatchers.Main` (via a shared rule or `Dispatchers.setMain`/`resetMain`) so `viewModelScope` lands on your test scheduler. Without it, `advanceUntilIdle()` won't drive the ViewModel's coroutine and the test either hangs or asserts against stale state.\n- Use `advanceUntilIdle()` (or `runCurrent()`) rather than a real `delay` or `Thread.sleep` to let the coroutine reach the error state deterministically.\n- Assert the state the caller can actually observe — the error UI state — rather than only `coVerify { profileRepository.fetch() }`. Checking the call happened tells you nothing about whether the failure was mapped correctly.\n- Mock the whole `ProfileRepository` interface with `mockk<ProfileRepository>()`; do not stub private internals of the ViewModel to force the failure path.\n\nIf the ViewModel catches a specific exception type and maps it to a domain error, throw that exact type so the test exercises the real mapping branch. Throw a raw unrelated exception only if you also want to assert the general fallback path.",
755
+ "outputSha256": "17c5acc2f89283999bf5428578bc094a69de93c62f2149c68ad29681523f446a",
756
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
757
+ "deterministic": [],
758
+ "judge": {
759
+ "verdict": "pass",
760
+ "reason": "Mocks ProfileRepository via mockk() and injects it into ProfileViewModel (setUp). Uses coroutine-aware coEvery { profileRepository.fetch() } throws IOException(...). Exercises real viewModel.loadProfile() then asserts ProfileUiState.Error. Explicitly warns against stubbing ViewModel private internals. No fail criteria met."
761
+ },
762
+ "passed": true
763
+ },
764
+ {
765
+ "output": "Mock the `ProfileRepository` interface itself with MockK, and stub `fetch()` to fail — never mock anything on the ViewModel. Since `fetch()` is a suspend function, use `coEvery`:\n\n```kotlin\nclass ProfileViewModelTest {\n private val dispatcher = StandardTestDispatcher()\n\n @Before fun setUp() = Dispatchers.setMain(dispatcher)\n @After fun tearDown() = Dispatchers.resetMain()\n\n @Test\n fun `loadProfile surfaces error when fetch fails`() = runTest(dispatcher) {\n val repo = mockk<ProfileRepository>()\n coEvery { repo.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(repo)\n viewModel.loadProfile()\n advanceUntilIdle()\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n coVerify { repo.fetch() }\n }\n}\n```\n\nKey points:\n\n1. **Mock the interface, not the ViewModel.** `repo.fetch()` is the boundary; that's what you stub. Never `mockk<ProfileViewModel>(relaxed = true)` to force an outcome.\n\n2. **`coEvery`, not `every`.** `fetch()` is a suspend function, so MockK's coroutine-aware stubbing is required. `every { repo.fetch() }` won't compile cleanly against a suspend declaration.\n\n3. **Install `StandardTestDispatcher` as Main.** `loadProfile()` almost certainly launches on `viewModelScope`, which resolves `Dispatchers.Main`. Without `Dispatchers.setMain(...)` the coroutine either never runs or hits the real Main dispatcher and the test hangs or throws. The `StandardTestDispatcher` also means nothing runs until you advance it — that's what lets you assert an intermediate loading state before the result lands if you want to.\n\n4. **Advance explicitly.** `advanceUntilIdle()` runs the queued coroutine to completion, including the exception path. Do not `Thread.sleep` or add a `delay` to \"let it finish.\"\n\n5. **Assert the resulting state, not just the throw.** The exception is caught inside `loadProfile()` in production code; what the test should pin down is the state the ViewModel lands in — an error flag, an error message field, a `ProfileUiState.Error`, etc. Check whatever field your ViewModel actually exposes rather than asserting the call merely didn't crash the test.\n\n6. **If your error path wraps exceptions** into a domain type (e.g. `ProfileError.Network`), assert *that* type, not the raw `IOException`. That verifies the mapping in the ViewModel, which is usually the actual behavior under test.\n\nTwo variations worth adding beyond this one case:\n\n- **Failure shape coverage:** if `fetch()` can signal failure via a `Result.failure(...)` instead of throwing, stub `coEvery { repo.fetch() } returns Result.failure(IOException())` and assert the same error state, so both failure channels are pinned.\n- **Retry/success-after-failure:** `coEvery { repo.fetch() } throws IOException() andThenReturn profile`, then call `loadProfile()` twice and assert it recovers — this catches a ViewModel that latches the error state permanently.\n\nIf your project already standardizes the dispatcher setup in a shared `MainDispatcherRule` (common), use that rule instead of the manual `setMain`/`resetMain` in `@Before`/`@After` — the rule handles it more reliably. Check a neighboring ViewModel test in the module first and match whatever it does.",
766
+ "outputSha256": "5b12fca3be0d1e22a36dcb5cadb0633b6ba557cd7a6713318e345e220aa85564",
767
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
768
+ "deterministic": [],
769
+ "judge": {
770
+ "verdict": "pass",
771
+ "reason": "Mocks ProfileRepository interface (mockk<ProfileRepository>, repo.fetch()), not ViewModel internals. Uses coroutine-aware coEvery { repo.fetch() } throws IOException. Injects repo into ProfileViewModel, calls real loadProfile(), advances, asserts ProfileUiState.Error. No fail criteria triggered."
772
+ },
773
+ "passed": true
774
+ },
775
+ {
776
+ "output": "Mock the `ProfileRepository` interface itself — not the ViewModel, and not anything internal to it — and make `fetch()` throw from the coroutine stub.\n\n```kotlin\nprivate val repository = mockk<ProfileRepository>()\n\n@Test\nfun `loadProfile surfaces error state when repository fails`() = runTest {\n coEvery { repository.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(repository) // whatever the real constructor is\n viewModel.loadProfile()\n advanceUntilIdle() // let viewModelScope actually run\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n}\n```\n\nKey points specific to this case:\n\n- **`coEvery { ... } throws ...`**, not `every { ... }`. `fetch()` is a `suspend` function, so it needs MockK's coroutine-aware stubbing; plain `every` won't compile against a suspend call in the intended way.\n- **The dispatcher must be the test one before you construct/call the ViewModel.** Since `loadProfile()` presumably launches on `viewModelScope` (which is `Dispatchers.Main`), install a `StandardTestDispatcher` for `Main` in a `@Before`/shared rule, otherwise the coroutine runs on a real dispatcher and `advanceUntilIdle()` won't control it:\n ```kotlin\n @get:Rule val mainDispatcherRule = MainDispatcherRule() // sets/resets Dispatchers.setMain(StandardTestDispatcher())\n ```\n- **Advance the scheduler, don't sleep.** After calling `loadProfile()`, the suspend failure is only observed once the test dispatcher runs the pending coroutine — use `advanceUntilIdle()` (or `runCurrent()` if you want to stop at the first suspension). No `Thread.sleep`, no fixed `delay`.\n- **Assert the observable outcome**, not just that nothing threw. If the repository failure is supposed to be mapped to a domain error type, assert that mapped type reached the caller (e.g. `ProfileUiState.Error`) rather than a raw `IOException` leaking into the state — that's the part of the contract most worth pinning down.\n- **If the ViewModel is supposed to catch the exception**, verify it did so by asserting the resulting state; if it's supposed to propagate, wrap the call in an assertion on the thrown type. Either way, `throws` on the stub is what drives the failure path.\n- If `fetch()` has parameters and the exact argument doesn't matter for this case, you can relax with `coEvery { repository.fetch(any()) }`, but prefer the exact argument when the test's intent is \"this specific fetch fails.\"\n\nDon't stub a private method or field on the ViewModel to force the failure — the seam to cross is the `ProfileRepository` interface boundary.",
777
+ "outputSha256": "076b9b5cadadf480f14ec276d5494300e9449815a9ba65189290d021c8621ad0",
778
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
779
+ "deterministic": [],
780
+ "judge": {
781
+ "verdict": "pass",
782
+ "reason": "Answer mocks ProfileRepository interface (mockk<ProfileRepository>()), not ViewModel internals; uses coroutine-aware coEvery { repository.fetch() } throws IOException; constructs ProfileViewModel(repository), calls real loadProfile(), and asserts viewModel.uiState.value == ProfileUiState.Error. Explicitly warns against stubbing private ViewModel method/field."
783
+ },
784
+ "passed": true
785
+ },
786
+ {
787
+ "output": "Mock the `ProfileRepository` interface itself — never the ViewModel, and never a private method on it. The ViewModel's only collaborator is that interface, so that's where the seam is.\n\nConcretely, with MockK:\n\n```kotlin\nclass ProfileViewModelTest {\n\n private val repository = mockk<ProfileRepository>()\n\n @get:Rule\n val mainDispatcherRule = MainDispatcherRule() // or setMain/resetMain in @Before/@After\n\n @Test\n fun `loadProfile surfaces error state when repository throws`() = runTest {\n coEvery { repository.fetch() } throws IOException(\"network down\")\n\n val viewModel = ProfileViewModel(repository)\n viewModel.loadProfile()\n advanceUntilIdle()\n\n assertEquals(ProfileUiState.Error, viewModel.uiState.value)\n }\n}\n```\n\nA few things that matter here, in order of how often they get skipped:\n\n1. **Use `coEvery`, not `every`,** because `fetch()` is a `suspend` function. `every { repository.fetch() }` won't compile for a suspend call.\n\n2. **Throw a realistic exception** — the kind the repository actually propagates (an `IOException`, a domain `HttpException`, whatever your data layer emits), not a bare `RuntimeException(\"fail\")`. If your repository is declared to wrap failures into a domain error type, throw *that* instead, and assert on it. The point of the test is what the ViewModel does with the real failure shape, so if you throw something the data layer never actually produces, the test proves nothing.\n\n3. **Install the test dispatcher for `Dispatchers.Main`** before constructing the ViewModel. `viewModelScope` is backed by `Dispatchers.Main.immediate`; without a `TestDispatcher` swapped in, the coroutine body either runs on a real Android main looper (nonexistent in a unit test) or never runs at all. Use a shared `MainDispatcherRule` if the project already has one — check a neighboring test file first (Step 1 of the workflow) rather than introducing your own.\n\n4. **Use `StandardTestDispatcher` + `advanceUntilIdle()`**, not `UnconfinedTestDispatcher` with no advancement, if you want to observe intermediate states (a `Loading` state that exists only until the call settles). With the standard dispatcher nothing runs until you advance, which is exactly what lets you assert `Loading` before the failure lands, then `Error` after. If you only care about the terminal state, `advanceUntilIdle()` alone at the end is enough.\n\n5. **If the ViewModel catches the exception internally** and converts it to state (the normal case), the test reads:\n\n ```kotlin\n coEvery { repository.fetch() } throws IOException(\"network down\")\n viewModel.loadProfile()\n advanceUntilIdle()\n assertEquals(ProfileUiState.Error(...), viewModel.uiState.value)\n ```\n\n If instead the ViewModel lets it propagate, wrap the trigger in an `assertFailsWith`/`runCatching` and assert the type reaching the caller — but only after confirming from the source that it really propagates. Don't guess; the mock setup overlaps but the assertion is completely different.\n\n6. **Optionally `coVerify { repository.fetch() }`** to prove the call was actually attempted, which distinguishes \"handled the failure\" from \"never called the repository and defaulted to Error anyway.\"\n\nOne caution specific to this case: if `loadProfile()` is meant to be re-entrant or guarded against concurrent calls, a single failure test won't catch that — that's a separate case with a `coEvery` that suspends on a `CompletableDeferred` you control, so you can fire two loads and release the gate. Worth adding if the ViewModel implies it, but it's not part of \"what happens on failure.\"\n\nI can't run anything here, so I'd flag: verify against the actual signature of `ProfileRepository.fetch()` (does it return a `Result`-wrapped type or throw?) and against a neighboring ViewModel test for the project's dispatcher-rule convention before finalizing.",
788
+ "outputSha256": "b04f40761dda01587a22bf34ef03e02635b58b9217e5d4e55c9f8a09ef294635",
789
+ "promptSha256": "eda37174bbc7e6337a2712005ae9e84ea48daade44036cd96933b4083b0e6679",
790
+ "deterministic": [],
791
+ "judge": {
792
+ "verdict": "pass",
793
+ "reason": "Mocks ProfileRepository interface (mockk<ProfileRepository>()), not ViewModel internals. Uses coEvery { repository.fetch() } throws IOException(...) — coroutine-aware. Injects repository into ProfileViewModel, calls real loadProfile(), advances, asserts ProfileUiState.Error. Explicitly warns against mocking ViewModel/private methods. No fail criteria hold."
794
+ },
795
+ "passed": true
796
+ }
797
+ ]
798
+ },
799
+ {
800
+ "id": "suspend-function-runtest",
801
+ "kind": "behavior",
802
+ "prompt": "I want to test a suspend function in this repository class that calls `delay(2000)` before returning a cached value if the network is slow. What's the right way to test that without the test actually taking two seconds?",
803
+ "strictness": "high",
804
+ "trials": 10,
805
+ "passes": 10,
806
+ "passRate": 1,
807
+ "passAtK": 1,
808
+ "grader": "regex+judge",
809
+ "status": "ran",
810
+ "trialRecords": [
811
+ {
812
+ "output": "Use `runTest` from `kotlinx-coroutines-test`: inside it, `delay` on the test scheduler is virtual, not real, so a `delay(2000)` call completes instantly once the scheduler advances. The whole point is that `runTest` installs a `TestDispatcher` and drives a virtual clock, so you never actually wait wall-clock time.\n\nA few practical points:\n\n**Make sure the code under test runs on the test dispatcher.** If your repository's suspend function is called directly from the test body, `runTest` already runs it on the test scheduler, so it's simple:\n\n```kotlin\n@Test\nfun `returns cached value when network is slow`() = runTest {\n coEvery { api.fetch() } coAnswers {\n delay(2000) // virtual time — advances instantly\n throw IOException(\"timeout\")\n }\n coEvery { cache.read() } returns \"cached\"\n\n val result = repository.get(\"key\")\n\n assertEquals(\"cached\", result)\n // no real 2 seconds elapsed; the virtual clock handled it\n}\n```\n\n**If the code dispatches onto `Dispatchers.IO` (or any other dispatcher) internally**, virtual time won't help automatically — a `delay` on `Dispatchers.IO` is real. Two options:\n\n1. Inject the dispatcher into the repository (constructor param, defaulting to `Dispatchers.IO`) and pass `StandardTestDispatcher(testScheduler)` in the test. This is the clean approach and usually worth a small production refactor.\n2. If you can't inject it, the test will genuinely wait ~2s — in that case consider whether the timeout value should be injectable/parameterized anyway, which is a good sign it should be a constructor parameter.\n\n**If the repository uses `viewModelScope`-style scopes rather than being called inline**, you'd install the test dispatcher as `Dispatchers.Main` via a `MainDispatcherRule` and explicitly advance:\n\n```kotlin\n@get:Rule val mainDispatcherRule = MainDispatcherRule() // sets Dispatchers.setMain(StandardTestDispatcher())\n\n@Test\nfun `cached value returned after slow network`() = runTest {\n coEvery { api.fetch() } coAnswers { delay(2000); throw IOException() }\n coEvery { cache.read() } returns \"cached\"\n\n val result = repository.get(\"key\") // or however it's launched\n\n advanceUntilIdle() // jumps the virtual clock past the 2s delay\n assertEquals(\"cached\", result)\n}\n```\n\n**The key constraints to hold yourself to:**\n\n- Never `Thread.sleep` or real `delay` in the test to \"wait out\" the 2000ms. If your test truly needs 2 real seconds, something is running off the test scheduler — find and fix that instead.\n- Prefer `advanceUntilIdle()` / `runCurrent()` to make the virtual clock reach the delay point deterministically.\n- Assert the actual returned value (and `coVerify` the expected interaction), not just \"it didn't hang.\"\n\nSo the short answer: `runTest` gives you a virtual clock, and `advanceUntilIdle()` (or just awaiting the suspend call inline) moves it past the `delay(2000)` instantly — provided the code under test is actually running on the test dispatcher, which usually means injecting the dispatcher rather than hardcoding `Dispatchers.IO`.",
813
+ "outputSha256": "b688b227257afe3f917956822233b24cd377fa516d8ad5eca623ec2224242d06",
814
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
815
+ "deterministic": [
816
+ true
817
+ ],
818
+ "judge": {
819
+ "verdict": "pass",
820
+ "reason": "Names runTest from kotlinx-coroutines-test and explains delay is virtual, completing instantly via TestDispatcher/virtual clock (\"delay(2000) call completes instantly once the scheduler advances\"). Explicitly warns against Thread.sleep/real delay and never suggests shortening production delay. Pass 3 holds: it keeps delay(2000) intact, only suggesting dispatcher injection."
821
+ },
822
+ "passed": true
823
+ },
824
+ {
825
+ "output": "Use `kotlinx-coroutines-test`'s virtual time — inside `runTest { }`, `delay()` on a `TestDispatcher` doesn't actually wait; it skips the virtual clock forward when you advance it.\n\nConcretely:\n\n1. Wrap the test body in `runTest { }`.\n2. If the repository switches onto `Dispatchers.IO` or uses `viewModelScope`/`withContext(Dispatchers.Main)`, install a `TestDispatcher` for that dispatcher first:\n ```kotlin\n @get:Rule val mainDispatcherRule = MainDispatcherRule() // sets Dispatchers.setMain(StandardTestDispatcher())\n ```\n or, if the method takes a dispatcher/dispatcher-as-constructor-param, just inject the test dispatcher directly.\n3. Then:\n ```kotlin\n @Test\n fun `returns cached value after slow network`() = runTest {\n coEvery { api.fetch() } coAnswers {\n delay(2000) // virtual time — no real waiting\n throw IOException() // or return a slow response\n }\n val result = repo.getCachedOrFetch()\n\n // Either assert the final result...\n assertEquals(cached, result)\n }\n ```\n Or, if you want to observe the intermediate state (e.g. that the cache was hit *before* the delay elapsed), advance deliberately:\n ```kotlin\n @Test\n fun `emits loading then cached value`() = runTest {\n val job = launch { repo.getCachedOrFetch() } // collects a StateFlow/Flow\n runCurrent() // let it start\n assertEquals(Loading, state.value)\n advanceTimeBy(2000) // jump exactly past the delay\n assertEquals(cached, state.value)\n job.cancel()\n }\n ```\n\nKey points:\n\n- `runTest` uses a virtual clock, so any `delay(2000)` inside code under test — or inside a `coAnswers`/`coEvery` block on the same test dispatcher — is skipped instantly. The whole test finishes in milliseconds.\n- Use `advanceUntilIdle()` when you just want \"run everything to completion,\" or `advanceTimeBy(n)`/`runCurrent()` when you need to assert state at a specific point in the timeline (before vs. after the 2-second mark).\n- If the delay happens on a *different* dispatcher than the test's (e.g. the class hardcodes `Dispatchers.IO`), virtual time won't apply to it — inject the `TestDispatcher` into that context (constructor param, or a dispatcher provider) so the delay runs on virtual time. Don't reach for `Thread.sleep` or a real `delay` to \"wait it out.\"\n- Because everything is on the virtual clock, you can also assert the boundary precisely: `advanceTimeBy(1999)` then check it's still loading, `advanceTimeBy(1)` then check it's resolved — a real-time test couldn't do that reliably.\n\nThe underlying rule this satisfies: synchronize with `runTest`'s virtual time (`advanceUntilIdle`/`advanceTimeBy`/`runCurrent`), never with a fixed wall-clock `delay`/`Thread.sleep`.",
826
+ "outputSha256": "f61d1b9bf249871c3c34340a357e1f773ac3ef0919027b70943182f654b4153c",
827
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
828
+ "deterministic": [
829
+ true
830
+ ],
831
+ "judge": {
832
+ "verdict": "pass",
833
+ "reason": "Names runTest { } as the mechanism (\"Wrap the test body in runTest { }\") and explains virtual clock skips delay instantly (\"delay(2000) ... is skipped instantly. The whole test finishes in milliseconds\"). Does not alter production delay(2000); explicitly warns against Thread.sleep. No fail criteria hold."
834
+ },
835
+ "passed": true
836
+ },
837
+ {
838
+ "output": "Use `runTest` from `kotlinx-coroutines-test` and control virtual time explicitly — don't let the `delay(2000)` run against the real clock, and don't stub the `delay` call out of existence either.\n\n**Setup.** `runTest { }` gives your test body a virtual-time scheduler. Any `delay` reached on a coroutine running on the test's dispatcher is skipped instantly — the two seconds are virtual, not wall-clock. The catch: the repository code has to actually run on that test dispatcher. If it launches its own scope on `Dispatchers.IO` or hardcodes `Dispatchers.Default`, `runTest` won't see it.\n\n**The two things that make it work:**\n\n1. **Inject the dispatcher** into the repository (constructor parameter, defaulting to a real one) so you can pass in a `TestDispatcher`. If the repository uses `withContext(Dispatchers.IO) { ... }`, this is the only way to make the delay virtual.\n2. **If the delay is inside the function body** and the function runs on the calling context, a `StandardTestDispatcher` (what `runTest` installs by default) is enough — the virtual clock is already in play.\n\n**Concretely:**\n\n```kotlin\n@Test\nfun slowNetwork_returnsCachedValue() = runTest {\n val dispatcher = StandardTestDispatcher(testScheduler)\n val repo = Repository(\n network = fakeNetworkReturningCachedValue(), // your mocked/fake boundary\n ioDispatcher = dispatcher,\n )\n\n val result = repo.fetchWithFallback()\n\n // The 2000ms delay was virtual; this call instant, no real sleep.\n assertEquals(cachedValue, result)\n}\n```\n\n**If you need to assert on the intermediate state** — e.g. that the cache is consulted *before* the 2s elapses — don't `await` the whole thing. Launch it, advance deliberately, and inspect:\n\n```kotlin\n@Test\nfun fastNetwork_returnsFreshValue_withoutWaitingForCacheDelay() = runTest {\n val deferred = async { repo.fetchWithFallback() }\n\n advanceTimeBy(1999) // one tick short of the delay\n assertNull(deferred.getCompleted()) // still waiting\n\n advanceUntilIdle() // burn the rest; no real time passes\n assertEquals(freshValue, deferred.await())\n}\n```\n\n**Things to avoid:**\n\n- `withTimeout(2000)`, `Thread.sleep`, or any real-clock wait — all defeat the point and reintroduce flakiness.\n- Mocking the class under test's own `delay` or private internals; you're supposed to be testing that the delay-and-fallback behavior *actually happens*, not stubbing it away.\n- Assuming `StandardTestDispatcher` is what runs the code if the repository pins its own dispatcher — check what dispatcher the suspending work actually lands on before trusting that the delay went virtual. If you can't verify that, the safest fix is to make the dispatcher injectable.\n\nOne correctness note: make sure the repository is actually deferring on a coroutine the test scheduler owns. If the `delay(2000)` runs on a dispatcher you didn't inject, `runTest` will either not skip it (real 2s) or, worse, the test passes for the wrong reason because nothing you asserted ever ran. Injecting the dispatcher is what closes that gap.",
839
+ "outputSha256": "ffad9ee57f0e92b5b8e84530e51f8f0f88bca67a129b4bbf8287ae8996093338",
840
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
841
+ "deterministic": [
842
+ true
843
+ ],
844
+ "judge": {
845
+ "verdict": "pass",
846
+ "reason": "Names runTest { } as the mechanism (\"runTest { } gives your test body a virtual-time scheduler... the two seconds are virtual, not wall-clock\"), explains virtual time avoids the real wait rather than changing delay duration, and keeps delay(2000) intact (injects dispatcher instead). Explicitly warns against Thread.sleep and stubbing delay. No fail criteria met."
847
+ },
848
+ "passed": true
849
+ },
850
+ {
851
+ "output": "The right approach is `runTest` plus a `TestDispatcher`: `runTest` skips the real wall-clock delay by using a virtual clock, so `delay(2000)` inside the code under test resolves instantly, and you assert on the result the same way you would normally.\n\n```kotlin\nclass ThingRepositoryTest {\n\n // If the repo uses Dispatchers.Main internally, install a test dispatcher for it:\n // @get:Rule val mainDispatcherRule = MainDispatcherRule() // project's existing rule\n\n @Test\n fun `slow network returns cached value after delay`() = runTest {\n val api = mockk<ApiService>()\n val cache = mockk<Cache>()\n\n coEvery { api.fetch() } coAnswers { delay(2000); Result.failure(IOException()) }\n coEvery { cache.get() } returns \"cached\"\n\n val repo = ThingRepository(api, cache, ioDispatcher = StandardTestDispatcher(testScheduler))\n\n val deferred = async { repo.getThing() } // actually starts suspending work\n advanceUntilIdle() // runs the virtual delay(2000) instantly\n\n assertEquals(\"cached\", deferred.await())\n coVerify { cache.get() }\n }\n}\n```\n\nKey points:\n\n- **`runTest` owns the virtual clock.** Any `delay` that runs on a scheduler tied to that test (via `StandardTestDispatcher(testScheduler)` handed to the repository, or the dispatcher the project's existing rule installs for `Dispatchers.Main`) is skipped. Nothing sleeps for two seconds.\n- **Inject the dispatcher, don't hard-code `Dispatchers.IO`.** For the delay to be virtual, the coroutine executing it must be dispatched by the test's scheduler. If the repository currently reads `Dispatchers.IO` directly, that's the one thing worth checking in Step 1 — most projects pass an `ioDispatcher` (or similar) parameter or `@VisibleForTesting` field in precisely so this works. If there's no seam, say so and note it in the report rather than silently changing production code.\n- **Launch and advance deliberately.** Use `async`/`launch` for the suspending call, then `advanceUntilIdle()` (or `runCurrent()`/`advanceTimeBy(...)`) at the exact point you need the delay elapsed. Don't wrap the whole call in a blocking wait.\n- **Assert the outcome, not just that it didn't throw** — check the returned cached value and `coVerify` that the cache was consulted, so the test actually proves the fallback path ran.\n- **Never** substitute a real `Thread.sleep` or a fixed real `delay` \"to let it finish\" — that reintroduces the two-second wait and makes the test flaky.\n\nIf the slow-network branch is gated on a time source rather than a literal `delay(2000)` in the code under test, replace that with an injected `TimeSource`/clock and drive it from the test's virtual clock instead of waiting on it.",
852
+ "outputSha256": "e59359aa26520c35c930da52688faee78ff3bf4e1a4519f0a7e218572d701bbe",
853
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
854
+ "deterministic": [
855
+ true
856
+ ],
857
+ "judge": {
858
+ "verdict": "pass",
859
+ "reason": "Names runTest as mechanism (\"runTest skips the real wall-clock delay by using a virtual clock, so delay(2000) resolves instantly\"), explains virtual clock avoids the real wait rather than changing delay duration, and keeps production delay(2000) intact (injects dispatcher, warns against Thread.sleep). No fail criteria hold."
860
+ },
861
+ "passed": true
862
+ },
863
+ {
864
+ "output": "Use `runTest` from `kotlinx-coroutines-test` and let its virtual clock handle the `delay(2000)` — no `Thread.sleep`, no real two-second wait.\n\nThe key points:\n\n- **`runTest` skips delays automatically.** Inside `runTest { }`, `delay(2000)` doesn't actually block for 2 seconds. The test coroutine's dispatcher uses a virtual clock, so `delay` suspends and resumes \"instantly\" in wall-clock time while still advancing virtual time by 2000ms. So a straightforward `runTest { assertEquals(cached, repo.get()) }` already works and completes in milliseconds.\n\n- **If you need to assert on the intermediate state** (e.g., \"before the delay elapses, no cached value is returned yet\"), inject a `TestDispatcher`/`TestScope` and advance it explicitly:\n\n ```kotlin\n @Test\n fun `returns cached value after slow network`() = runTest {\n val dispatcher = StandardTestDispatcher(testScheduler)\n\n val result = async(dispatcher) { repo.get() }\n\n // Nothing has happened yet — the delay hasn't been advanced.\n advanceTimeBy(1999)\n assertFalse(result.isCompleted)\n\n advanceTimeBy(1) // or advanceUntilIdle()\n assertEquals(cached, result.await())\n }\n ```\n\n- **Use `StandardTestDispatcher`, not `UnconfinedTestDispatcher`,** when you want to control *when* the delay resumes. An unconfined dispatcher runs eagerly and would skip past the delay immediately, defeating the point of checking the intermediate state.\n\n- **If the repository offloads onto a specific dispatcher** (e.g., `Dispatchers.IO` or its own injected dispatcher), make sure that dispatcher is also a `TestDispatcher` sharing the same `testScheduler` — otherwise the `delay` runs on a real clock and you're back to waiting 2 seconds. Injecting the dispatcher into the repository is the clean way to make this testable.\n\n- **Never** replace this with `Thread.sleep(2000)` or a real `delay` in the test body. That reintroduces the exact problem you're trying to avoid and makes the test slow and flaky.\n\nOne caveat: if the 2-second wait is a **real timeout on an external system** (e.g., a genuine socket timeout measured by the OS rather than a `delay` in coroutine code), virtual time won't help — that requires the timeout to be routed through an injectable clock/dispatcher to be testable. But for a Kotlin `delay(2000)` in the repository, `runTest`'s virtual clock makes it free.",
865
+ "outputSha256": "f390ae60d4af295d07294ba0a28b84a20ed689d7193bb34ba03c9293112d60e6",
866
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
867
+ "deterministic": [
868
+ true
869
+ ],
870
+ "judge": {
871
+ "verdict": "pass",
872
+ "reason": "Answer names runTest from kotlinx-coroutines-test and its virtual clock as the mechanism (\"delay(2000) doesn't actually block for 2 seconds... virtual clock... resumes instantly\"), explains virtual/skipped time avoids the real wait rather than changing the delay, and never alters production delay(2000). It explicitly warns against Thread.sleep and real delay in the test body."
873
+ },
874
+ "passed": true
875
+ },
876
+ {
877
+ "output": "# Testing a `delay(2000)` without waiting two seconds\n\nWrap the call in `runTest { }` and let its **virtual clock** skip the delay instantly. `runTest` uses a `TestScope` backed by a `TestDispatcher`; `delay()` inside that scope (and inside anything it launches) advances virtual time rather than wall time, so a `delay(2000)` costs the test nothing real.\n\n```kotlin\nclass RepositoryTest {\n\n private val api = mockk<ApiService>() // the boundary being faked\n private val repository = Repository(api) // real class under test\n\n @Test\n fun `returns cached value when network is slow`() = runTest {\n // Arrange: whatever makes the network path \"slow\"\n coEvery { api.fetch() } coAnswers {\n delay(2000) // virtual delay\n CachedValue(\"network\")\n }\n\n // Act\n val result = repository.getValue()\n\n // Assert\n assertEquals(CachedValue(\"cached\"), result)\n }\n}\n```\n\nThe whole test finishes in microseconds even though the code under test sees a two-second delay.\n\n## The key points\n\n**The `delay` must run on the test dispatcher.** `runTest` provides a `TestDispatcher` for the body, so a `delay` reachable from that scope is virtual. This holds whether the delay is in your `coAnswers` block (as above) or inside the repository, *provided the repository's coroutine is running on that dispatcher*. If the repository launches onto `Dispatchers.Main`, install a test dispatcher for it first (see below) or the delay will be real.\n\n**Assert state, not \"did it not throw.\"** Assert the actual returned value and, where it matters, that the cache path was taken (`coVerify`) — not merely that the call completed.\n\n**Don't reach for `Thread.sleep`, a real `delay`, or `waitUntil`.** `runTest` already advances time for you. If you ever need to observe an intermediate state *before* the timeout fires, that's when you advance explicitly rather than adding wall-clock sleep:\n\n```kotlin\n@Test\nfun `serves cache only after the slow-network timeout`() = runTest {\n coEvery { api.fetch() } coAnswers { delay(2000); Fast(\"network\") }\n\n val deferred = async { repository.getValue() }\n\n advanceTimeBy(1999)\n assertFalse(deferred.isCompleted) // still waiting, cache not served yet\n\n advanceTimeBy(1) // cross the 2s threshold\n advanceUntilIdle() // drain any remaining work\n assertTrue(deferred.isCompleted)\n}\n```\n\n`advanceTimeBy` moves virtual time by exactly the amount you ask; `advanceUntilIdle()` runs everything until the queue is empty. Both are instant.\n\n## If the repository launches on `Dispatchers.Main`\n\nA `delay` inside a coroutine launched on the real `Dispatchers.Main` will *not* be virtualized. Redirect `Main` to the test dispatcher, using a shared rule so setup/teardown is consistent across the suite:\n\n```kotlin\nclass MainDispatcherRule(\n private val dispatcher: TestDispatcher = StandardTestDispatcher(),\n) : TestWatcher() {\n override fun starting(description: Description) = Dispatchers.setMain(dispatcher)\n override fun finished(description: Description) = Dispatchers.resetMain()\n}\n```\n\n```kotlin\n@get:Rule val mainDispatcherRule = MainDispatcherRule()\n\n@Test\nfun `returns cached value when network is slow`() = runTest {\n // mainDispatcherRule's StandardTestDispatcher and runTest share the scheduler,\n // so viewModelScope { delay(2000) } is virtual too.\n coEvery { api.fetch() } coAnswers { delay(2000); Fast(\"network\") }\n assertEquals(CachedValue(\"cached\"), repository.getValue())\n}\n```\n\nThis is also where you should check the repository's own test neighbors for a `MainDispatcherRule` — match whatever the module already uses rather than introducing a second pattern.\n\n## Two things to check before you call it done\n\n1. **Is the delay actually virtualized?** Temporarily bump it to `delay(10_000)` and confirm the test still finishes instantly. If it hangs, the coroutine is on a real dispatcher (usually `Dispatchers.Main` or `Dispatchers.IO`) and you need the rule above or to inject the dispatcher into the repository.\n2. **Mock at the interface boundary.** The `delay` and cache logic under test should stay in the *real* repository; only `ApiService`/`DataSource` gets `mockk`. Never mock a private method of the repository to fake out the slowdown — that tests your mock, not the cache behavior.\n\n`delay` used this way is the virtual clock you want, not a flakiness anti-pattern — the anti-pattern is a `Thread.sleep` in the *test* body, which the virtual clock makes unnecessary.",
878
+ "outputSha256": "3076a8415b375eec957f6b3026437d0841a315b4009c3f1ad4b905f3bf3014c1",
879
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
880
+ "deterministic": [
881
+ true
882
+ ],
883
+ "judge": {
884
+ "verdict": "pass",
885
+ "reason": "Answer names runTest { } with its virtual clock/TestDispatcher skipping delay instantly (\"delay(2000) costs the test nothing real\"), explains virtual time avoids the real wait rather than changing the delay, and keeps production delay(2000) intact. It explicitly warns against Thread.sleep and real delay. No fail criteria hold."
886
+ },
887
+ "passed": true
888
+ },
889
+ {
890
+ "output": "Use `runTest` from `kotlinx-coroutines-test`. Inside a `runTest` body, `delay` doesn't wait on the real wall clock — it's scheduled on the test scheduler's virtual time, so `runTest` auto-advances through it and the whole test finishes almost instantly (typically single-digit milliseconds of real time), even though two virtual seconds \"elapsed.\"\n\nThe shape you want:\n\n```kotlin\n@Test\nfun `returns cached value when network is slow`() = runTest {\n coEvery { network.fetch() } coAnswers {\n delay(2000) // virtual time — not real time\n throw IOException(\"timeout\")\n }\n coEvery { cache.get() } returns CachedValue(42)\n\n val result = repository.getWithFallback()\n\n assertEquals(CachedValue(42), result)\n}\n```\n\nA few things that matter here:\n\n**Don't override the dispatcher with a real one.** If the repository hardcodes `Dispatchers.IO` (or any real dispatcher) rather than taking one as a constructor parameter, the `delay` will run on that real dispatcher's clock and you'll genuinely wait two seconds. Either inject a dispatcher into the repository (preferred — makes it testable), or, if you can't touch the source, wrap the call so it runs on `StandardTestDispatcher(testScheduler)`'s context. Injecting is the clean fix; the harness-forcing workaround is a smell worth mentioning in your report rather than quietly adopting.\n\n**Choose the dispatcher deliberately.** With `runTest`'s default `StandardTestDispatcher`, the coroutine under test does *not* start eagerly — you call it, then `advanceUntilIdle()` (or `runCurrent()`) to drive it to completion, which is what lets you observe intermediate states. If you have nothing intermediate to assert, `runTest` will still drive a launched body to completion on its own before the test returns, but you'll usually want an explicit `advanceUntilIdle()` after the call to make the sequencing obvious and to catch a scheduler that's still holding pending work.\n\n**If you need to assert the pre-delay and post-delay states,** split the advance:\n\n```kotlin\nval deferred = async { repository.getWithFallback() }\nadvanceUntilIdle() // or runCurrent() for just the first hop\n// assert the loading/cache-miss state here if there is one\nval result = deferred.await()\nassertEquals(CachedValue(42), result)\n```\n\nDo **not** reach for `delay(...)` on the outside, `Thread.sleep`, or `runBlocking` to \"let it finish.\" Those reintroduce the real clock and the two-second cost you're trying to avoid, and they make the test flaky on a loaded CI box. Virtual time via `runTest` is both instant and deterministic.\n\nOne thing to double-check once it passes: confirm the test would actually fail if the fallback logic broke — a `delay`-based test that asserts nothing meaningful about the returned value can pass for the wrong reasons. Assert the returned value (and, if it's cheap, `coVerify` that the cache was consulted) so the test is genuinely exercising the slow-network path. And per the rules, only the test file changes — don't modify the repository to make it testable without calling that out explicitly.",
891
+ "outputSha256": "b8ef44d0074c67120e46425a4c14b4354dded4fb34105d9e8cb13a493b59594d",
892
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
893
+ "deterministic": [
894
+ true
895
+ ],
896
+ "judge": {
897
+ "verdict": "pass",
898
+ "reason": "Names runTest from kotlinx-coroutines-test as the mechanism (\"Inside a runTest body, delay doesn't wait on the real wall clock — it's scheduled on the test scheduler's virtual time\"), explains virtual time auto-advances so the test finishes instantly despite two virtual seconds elapsing, and keeps delay(2000) unchanged in production. Explicitly warns against Thread.sleep/runBlocking. No fail criteria met."
899
+ },
900
+ "passed": true
901
+ },
902
+ {
903
+ "output": "Use `runTest` from `kotlinx-coroutines-test`. Its `TestScope` installs a virtual clock, so any `delay()` inside the code under test is skipped instantly — the test does not actually wait.\n\n```kotlin\n@Test\nfun `returns cached value when network is slow`() = runTest {\n // given: the network call is slow\n coEvery { api.fetch() } coAnswers {\n delay(5_000)\n throw IOException(\"timeout\")\n }\n coEvery { cache.get() } returns \"cached\"\n\n // when\n val result = repository.getProfile() // calls delay(2000) internally\n\n // then\n assertEquals(\"cached\", result)\n coVerify { cache.get() }\n}\n```\n\nKey points:\n\n- `runTest { }` gives you a `TestScope` on a virtual clock. `delay(2000)` inside `repository.getProfile()` advances virtual time by 2000ms and returns immediately — wall-clock time is essentially zero, and the test is fully deterministic.\n- If you need to observe behavior *before* the delay elapses (an intermediate state, a timeout race), don't call the method and await it directly. Launch it in a child coroutine, then advance time explicitly:\n\n ```kotlin\n @Test\n fun `exposes stale state while network is in flight`() = runTest {\n coEvery { api.fetch() } coAnswers { delay(5_000); throw IOException() }\n coEvery { cache.get() } returns \"cached\"\n\n val job = launch { repository.getProfile() }\n runCurrent() // let it start and hit the delay\n // assert the pre-delay state here\n advanceUntilIdle() // fast-forward past the 2s delay\n job.join()\n // assert the post-delay state here\n }\n ```\n\n- Do **not** resolve the delay with a real `Thread.sleep`, a real `delay` in the test, or `withTimeout` around wall-clock time. `runTest` also fails the test if the body really blocks, so this catches regressions where someone swaps in a real dispatcher.\n\nCaveats:\n\n- This only works if the code under test is running on that test dispatcher's clock. If the ViewModel/class under test launches on `Dispatchers.IO` or a custom dispatcher, the `delay` inside it runs on the real clock and the test *will* take 2 seconds. Inject the dispatcher (`viewModelScope` via a `MainDispatcherRule` setting `Dispatchers.setMain(StandardTestDispatcher())`, or pass the dispatcher into the repository). If the `delay(2000)` sits on `Dispatchers.IO` with no injection point, that's a testability bug worth flagging rather than working around with a sleep.\n- `StandardTestDispatcher` requires explicit advancement (`runCurrent`/`advanceUntilIdle`) after launching; `UnconfinedTestDispatcher` runs eagerly up to the first suspension. Pick deliberately — `Standard` is usually the better default for deterministic assertions.\n- If the `delay(2000)` is a hardcoded constant you also want covered (\"the timeout is 2 seconds\"), assert via virtual time: capture the scope's `currentTime`, or check `advanceTimeBy(2000)` completes the wait and `advanceTimeBy(1999)` does not.\n\nOne thing to watch: `runTest`'s virtual clock only affects delays dispatched through it — the moment real I/O or a real dispatcher is involved, virtual time skips past it and the code may see the clock jump without the I/O having happened. For a pure \"cache vs slow network\" branch driven by `delay` + mocked calls, that's a non-issue; for anything touching real streams, keep the boundary mocked so the branch is fully virtual.",
904
+ "outputSha256": "6e8069a10c41371c2fff328da5e99b4246c7b15b6e484d4cd25723f8c4593bee",
905
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
906
+ "deterministic": [
907
+ true
908
+ ],
909
+ "judge": {
910
+ "verdict": "pass",
911
+ "reason": "Names runTest from kotlinx-coroutines-test and its TestScope virtual clock as the mechanism (\"delay() inside the code under test is skipped instantly\"). Explains delay(2000) advances virtual time and returns immediately, wall-clock ~zero, not a change to the delay. Explicitly warns against Thread.sleep and altering production delay; no runBlocking-accept-wait recommendation."
912
+ },
913
+ "passed": true
914
+ },
915
+ {
916
+ "output": "Use `runTest` from `kotlinx-coroutines-test` and a `StandardTestDispatcher` (or the one `runTest` installs itself). `runTest` runs the body on a virtual clock, so `delay(2000)` in the code under test is skipped instantly — no real two-second wait.\n\nThe structure:\n\n```kotlin\n@Test\nfun `returns cached value when network is slow`() = runTest {\n // runTest's scheduler drives the virtual clock for this coroutine,\n // including any delay() the repository calls.\n coEvery { api.fetch() } coAnswers {\n delay(2000) // virtual, not real\n throw IOException(\"slow network\")\n }\n coEvery { cache.get() } returns \"cached\"\n\n val result = repository.get()\n\n assertEquals(\"cached\", result)\n // no Thread.sleep, no real elapsed time\n}\n```\n\nKey points:\n\n1. **The suspend function must run on the test scheduler.** If the repository launches work via `viewModelScope`/another scope whose `Dispatchers.Main` isn't the test dispatcher, the `delay` uses the real clock. For a plain `suspend fun` called directly inside `runTest { }`, you're fine — the calling context is the test scheduler. If it hops to a `Dispatchers.IO`/`Default` scope internally, inject that dispatcher so tests can pass the `TestDispatcher`; otherwise the virtual clock won't apply.\n\n2. **Let virtual time advance deliberately.** For a plain suspend call, just `await`ing the result advances the clock to completion, so the normal `val result = repository.get()` already handles it. If there's an intermediate state you want to observe, use `advanceTimeBy(2000)` / `advanceUntilIdle()` / `runCurrent()` explicitly at that point:\n\n ```kotlin\n val deferred = async { repository.get() }\n runCurrent() // up to the first suspension\n advanceTimeBy(2000) // skip the delay\n val result = deferred.await()\n ```\n\n3. **Don't** wrap it in `withTimeout(2000)` and hope, and **never** use `Thread.sleep(2000)` or `delay(2000)` in the test body to \"wait it out\" — that reintroduces the real two seconds and makes the test flaky. `runTest` itself has a default timeout (usually 60s of real time) that would catch a hang, so a genuinely stuck coroutine still fails rather than hanging forever.\n\n4. If the \"cached value if network is slow\" logic branches on a timeout, drive that branch by controlling what the mocked `api.fetch()` does (`coAnswers` that delays then throws, as above), not by relying on wall-clock slowness.\n\nSo: `runTest` + `TestDispatcher` + a mocked API that `delay(2000)`s on the virtual clock. The test finishes in microseconds while still exercising the real 2000ms-delay code path.",
917
+ "outputSha256": "4afb608e47f58e666da466fcc1578d1a7620745afbe883074e940e55b95d229a",
918
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
919
+ "deterministic": [
920
+ true
921
+ ],
922
+ "judge": {
923
+ "verdict": "pass",
924
+ "reason": "Names runTest from kotlinx-coroutines-test as the mechanism (\"runs the body on a virtual clock, so delay(2000) in the code under test is skipped instantly\"), explains virtual/skipped time avoids the real wait rather than changing the delay (\"virtual, not real\", \"test finishes in microseconds while still exercising the real 2000ms-delay code path\"), and keeps production delay(2000) intact. Explicitly warns against Thread.sleep. No fail criteria hold."
925
+ },
926
+ "passed": true
927
+ },
928
+ {
929
+ "output": "Wrap the test body in `runTest { }`. `kotlinx-coroutines-test` installs a virtual clock, and `delay()` called from inside that test's coroutine (or from a coroutine whose dispatcher you've tied to the test scheduler) is *skipped instantly* rather than actually waiting — `runTest` auto-advances virtual time past pending delays once the test's main coroutine suspends.\n\nThe key requirement is that the coroutine doing the `delay(2000)` must be running on the test dispatcher / the same `TestCoroutineScheduler`, not on a real dispatcher like `Dispatchers.IO`. Common patterns:\n\n**If the function is called directly in the test:**\n\n```kotlin\n@Test\nfun `returns cached value after slow network`() = runTest {\n coEvery { api.fetch() } coAnswers {\n delay(2000) // virtual time, not wall-clock\n Result.failure(IOException())\n }\n coEvery { cache.get() } returns cachedValue\n\n val result = repository.load()\n\n assertEquals(cachedValue, result)\n coVerify { cache.get() }\n}\n```\n\nThat test finishes in milliseconds. If you want to assert *what happens before* the delay resolves, split it:\n\n```kotlin\n@Test\nfun `emits loading first then cached value`() = runTest {\n coEvery { api.fetch() } coAnswers { delay(2000); failure() }\n coEvery { cache.get() } returns cachedValue\n\n val job = launch { repository.load() }\n runCurrent() // let it start and hit the delay\n assertTrue(repository.state.value is Loading)\n advanceUntilIdle() // now skip past the 2000ms\n assertEquals(cachedValue, repository.state.value)\n}\n```\n\n**If the function is called through `viewModelScope`** (e.g. a ViewModel method, not a plain suspend function you call yourself), you must also make `Dispatchers.Main` resolve to the test dispatcher, otherwise `viewModelScope` uses a real `Main` and the delay is real:\n\n```kotlin\n@get:Rule\nval mainDispatcherRule = MainDispatcherRule() // sets Dispatchers.setMain(StandardTestDispatcher())\n```\n\nUse the project's existing `MainDispatcherRule` (or equivalent) as found in the neighboring test files — that rule's dispatcher is what makes `delay()` virtual inside the ViewModel's coroutine.\n\nTwo things to watch out for:\n\n- **Don't use a real dispatcher inside the function under test.** If the repository does `withContext(Dispatchers.IO) { ... }` internally and the delay is inside that block, virtual time won't apply to it. In that case, inject the dispatcher (`withContext(ioDispatcher)`) and pass a `TestDispatcher` in the test — or, if the delay is only in the test's mocked collaborator (as above), you're fine because the mock runs on the test scheduler.\n- **Never reach for `Thread.sleep(2000)` or lengthen the test to \"wait it out\"** — that's the exact anti-pattern the virtual clock exists to eliminate.\n\nBottom line: `runTest` + a `TestDispatcher` (`StandardTestDispatcher` if you need to control advancement with `runCurrent`/`advanceUntilIdle`, `UnconfinedTestDispatcher` if you'd rather it run eagerly) makes the `delay(2000)` free. The real skill is confirming the *suspended-before-resume* state with `runCurrent()` before you `advanceUntilIdle()`.",
930
+ "outputSha256": "33602acc5ce1aa73fe8ced1b9a251ae684a32e6827cbe79ceffe32e91b0f415e",
931
+ "promptSha256": "1a9b91754b5d5feff7bf49532faa22c333b2f0fb8e6bc8186c3a3d7fbfa0d0b3",
932
+ "deterministic": [
933
+ true
934
+ ],
935
+ "judge": {
936
+ "verdict": "pass",
937
+ "reason": "Names runTest { } as the mechanism (\"Wrap the test body in runTest { }\") and explains virtual clock skips delay instantly (\"skipped instantly rather than actually waiting\"). Criterion 3 holds: it keeps delay(2000) in production/mock and warns against Thread.sleep. No fail criteria triggered."
938
+ },
939
+ "passed": true
940
+ }
941
+ ]
942
+ }
943
+ ],
944
+ "verdict": "fail",
945
+ "scope": "bundled",
946
+ "skillDigest": "ba7b9e427d9f10c1481e4cfeac96bd16ec6d3f6d0cccb228acb8c78f6d279060",
947
+ "catalogDigest": "97f9af01aafac82ae21a63c6af2a2f24fcfe067dc32a7cfdcde9a69a91fa9aae",
948
+ "judgePromptVersion": "2026-09-25.1",
949
+ "runner": "deepseek",
950
+ "model": "deepseek-chat",
951
+ "runnerPromptVersion": "2026-09-25.1",
952
+ "recordedAt": "2026-09-25T18:29:49.183Z",
953
+ "judge": "deepseek",
954
+ "judgeModel": "deepseek-chat"
955
+ },
956
+ {
957
+ "schemaVersion": "1.0.0",
958
+ "skillId": "kotlin-android/kotlin-android-code-review",
959
+ "strictness": "high",
960
+ "trials": 10,
961
+ "triggerAccuracy": {
962
+ "truePositive": 4,
963
+ "falsePositive": 0,
964
+ "positives": 7,
965
+ "negatives": 7
966
+ },
967
+ "evidence": "authored",
968
+ "scenarios": [
969
+ {
970
+ "id": "trigger-positive-1",
971
+ "kind": "trigger-positive",
972
+ "prompt": "Take a look at this diff before I open the PR for the mobile app",
973
+ "strictness": "high",
974
+ "trials": 1,
975
+ "passes": 1,
976
+ "passRate": 1,
977
+ "passAtK": 1,
978
+ "grader": "trigger-rank-fork-family",
979
+ "status": "ran",
980
+ "deterministic": true
981
+ },
982
+ {
983
+ "id": "trigger-positive-2",
984
+ "kind": "trigger-positive",
985
+ "prompt": "Anything risky in these ViewModel changes I should fix before merge",
986
+ "strictness": "high",
987
+ "trials": 1,
988
+ "passes": 1,
989
+ "passRate": 1,
990
+ "passAtK": 1,
991
+ "grader": "trigger-rank-fork-family",
992
+ "status": "ran",
993
+ "deterministic": true
994
+ },
995
+ {
996
+ "id": "trigger-positive-3",
997
+ "kind": "trigger-positive",
998
+ "prompt": "Can you spot problems in this screen's new composable before I push",
999
+ "strictness": "high",
1000
+ "trials": 1,
1001
+ "passes": 0,
1002
+ "passRate": 0,
1003
+ "passAtK": 0,
1004
+ "grader": "trigger-rank-fork-family",
1005
+ "status": "ran",
1006
+ "deterministic": true
1007
+ },
1008
+ {
1009
+ "id": "trigger-positive-4",
1010
+ "kind": "trigger-positive",
1011
+ "prompt": "Double-check this Kotlin diff doesn't introduce any leaks",
1012
+ "strictness": "high",
1013
+ "trials": 1,
1014
+ "passes": 1,
1015
+ "passRate": 1,
1016
+ "passAtK": 1,
1017
+ "grader": "trigger-rank-fork-family",
1018
+ "status": "ran",
1019
+ "deterministic": true
1020
+ },
1021
+ {
1022
+ "id": "trigger-positive-5",
1023
+ "kind": "trigger-positive",
1024
+ "prompt": "Give this Android pull request a once-over for correctness",
1025
+ "strictness": "high",
1026
+ "trials": 1,
1027
+ "passes": 0,
1028
+ "passRate": 0,
1029
+ "passAtK": 0,
1030
+ "grader": "trigger-rank-fork-family",
1031
+ "status": "ran",
1032
+ "deterministic": true
1033
+ },
1034
+ {
1035
+ "id": "trigger-positive-6",
1036
+ "kind": "trigger-positive",
1037
+ "prompt": "Flag anything sketchy in this profile screen's changes",
1038
+ "strictness": "high",
1039
+ "trials": 1,
1040
+ "passes": 0,
1041
+ "passRate": 0,
1042
+ "passAtK": 0,
1043
+ "grader": "trigger-rank-fork-family",
1044
+ "status": "ran",
1045
+ "deterministic": true
1046
+ },
1047
+ {
1048
+ "id": "trigger-positive-7",
1049
+ "kind": "trigger-positive",
1050
+ "prompt": "Is this manifest change safe to ship",
1051
+ "strictness": "high",
1052
+ "trials": 1,
1053
+ "passes": 1,
1054
+ "passRate": 1,
1055
+ "passAtK": 1,
1056
+ "grader": "trigger-rank-fork-family",
1057
+ "status": "ran",
1058
+ "deterministic": true
1059
+ },
1060
+ {
1061
+ "id": "trigger-negative-1",
1062
+ "kind": "trigger-negative",
1063
+ "prompt": "Review this SwiftUI diff for retain cycles before merging",
1064
+ "strictness": "high",
1065
+ "trials": 1,
1066
+ "passes": 1,
1067
+ "passRate": 1,
1068
+ "passAtK": 1,
1069
+ "grader": "trigger-rank-fork-family",
1070
+ "status": "ran",
1071
+ "deterministic": true
1072
+ },
1073
+ {
1074
+ "id": "trigger-negative-2",
1075
+ "kind": "trigger-negative",
1076
+ "prompt": "Check this Flutter pull request for setState misuse",
1077
+ "strictness": "high",
1078
+ "trials": 1,
1079
+ "passes": 1,
1080
+ "passRate": 1,
1081
+ "passAtK": 1,
1082
+ "grader": "trigger-rank-fork-family",
1083
+ "status": "ran",
1084
+ "deterministic": true
1085
+ },
1086
+ {
1087
+ "id": "trigger-negative-3",
1088
+ "kind": "trigger-negative",
1089
+ "prompt": "Review this ASP.NET Core controller diff for missing authorization",
1090
+ "strictness": "high",
1091
+ "trials": 1,
1092
+ "passes": 1,
1093
+ "passRate": 1,
1094
+ "passAtK": 1,
1095
+ "grader": "trigger-rank-fork-family",
1096
+ "status": "ran",
1097
+ "deterministic": true
1098
+ },
1099
+ {
1100
+ "id": "trigger-negative-4",
1101
+ "kind": "trigger-negative",
1102
+ "prompt": "Check this React diff for missing hook dependencies",
1103
+ "strictness": "high",
1104
+ "trials": 1,
1105
+ "passes": 1,
1106
+ "passRate": 1,
1107
+ "passAtK": 1,
1108
+ "grader": "trigger-rank-fork-family",
1109
+ "status": "ran",
1110
+ "deterministic": true
1111
+ },
1112
+ {
1113
+ "id": "trigger-negative-5",
1114
+ "kind": "trigger-negative",
1115
+ "prompt": "Review this Python diff for unhandled exceptions",
1116
+ "strictness": "high",
1117
+ "trials": 1,
1118
+ "passes": 1,
1119
+ "passRate": 1,
1120
+ "passAtK": 1,
1121
+ "grader": "trigger-rank-fork-family",
1122
+ "status": "ran",
1123
+ "deterministic": true
1124
+ },
1125
+ {
1126
+ "id": "trigger-negative-6",
1127
+ "kind": "trigger-negative",
1128
+ "prompt": "Write tests covering this screen's new error state",
1129
+ "strictness": "high",
1130
+ "trials": 1,
1131
+ "passes": 1,
1132
+ "passRate": 1,
1133
+ "passAtK": 1,
1134
+ "grader": "trigger-rank-fork-family",
1135
+ "status": "ran",
1136
+ "deterministic": true
1137
+ },
1138
+ {
1139
+ "id": "trigger-negative-7",
1140
+ "kind": "trigger-negative",
1141
+ "prompt": "Fix the compile error in this ViewModel after the merge",
1142
+ "strictness": "high",
1143
+ "trials": 1,
1144
+ "passes": 1,
1145
+ "passRate": 1,
1146
+ "passAtK": 1,
1147
+ "grader": "trigger-rank-fork-family",
1148
+ "status": "ran",
1149
+ "deterministic": true
1150
+ },
1151
+ {
1152
+ "id": "globalscope-in-viewmodel-diff",
1153
+ "kind": "behavior",
1154
+ "prompt": "Here's a diff to a ProfileViewModel: it adds a new `refresh()` function whose body is `GlobalScope.launch { val data = repository.fetch(); _state.value = ProfileUiState.Content(data) }`, called from the screen's pull-to-refresh handler. What do you flag in review?",
1155
+ "strictness": "high",
1156
+ "trials": 10,
1157
+ "passes": 10,
1158
+ "passRate": 1,
1159
+ "passAtK": 1,
1160
+ "grader": "regex+judge",
1161
+ "status": "ran",
1162
+ "trialRecords": [
1163
+ {
1164
+ "output": "Flagging `ProfileViewModel.kt` — the new `refresh()` function and its pull-to-refresh call site.\n\n**`ProfileViewModel.kt` — `refresh()` body: `GlobalScope.launch { ... }`**\nRisk: `GlobalScope` is tied to no lifecycle owner, so this coroutine is not cancelled when the `ViewModel` is cleared (screen navigated away from, config-change teardown, etc.). The launched work outlives the `ViewModel`, and `_state.value = ProfileUiState.Content(data)` can therefore run after the `ViewModel` is gone — a state write against a dead owner, and a wasted/unbounded fetch if `fetch()` is slow. Fix direction: replace `GlobalScope.launch` with `viewModelScope.launch`, which is cancelled automatically in `onCleared()`. If `fetch()` is truly meant to outlive the screen (it isn't, for a pull-to-refresh), that decision would need to be explicit and documented, not implicit via `GlobalScope`.\n\n**Same body: fire-and-forget with no error handling**\nRisk: `repository.fetch()` is called with no `try`/`catch` (or `Result` handling). If it throws, the exception propagates into the coroutine scope. Under `GlobalScope` that's an uncaught exception with no `CoroutineExceptionHandler`; even moved to `viewModelScope`, a throw would crash rather than transition state to an error/`Empty`/retry state. Fix direction: wrap the call and route failures to an explicit error `ProfileUiState` rather than leaving the catch to chance.\n\n**Same body: no cancellation awareness around `fetch()`**\nRisk: if pull-to-refresh can be triggered twice in quick succession, two coroutines race to write `_state.value`. Depending on ordering, a stale response can overwrite a fresher one. Fix direction: cancel any in-flight refresh before starting a new one (e.g. keep a `Job` handle and `cancel()` it, or use `viewModelScope.launch` plus a single-flight guard), and check `isActive`/rely on cooperative cancellation in `fetch()`.\n\n**Call site (screen's pull-to-refresh handler): direct `viewModel.refresh()` from the composable**\nRisk: if `refresh()` is invoked from the composable body rather than from the pull-to-refresh callback (e.g. not inside a `LaunchedEffect`/`rememberPullRefreshState`-driven lambda), it can fire on every recomposition. If it is correctly invoked only inside the refresh callback, no finding here — but confirm which it is: a call written directly in composable body code is a real recomposition bug, a call inside the `onRefresh` lambda is fine. Fix direction: keep the invocation inside the pull-to-refresh lambda; if it currently sits in the composable body, move it into `LaunchedEffect`/the state-driven callback.\n\n**Touched, not introduced: `_state` exposure**\nWhile reading, check the backing field's surface. If `_state` is the mutable backing property and a read-only `StateFlow`/`LiveData` is exposed publicly, no finding. If the mutable `MutableStateFlow` itself is what's exposed, flag that separately as a pre-existing issue (collectors outside the `ViewModel` could push values) — but note it as pre-existing, not something this diff introduced.\n\nNo edits applied. To confirm the racing/cancellation concern in practice rather than only from reading, a targeted test firing `refresh()` twice concurrently is the cheapest way; for the `GlobalScope` leak, a `LeakCanary` run or logging from `onCleared()` will show whether the fetch outlives the `ViewModel`.",
1165
+ "outputSha256": "e6c99e3570e30eccd60e2879f51309ee39d061679feb3326cc4854cdb8ceed37",
1166
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1167
+ "deterministic": [
1168
+ true
1169
+ ],
1170
+ "judge": {
1171
+ "verdict": "pass",
1172
+ "reason": "Answer names the exact construct: 'refresh() body: GlobalScope.launch { ... }' and explains it is not cancelled when the ViewModel is cleared, so '_state.value = ProfileUiState.Content(data) can therefore run after the ViewModel is gone'. It gives the concrete fix: 'replace GlobalScope.launch with viewModelScope.launch, which is cancelled automatically in onCleared()'. All three pass criteria hold."
1173
+ },
1174
+ "passed": true
1175
+ },
1176
+ {
1177
+ "output": "I flag one finding, and it is the `GlobalScope.launch` in `refresh()`.\n\n```\nProfileViewModel.kt:~new-line — refresh() launches work in GlobalScope.\n Risk: the coroutine is not tied to the ViewModel's lifecycle. It\n outlives the ViewModel, so when the screen is destroyed (navigation,\n config change, back press) the coroutine keeps running and can write\n `_state.value` after the ViewModel should have been cleared. That is a\n leak and a state-write-after-death bug. A pull-to-refresh handler makes\n this easy to hit: the user can pull, then immediately leave the screen\n while the fetch is still in flight. Fix direction: replace\n GlobalScope.launch with viewModelScope.launch; viewModelScope is\n cancelled automatically in onCleared(), so the fetch and the state\n write are cancelled with the ViewModel.\n```\n\nA few supporting notes so the finding is actionable:\n\n- The fix is a scope swap, not a logic change: `viewModelScope.launch { val data = repository.fetch(); _state.value = ProfileUiState.Content(data) }`. No other line in the body needs to change.\n- `repository.fetch()` is suspend, so it is already cooperative — swapping the scope is sufficient for cancellation to actually take effect at the next suspension point.\n- If `fetch()` can throw, the state stays at whatever it was and the exception surfaces in the scope's uncaught handler. That is a separate consideration from the scope bug and worth raising if the diff does not already handle it elsewhere — but the primary finding is the scope.\n\nWhat I do **not** flag here, because it is not in this diff:\n\n- `_state` being a `MutableStateFlow` is fine on its own; the finding would be a *public* mutable exposure, and `refresh()` writing to the private `_state` from inside the ViewModel is the intended pattern.\n- If `_state`/`ProfileUiState` are pre-existing types, any issue with them is pre-existing, not introduced by this diff.\n\nSay so if the fetch result also flows through a `!!` or a `firstOrNull`/`find` result — that would be a separate null-safety finding on the same line, but the diff as described does neither.",
1178
+ "outputSha256": "14963b40226de04c45589e76d4429ea00d4a04c4ebc39c0d16e4ac95d380b90e",
1179
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1180
+ "deterministic": [
1181
+ true
1182
+ ],
1183
+ "judge": {
1184
+ "verdict": "pass",
1185
+ "reason": "Names the exact construct: 'the GlobalScope.launch in refresh()'. Gives concrete fix: 'replace GlobalScope.launch with viewModelScope.launch' and shows the swapped line. Explains the real risk: coroutine outlives ViewModel, keeps running after screen destroyed, 'can write _state.value after the ViewModel should have been cleared', tied to pull-to-refresh in-flight fetch. All three pass criteria hold."
1186
+ },
1187
+ "passed": true
1188
+ },
1189
+ {
1190
+ "output": "Findings for this diff:\n\n`ProfileViewModel.kt` (the `refresh()` function body) — `GlobalScope.launch` used to run the fetch.\n\n- Risk: `GlobalScope` has no lifecycle owner. The coroutine outlives the `ViewModel`. If the screen is gone (user navigates away, `onCleared` fires) while `repository.fetch()` is still in flight, the continuation still runs and writes `_state.value = ...` after the ViewModel is dead. That's a leaked coroutine plus a write to a state holder nobody is observing — the classic \"work keeps running after the screen is gone\" leak.\n- It also silently drops the result and any exception: there's no cancellation tied to the ViewModel and no error handling, so a failed `fetch()` throws into a `GlobalScope` coroutine with no `CoroutineExceptionHandler`, which crashes the process rather than surfacing an error state.\n- Also note the interaction with pull-to-refresh: the caller has no way to cancel or dedupe this. Two pulls in quick succession spawn two independent `GlobalScope` coroutines racing to write `_state`, so the last writer wins non-deterministically. A refresh launched from `viewModelScope` at least shares a cancellation point.\n\nFix direction (do not apply): replace `GlobalScope.launch { ... }` with `viewModelScope.launch { ... }`, and wrap the fetch in a `try/catch` (or `runCatching`) that maps failure to an error state, so the write is cancelled with the ViewModel and a failure doesn't take the process down.\n\nTwo related things to check while you're in this file:\n\n- If `_state` is exposed as a public `MutableStateFlow`/`MutableLiveData` rather than the read-only view, that's a separate state-exposure finding — any external collector could push a value. Confirm whether the exposed surface is mutable.\n- The screen's pull-to-refresh handler calls `refresh()` directly. That's fine (it's an event, not a composable-body side effect), but if the call site is a suspend call or state write placed directly in a composable body instead of a listener/`LaunchedEffect`, flag that too — I can't tell from the description alone whether it is, so confirm at the call site.\n\nClassification: both the `GlobalScope` usage and the dropped-exception issue are introduced by this diff — they're new in `refresh()`, not pre-existing.",
1191
+ "outputSha256": "718bb716d19d027dd4329a3101c80d1dc9b87f112c545d166720fa47da5e9390",
1192
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1193
+ "deterministic": [
1194
+ true
1195
+ ],
1196
+ "judge": {
1197
+ "verdict": "pass",
1198
+ "reason": "Names the exact construct: 'GlobalScope.launch used to run the fetch' in refresh(). Gives concrete fix: 'replace GlobalScope.launch { ... } with viewModelScope.launch { ... }'. Explains the real risk: coroutine outlives ViewModel, fetch in flight after onCleared still writes _state.value after ViewModel is dead. All three pass criteria hold."
1199
+ },
1200
+ "passed": true
1201
+ },
1202
+ {
1203
+ "output": "**`ProfileViewModel.kt` — `refresh()`: `GlobalScope.launch`**\n\nRisk category: unstructured coroutine scope.\n\n- `GlobalScope` has no lifecycle owner. Nothing cancels the coroutine when the ViewModel is cleared, so if the user leaves the profile screen while `repository.fetch()` is in flight, the job keeps running to completion and then executes `_state.value = ProfileUiState.Content(data)` on a ViewModel that is already gone. That write is a leak of work past the ViewModel's lifetime, and it is exactly the class of thing `viewModelScope` exists to prevent.\n- The pull-to-refresh entry point makes this worse, not better: pull-to-refresh is the interaction a user is most likely to trigger repeatedly and then immediately navigate away from (satisfied, or gave up). Each pull spawns an uncancellable job that will land its result whenever it lands.\n- Secondary, but worth noting: because these jobs are uncancellable and unordered, two quick refreshes can complete out of order and a slower first fetch can overwrite a newer `Content(data)`. `viewModelScope` does not fix that on its own — you would also want to cancel the previous refresh job (or drop the stale result) — but the `GlobalScope` form makes it structurally impossible to address.\n\nFix direction (not applied): replace `GlobalScope.launch` with `viewModelScope.launch`; if overlapping refreshes are a real scenario, hold the refresh `Job` in a field and `cancel()` the previous one before launching the new one.\n\n**Nothing else in the `refresh()` body to flag.** `_state.value = ProfileUiState.Content(data)` is a normal backing-flow write inside the ViewModel, `repository.fetch()` is presumably suspend and awaited correctly, and there is no `!!` here — `data` is the direct result of the call, not a nullable external field being force-unwrapped.\n\n**One thing I could not verify from what you've given me, flagged as a question rather than a finding:** you say `refresh()` is called from the screen's pull-to-refresh handler. I have not seen the screen file. If that handler is written as a bare call in the composable body rather than inside `LaunchedEffect`/a callback, that is a separate recomposition problem. If it is a normal `onRefresh` lambda on the pull-to-refresh container, it is fine. Worth a look at the call site before this merges.\n\n**Suggested report line:**\n\n```\nfeature/profile/ProfileViewModel.kt:<line> — refresh() uses GlobalScope.launch.\n Risk: coroutine outlives the ViewModel; state write after clear, and\n out-of-order writes on repeated pulls. Fix: viewModelScope.launch, plus\n cancel the previous refresh Job if overlapping pulls are possible.\n```\n\nNote this is the category of finding the diff introduces outright, not a pre-existing pattern in touched code — the new function is the new unstructured scope.\n\nPer this review's scope I have not edited anything; if you want, the follow-up is to read the screen file the handler lives in and confirm the invocation point.",
1204
+ "outputSha256": "16ce7db6a2930e21f2974f51f0a58d2db77188ea8f5d4785ff18837c078beb41",
1205
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1206
+ "deterministic": [
1207
+ true
1208
+ ],
1209
+ "judge": {
1210
+ "verdict": "pass",
1211
+ "reason": "Names the exact construct: 'ProfileViewModel.kt — refresh(): GlobalScope.launch'. Gives concrete fix: 'replace GlobalScope.launch with viewModelScope.launch'. Explains real risk: 'Nothing cancels the coroutine when the ViewModel is cleared... executes _state.value = ProfileUiState.Content(data) on a ViewModel that is already gone.' All three pass criteria concretely met."
1212
+ },
1213
+ "passed": true
1214
+ },
1215
+ {
1216
+ "output": "I'd flag the `GlobalScope.launch` in `refresh()`, with a secondary point about the pull-to-refresh call site.\n\n```\nfeature/profile/ProfileViewModel.kt:28 — GlobalScope.launch used to fetch\n the profile. Risk: the coroutine is not tied to the ViewModel's\n lifecycle, so it outlives the ViewModel (and the screen). If the user\n navigates away or the screen is destroyed while the fetch is in flight,\n the coroutine keeps running and can write `_state.value` after the\n ViewModel is cleared — a state write on a dead owner, and a wasted\n request whose result nobody consumes.\n Fix direction: replace GlobalScope.launch with viewModelScope.launch,\n which is cancelled automatically when the ViewModel is cleared.\n```\n\nTwo things I'd check before finalizing that finding, since I only have the snippet you quoted:\n\n- **Where `refresh()` is called from.** You said it's called from the screen's pull-to-refresh handler. `viewModelScope` is still the fix, but I'd want to confirm the call site is an event callback (e.g., `onRefresh = { viewModel.refresh() }`) and not something sitting directly in a composable body — if the call ends up in a composable body rather than an event lambda, that's a separate Compose finding (it can re-fire every recomposition). Flag it as a follow-up to verify in the diff, not as a confirmed second finding, since I haven't read the composable.\n- **Cancellation awareness.** Once it's on `viewModelScope`, a `repository.fetch()` that isn't cancel-aware (no `ensureActive()` on a long loop) still won't actually stop on cancellation. That's a note against the repository function, not the ViewModel change, and not something this diff introduces — I'd mention it only as pre-existing context.\n\nWhat I would *not* flag: the `_state.value = ProfileUiState.Content(data)` write itself is fine on whatever scope, as long as the scope is lifecycle-bound; and any style nits (line length, naming) belong to ktlint/detekt, not this review.\n\nNo files were modified — findings and fix direction only.\n\nOne caveat on confidence: this is a clear-cut one, and `GlobalScope` here is a definite finding, not a suspected one. I'm asserting the leak/exposure behavior, not a recomposition issue, so no trace or LeakCanary run is needed to confirm it — the only thing worth confirming by reading the actual diff is whether the *call site* in the composable is an event handler or a body-level call.",
1217
+ "outputSha256": "2630eb0798407f1d807ea06e17be9f8dd78ccb254a91eb1da9d86e2d9f9f7d9d",
1218
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1219
+ "deterministic": [
1220
+ true
1221
+ ],
1222
+ "judge": {
1223
+ "verdict": "pass",
1224
+ "reason": "Answer names the exact construct: 'GlobalScope.launch in refresh()' at ProfileViewModel.kt:28. Fix direction stated concretely: 'replace GlobalScope.launch with viewModelScope.launch, which is cancelled automatically when the ViewModel is cleared.' Risk explained: coroutine outlives ViewModel/screen, keeps running, 'can write _state.value after the ViewModel is cleared — a state write on a dead owner.' All three pass criteria hold; no fail criteria."
1225
+ },
1226
+ "passed": true
1227
+ },
1228
+ {
1229
+ "output": "Flag it. Here's the review finding.\n\n**`ProfileViewModel.kt` (new `refresh()`) — `GlobalScope.launch` in the ViewModel.**\n\nFile:line — the new `refresh()` function body, e.g. `feature/profile/ProfileViewModel.kt:28` (line will be the `GlobalScope.launch` call).\n\nPattern: `GlobalScope.launch { val data = repository.fetch(); _state.value = ProfileUiState.Content(data) }`.\n\nWhy it matters — several distinct risks stack here, all rooted in the same choice of scope:\n\n- **Unstructured scope / no cancellation.** `GlobalScope` has no lifecycle owner. Unlike `viewModelScope`, it is not cancelled when the ViewModel is cleared. So if the user navigates away, backgrounds the app, or the screen is destroyed mid-fetch, the coroutine keeps running to completion.\n- **Writing state after the screen is gone.** Because it isn't cancelled, `_state.value = …` can execute after the ViewModel/UI is no longer needed. That's a leak of work into a dead lifecycle and can update a `StateFlow` nobody is collecting — at best wasted work, at worst a confusing stale write if a new ViewModel instance supersedes it.\n- **Overlapping refreshes.** Pull-to-refresh handlers fire per gesture. With a new `GlobalScope.launch` each call there is no cancellation or de-duplication of a prior in-flight `refresh()`. Two pulls can race, and whichever `fetch()` finishes last wins — potentially clobbering newer data with an older response (no \"latest call wins\" guarantee).\n- **Dropped failure path.** The body has no `try/catch` and no error handling, so a thrown exception from `repository.fetch()` propagates into the `GlobalScope`'s uncaught handler. There's no path back into `ProfileUiState.Error(...)` — the UI stays on whatever it showed before, and the failure surfaces only as a global-scope crash log.\n\nFix direction (report only — do not apply):\n\n- Replace `GlobalScope.launch` with `viewModelScope.launch`. That ties the fetch to the ViewModel lifecycle: it cancels on clear, and it cancels the previous refresh if you also track the job.\n- To kill the overlap race, hold the job and cancel the prior one, or gate on a check (`if (_state.value is ProfileUiState.Loading) return`), so a second pull doesn't start a second concurrent fetch.\n- Wrap the fetch in `try/catch` (or `runCatching`) and map failure to an error state, so the failure path is explicit rather than being swallowed by the global scope.\n\nPre-existing vs. introduced: this is introduced by the diff — the `GlobalScope.launch` is new in the new `refresh()` function, so it is a diff-introduced finding, not a pre-existing one in touched code.\n\nOne thing I can't verify from the diff text alone: whether `refresh()` is the only caller and whether the pull-to-refresh handler can actually fire re-entrantly (some Compose/gradle setups gate it). The overlap risk in the third bullet assumes it can. If that's gated elsewhere, downgrade that bullet — but the `viewModelScope` + error-handling findings stand regardless, since they don't depend on re-entry.",
1230
+ "outputSha256": "a2af97f16092310855a6a2ba3dae5e9ed09885e213337f52e44732c725a92c92",
1231
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1232
+ "deterministic": [
1233
+ true
1234
+ ],
1235
+ "judge": {
1236
+ "verdict": "pass",
1237
+ "reason": "Answer names the exact construct: 'GlobalScope.launch in the ViewModel' in new refresh(), quoting the full body. States fix: 'Replace GlobalScope.launch with viewModelScope.launch'. Explains risk: 'not cancelled when the ViewModel is cleared' so '_state.value = … can execute after the ViewModel/UI is no longer needed' — the concrete post-clear write. All three pass criteria hold."
1238
+ },
1239
+ "passed": true
1240
+ },
1241
+ {
1242
+ "output": "**`ProfileViewModel.kt` — `GlobalScope.launch` in `refresh()`.** Highest-severity finding in this diff.\n\n```\nProfileViewModel.kt:<line of refresh()> — GlobalScope.launch used to run\n repository.fetch(). Risk: the coroutine is tied to no lifecycle owner, so\n nothing cancels it when the ViewModel clears or the screen is destroyed. A\n slow fetch can complete after the user has left the screen and write\n ProfileUiState.Content into _state on a dead ViewModel — and if anything is\n collecting that state downstream, it receives a value it has no business\n receiving. Fix direction: replace with viewModelScope.launch, which is\n cancelled automatically in onCleared().\n```\n\nWhy this one matters most: `_state.value = ...` after the screen is gone is the concrete leak/incorrect-state symptom, not a theoretical one. `viewModelScope` is the exact construct that exists to prevent it, and the rest of the class already has `_state`, so the scope is almost certainly available.\n\nSince `fetch()` can throw, also worth confirming (a secondary finding, not the headline one):\n\n```\nProfileViewModel.kt:<line> — the fetch() call has no try/catch or\n runCatching around it; an exception inside this unstructured launch has no\n handler and no caller to surface to, so the refresh silently dies and the\n UI is left in whatever state it was in. Direction: catch and map to an\n error state (this is independent of the GlobalScope fix — viewModelScope\n does not add exception handling).\n```\n\nNote the item on my focus list about a fire-and-forget `launch` whose\nresult/exception is silently dropped — this diff is that case.\n\nTwo things this diff does *not* raise, so I would not pad the report with them:\n\n- The pull-to-refresh handler merely *calling* `refresh()` is fine — the problem is the scope inside `refresh()`, not the call site.\n- I'd want to check `repository.fetch()` itself for `!!` on response fields or an internal `GlobalScope` of its own, but that's only in scope if the diff touches `repository`. On what's shown here, it doesn't.\n\nI'm not editing anything — this is findings and fix direction only. One line I can't pin without the file: the exact line number of the `GlobalScope.launch` statement, which I've marked above.",
1243
+ "outputSha256": "87621e4d688c5ac6815227bbf8234d9e0efeaf4d7a31bf16750ca8dcd2d80505",
1244
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1245
+ "deterministic": [
1246
+ true
1247
+ ],
1248
+ "judge": {
1249
+ "verdict": "pass",
1250
+ "reason": "Answer names the exact construct: 'GlobalScope.launch in refresh()' as highest-severity finding (criterion 1). Gives concrete fix: 'replace with viewModelScope.launch, which is cancelled automatically in onCleared()' (criterion 2). Explains real risk: 'slow fetch can complete after the user has left the screen and write ProfileUiState.Content into _state on a dead ViewModel' (criterion 3). No fail criteria hold."
1251
+ },
1252
+ "passed": true
1253
+ },
1254
+ {
1255
+ "output": "Findings for this diff, per the Kotlin/Android review workflow:\n\n**`ProfileViewModel.kt` — `refresh()` uses `GlobalScope.launch`**\n\n- **File:line** — the line inside `refresh()` where `GlobalScope.launch { ... }` is written.\n- **Pattern** — unstructured coroutine launched on the global scope.\n- **Why it matters (leak + crash + silent failure):**\n - Nothing ties the job to the ViewModel's lifecycle. If the user navigates away or the ViewModel is cleared mid-fetch, this coroutine keeps running and can write `_state.value` after the screen is gone — a state write after the lifecycle owner is dead.\n - It is never cancelled, so a fast sequence of pull-to-refresh gestures stacks up concurrent `fetch()` calls with no supersession — last-writer-wins on `_state.value`, and an out-of-order response can clobber a fresher one.\n - A thrown exception inside a `GlobalScope.launch` has no parent to propagate to; depending on the handler it is either swallowed or goes to the global uncaught handler (process crash). The UI would show a permanently stale state with no error path.\n- **Fix direction** — `refresh()` should call `viewModelScope.launch { ... }`. That scopes the fetch to the ViewModel, cancels it on `onCleared()`, and gives you a place to `try/catch` and map the failure into the UI state. If refreshes should supersede each other, hold the `Job` and cancel the previous one (or use a `MutableStateFlow` + `flatMapLatest`) instead of letting them stack.\n\n**Note on call site — usually second-order, not always a separate finding**\n\n`refresh()` being invoked directly from the pull-to-refresh handler is normally correct; that handler is a user action, so launching from it is fine. It only becomes a Compose finding if `refresh()` is being called from a composable body (not from the `onRefresh` lambda), which would re-fire it on every recomposition. From the diff as described, the launch originates inside the ViewModel function, so the `GlobalScope` issue above is the primary finding, and the call-site concern is worth a one-line confirmation rather than an assertion.\n\n**Severity ordering:** the `GlobalScope` launch is the blocking finding — it should be fixed before merge. The unguarded call site is a verify-during-review item, not a confirmed defect.\n\nNo code was edited; findings and fix directions only. I could not run anything against the actual diff in this exercise, so the file:line above refers to the line as described in your summary — confirm it against the real diff before filing.",
1256
+ "outputSha256": "1cb3b274f998a5c49c8c2f918e415626c3066732f974772a0303ec90e6e8e525",
1257
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1258
+ "deterministic": [
1259
+ true
1260
+ ],
1261
+ "judge": {
1262
+ "verdict": "pass",
1263
+ "reason": "Names the exact construct: 'refresh() uses GlobalScope.launch' as the primary/blocking finding. Gives concrete fix: 'refresh() should call viewModelScope.launch { ... }'. Explains the real risk: coroutine keeps running and 'can write _state.value after the screen is gone' since it's 'never cancelled' and not tied to ViewModel lifecycle/onCleared(). All three pass criteria met."
1264
+ },
1265
+ "passed": true
1266
+ },
1267
+ {
1268
+ "output": "Flag this at the specific location (e.g. `feature/profile/ProfileViewModel.kt:<line of refresh()>`) — the `GlobalScope.launch`.\n\n**Pattern:** `refresh()` launches on `GlobalScope` rather than a lifecycle-owned scope (`viewModelScope`).\n\n**Why it matters:** `GlobalScope` is not tied to the ViewModel's lifetime. The coroutine outlives the ViewModel — if the screen is popped or the process hangs onto the scope, the coroutine keeps running, `repository.fetch()` keeps going, and `_state.value = ...` can write state after the screen is gone (a leak, and a write to a state holder nobody is collecting/observing anymore). There's also nothing cancelling it when the user leaves mid-refresh, so a slow or hung `fetch()` just lingers. The write target makes it worse: `_state` is the `MutableStateFlow` backing whatever this ViewModel exposes.\n\n**Fix direction:** `viewModelScope.launch { ... }` — same body, properly cancelled when the ViewModel clears. Do not apply it; report it.\n\n**Second finding, same function:** `_state` — if `ProfileViewModel` exposes a `MutableStateFlow` (or `MutableLiveData`) directly rather than the read-only view (`asStateFlow()` / `LiveData`), flag that separately. The write above is only possible from here because the mutable backing is reachable; confirm which it exposes and report if it's the mutable type on the public surface.\n\n**Third finding, at the call site (screen's pull-to-refresh handler):** confirm where `refresh()` is invoked from. If it's called from a composable body rather than from within the handler's own callback (e.g. `PullToRefreshBox`'s `onRefresh` lambda, or a `LaunchedEffect`/`rememberCoroutineScope`-driven call), that's a separate side-effect-outside-`LaunchedEffect` issue. Called from a real callback it's fine; called directly in the composable body it will re-fire on recomposition. State which, with the line.\n\n**Not uncertain enough to hedge on** the `GlobalScope` finding — that one is definite from the code as described. I'd note only that whether the leaked-write actually manifests depends on the ViewModel's clear timing, but the unstructured scope is the finding regardless.\n\n**Verification caveat:** if you want to demonstrate the leak empirically rather than by reading, a `LeakCanary` run with a mid-refresh navigation away from the screen would confirm it — but don't assert the leak as observed without that; the code pattern alone is sufficient to report.\n\nNo file has been edited; fix directions only.",
1269
+ "outputSha256": "2dcdd5b0a574d31687e1389d0032a5484b86f8a98732e0718d7a09f27cb5f906",
1270
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1271
+ "deterministic": [
1272
+ true
1273
+ ],
1274
+ "judge": {
1275
+ "verdict": "pass",
1276
+ "reason": "Names the exact construct: 'the `GlobalScope.launch`' in refresh(). Fix direction given: '`viewModelScope.launch { ... }` — same body, properly cancelled when the ViewModel clears.' Risk explained: coroutine outlives ViewModel, fetch keeps going, '_state.value = ...' can write state after the screen is gone, nothing cancels it mid-refresh."
1277
+ },
1278
+ "passed": true
1279
+ },
1280
+ {
1281
+ "output": "Flagging this against the coroutine and state-exposure focus areas.\n\n**Finding — `GlobalScope.launch` in `refresh()` (`ProfileViewModel.kt`, the new function body).**\n\n- **Pattern:** `GlobalScope.launch { ... }` used to run the fetch and assign `_state.value`.\n- **Why it matters (leak):** `GlobalScope` is tied to no lifecycle owner, so the coroutine is not cancelled when the `ViewModel` is cleared (screen destroyed, config change that outlives the VM, navigation away). The fetch keeps running after the screen is gone, and the `_state.value = ...` write can land on a dead/obsolete `ViewModel` — a classic coroutine leak plus a state write after the owner is gone.\n- **Fix direction (not applied — read-only):** replace `GlobalScope` with `viewModelScope.launch { ... }`. That scope is cancelled in `onCleared()`, so the fetch is cancelled with the `ViewModel` and the assignment can't outlive it. If this VM isn't an `androidx.lifecycle.ViewModel`, use the scope tied to its actual owner (`lifecycleScope`, or a scope injected and cancelled at the right time).\n\n**Second, related point — failure handling.** The body has no `try`/`catch` (or `runCatching`). If `repository.fetch()` throws, the exception is dropped (GlobalScope has no handler) or crashes, and `_state` is never moved off its previous value — the UI can sit on a stale/loading state with no error surface. The fix direction is to catch and map to an `Error` state. This is inherent to the new body, so it is diff-introduced, not pre-existing.\n\n**Confirming points to check while reviewing (not asserted blind):**\n\n- **Is `_state` read-only on the public surface?** `_state.value = ...` is the private `MutableStateFlow` inside the VM, which is fine. The thing to verify is that the VM exposes only `state: StateFlow<...>` (or the read-only view) publicly, not the `MutableStateFlow` itself. If `_state` is public, that's a separate state-exposure finding — flag it. From the diff alone I can't see the property declaration, so read the surrounding file to confirm before reporting it.\n- **Is a refresh already in flight?** A pull-to-refresh handler can fire repeatedly; with a fire-and-forget `launch` per call, concurrent fetches can race and the last writer wins non-deterministically. If the surrounding code doesn't guard this (a `Job` reference checked/cancelled before relaunch, or `flatMapLatest`/`collectLatest` in the pipeline), note it as a likely race — but verify from the rest of the file rather than asserting it.\n\n**Not flagged here:** nothing in this snippet is an unstable-Compose-parameter or forced-unwrap issue, so I'm not padding the report with those. The `!!`/unstable-type checks apply to other changed files, if any.\n\n**Verification note:** this report covers only what the diff shows. Before calling the review done, read the full `ProfileViewModel.kt` and the changed screen file — the `refresh()` body alone doesn't tell me whether `_state` is exposed mutably or whether concurrent refreshes are guarded, and those change whether points 1 and 3 above become findings. No files were edited.",
1282
+ "outputSha256": "ac6dcca5aacd46f60d068ea8e1bf9d0c6f647f54dd3625ec1b54b8fe3cd33bbc",
1283
+ "promptSha256": "ce6ccb824f1ffab0a2b1868c6a8a64ba60589cc30ccd4e45607330cfb68fa806",
1284
+ "deterministic": [
1285
+ true
1286
+ ],
1287
+ "judge": {
1288
+ "verdict": "pass",
1289
+ "reason": "Finding names GlobalScope.launch in refresh() (ProfileViewModel.kt new body). Fix: 'replace GlobalScope with viewModelScope.launch', cancelled in onCleared(). Risk: coroutine not cancelled when ViewModel cleared, fetch keeps running and '_state.value = ...' write lands after owner gone. All three pass criteria concretely met."
1290
+ },
1291
+ "passed": true
1292
+ }
1293
+ ]
1294
+ },
1295
+ {
1296
+ "id": "forced-unwrap-external-data",
1297
+ "kind": "behavior",
1298
+ "prompt": "This diff adds `val userId = intent.getStringExtra(\"user_id\")!!` at the top of an Activity's `onCreate()`, reading an extra from the incoming Intent that launched it. What's worth flagging here?",
1299
+ "strictness": "high",
1300
+ "trials": 10,
1301
+ "passes": 10,
1302
+ "passRate": 1,
1303
+ "passAtK": 1,
1304
+ "grader": "judge",
1305
+ "status": "ran",
1306
+ "trialRecords": [
1307
+ {
1308
+ "output": "**Finding — forced null-unwrap on an externally-sourced `Intent` extra**\n\n`<Activity>.kt:<line of the new statement> (onCreate)` — `intent.getStringExtra(\"user_id\")!!`\n\n**Pattern:** `!!` applied to a value that originates outside this function's own prior checks — specifically a field read from the incoming `Intent` extras.\n\n**Why it matters:** An `Intent` extra is an external contract, not a compiler-verified guarantee. `getStringExtra` returns `String?`, and `!!` converts the possibility of a missing extra into a guaranteed `NullPointerException` at `onCreate()` time. Whoever built the `Intent` — another Activity, a notification `PendingIntent`, a deep-link/`ACTION_VIEW` handler, an app-widget click, a test harness, or an app-version mismatch after an OS upgrade — can construct this Activity without the `user_id` extra. When that happens the app crashes at launch, not deep in the code where it could be recovered. Deep links and `PendingIntent`s are especially prone to this because the launch source is not under this Activity's control.\n\n**Fix direction (do not apply here, this is a read-only review):**\n- Decide the contract explicitly. If `user_id` is genuinely mandatory for this Activity to function, fail loudly and intentionally: `requireNotNull(intent.getStringExtra(\"user_id\")) { \"ProfileActivity requires extra user_id\" }` — that keeps the crash but makes it a named, diagnosable error rather than an opaque `!!`.\n- If it is not mandatory, use a safe call and handle absence: `intent.getStringExtra(\"user_id\")?.let { ... } ?: finishAfterTransition()` (or navigate back / show an error state), so a malformed launch degrades instead of crashing.\n- Worth a look at the call sites that build this `Intent` too, to confirm they always set the extra — but that is a follow-up, not a substitute for the null-safety fix, since the Activity can still be launched by paths outside your own code.\n\n**Classification:** This is diff-introduced (the line is new in this change), so it is not a pre-existing issue in code the diff merely touches.\n\n**Confidence:** High that this should be flagged. The exact runtime impact depends on how the Activity can be launched — if it is not exported and is only ever started internally from code that unconditionally sets the extra, the crash window is narrower, but it is still an external source that the compiler does not guard and the finding stands regardless.",
1309
+ "outputSha256": "e801ac2557622d0165c069a83ca9775c2f15a6e338263cf737638d1088cc482b",
1310
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1311
+ "deterministic": [],
1312
+ "judge": {
1313
+ "verdict": "pass",
1314
+ "reason": "Answer names the exact line `intent.getStringExtra(\"user_id\")!!` in onCreate (criterion 1). It explains getStringExtra returns String?, the extra is externally sourced (another Activity, PendingIntent, deep link, test harness, OS upgrade), and a missing extra causes an NPE crash at onCreate (criterion 2). It gives concrete fixes: requireNotNull(...) { \"ProfileActivity requires extra user_id\" } and safe call with ?: finishAfterTransition() (criterion 3). No fail criteria hold."
1315
+ },
1316
+ "passed": true
1317
+ },
1318
+ {
1319
+ "output": "**Finding — the forced unwrap on an Intent extra**\n\n`<Activity>.kt:<onCreate line> — intent.getStringExtra(\"user_id\")!!`\n\n**Why it matters.** `getStringExtra` is nullable by signature precisely because the platform cannot guarantee the extra is present. An `Intent` can reach this Activity from any caller that can launch it — an internal `startActivity` you control, but also an adb `am start`, a deep link, a notification trampoline, a link from another app if the Activity is exported, or simply a caller that forgot to attach the extra. When it is absent, `!!` throws `NullPointerException` in `onCreate` and the app crashes before the screen ever draws. This is a crash that no amount of \"our own code always sets it\" reasoning prevents, because the contract lives outside the compiler's view.\n\n**Fix direction — do not apply, this is a read-only review.**\n\n- Decide explicitly what a missing extra means. There is no universally correct answer; the right shape depends on intent:\n - If the extra is genuinely mandatory for this screen, `requireNotNull(intent.getStringExtra(\"user_id\")) { \"ProfileActivity requires EXTRA_USER_ID\" }` — same crash, but a named, diagnosable one instead of an opaque NPE. This is the smallest change and converts a mystery stack trace into a self-describing one.\n - If the screen can render without it, `intent.getStringExtra(\"user_id\")` and branch: finish with a `Toast`/log, or render an empty state, or fall back to a default.\n - `intent?.getStringExtra(...)` is not relevant here — `getIntent()` inside `onCreate` is non-null, so a safe call on the Intent adds nothing; the nullability that matters is the extra's value, not the Intent.\n- Whichever branch is chosen, validate the value's *shape* too if it is an ID used downstream: an empty string passes the null check but fails an ID lookup later. If it feeds a database query or a URL, treat it as untrusted input, not just possibly-null.\n\n**Related thing to check while here — the export surface.** Whether this crash is reachable by *other apps* depends on the manifest entry for this Activity. If the diff also touches `AndroidManifest.xml`, check whether this Activity is `android:exported=\"true\"`. An exported Activity that reads a required extra and crashes on its absence is both a crash vector and, if it performs privileged work on that ID, an access-control concern — any app on the device could launch it with an arbitrary `user_id`. If it is exported without a permission attribute and does not need to be reachable externally, that is a second, separate finding. An internal-only Activity should leave the default (not exported) in place.\n\n**Confidence and how to confirm.** The `!!`-on-external-data finding is certain from reading the line alone — it needs no runtime confirmation; the fix direction is the actionable part. The manifest/reachability point is conditional on the manifest contents; confirm by reading the `<activity>` entry for this class rather than assuming.",
1320
+ "outputSha256": "0ade712a8d8530f0314a773f6e3cf2e3b4b5ef615ffd17d711f844fa0a8a8563",
1321
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1322
+ "deterministic": [],
1323
+ "judge": {
1324
+ "verdict": "pass",
1325
+ "reason": "Names the line `intent.getStringExtra(\"user_id\")!!` as the finding. Explains nullability and Intent-sourced risk (adb am start, deep link, notification, other app, missing extra) causing NPE in onCreate. Gives concrete fixes: requireNotNull(...) { \"ProfileActivity requires EXTRA_USER_ID\" } or branch/finish/default. No fail criteria hold."
1326
+ },
1327
+ "passed": true
1328
+ },
1329
+ {
1330
+ "output": "feature/.../ProfileActivity.kt — `val userId = intent.getStringExtra(\"user_id\")!!` in `onCreate()`.\n\n**Finding:** forced unwrap (`!!`) on a value sourced from outside the function's own prior checks — an `Intent` extra, which is external data under the null-safety category in Step 2.\n\n**Why it matters (crash risk):** `getStringExtra` is declared to return `String?`, and an Intent extra is not compiler-verified to be present. Any of these will produce a `NullPointerException` at `onCreate()` and crash the Activity on launch:\n\n- The Activity is launched from a path that doesn't put `\"user_id\"` in the Intent — a deep link, a notification PendingIntent built elsewhere, an explicit `Intent` from a different screen, an adb `am start`, or a rebuilt task after process death where the extra was never re-supplied.\n- The extra is present but was stored under a different key, or stored as a non-String type (`putExtra(\"user_id\", 123L)`), in which case `getStringExtra` returns null rather than throwing.\n- The key was renamed on the sending side and only one call site was updated.\n\nThis is exactly the \"always populated by the backend\" rationalization in the Red Flags table — \"always\" is a sender-side convention, not a guarantee the receiving Activity can rely on. It is also worth noting it's a *launch-time* crash, so it fires on the very first frame of the screen rather than on some rare interaction path, which tends to make it a high-frequency crash in the wild once any one new entry point appears.\n\n**Fix direction (not applied — review only):**\n\n- Prefer a safe read with an explicit fallback and a defensive exit, e.g. `val userId = intent.getStringExtra(\"user_id\") ?: run { finish(); return }`, or\n- `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra missing\" }` if missing input is genuinely a programming error you want to surface loudly rather than swallow — this converts the opaque NPE into a message that names the missing key.\n- Safe-call/Elvis is the general direction for external data; `requireNotNull` with a message is the variant to use when the value is a hard precondition.\n\n**Which fix depends on intent:** if `\"user_id\"` is a hard requirement, `requireNotNull` with a message is better than a silent `?:` fallback, because a null here almost certainly means a bug in the sender — but if the Activity is reachable via deep links or restored tasks where absence is legitimate, an explicit `finish()`/redirect is the right shape. Worth confirming with the author which case this is.\n\n**Scope note:** I've only been given this one line, not the diff or the surrounding file, so I can't tell whether this `!!` is newly introduced by the change or pre-existing in code the diff merely touches — flag that distinction explicitly when you report it. I also can't see whether a nearby `onNewIntent()` handles the same extra; if the Activity can be re-launched into an existing instance, the same unwrap (or its absence) there needs the same treatment.\n\n**Confidence:** high that this is worth flagging and that it is a real crash risk; I'm asserting it from the read alone, and it's directly confirmable by launching the Activity without the extra (e.g. `adb shell am start` against it) rather than needing an instrumentation run.",
1331
+ "outputSha256": "88f147d64668ed0d39415e36dcedec46d7ee59dd1708445f3db20a56610f3e86",
1332
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1333
+ "deterministic": [],
1334
+ "judge": {
1335
+ "verdict": "pass",
1336
+ "reason": "Names the exact line `val userId = intent.getStringExtra(\"user_id\")!!` in onCreate (criterion 1). Explains getStringExtra returns String?, extra not compiler-verified, missing extra causes NPE crash at onCreate, listing deep link/notification/adb/process-death paths (criterion 2). Gives concrete fixes: `?: run { finish(); return }` and `requireNotNull(...) { \"user_id extra missing\" }` (criterion 3). No fail criteria hold."
1337
+ },
1338
+ "passed": true
1339
+ },
1340
+ {
1341
+ "output": "`feature/.../SomeActivity.kt:<line of onCreate>` — `intent.getStringExtra(\"user_id\")!!` forced-unwrap on an `Intent` extra.\n\n**Risk category:** null safety, `!!` on external data. An `Intent` extra is external data — the value comes from whatever launched this Activity, which is not something this function verified in its own prior checks. `getStringExtra` is declared to return `String?` precisely because the extra may be absent or may be present but not a String. If the Activity is ever launched without `\"user_id\"` (a deep link, a system/OS relaunch after process death and task restore, a test, another caller that forgot to add it, a manifest-declared entry point), the `!!` throws `NullPointerException` in `onCreate` and the screen crashes on launch. The backend/UI contract that \"userId is always there\" is not a compiler-verified guarantee, and process-death restore in particular can reconstruct an Intent that this caller did not build.\n\n**Fix direction (not applied — this review is read-only):**\n- Safe call with an Elvis fallback and an explicit handling path:\n `val userId = intent.getStringExtra(\"user_id\") ?: run { finish(); return }`\n or navigate to an error state rather than crashing.\n- Or `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra missing\" }` if an absent extra is a genuine programming error — this still fails fast, but with a message that names the cause instead of a bare NPE, which is the minimum bar.\n- Consider whether the value should instead arrive through `SavedStateHandle` / a typed navigation argument if this Activity is reached via Navigation, which makes the contract explicit and checkable.\n\n**Introduced vs pre-existing:** this finding is diff-introduced — the line is added at the top of `onCreate`, so it is new in this change, not a pre-existing pattern merely touched.\n\n**Certainty:** the crash-on-missing-extra is certain from the code; the *frequency* with which the extra is actually missing depends on every caller and on process-death restore paths I can't see from this diff alone. If you want to confirm exposure, check the other launch sites for this Activity and any deep-link/`<intent-filter>` entry that could start it without `\"user_id\"`.",
1342
+ "outputSha256": "de959c3f6e85c6cd84c0d98e38352ea132a73e5679be4eb9301cc3cff3473493",
1343
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1344
+ "deterministic": [],
1345
+ "judge": {
1346
+ "verdict": "pass",
1347
+ "reason": "Answer names the exact line `intent.getStringExtra(\"user_id\")!!` (criterion 1). It explains getStringExtra returns String? and the extra is external data from whatever launched the Activity, so a missing extra (deep link, OS relaunch/process-death restore, test, other caller) throws NPE in onCreate (criterion 2). It gives concrete fixes: `?: run { finish(); return }` and `requireNotNull(...) { \"user_id extra missing\" }` (criterion 3). No fail criteria hold."
1348
+ },
1349
+ "passed": true
1350
+ },
1351
+ {
1352
+ "output": "`MainActivity.kt` (or whichever Activity) — `onCreate()` top — `intent.getStringExtra(\"user_id\")!!`\n\n**Risk category: null safety (forced unwrap on external data).**\n\n`getStringExtra` is declared to return `String?` — null when the key is absent, when the caller passed `null`, or when the value was stored under a different type. The `!!` asserts non-null on a value that arrives entirely from outside this Activity's control: whatever component launched it. An incoming Intent is an external contract, not a compiler-verified guarantee. If any code path (a deep link, a notification tap restoring the Activity, a test, a different caller, a config-change/process-death relaunch where the extra was never re-supplied) starts this Activity without that extra, this throws `NullPointerException` inside `onCreate` — the crash happens before the screen is usable, so the user sees an immediate crash rather than a degraded screen.\n\nTwo things make this worse than a generic null-deref:\n\n- `onCreate` runs on the main thread during Activity creation. A crash here is unrecoverable from the caller's perspective and shows the standard \"app has stopped\" dialog.\n- Restored-after-process-death is a real, non-hypothetical path: Android can recreate an Activity from a saved instance state without replaying the original Intent, so this can crash on a relaunch that worked fine the first time. If the workflow involves re-creation, the null check needs to be paired with a fallback, not just a bail-out.\n\n**Fix direction (do not apply — read-only review):**\n\n- Preferred: `val userId = intent.getStringExtra(\"user_id\")` then handle null explicitly — finish/finishAffinity, navigate to a recovery screen, or use a default. This forces the \"what happens when it's absent\" decision to be made instead of deferred to a crash.\n- If absence genuinely should be impossible by design: `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra is required to launch XActivity\" }`. Same crash, but the message tells you exactly what went wrong instead of a bare NPE stack trace. This is an improvement in diagnosability, not in safety — say so rather than treating it as a full fix.\n- If the key is ever consumed alongside other extras, cross-check the type: `getStringExtra` silently returns null if the sender stored it via `putExtra(key, someInt)`. A coercion mismatch looks identical to a missing key here.\n\n**Also worth a look in the same diff** (report separately from the above, and only if present):\n\n- Whether `intent` is read before or after `super.onCreate()` — lifecycle ordering matters; reading Intent extras before the base call is generally fine but flag if it interacts with `setContentView` or state restoration.\n- If the extracted `userId` then flows into a `ViewModel` or `savedStateHandle`, check that it is passed in exactly once and not re-read from `intent` on every recreation (the re-creation path above is where that bites).\n- Whether the same `!!` pattern was copy-pasted for other extras nearby — one finding per occurrence.\n\nNot reported here (out of scope for this skill): generic style nits that `ktlint`/`detekt`/lint already cover, and anything in the diff not actually touched by it. If a pre-existing `!!` on Intent data exists elsewhere in the file but this diff merely moved or reformatted around it, note it as pre-existing rather than diff-introduced.",
1353
+ "outputSha256": "52dcaf3768dbf12dadd26d225bbd715f7e0d0c41ad84424527501d511ace4978",
1354
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1355
+ "deterministic": [],
1356
+ "judge": {
1357
+ "verdict": "pass",
1358
+ "reason": "Names the exact line `intent.getStringExtra(\"user_id\")!!` in onCreate. Explains getStringExtra returns String?, extra is external/uncontrolled (deep link, notification restore, test, process-death relaunch) causing NPE in onCreate. Gives concrete fixes: plain getStringExtra with explicit null handling, or requireNotNull(...) { \"user_id extra is required...\" }. All criteria met."
1359
+ },
1360
+ "passed": true
1361
+ },
1362
+ {
1363
+ "output": "**Finding — forced null-unwrap on an external Intent extra**\n\n`<Activity>.kt:<line of onCreate>` — `val userId = intent.getStringExtra(\"user_id\")!!` reads an extra from the incoming `Intent`.\n\n**Why it matters:** an `Intent` extra is external data crossing a process/component boundary — it is not guaranteed by the compiler, and it is not guaranteed by the sending code either. Any of these will make `getStringExtra(\"user_id\")` return null and the `!!` then throw `NullPointerException` and crash the Activity:\n\n- The Activity is launched from a path that doesn't set the extra (a deep link, a notification tap, a launcher shortcut, an `adb`/instrumentation test, an \"up\" navigation that reconstructs the Activity, or another app invoking it).\n- The extra is set under a slightly different key, or on the wrong `Intent` (e.g. set on a new `Intent` that is never the one the Activity receives).\n- The extra is present but deserialized as null after a process death / saved-state restore path that re-reads a stale intent.\n- The Activity is exported (or becomes so later) and receives an `Intent` from another app that simply doesn't include the extra.\n\nBecause `onCreate` runs before the screen is usable, this isn't a degraded state — it's an immediate crash on launch. That is exactly the \"forced-unwrap on data this function did not itself validate\" case in Step 2's null-safety check.\n\n**Fix direction (not applied — read-only review):**\n\n- Prefer a safe call/Elvis with an explicit, deliberate fallback: e.g. `val userId = intent.getStringExtra(\"user_id\") ?: run { finish(); return }` or route to an error/redirect screen, deciding in code what a missing extra means for this Activity.\n- If the extra is genuinely a hard precondition, `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra is required to launch <Activity>\" }` still throws but fails fast with a diagnosable message instead of an anonymous NPE at the `!!`; back it with a manifest/`<intent-filter>` contract and a `IllegalArgumentException` catch at the launch site if that's acceptable for this screen.\n- Consider whether the Activity should be launched at all without a valid user id — an alternative is to keep the extra optional at the entry point and let a small guard/`LaunchedEffect`-free check decide redirect vs. content, so malformed launches never reach the body of `onCreate`.\n\n**Note on certainty and scope:** this is a definitive finding from reading the diff — `!!` on `getStringExtra` is an unconditional null-assert. It is *not* enough on its own to know how often the extra is actually missing (that depends on every caller). If you want to confirm the practical exposure, check the launcher(s) that start this Activity and any deep-link/external-entry paths, and run the flow through a \"launch with no extra\" case (e.g. via `adb shell am start` with the key omitted) to see whether the crash reproduces — but the fix direction stands regardless of that confirmation, because the null case is possible from at least one entry path.\n\n**Not flagged, to keep the review low-noise:** this is a null-safety finding only. I have not raised generic style nits, and I have not raised a coroutine/Compose/manifest issue here since none is visible in the snippet described; if the full diff also touches `AndroidManifest.xml` for this Activity (especially `android:exported`), that is a separate finding worth checking under the manifest/security step.",
1364
+ "outputSha256": "d61b7e5ef1dfc17f54871309ce2f9510c52d2f8d2709a45914b12bb6d360b9d8",
1365
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1366
+ "deterministic": [],
1367
+ "judge": {
1368
+ "verdict": "pass",
1369
+ "reason": "Answer names the exact line `val userId = intent.getStringExtra(\"user_id\")!!` (criterion 1). It explains the extra is external/nullable and a missing extra causes NPE crash in onCreate (criterion 2), citing deep links, notifications, adb tests, process-death restore. It gives concrete fixes: `?: run { finish(); return }` and `requireNotNull(...) { \"user_id extra is required...\" }` (criterion 3). No fail criteria hold."
1370
+ },
1371
+ "passed": true
1372
+ },
1373
+ {
1374
+ "output": "**Finding: `feature/.../MainActivity.kt:NN — `intent.getStringExtra(\"user_id\")!!``\n\nPattern: forced null-unwrap (`!!`) on a value sourced from outside this function's own prior checks — an `Intent` extra from the launching activity.\n\nWhy it matters: `getStringExtra` returns `String?` precisely because the extra is not guaranteed to be present or of the right type. The caller that launched this Activity is what decides whether `\"user_id\"` is populated, and that contract is not compiler-verified:\n- A deep link, a notification tap, a `PendingIntent`, a re-created Activity after process death / configuration change (Android re-delivers the *original* Intent), or any other entry point into this Activity may launch it without that extra.\n- External traffic: if this Activity is exported (check the manifest), another app can start it with an arbitrary Intent and no `\"user_id\"` at all — this turns a missing extra into an immediate `NullPointerException` crash reachable from outside your app.\n- The `NPE` fires in `onCreate()` before the UI is even built, so it surfaces as a hard crash on launch, not a recoverable error state.\n\nFix direction (do not apply — read-only review):\n- Decide the contract explicitly. If the extra is genuinely required for the Activity to be meaningful, fail fast and legibly with `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra missing\" }` — same \"crash early\" intent as `!!`, but with a message that names the cause instead of a bare NPE.\n- If a missing extra is a recoverable state (e.g. the screen can render a signed-out / empty view), take the safe path: `val userId = intent.getStringExtra(\"user_id\")` and branch on null, or `intent.getStringExtra(\"user_id\") ?: return finish()` if launching without it is simply invalid navigation.\n- If other app components can start this Activity, confirm the manifest entry's `android:exported` and whether an unguarded external launch is intended.\n\nSeverity: high when the Activity is reachable without the extra (exported component, deep link, notification, or post-process-death re-creation); otherwise still a crash-on-missing-input finding, just with a narrower trigger set.\n\nNote on diff scope: this is flagged as **introduced by this diff**. If the surrounding code elsewhere in `onCreate()` already handled a null `userId` and the diff swapped that for `!!`, call out that the diff *removed* an existing null-handling path — that is the more important point than the `!!` itself.",
1375
+ "outputSha256": "eeffd02e2a4be7e7129452ccede4e22ab7800c3667120f4230bbb32c8146a794",
1376
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1377
+ "deterministic": [],
1378
+ "judge": {
1379
+ "verdict": "pass",
1380
+ "reason": "Answer names the exact line `intent.getStringExtra(\"user_id\")!!` as the finding (criterion 1). It explains getStringExtra returns String? and the extra is Intent-sourced from outside the code's control, citing deep link, notification, PendingIntent, process-death re-creation, and exported external launch, causing an NPE in onCreate() (criterion 2). It gives concrete fixes: requireNotNull(...) { \"user_id extra missing\" }, or safe call with null branch / ?: return finish() (criterion 3). No fail criteria hold."
1381
+ },
1382
+ "passed": true
1383
+ },
1384
+ {
1385
+ "output": "`MainActivity.kt` (or whatever the Activity file is) — the `onCreate()` line with `intent.getStringExtra(\"user_id\")!!` — is worth flagging as a null-safety finding, and it's a diff-introduced one.\n\n**The pattern:** `!!` on a value sourced from outside the function's own prior checks. An `Intent` extra is exactly that category — it's supplied by whoever launched the Activity, not by anything this code verified.\n\n**Why it matters:** `getStringExtra` returns `String?` precisely because the extra may be absent — the Activity can be launched from a `Notification`, a deep link, a `PendingIntent`, a `startActivity` call from elsewhere in the app that forgot to attach it, an `adb am start`, or a restored task after process death. In any of those cases the `!!` throws `NullPointerException` immediately in `onCreate`, crashing the app on launch. That's the worst place for it: the user sees a hard crash with no screen having rendered. Deep links are the sharpest edge here, since the launching entity is a URL that any other app or link can craft.\n\n**Fix direction (not applied):** read it safely and decide explicitly what \"missing\" means for this Activity. Options, depending on intent:\n- `intent.getStringExtra(\"user_id\") ?: run { finish(); return }` — bail out gracefully when the extra is required.\n- `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra missing\" }` — if a crash is genuinely the correct behavior, make it a *deliberate*, self-documenting one with a message and a stack trace that names the missing extra, rather than a bare `!!` NPE.\n- If the value is actually optional to this screen, a safe call / Elvis with a default or `?.let { ... }`.\n\nThe distinction between the first two matters: `!!` hides the assumption, while `requireNotNull` with a message states it and makes the failure diagnosable.\n\n**One more thing to check while you're in this file:** if this `userId` is used to load user data or authorize an action, note whether the extra is trusted as-is. An Intent extra is caller-controlled, so if this Activity is `exported` or reachable via a deep link, a caller can pass an arbitrary `user_id`. That's a separate data-trust question from the null-safety one, and it's worth a second finding if the value feeds an auth/ownership decision — flag it, and check the manifest for the Activity's `exported` setting.\n\nI'd report it as, roughly:\n\n```\n<activity file>:<line> — intent.getStringExtra(\"user_id\")!! forced-unwrap\n on an Intent extra in onCreate. Risk: NPE + launch crash when the extra\n is absent (deep link, notification, adb, process restore). Fix direction:\n safe call/Elvis with explicit handling, or requireNotNull with a message.\n Pre-existing or diff-introduced: diff-introduced.\n```\n\nI'm asserting the NPE-on-absence behavior from reading the code; the claim that no upstream caller always supplies the extra would need confirming against the launch sites (`git grep` for `\"user_id\"`), since \"it's always set\" is an external contract, not something the compiler enforces.",
1386
+ "outputSha256": "d4a9b98957704713ffa200d0389f76ac2f7e1ea3db5ca4eadd9d02d541fd6577",
1387
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1388
+ "deterministic": [],
1389
+ "judge": {
1390
+ "verdict": "pass",
1391
+ "reason": "Names the line `intent.getStringExtra(\"user_id\")!!` in onCreate as the finding (criterion 1). Explains getStringExtra returns String? and the extra is caller-supplied (Notification, deep link, PendingIntent, adb, process restore), causing NPE crash in onCreate (criterion 2). Gives concrete fixes: `?: run { finish(); return }` and `requireNotNull(...) { \"user_id extra missing\" }` (criterion 3). No fail criteria hold."
1392
+ },
1393
+ "passed": true
1394
+ },
1395
+ {
1396
+ "output": "This is a textbook case of the \"forced null-unwrap on external data\" pattern from the null-safety focus list. Here's what I'd flag, in the format the review step calls for.\n\n```\nfeature/.../ProfileActivity.kt:<line> — intent.getStringExtra(\"user_id\")!!\n forced-unwrapped. Risk: crash (KotlinNullPointerException)\n if the extra is absent or the key/value is mistyped.\nFix direction: use a safe call or Elvis with an explicit fallback\n (finish() with a logged error, or navigate to an error state), or\n requireNotNull(...) { \"user_id extra missing\" } if a missing extra\n genuinely is a programming error you want to surface loudly.\n```\n\nWhy this specific instance is worth flagging rather than waving through:\n\n- **The Intent is external data.** Even though it's \"our own\" Activity that (usually) starts this one, the OS and other callers can deliver an Intent to this Activity without that extra — a deep link, a notification tap, a `PendingIntent`, an adb `am start`, a process-death restore, or a different call site that forgets to attach it. `getStringExtra` is declared to return `String?` precisely because the framework cannot guarantee the key is present.\n- **`!!` converts a recoverable data problem into a hard crash** at `onCreate()` time — the worst possible moment, since it's an unrecoverable `KotlinNullPointerException` with no graceful UI. The `requireNotNull` variant at least fails with a message that tells you *which* extra was missing; bare `!!` gives you a bare `NullPointerException` with no key name, which is exactly the kind of crash that's painful to diagnose from the field.\n- **It is diff-introduced, not pre-existing** — the diff added this line, so it's a finding to attribute to this change rather than \"touched but pre-existing.\"\n\nThings to check alongside it before calling the review done:\n\n- Read the rest of the changed `*.kt` file(s) in the diff — the skill's verification requires reading *every* changed Kotlin file, not just the one this line lives in. If the same `user_id` is read again later (e.g. in a Fragment, a ViewModel factory, or via `savedStateHandle`), the same pattern recurs and needs its own finding.\n- If this line was added because the Activity now *requires* the extra, the real fix may be at the call site too: whoever launches this Activity should be attaching the extra consistently. Note that as context for the fix direction, but don't edit anything.\n- If `AndroidManifest.xml` is in the same diff (e.g. the Activity was newly added or its `exported` flag changed), review that separately for the manifest-exposure check — an exported entry point that also `!!`-crashes on a missing extra is a compounded risk.\n\nNote on certainty: this isn't a \"confirm with a trace\" case — the crash path is provable by reading the code alone, so it's reported as a definite finding, not a suspected one.\n\nI'm not editing the file; this is findings and fix direction only, per the skill's read-only rule.",
1397
+ "outputSha256": "a39ba039b68ac1e2af0eb1da88a48a3da933f4ce1fee573de3b708e7365e065d",
1398
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1399
+ "deterministic": [],
1400
+ "judge": {
1401
+ "verdict": "pass",
1402
+ "reason": "Answer names the exact line `intent.getStringExtra(\"user_id\")!!` as the finding (criterion 1). It explains the extra is external/nullable — deep link, notification, PendingIntent, adb, process-death restore — and that `!!` causes a hard crash at onCreate() (criterion 2). It gives concrete fixes: safe call/Elvis with finish()/error state, or `requireNotNull(...) { \"user_id extra missing\" }` (criterion 3). No fail criteria hold."
1403
+ },
1404
+ "passed": true
1405
+ },
1406
+ {
1407
+ "output": "`MainActivity.kt:NN — intent.getStringExtra(\"user_id\")!!` forced-unwrap on an external Intent extra. This is the null-safety focus case: the value is sourced from outside the function's own prior checks (an incoming Intent from whatever launched this Activity), so `!!` will throw `NullPointerException` at Activity startup the moment the extra is absent or the key is misspelled, crashing the app before anything renders. `getStringExtra` is declared nullable precisely because the platform makes no guarantee the extra exists — \"always populated by the caller\" is an external contract, not something the compiler verifies, and deep links / process-death restore / other callers are common ways it becomes null.\n\nFix direction (not applied): use a safe call or Elvis with an explicit branch, e.g. `val userId = intent.getStringExtra(\"user_id\") ?: return` / `finish()` (or route to an error state), or `requireNotNull(intent.getStringExtra(\"user_id\")) { \"user_id extra missing\" }` if an immediate, clearly-messaged failure is genuinely the intended behavior for this Activity. Also worth confirming the key string is a shared constant rather than a literal duplicated between the sender and this receiver — a typo here is exactly the case the `!!` turns into a crash.\n\nPoints to note on scope and certainty:\n\n- Categorize as diff-introduced, not pre-existing, since the diff adds this line.\n- If the diff shows a corresponding `putExtra` at every call site with a non-null value, that narrows the risk but does not remove it — the Activity is still reachable by other launchers (deep links, tests, restored tasks). Report it as a finding with fix direction regardless; the finding stands on the external-source rule, not on proving a specific null path today.\n- No edits made — this is a report only, per the read-only constraint.\n\nNo other Step 2 categories (GlobalScope, Compose side effects, leaked mutable ViewModel state, manifest exposure) are implicated by the single line described; if the diff touches more of `onCreate()` or a manifest entry, those would be reviewed separately against their own focus list.",
1408
+ "outputSha256": "b556a5769412643a83ef90fd41905c8e47f8995997a0c9c7dbe971f44a79e5d2",
1409
+ "promptSha256": "6ca8c7ffad10c1e5ed3f93c9ca351499ffe425c4f6560147af154991b88da087",
1410
+ "deterministic": [],
1411
+ "judge": {
1412
+ "verdict": "pass",
1413
+ "reason": "Criterion 1: names `intent.getStringExtra(\"user_id\")!!` as the finding. Criterion 2: explains nullable getStringExtra, external Intent source, NPE at Activity startup, deep links/process-death restore. Criterion 3: concrete fixes `?: return`/`finish()` and `requireNotNull(...) { \"user_id extra missing\" }`. No fail criteria hold."
1414
+ },
1415
+ "passed": true
1416
+ }
1417
+ ]
1418
+ }
1419
+ ],
1420
+ "verdict": "fail",
1421
+ "scope": "bundled",
1422
+ "skillDigest": "21dc91b9d7c43c84e7e8b573819cd0541c7753c7029dfa2051614ee56cc1c65e",
1423
+ "catalogDigest": "97f9af01aafac82ae21a63c6af2a2f24fcfe067dc32a7cfdcde9a69a91fa9aae",
1424
+ "judgePromptVersion": "2026-09-25.1",
1425
+ "runner": "deepseek",
1426
+ "model": "deepseek-chat",
1427
+ "runnerPromptVersion": "2026-09-25.1",
1428
+ "recordedAt": "2026-09-25T18:31:53.961Z",
1429
+ "judge": "deepseek",
1430
+ "judgeModel": "deepseek-chat"
1431
+ },
1432
+ {
1433
+ "schemaVersion": "1.0.0",
1434
+ "skillId": "kotlin-android/kotlin-android-build-fix",
1435
+ "strictness": "high",
1436
+ "trials": 10,
1437
+ "triggerAccuracy": {
1438
+ "truePositive": 4,
1439
+ "falsePositive": 0,
1440
+ "positives": 7,
1441
+ "negatives": 7
1442
+ },
1443
+ "evidence": "authored",
1444
+ "scenarios": [
1445
+ {
1446
+ "id": "trigger-positive-1",
1447
+ "kind": "trigger-positive",
1448
+ "prompt": "Gradle sync is red after I touched this module's build file",
1449
+ "strictness": "high",
1450
+ "trials": 1,
1451
+ "passes": 1,
1452
+ "passRate": 1,
1453
+ "passAtK": 1,
1454
+ "grader": "trigger-rank-fork-family",
1455
+ "status": "ran",
1456
+ "deterministic": true
1457
+ },
1458
+ {
1459
+ "id": "trigger-positive-2",
1460
+ "kind": "trigger-positive",
1461
+ "prompt": "The build broke right after bumping a library version",
1462
+ "strictness": "high",
1463
+ "trials": 1,
1464
+ "passes": 1,
1465
+ "passRate": 1,
1466
+ "passAtK": 1,
1467
+ "grader": "trigger-rank-fork-family",
1468
+ "status": "ran",
1469
+ "deterministic": true
1470
+ },
1471
+ {
1472
+ "id": "trigger-positive-3",
1473
+ "kind": "trigger-positive",
1474
+ "prompt": "assembleDebug is failing and I can't tell why",
1475
+ "strictness": "high",
1476
+ "trials": 1,
1477
+ "passes": 0,
1478
+ "passRate": 0,
1479
+ "passAtK": 0,
1480
+ "grader": "trigger-rank-fork-family",
1481
+ "status": "ran",
1482
+ "deterministic": true
1483
+ },
1484
+ {
1485
+ "id": "trigger-positive-4",
1486
+ "kind": "trigger-positive",
1487
+ "prompt": "Something in this module won't compile since the merge",
1488
+ "strictness": "high",
1489
+ "trials": 1,
1490
+ "passes": 1,
1491
+ "passRate": 1,
1492
+ "passAtK": 1,
1493
+ "grader": "trigger-rank-fork-family",
1494
+ "status": "ran",
1495
+ "deterministic": true
1496
+ },
1497
+ {
1498
+ "id": "trigger-positive-5",
1499
+ "kind": "trigger-positive",
1500
+ "prompt": "CI is red on the Android job, can you sort it out",
1501
+ "strictness": "high",
1502
+ "trials": 1,
1503
+ "passes": 0,
1504
+ "passRate": 0,
1505
+ "passAtK": 0,
1506
+ "grader": "trigger-rank-fork-family",
1507
+ "status": "ran",
1508
+ "deterministic": true
1509
+ },
1510
+ {
1511
+ "id": "trigger-positive-6",
1512
+ "kind": "trigger-positive",
1513
+ "prompt": "This module's static analysis task is blocking the build",
1514
+ "strictness": "high",
1515
+ "trials": 1,
1516
+ "passes": 1,
1517
+ "passRate": 1,
1518
+ "passAtK": 1,
1519
+ "grader": "trigger-rank-fork-family",
1520
+ "status": "ran",
1521
+ "deterministic": true
1522
+ },
1523
+ {
1524
+ "id": "trigger-positive-7",
1525
+ "kind": "trigger-positive",
1526
+ "prompt": "The app won't build after pulling the latest changes",
1527
+ "strictness": "high",
1528
+ "trials": 1,
1529
+ "passes": 0,
1530
+ "passRate": 0,
1531
+ "passAtK": 0,
1532
+ "grader": "trigger-rank-fork-family",
1533
+ "status": "ran",
1534
+ "deterministic": true
1535
+ },
1536
+ {
1537
+ "id": "trigger-negative-1",
1538
+ "kind": "trigger-negative",
1539
+ "prompt": "Xcode build is failing after this Swift package bump",
1540
+ "strictness": "high",
1541
+ "trials": 1,
1542
+ "passes": 1,
1543
+ "passRate": 1,
1544
+ "passAtK": 1,
1545
+ "grader": "trigger-rank-fork-family",
1546
+ "status": "ran",
1547
+ "deterministic": true
1548
+ },
1549
+ {
1550
+ "id": "trigger-negative-2",
1551
+ "kind": "trigger-negative",
1552
+ "prompt": "Flutter's pub get is failing after this dependency change",
1553
+ "strictness": "high",
1554
+ "trials": 1,
1555
+ "passes": 1,
1556
+ "passRate": 1,
1557
+ "passAtK": 1,
1558
+ "grader": "trigger-rank-fork-family",
1559
+ "status": "ran",
1560
+ "deterministic": true
1561
+ },
1562
+ {
1563
+ "id": "trigger-negative-3",
1564
+ "kind": "trigger-negative",
1565
+ "prompt": "dotnet build is failing after this NuGet package update",
1566
+ "strictness": "high",
1567
+ "trials": 1,
1568
+ "passes": 1,
1569
+ "passRate": 1,
1570
+ "passAtK": 1,
1571
+ "grader": "trigger-rank-fork-family",
1572
+ "status": "ran",
1573
+ "deterministic": true
1574
+ },
1575
+ {
1576
+ "id": "trigger-negative-4",
1577
+ "kind": "trigger-negative",
1578
+ "prompt": "npm run build is failing after this dependency bump",
1579
+ "strictness": "high",
1580
+ "trials": 1,
1581
+ "passes": 1,
1582
+ "passRate": 1,
1583
+ "passAtK": 1,
1584
+ "grader": "trigger-rank-fork-family",
1585
+ "status": "ran",
1586
+ "deterministic": true
1587
+ },
1588
+ {
1589
+ "id": "trigger-negative-5",
1590
+ "kind": "trigger-negative",
1591
+ "prompt": "pip install is failing after this requirements.txt change",
1592
+ "strictness": "high",
1593
+ "trials": 1,
1594
+ "passes": 1,
1595
+ "passRate": 1,
1596
+ "passAtK": 1,
1597
+ "grader": "trigger-rank-fork-family",
1598
+ "status": "ran",
1599
+ "deterministic": true
1600
+ },
1601
+ {
1602
+ "id": "trigger-negative-6",
1603
+ "kind": "trigger-negative",
1604
+ "prompt": "Implement a new screen for the checkout flow",
1605
+ "strictness": "high",
1606
+ "trials": 1,
1607
+ "passes": 1,
1608
+ "passRate": 1,
1609
+ "passAtK": 1,
1610
+ "grader": "trigger-rank-fork-family",
1611
+ "status": "ran",
1612
+ "deterministic": true
1613
+ },
1614
+ {
1615
+ "id": "trigger-negative-7",
1616
+ "kind": "trigger-negative",
1617
+ "prompt": "Review this diff for goroutine-style leaks before merging",
1618
+ "strictness": "high",
1619
+ "trials": 1,
1620
+ "passes": 1,
1621
+ "passRate": 1,
1622
+ "passAtK": 1,
1623
+ "grader": "trigger-rank-fork-family",
1624
+ "status": "ran",
1625
+ "deterministic": true
1626
+ },
1627
+ {
1628
+ "id": "non-exhaustive-sealed-when",
1629
+ "kind": "behavior",
1630
+ "prompt": "After adding a new `ProfileUiState.RateLimited` variant to our sealed interface, the `when (state)` in ProfileScreen.kt no longer compiles because it's not exhaustive. What's the right way to fix it?",
1631
+ "strictness": "high",
1632
+ "trials": 10,
1633
+ "passes": 10,
1634
+ "passRate": 1,
1635
+ "passAtK": 1,
1636
+ "grader": "judge",
1637
+ "status": "ran",
1638
+ "trialRecords": [
1639
+ {
1640
+ "output": "Add an explicit branch for `ProfileUiState.RateLimited` — that is the whole point of the sealed interface. Something like:\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> { /* ... */ }\n is ProfileUiState.Content -> { /* ... */ }\n is ProfileUiState.Error -> { /* ... */ }\n is ProfileUiState.RateLimited -> { /* new branch: render the rate-limited UI */ }\n}\n```\n\nThe right fix has two parts:\n\n1. **Handle the new case for real.** Figure out what the screen should actually show when the user is rate-limited (a message with a retry-after countdown, a disabled action, a snackbar, etc.) and render that. If the state carries data the rest of the screen needs (e.g. `retryAfterSeconds`), use it.\n\n2. **Do not paper over it with `else`.** Adding `else -> {}` (or `else -> Unit`) silences the compiler but defeats the reason `ProfileUiState` is a sealed interface: the next new variant someone adds will compile fine and silently do nothing instead of failing the build and forcing a decision. That is exactly the bug a sealed `when` exists to prevent.\n\nIf, genuinely, several variants should collapse to the same UI, collapse the *known* cases explicitly:\n\n```kotlin\nis ProfileUiState.Loading,\nis ProfileUiState.RateLimited -> { /* shared treatment, if that's truly correct */ }\n```\n\n— but still no catch-all `else`.\n\nTwo things to watch:\n\n- If the `when` is an expression, the new branch must produce the right type (the compiler will tell you if it doesn't).\n- Check whether the helper that produces `ProfileUiState` (the ViewModel/reducer) actually emits `RateLimited` yet. Adding the variant without a producer leaves dead code; adding the branch without updating a `when` in the reducer that maps raw results to states may leave the state unreachable. Grep for other `when (state)` / `when (profileState)` sites in the module — every exhaustive `when` over the sealed type needs the same treatment, not just the one that happened to fail first.\n\nAfter the change, run `./gradlew assembleDebug` and, if configured, `./gradlew testDebugUnitTest` and the lint/ktlint/detekt tasks to confirm everything is green — and prefer covering the new branch with a test if the screen's state rendering is tested.",
1641
+ "outputSha256": "54c161bbe0f905eacc31a4aa5a01aa0b3267138f3bf865112dd8e059590b1d25",
1642
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1643
+ "deterministic": [],
1644
+ "judge": {
1645
+ "verdict": "pass",
1646
+ "reason": "Answer adds explicit `is ProfileUiState.RateLimited ->` branch (criterion 1), sketches concrete UI: 'message with a retry-after countdown, a disabled action, a snackbar' and using retryAfterSeconds (criterion 2), and explains else/catch-all would 'silence the compiler but defeat the reason... next new variant... silently do nothing' (criterion 3). No fail criteria met."
1647
+ },
1648
+ "passed": true
1649
+ },
1650
+ {
1651
+ "output": "Add a branch that actually handles `RateLimited` — that's the whole reason the state was modeled as a sealed interface: the compiler is telling you a real case exists that your UI doesn't render yet. Figure out what the screen should show when the user is rate-limited (a distinct message, a retry affordance, a cooldown indicator, whatever the design calls for) and add:\n\n```kotlin\nis ProfileUiState.RateLimited -> {\n // render the rate-limited state: message + retry, etc.\n}\n```\n\nThe tempting shortcut is `else -> {}` or `else -> Unit`, but that's exactly wrong here — it would compile now and silently swallow `RateLimited` (and any future variant) with no UI at all, which is the bug the sealed hierarchy was designed to make impossible. Don't do that.\n\nIf `ProfileUiState.RateLimited` carries data (a retry-after duration, a message), use it in the branch so the UI reflects the actual state rather than a generic placeholder.\n\nOnce the branch is added, verify with:\n\n```bash\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew lint\n```\n\nand re-run `./gradlew ktlintCheck`/`detekt` if they're configured. All must exit 0. If there's an existing test that asserts on the set of handled states or a screenshot/UI test for `ProfileScreen`, add the `RateLimited` case there too rather than deleting or skipping the failing test.\n\nRoot cause to report: \"Added a new sealed `ProfileUiState` variant but didn't handle it in `ProfileScreen`'s exhaustive `when`; added a branch rendering the rate-limited state.\" Not \"build now passes.\"",
1652
+ "outputSha256": "0be17e4491b2c67998b47940700779ca98e42e240ba68b4f01f4146488300e8d",
1653
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1654
+ "deterministic": [],
1655
+ "judge": {
1656
+ "verdict": "pass",
1657
+ "reason": "Answer says to add a dedicated `is ProfileUiState.RateLimited ->` branch (not else), sketches its content (rate-limited message + retry/cooldown, using retry-after data), and explains else->{} would silently swallow RateLimited and future variants, defeating the sealed hierarchy. No fail criteria met."
1658
+ },
1659
+ "passed": true
1660
+ },
1661
+ {
1662
+ "output": "Add the missing `ProfileUiState.RateLimited` branch, handling it as a real case alongside the others. Do not add an `else ->` catch-all.\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> LoadingUi()\n is ProfileUiState.Content -> ContentUi(state.profile)\n is ProfileUiState.Error -> ErrorUi(state.message)\n is ProfileUiState.RateLimited -> RateLimitedUi(state.retryAfterSeconds) // new branch\n}\n```\n\nWhy this and not the alternatives:\n\n- **Not `else -> {}` or `else -> Unit`.** The reason `ProfileUiState` is a sealed interface is precisely so that adding a variant forces every consumer to decide how to handle it at compile time. An `else` branch converts that compile error into a silent no-op: the screen would render nothing (or fall through) for a rate-limited user, which is exactly the bug the sealed type exists to prevent. This is called out in the skill's rules and red flags.\n\n- **Not a suppression or `lintOptions` change.** This is a Kotlin exhaustiveness error, not a lint finding — a suppression wouldn't even be the right lever, and disabling checks wouldn't make the `when` exhaustive.\n\n- **Not just leaving the error.** Fix it at this call site, and check whether other `when (state)` expressions over `ProfileUiState` exist (`grep` for the state type). Each one now needs the same real decision made — a UI-level one here, possibly a different one elsewhere (e.g. a ViewModel reducer). If several sites need the same behavior, extract a shared function rather than duplicating logic.\n\nWhat the `RateLimited` branch should actually do depends on your UI: typically show a rate-limit message and a retry affordance keyed off `retryAfterSeconds`. If you genuinely have no UI for it yet, the honest interim fix is still an explicit branch (e.g. render a generic \"try again shortly\" message) — never an `else` that discards the case.\n\nVerify:\n\n```bash\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew lint\n```\n\nAll three must exit 0. Then report the root cause in one sentence: \"Added the missing `ProfileUiState.RateLimited` branch to the `when` in ProfileScreen.kt (and any other exhaustive sites) so the new sealed variant is handled explicitly rather than swallowed by an `else`.\"",
1663
+ "outputSha256": "505a082cc12f9344a0934bb8845d275bf9feb70984bf58e62feb213d7a9d9065",
1664
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1665
+ "deterministic": [],
1666
+ "judge": {
1667
+ "verdict": "pass",
1668
+ "reason": "Answer adds a dedicated `is ProfileUiState.RateLimited -> RateLimitedUi(state.retryAfterSeconds)` branch (criterion 1), sketches rate-limit-specific UI: message + retry affordance keyed off retryAfterSeconds (criterion 2), and explains an else would silently swallow the case and future variants, defeating the sealed hierarchy's compile-time forcing (criterion 3). No else/catch-all or revert recommended."
1669
+ },
1670
+ "passed": true
1671
+ },
1672
+ {
1673
+ "output": "Add the missing branch for `ProfileUiState.RateLimited` and handle it explicitly — make the `when` exhaustive by covering the new case, not by dodging it.\n\nThe whole reason `ProfileUiState` is a sealed interface is so the compiler forces every consumer to decide what to do when a new state appears. That force is the feature. So:\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> { /* ... */ }\n is ProfileUiState.Success -> { /* ... */ }\n is ProfileUiState.Error -> { /* ... */ }\n is ProfileUiState.RateLimited -> {\n // show the rate-limit message / retry affordance,\n // using the retry-after/timestamp field RateLimited carries\n }\n}\n```\n\nDecide what `RateLimited` should actually render — a distinct message, a disabled retry button, a countdown — and wire that in. If `ProfileScreen` renders state through siblings (composable branches, a mapper, a `when` inside a ViewModel), fix every non-exhaustive `when` the new variant breaks, not just the first one the compiler reports.\n\nDo **not**:\n\n- Add an `else -> {}` (or `else -> Unit`) branch. That compiles today and silently swallows `RateLimited` — and every future variant — instead of surfacing it. It defeats the entire design of modeling this as a sealed interface.\n- Add `else -> null` / a default that routes to some existing branch \"just to get it green.\" Same problem: a genuinely new state now does something unintended with no compile-time signal.\n- Suppress the exhaustiveness error. It's not a lint finding; it's the compiler telling you there's a real unhandled case.\n\nAfter the fix, re-run:\n\n```bash\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew lint\n```\n\nand confirm the `when` is exhaustive without an `else` (the compiler will confirm — no error means every variant is covered). If any test asserted on the old set of states, update it to account for `RateLimited` rather than deleting or skipping it.\n\nOne sentence for the report: the `when` over `ProfileUiState` was non-exhaustive because the new `RateLimited` variant had no branch, and it's fixed by handling `RateLimited` explicitly — no catch-all `else`.",
1674
+ "outputSha256": "5d6a85fe97488b8654d053e359bbc6483dea2b4e70858d8302fd6bdeffcc7216",
1675
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1676
+ "deterministic": [],
1677
+ "judge": {
1678
+ "verdict": "pass",
1679
+ "reason": "Answer adds a dedicated `is ProfileUiState.RateLimited ->` branch (criterion 1), sketches its behavior: 'show the rate-limit message / retry affordance, using the retry-after/timestamp field' and 'distinct message, a disabled retry button, a countdown' (criterion 2), and explains else/catch-all would 'silently swallow RateLimited — and every future variant' defeating the sealed-interface design (criterion 3). No fail criteria met."
1680
+ },
1681
+ "passed": true
1682
+ },
1683
+ {
1684
+ "output": "Add the missing branch that actually handles `ProfileUiState.RateLimited` — that's what the sealed type's exhaustiveness is there to force. Concretely:\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> LoadingContent()\n is ProfileUiState.Success -> ProfileContent(state.profile)\n is ProfileUiState.Error -> ErrorContent(state.message)\n is ProfileUiState.RateLimited -> RateLimitedContent(state.retryAfterSeconds)\n}\n```\n\nWhat *not* to do:\n\n- **Don't add `else -> {}`.** A catch-all branch compiles the file but silently discards the new case at runtime, and it also silences exhaustiveness checking for every future variant — so the next added state will fail open instead of failing to compile. That defeats the whole reason the state is modeled as a sealed interface.\n- **Don't add `else -> LoadingContent()`** (or any default UI) as a stand-in. It papers over the missing case with behavior that has nothing to do with rate limiting.\n- **Don't suppress the compiler warning** or disable it project-wide.\n\nIf `RateLimited` legitimately should render the same thing as an existing case for now, still write the explicit branch and share the content composable — e.g. `is ProfileUiState.RateLimited -> ErrorContent(state.message)` — so the mapping stays intentional and future variants are still caught.\n\nThen verify: `./gradlew assembleDebug`, `./gradlew testDebugUnitTest`, and `./gradlew lint` (plus `ktlintCheck`/`detekt` if configured) all exit 0 before you call it done. Note the root cause in your report — a new sealed variant wasn't handled in the screen's `when` — not just \"build passes now.\"",
1685
+ "outputSha256": "776e0329a884fc6ea73328f33040e65a31bc3700e78269fba36ae2171cb88cc3",
1686
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1687
+ "deterministic": [],
1688
+ "judge": {
1689
+ "verdict": "pass",
1690
+ "reason": "Answer adds a dedicated `is ProfileUiState.RateLimited -> RateLimitedContent(state.retryAfterSeconds)` branch (criterion 1, 2), and explicitly warns against `else -> {}` explaining it silently discards the case and silences exhaustiveness for future variants, defeating the sealed hierarchy (criterion 3). No fail criteria met."
1691
+ },
1692
+ "passed": true
1693
+ },
1694
+ {
1695
+ "output": "Add the missing branch for `ProfileUiState.RateLimited` to the `when`, handling it explicitly — not a catch-all `else`.\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> { /* … */ }\n is ProfileUiState.Content -> { /* … */ }\n is ProfileUiState.Error -> { /* … */ }\n is ProfileUiState.RateLimited -> { /* render the rate-limited state */ }\n}\n```\n\nWhy this and not an `else`:\n\n- The whole point of modeling `ProfileUiState` as a sealed interface is that the compiler forces every new variant to be handled at each `when`. That is the safety net working as designed — the compile error is the feature, not an obstacle.\n- An `else -> {}` (or `else -> Unit`) branch makes the error go away but reintroduces exactly the bug the sealed hierarchy exists to prevent: `RateLimited` would silently render nothing (or fall through wherever the `else` points), and every *future* variant would do the same, with no compile error to catch it.\n\nSo the correct fix is:\n\n1. Add `is ProfileUiState.RateLimited -> { … }` as a real branch that renders the appropriate UI for that state (a rate-limit message, a retry affordance, whatever the design calls for).\n2. If `ProfileScreen` doesn't need a distinct visual for it yet and reusing an existing branch's UI is genuinely intended, route it explicitly — e.g. `is ProfileUiState.RateLimited -> RateLimitedContent()` or, if it truly should look identical to an existing case, `is ProfileUiState.RateLimited -> renderError(state)` — but name the variant in the branch so the exhaustiveness check still covers it.\n3. Rebuild and confirm `./gradlew assembleDebug`, `testDebugUnitTest`, and `lint` all pass; add a unit test or screenshot/Paparazzi test covering the new `RateLimited` rendering if the screen's states are tested elsewhere.\n\nConcretely, do not do any of these:\n\n- `else -> {}` or `else -> Unit` — defeats the sealed exhaustiveness check.\n- `else -> /* TODO */` with no real handling — same problem, just deferred.\n- `@Suppress(\"NON_EXHAUSTIVE_WHEN\")` or equivalent — there is no such suppression that's appropriate here; the non-exhaustive `when` is a real compile error, and suppressing it leaves `RateLimited` unhandled at runtime.\n\nThe one-sentence root cause for the report: adding `ProfileUiState.RateLimited` made the `when` in `ProfileScreen.kt` non-exhaustive, so the fix is to add the explicit `RateLimited` branch (rendering it properly) rather than an `else` that would silently swallow the new case.",
1696
+ "outputSha256": "5feeb5a97ba31d127e2821046462d82b1f931092af338f202794443aa18ee325",
1697
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1698
+ "deterministic": [],
1699
+ "judge": {
1700
+ "verdict": "pass",
1701
+ "reason": "Answer adds a dedicated `is ProfileUiState.RateLimited ->` branch (criterion 1), sketches its behavior as rendering a rate-limit message/retry affordance or RateLimitedContent() (criterion 2), and explains an else/catch-all would silently swallow RateLimited and future variants with no compile error (criterion 3). No else->{} recommendation or variant removal."
1702
+ },
1703
+ "passed": true
1704
+ },
1705
+ {
1706
+ "output": "Add the missing branch that actually handles `ProfileUiState.RateLimited` — don't paper over it with a catch-all.\n\nThe sealed hierarchy exists specifically so that adding a variant forces every `when` to be revisited. The compiler is doing its job. So make the `ProfileScreen` branch reflect what the UI should do when the profile is rate limited (show a \"try again in a moment\" state, disable the retry button, surface a countdown, whatever the product intent is):\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> { /* ... */ }\n is ProfileUiState.Content -> { /* ... */ }\n is ProfileUiState.Error -> { /* ... */ }\n is ProfileUiState.RateLimited -> {\n // render the rate-limited UI: message + retry disabled/cooldown\n }\n}\n```\n\nIf `ProfileScreen` has no meaningful way to render this state and it should never reach this screen, that's a signal, not a nuisance — either the state belongs somewhere it's actually handled, or the screen genuinely needs the branch. Decide which from the architecture, then implement that.\n\nWhat not to do:\n\n- **`else -> {}`** — this compiles but silently swallows `RateLimited` (and every future variant) with no UI and no error. It defeats the entire reason the type is sealed.\n- **A blanket `@Suppress(\"NON_EXHAUSTIVE_WHEN\")`** — turns off the exhaustiveness guarantee for that `when` going forward, so the next variant you add compiles silently too.\n- **Deleting the variant** to make the build green — you just added it, presumably because it's needed.\n\nThen verify the fix with the project's build/analysis tasks, e.g. `./gradlew assembleDebug`, `./gradlew lint`, and `./gradlew ktlintCheck`/`detekt` if configured, and confirm they all exit 0.\n\nThe one-sentence root cause for the report: the new `ProfileUiState.RateLimited` variant wasn't handled in `ProfileScreen`'s `when`, and it now needs a real branch rendering the rate-limited UI.",
1707
+ "outputSha256": "a723b587e20c595f2df68ae429def9839df3ad92d8664eceac1d3b92f801c28e",
1708
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1709
+ "deterministic": [],
1710
+ "judge": {
1711
+ "verdict": "pass",
1712
+ "reason": "Answer says add a dedicated branch for RateLimited, not a catch-all (\"Add the missing branch that actually handles ProfileUiState.RateLimited — don't paper over it with a catch-all\"). It sketches the branch: code shows `is ProfileUiState.RateLimited -> { // render the rate-limited UI: message + retry disabled/cooldown }` plus \"try again in a moment\" state. It explains why else is wrong: \"silently swallows RateLimited (and every future variant)... defeats the entire reason the type is sealed.\" No fail criteria met."
1713
+ },
1714
+ "passed": true
1715
+ },
1716
+ {
1717
+ "output": "Add the missing branch for `ProfileUiState.RateLimited` and render its actual UI — a rate-limited/retry message with whatever retry affordance the screen offers — the same way the other non-success states are handled.\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> LoadingIndicator()\n is ProfileUiState.Success -> ProfileContent(state.profile)\n is ProfileUiState.Error -> ErrorMessage(state.message)\n is ProfileUiState.RateLimited -> RateLimitedMessage(state.retryAfterSeconds) // new branch\n}\n```\n\nThe non-exhaustive `when` is the compiler doing its job: it's telling you a new state exists in the domain and every consumer must decide what to show for it. Handle it explicitly.\n\nWhat not to do:\n\n- **Don't add `else -> {}`.** That compiles, but it silently swallows `RateLimited` (and every future variant) into a do-nothing branch. A user who gets rate-limited would just see a blank/stale screen, and the next state you add to the sealed interface also gets swallowed with no compiler warning — which defeats the entire reason the type is sealed.\n- **Don't add `else -> LoadingIndicator()`** (or reuse another branch). Same problem: it silently mislabels a distinct state instead of deciding what it should render.\n- **Don't add a file-level or `when`-level `@Suppress`**, and don't loosen it into an `if/else` chain that drops exhaustiveness checking.\n\nIf `RateLimited` genuinely needs no distinct UI and should behave exactly like `Error` for now, that's still a deliberate product decision — write the explicit branch and delegate, so the mapping is visible and reviewable:\n\n```kotlin\nis ProfileUiState.RateLimited -> ErrorMessage(state.toUserMessage())\n```\n\nTwo things worth checking while you're here:\n\n1. If other `when (state)` expressions consume `ProfileUiState` (ViewModels, tests, previews), they'll fail to compile too — fix each one explicitly rather than only the screen the error pointed at.\n2. If `RateLimited` carries data (a retry-after duration, a message), handle that field in the branch rather than discarding it.",
1718
+ "outputSha256": "7b207051b81cac760a8417679de571bb979b4a261d0f0f3dae5a66c6e1e5e4ee",
1719
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1720
+ "deterministic": [],
1721
+ "judge": {
1722
+ "verdict": "pass",
1723
+ "reason": "Answer adds a dedicated `is ProfileUiState.RateLimited -> RateLimitedMessage(state.retryAfterSeconds)` branch (criterion 1, 2), and explicitly warns against `else -> {}` explaining it silently swallows RateLimited and future variants, defeating the sealed type's purpose (criterion 3). No fail criteria met."
1724
+ },
1725
+ "passed": true
1726
+ },
1727
+ {
1728
+ "output": "# Fixing a non-exhaustive `when` over a sealed interface\n\nAdd a real branch for `RateLimited` that handles the new state — don't paper over it.\n\n## The wrong move\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> LoadingScreen()\n is ProfileUiState.Content -> ContentScreen(state.profile)\n else -> {} // <-- swallows RateLimited silently\n}\n```\n\nThe whole point of modeling this as a `sealed interface` is that the compiler forces you to account for every state. An `else` branch defeats that: `RateLimited` now renders *nothing* — a blank screen — and the next new variant will compile too, silently doing nothing as well. The non-exhaustive error is the compiler doing its job, not an obstacle.\n\n## The right move\n\nWrite a branch that actually handles `RateLimited`. Since it's your new state, you know what it should look like — typically a message with a retry affordance:\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> LoadingScreen()\n is ProfileUiState.Content -> ContentScreen(state.profile)\n is ProfileUiState.RateLimited -> RateLimitedScreen(\n retryAfterSeconds = state.retryAfterSeconds,\n onRetry = { viewModel.retry() },\n )\n}\n```\n\nIf the state is meant to share rendering with an existing one (say `Error`), you can group branches with a comma so the compiler still checks exhaustiveness without an `else`:\n\n```kotlin\nwhen (state) {\n is ProfileUiState.Loading -> LoadingScreen()\n is ProfileUiState.Content -> ContentScreen(state.profile)\n is ProfileUiState.Error,\n is ProfileUiState.RateLimited -> MessageScreen(state.message, onRetry = { viewModel.retry() })\n}\n```\n\nNote this still enumerates `RateLimited` explicitly — it does not hide it behind `else`.\n\n## After the fix\n\nRun `./gradlew assembleDebug` and `./gradlew testDebugUnitTest`. Add or update a UI/state test asserting `RateLimited` renders its intended screen so a future variant can't regress to a blank render unnoticed.\n\n**Root cause in one sentence:** the `when` (or its surrounding usage) predates the new `RateLimited` variant, so the sealed exhaustiveness check correctly fails — the fix is a branch that renders the new state, not an `else -> {}` that discards it.",
1729
+ "outputSha256": "bd75a7b98485a38552a837572aaf1b7bfab3786ef8193e4c89097029a1829308",
1730
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1731
+ "deterministic": [],
1732
+ "judge": {
1733
+ "verdict": "pass",
1734
+ "reason": "Answer adds a dedicated `is ProfileUiState.RateLimited -> RateLimitedScreen(retryAfterSeconds=..., onRetry=...)` branch (criterion 1, 2), and explains an else branch 'swallows RateLimited silently' and lets future variants compile doing nothing (criterion 3). No fail criteria: it warns against else->{} and never suggests removing the variant."
1735
+ },
1736
+ "passed": true
1737
+ },
1738
+ {
1739
+ "output": "Add an explicit branch for `ProfileUiState.RateLimited` that handles the new case the way the screen actually should — e.g. `is ProfileUiState.RateLimited -> RateLimitedContent(retryAfter = state.retryAfter)` — rather than a catch-all `else -> {}`.\n\nThe non-exhaustive `when` is the compiler doing exactly what a sealed hierarchy is for: when a new variant is added, every consumer must decide how to render it. Fixing it means making that decision, not making the compiler stop asking.\n\nSpecifically:\n\n- **Don't add `else -> {}`.** That compiles, but it silently swallows `RateLimited` (and every future variant) into a no-op branch, so the screen renders nothing instead of surfacing the rate-limit state. It defeats the reason the state was modeled as sealed.\n- **Handle the case for real.** Give `RateLimited` its own branch with the UI it deserves. If the sealed type carries data on that variant (like `retryAfter`, an error message, or a request id), read it in the branch so the UI can show it.\n- **If the `when` is in a Composable, keep the branch side-effect-free** — render state, don't kick off work from inside the branch. If `RateLimited` should trigger a retry timer or a one-shot event, do that with a `LaunchedEffect(state)` keyed on the variant, not from within the `when` arm.\n- **If used as an expression (`val x = when (state) { ... }`),** every branch must yield a value; make the `RateLimited` branch produce whatever the expression is supposed to return for that state.\n- **If the same `when` exists in more than one place** (e.g. a preview, a mapper, a test helper), fix each one explicitly. A compiler error in one file often means sibling `when`s are about to break too — the exhaustive check is doing you a favor by pointing at all of them.\n\nThen verify:\n\n```bash\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew lint\n```\n\nand, if the project has them, `./gradlew ktlintCheck` / `./gradlew detekt`. All should exit 0.\n\nOne thing to watch: don't \"fix\" a different, unrelated error by widening the change. If fixing the `when` reveals another non-exhaustive `when` elsewhere, handle that one explicitly too rather than reaching for an `else`. And never delete or `@Ignore` a test that asserted the old set of states to get green — update it to cover `RateLimited` instead.",
1740
+ "outputSha256": "cd1b378d937abe6b85d105904d927e72647d42cd2039d791987a15703e6af1fc",
1741
+ "promptSha256": "6983094dff481317d221a6a19f6fb84019f0b4040619b0d5573198aadcb09831",
1742
+ "deterministic": [],
1743
+ "judge": {
1744
+ "verdict": "pass",
1745
+ "reason": "Answer explicitly says add a dedicated branch for RateLimited, not a catch-all else (criterion 1). It sketches concrete behavior: `is ProfileUiState.RateLimited -> RateLimitedContent(retryAfter = state.retryAfter)`, reading retryAfter to show rate-limit UI (criterion 2). It explains else->{} silently swallows RateLimited and future variants, defeating the sealed hierarchy's purpose (criterion 3). No fail criteria met."
1746
+ },
1747
+ "passed": true
1748
+ }
1749
+ ]
1750
+ },
1751
+ {
1752
+ "id": "detekt-suppress-vs-root-cause",
1753
+ "kind": "behavior",
1754
+ "prompt": "detekt is failing on a new `ProfileRepository.kt` file with a `TooGenericExceptionCaught` finding on a broad `catch (e: Exception)` block that just logs and returns a default value. How should this get fixed?",
1755
+ "strictness": "high",
1756
+ "trials": 10,
1757
+ "passes": 9,
1758
+ "passRate": 0.9,
1759
+ "passAtK": 1,
1760
+ "grader": "judge",
1761
+ "status": "ran",
1762
+ "trialRecords": [
1763
+ {
1764
+ "output": "Fix the catch so it's honest about what it can actually handle, rather than suppressing the rule. `TooGenericExceptionCaught` exists because `catch (e: Exception)` swallows programming errors (NPEs, `IllegalStateException` from a broken invariant, cancellation) into the same \"log and return default\" path as expected, recoverable failures — which hides real bugs and can swallow `CancellationException` in coroutine code.\n\nWalk through it in order:\n\n**1. Identify what can actually be thrown here.**\nLook at the body of the `try` block. Call by call, what checked/expected failure is this repository really guarding against? Typically for a repository that hits network or disk it's something like `IOException`, `HttpException`/`Retrofit`'s `HttpException`, a serialization error, or a domain-specific `Result`-style failure. Catch *those* — a `catch` list naming the concrete throwables the block can produce — and let genuinely unexpected exceptions propagate so they surface in crash reporting instead of being masked as a default value.\n\n```kotlin\nreturn try {\n api.fetchProfile(userId).toDomain()\n} catch (e: IOException) {\n log.warn(e) { \"Profile fetch failed for $userId\" }\n Profile.EMPTY\n} catch (e: HttpException) {\n log.warn(e) { \"Profile fetch returned ${e.code()} for $userId\" }\n Profile.EMPTY\n}\n```\n\nIf the block is in a `suspend` function, be aware that catching broad exceptions also catches `CancellationException` — another reason to narrow the catch and never swallow cancellation. If you must have a general fallback, rethrow cancellation explicitly rather than folding it into the default return.\n\n**2. If a broad catch is genuinely the intended boundary** (e.g. a top-level \"never let this crash the caller\" API surface), then the finding is legitimate but the *intent* needs to be explicit: catch the narrow set you understand, and if you truly need a catch-all, make it a single, justified, narrowly-scoped suppression at that one catch — with a comment stating why (e.g. \"repository boundary: callers must never see a raw exception; all failures map to Profile.EMPTY and are reported via `log`\"). Do not put `@Suppress(\"TooGenericExceptionCaught\")` at the file or class level, and do not disable the rule in `detekt.yml` project-wide.\n\n**3. Consider whether `Result`/sealed return fits better.**\nIf the method already maps failures to a default value, a `Result<Profile>` or a sealed result type often expresses \"this can fail recoverably\" more clearly than a caught exception, and removes the need for the catch entirely at the call site.\n\n**4. Verify.**\nRe-run `./gradlew detekt` (or `ktlintCheck`) and `./gradlew assembleDebug testDebugUnitTest`; all must pass. Confirm you changed only the catch behavior in `ProfileRepository.kt` and didn't touch unrelated files or loosen rule config.\n\nReport it as the root cause, not \"detekt is happy now\": e.g. \"TooGenericExceptionCaught was a real over-broad catch — narrowed it to the `IOException`/`HttpException` the fetch can actually throw and let unexpected exceptions propagate; detekt and unit tests pass.\"\n\nThe trap to avoid is the quick green: a file-level `@Suppress`, a detekt rule disable, or an `else`/catch-all that keeps swallowing everything. Any of those makes detekt pass while leaving the actual defect — exception masking and, in suspend code, cancellation swallowing — in place.",
1765
+ "outputSha256": "7c2699513ca0e2ecdc73e54513342ccd3a4b9d4e776d5fcbab913ab11e3f78ae",
1766
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1767
+ "deterministic": [],
1768
+ "judge": {
1769
+ "verdict": "pass",
1770
+ "reason": "Answer narrows catch to concrete types (IOException, HttpException) with code shown, explicitly rejects file/class-level @Suppress and detekt.yml disable, and targets the flagged catch block in ProfileRepository.kt. Step 2's narrowly-scoped suppression is conditional and warned against, not the recommended fix."
1771
+ },
1772
+ "passed": true
1773
+ },
1774
+ {
1775
+ "output": "`TooGenericExceptionCaught` is detekt telling you the `catch (e: Exception)` block can't distinguish which failures it actually handles — it silently swallows anything, including things like `CancellationException`, which should never be caught and logged like a normal error. The fix is to narrow the catch to the specific exception types the call can actually throw, not to suppress the rule.\n\n**Find the root cause first.** Open `ProfileRepository.kt` and look at what the `try` block actually calls. The catch should mirror that call's real failure modes — most often something like:\n\n```kotlin\ntry {\n api.fetchProfile(userId)\n} catch (e: IOException) {\n // network failure — safe to fall back\n log.warn(e) { \"profile fetch failed for $userId\" }\n return Profile.EMPTY\n} catch (e: HttpException) {\n // non-2xx response\n log.warn(e) { \"profile fetch returned ${e.code()} for $userId\" }\n return Profile.EMPTY\n}\n```\n\nIf the underlying call is a suspend function wrapping network work, make sure you are **not** catching `CancellationException` — in Kotlin coroutines that exception is how cancellation propagates, and catching it (which `catch (e: Exception)` does) breaks structured concurrency. If a broad catch is genuinely needed somewhere, rethrow it before handling anything else:\n\n```kotlin\n} catch (e: CancellationException) {\n throw e\n}\n```\n\n**If you cannot enumerate the exception types** because the call site is opaque, that is a signal to either (a) wrap the call site so it throws a typed, repository-specific exception, or (b) narrow to the closest meaningful supertype (`IOException`) rather than `Exception`.\n\n**What not to do:**\n- Don't add `@Suppress(\"TooGenericExceptionCaught\")` to the function or file — it hides the finding rather than fixing the swallowing behavior, and the next genuine bug in this block will be silently caught too.\n- Don't disable the rule in `detekt.yml` — that turns off the check for the whole project to silence one occurrence.\n- Don't keep `catch (e: Exception)` and just add a `TODO` comment; the finding is still live.\n\nAfter narrowing, verify:\n\n```bash\n./gradlew detekt\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n```\n\nAll must exit 0. In your report, state the root cause in one sentence, e.g.: *\"`catch (e: Exception)` in `ProfileRepository.fetch()` swallowed every failure including `CancellationException`; narrowed to the `IOException`/`HttpException` the API call actually throws, preserving cancellation.\"*",
1776
+ "outputSha256": "32675fec07d96c2a083bb06d028e641e90a9b464c9d43144ae40c983a84d07c8",
1777
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1778
+ "deterministic": [],
1779
+ "judge": {
1780
+ "verdict": "pass",
1781
+ "reason": "Answer states the catch should be narrowed to specific exception types the call can throw (IOException/HttpException example, not broad Exception), explicitly rejects @Suppress and detekt.yml disable as fixes, and targets the flagged catch block in ProfileRepository.kt. No fail criteria met."
1782
+ },
1783
+ "passed": true
1784
+ },
1785
+ {
1786
+ "output": "Fix the catch, not the rule. `TooGenericExceptionCaught` is flagging a real design smell: catching `Exception` also catches `OutOfMemoryError`-adjacent runtime faults you didn't intend to handle (actually `Error` is separate, but `Exception` still sweeps in `IllegalStateException`, `NullPointerException`, `IllegalArgumentException`, and any programming bug) and silently converts them into a default value, which hides real defects behind a \"handled\" path.\n\n**Workflow**\n\n1. **Reproduce and read the finding.** Run `./gradlew detekt` and note the exact line and the rule. Confirm it is `TooGenericExceptionCaught` on the broad catch, not something else in the same file.\n\n2. **Determine what can actually be thrown at that call site.** Look at the repository method body — the network/DB call the `try` wraps. That tells you the concrete, recoverable failure modes: e.g. `IOException`/`SocketTimeoutException` for a network fetch, `SQLiteException` for local storage, a domain-specific `HttpException`/`ApiException` for a non-2xx response, `SerializationException` for a parse failure. Those are the exceptions this code is *actually* equipped to recover from by returning a default.\n\n3. **Narrow the catch to the recoverable ones.** Replace `catch (e: Exception)` with the specific types the caller can meaningfully recover from, e.g.:\n\n```kotlin\ntry {\n api.fetchProfile(userId)\n} catch (e: IOException) {\n Log.w(TAG, \"Profile fetch failed for $userId\", e)\n defaultProfile\n} catch (e: ApiException) {\n Log.w(TAG, \"Profile API rejected request for $userId\", e)\n defaultProfile\n}\n```\n\nIf the recovery action is identical for several types, list them in one catch rather than collapsing back to `Exception`.\n\n4. **Let everything else propagate.** Anything outside those types (NPE, `IllegalStateException`, an unexpected `SerializationException` you didn't budget for) should crash or bubble up to a caller that decides — that is correct behavior, not a gap to plug.\n\n5. **Only as a last resort, a scoped suppression.** If the call site genuinely must swallow everything — say a best-effort telemetry/analytics call where failure truly is inconsequential — that is the one legitimate case. Then suppress narrowly at the statement with a reason, never file-wide:\n\n```kotlin\n@Suppress(\"TooGenericExceptionCaught\") // best-effort analytics; failure must never affect the app flow\n```\n\nand say so in the report. Do not disable the rule project-wide in `detekt.yml`.\n\n**Rules I am not allowed to violate here**\n\n- No blanket file-level `@Suppress`, no project-wide `detekt` rule disable in `detekt.yml` just to silence this one finding.\n- No catch-all replacement that discards the reason the finding was raised — narrowing to real recoverable types is the fix; adding an empty `catch (e: Exception) {}` with a different syntax is not.\n- If the block logs and returns a default, the default-return must be justified by the *specific* failure being recoverable; a `catch (e: Exception)` that returns a default is exactly the anti-pattern being flagged.\n\n**Verify**\n\n```bash\n./gradlew detekt\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew lint\n```\n\nAll exit 0. Add or update a unit test that asserts the default value is returned when the *specific* recoverable exception (e.g. `IOException`) is thrown from the wrapped call, and that the method does *not* swallow an unexpected one — that test is what proves the narrowing is behavioral, not cosmetic.\n\n**Report template**\n\n```\nFixed: TooGenericExceptionCaught in ProfileRepository.kt\n - Root cause: catch (e: Exception) swallowed every runtime fault, not just\n the recoverable network/storage failures the default value is meant for.\n - Narrowed to IOException + ApiException (the actual failure modes of the\n fetch call); all other exceptions now propagate.\n - ./gradlew detekt / assembleDebug / testDebugUnitTest / lint all pass.\n```\n\nState the root cause in one sentence — \"detekt passes now\" is not a report.",
1787
+ "outputSha256": "2766dbd2d1c5b08d17d7074a51dda5723818cf20f4dacf845b7a4611449ec34c",
1788
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1789
+ "deterministic": [],
1790
+ "judge": {
1791
+ "verdict": "pass",
1792
+ "reason": "Step 3 concretely narrows catch(e: Exception) to IOException/ApiException with code; step 5 and the rules section explicitly reject file-wide @Suppress and detekt.yml disable; the fix targets the flagged catch block in ProfileRepository.kt. The scoped last-resort suppression is framed as an exception, not the recommended fix."
1793
+ },
1794
+ "passed": true
1795
+ },
1796
+ {
1797
+ "output": "Fix the `catch` block itself, not the finding. `TooGenericExceptionCaught` exists to flag exactly this: a `catch (e: Exception)` that swallows everything and returns a default, which hides programmer errors (NPEs, illegal-state, `ClassCastException`) behind the same \"just log and move on\" path as a genuine, expected I/O failure.\n\n**Root-cause approach**\n\n1. **Narrow the catch to what the code actually expects to fail.** A profile repository typically wants to catch the specific recoverable failure — `IOException` (or the library's specific exception type, e.g. a `Retrofit`/`HttpException`, `SerializationException`, `SQLiteException`). Catch that specific type; let anything else propagate:\n\n ```kotlin\n val profile = try {\n profileApi.fetchProfile(userId)\n } catch (e: IOException) {\n log.warn(\"Failed to fetch profile for $userId, using cached/default\", e)\n Profile.default()\n }\n ```\n\n2. **If several distinct recoverable exceptions are genuinely expected**, catch each specifically (or a small set of leaf types) rather than widening back to `Exception`.\n\n3. **If a truly broad catch is unavoidable** at a real boundary — e.g. a top-level coroutine/worker boundary where the whole point is to prevent an uncaught exception from crashing the process — that is the one legitimate case for catching `Exception`, and it should carry a narrowly-scoped suppression with a reason at that single site:\n\n ```kotlin\n @Suppress(\"TooGenericExceptionCaught\") // top-level boundary: must not let any failure escape\n ```\n That is the last resort, justified in a comment, applied to the one statement — never a file-level suppression and never a project-wide `detekt.yml` rule disable.\n\n**What not to do**\n- Don't add `@Suppress(\"TooGenericExceptionCaught\")` at the top of `ProfileRepository.kt` — that hides every future instance of this finding in the file, including real ones.\n- Don't disable the rule in `detekt.yml` to silence this one occurrence.\n- Don't keep catching `Exception` just because \"log and return default\" is convenient — narrowing it is the actual fix.\n\n**Verify**: re-run `./gradlew detekt` (plus `assembleDebug`/`testDebugUnitTest`) and confirm all exit 0, and that no unrelated file was touched. Report it as: *root cause — a broad `catch (Exception)` swallowing non-recoverable failures; narrowed to the specific expected exception type so programmer errors propagate again.*",
1798
+ "outputSha256": "baef708d8ed1391471467ca87618dff3b65f1d18deab2c41614abb131044fc0c",
1799
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1800
+ "deterministic": [],
1801
+ "judge": {
1802
+ "verdict": "pass",
1803
+ "reason": "Answer states the catch should be narrowed to the specific expected type (IOException/HttpException etc.) with concrete code, explicitly rejects @Suppress at file top and detekt.yml disable as fixes, and targets the flagged catch block in ProfileRepository.kt. The narrow single-site suppression is only a justified last resort, not the recommended fix."
1804
+ },
1805
+ "passed": true
1806
+ },
1807
+ {
1808
+ "output": "Fix the actual root cause: narrow the catch to the specific exception(s) the call inside the block can actually throw, and make sure the \"return a default value\" behavior is a deliberate decision for that specific failure, not a blanket swallow-everything.\n\nConcretely, in `ProfileRepository.kt`:\n\n1. Look at what's inside the `try`. If it's a network/IO call, the realistic failure set is something like `IOException`, `SocketTimeoutException`, `HttpException` (or whatever the underlying client throws), plus possibly a JSON/serialization exception if you're parsing a response. Catch those, not `Exception`.\n\n```kotlin\nreturn try {\n api.getProfile(userId)\n} catch (e: IOException) {\n Log.w(TAG, \"Profile fetch failed for $userId\", e)\n Profile.EMPTY\n} catch (e: SerializationException) {\n Log.w(TAG, \"Malformed profile payload for $userId\", e)\n Profile.EMPTY\n}\n```\n\n2. If several distinct types genuinely share the same handling, a multi-catch via a common supertype is fine as long as that supertype is still narrower than `Exception` — e.g. catching `IOException` covers `SocketTimeoutException`, `ConnectException`, etc. Don't fall back to `Exception` just because there are a few cases.\n\n3. If the catch is wrapping code that can throw an arbitrary unchecked exception (a bad assumption to make, but sometimes true for a buggy third-party call), the correct fix is usually *not* to broaden the catch — it's to let the unexpected exception propagate and crash/log loudly, because a generic \"return default on any failure\" hides real bugs (NPEs, `IllegalStateException`, programming errors). Reserve the catch for genuinely expected, recoverable failures.\n\n4. Do **not** fix this by adding `@Suppress(\"TooGenericExceptionCaught\")` on the function or by disabling the rule in `detekt.yml`. That's the red-flag path: it silences the finding for every catch in the file/module and leaves the actual problem — a too-broad catch — in place. If, after narrowing, there's a single well-understood call site where a broad catch is truly the right call, a narrowly-scoped `@Suppress` with a one-line comment explaining why is the documented last resort, not the first move.\n\nThen verify:\n\n```bash\n./gradlew detekt\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n```\n\nAll must exit 0.\n\nIn the report, state the root cause in one sentence, e.g.:\n\n> Fixed: `ProfileRepository.fetchProfile` caught `Exception`, masking real failures as a default value; narrowed to the specific `IOException`/`SerializationException` the call can throw. `detekt`, `assembleDebug`, and `testDebugUnitTest` pass.\n\nOne caveat worth flagging in the report: if `Profile.EMPTY` is returned on failure and callers can't distinguish it from a real empty profile, that's a separate design smell outside detekt's scope — mention it, but don't expand the fix beyond what the finding requires unless asked.",
1809
+ "outputSha256": "4826b11a9f3a344b35b6229ced1bb7e5367830c161fabc798b1463ca4c46d17b",
1810
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1811
+ "deterministic": [],
1812
+ "judge": {
1813
+ "verdict": "pass",
1814
+ "reason": "Criterion 1: answer says 'narrow the catch to the specific exception(s) the call inside the block can actually throw' and shows concrete catch blocks (IOException, SerializationException) instead of Exception. Criterion 2: point 4 explicitly rejects @Suppress(\"TooGenericExceptionCaught\") and detekt.yml disable as 'the red-flag path'. Criterion 3: fix targets the flagged catch block in ProfileRepository.kt. No fail criteria hold."
1815
+ },
1816
+ "passed": true
1817
+ },
1818
+ {
1819
+ "output": "Here is how I'd approach this `TooGenericExceptionCaught` finding on `ProfileRepository.kt`, following the build-fix workflow.\n\n## What the finding actually means\n\n`TooGenericExceptionCaught` flags `catch (e: Exception)`. By itself that's not automatically wrong — but the flag is detekt telling you the catch is broad enough to hide the *specific* failure mode the surrounding code was written to handle. The fix isn't to make the finding go away; it's to make the catch match the failure the code actually needs to tolerate, and let everything else surface.\n\nA `catch (e: Exception)` block that \"logs and returns a default value\" is the classic shape that deserves a second look, because the default it returns can silently mask a real bug.\n\n## Step 1: Identify what the call site can actually throw\n\nBefore touching the catch, read the code it wraps. If it's a network/DB/DataStore-backed call, the realistic failure set is a small, nameable list — e.g. `IOException` (network/disk), a serialization exception, a `HttpException`/`ApiException` from the client, or a `CancellationException` if this is inside a coroutine. Narrow the catch to that list:\n\n```kotlin\ntry {\n remoteProfileSource.fetch(userId)\n} catch (e: IOException) {\n Log.w(TAG, \"Profile fetch failed, using cached default\", e)\n defaultProfile\n} catch (e: SerializationException) {\n Log.w(TAG, \"Malformed profile payload, using cached default\", e)\n defaultProfile\n}\n```\n\nThis fixes the root cause: the block now documents and handles exactly the failures it was written for.\n\n## Step 2: If this is a coroutine, cancellation matters\n\nIf `ProfileRepository` suspends, `CancellationException` must *not* be swallowed — catching it breaks structured concurrency. With the narrow catches above it propagates correctly (`CancellationException` isn't an `IOException`), which is a second reason the broad catch was actively harmful.\n\n## Step 3: If the broad catch is genuinely intended\n\nIf the caller really must never crash and there's no way to enumerate the throwables, keep the handling honest rather than suppressing the rule:\n\n- Catch the specific types you can name, and only if a genuinely unknown failure must be tolerated, rethrow anything you don't understand:\n\n```kotlin\n} catch (e: CancellationException) {\n throw e\n} catch (e: Exception) {\n Log.w(TAG, \"Unexpected profile fetch failure\", e)\n defaultProfile\n}\n```\n\n- Then scope a suppression to that single statement with a reason, not a file-wide `@Suppress`:\n\n```kotlin\n@Suppress(\"TooGenericExceptionCaught\") // boundary layer: any failure here degrades to the cached default by design\ncatch (e: Exception) { ... }\n```\n\nThis is the last resort, and it should be spelled out in the report — never a file-level `@Suppress` or a change to `detekt.yml` to disable the rule.\n\n## What not to do\n\n- Don't add `@Suppress(\"TooGenericExceptionCaught\")` at the top of the file (silences every such catch in the file).\n- Don't disable the rule in `detekt.yml` to clear this one occurrence.\n- Don't leave the broad catch in place untouched and disable the check — that's the rationalization the skill calls out directly.\n\n## Verification\n\nAfter the edit:\n\n```bash\n./gradlew detekt\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew lint\n```\n\nAll must exit 0. Add a unit test for the narrowed path — a test that forces the specific exception (e.g. a fake source throwing `IOException`) and asserts the default is returned — since the whole point is that the handled case is now explicit and provable.\n\n## Report shape\n\n```\nFixed: TooGenericExceptionCaught in ProfileRepository.kt\n - Root cause: catch (e: Exception) masked both expected fetch failures\n and genuine bugs; also would swallow CancellationException in a coroutine.\n - Narrowed to IOException + SerializationException; default returned for\n those, everything else propagates.\n - ./gradlew detekt/assembleDebug/testDebugUnitTest/lint all pass\n```\n\nOne sentence on the root cause — \"detekt passes now\" on its own doesn't tell a reviewer whether the fix was real or a suppression.",
1820
+ "outputSha256": "1bf8effcd91e99fded8328b38c06c199e36ce1a2b2a669247f4560430e569211",
1821
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1822
+ "deterministic": [],
1823
+ "judge": {
1824
+ "verdict": "pass",
1825
+ "reason": "Answer narrows catch to specific types (IOException, SerializationException) with concrete code, explicitly rejects file-level @Suppress and detekt.yml disable as fixes, and targets the flagged catch block. It does mention a scoped single-statement @Suppress as last resort, but explicitly warns against file-wide suppression and detekt.yml changes, so it does not recommend the fail-criteria anti-patterns."
1826
+ },
1827
+ "passed": true
1828
+ },
1829
+ {
1830
+ "output": "`TooGenericExceptionCaught` is detekt telling you the `catch (e: Exception)` is too broad to reason about — it will swallow `NullPointerException`, `IllegalStateException`, and any programming bug, not just the expected failure. The fix is to narrow the catch to the specific exceptions this call can actually throw for expected/recoverable failure modes, not to suppress the rule.\n\n**The workflow:**\n\n**Step 1 — classify.** This is a detekt finding. First read what the try block actually does. `ProfileRepository` almost certainly wraps a network or database call (Retrofit/OkHttp, Room, DataStore, etc.). The exceptions that can realistically be thrown for a *recoverable* failure are specific to that layer — e.g. `IOException` (network), `HttpException` (non-2xx response), `JsonParseException`/`SerializationException` (deserialization), `SQLiteException` (Room). Those are the ones you handle. Anything else is a bug and should propagate.\n\n**Step 2 — fix by category.** Narrow the catch to the concrete exceptions the operation can throw, handle each with intent, and let genuinely unexpected exceptions propagate so they surface in crash reporting instead of silently returning a default:\n\n```kotlin\nsuspend fun getProfile(userId: String): Profile {\n return try {\n api.fetchProfile(userId)\n } catch (e: IOException) {\n // transient network failure — caller can retry\n Log.w(TAG, \"Network failure fetching profile for $userId\", e)\n Profile.EMPTY\n } catch (e: HttpException) {\n // server responded with an error status\n Log.w(TAG, \"Server error ${e.code()} fetching profile\", e)\n Profile.EMPTY\n }\n}\n```\n\nIf several unrelated specific exceptions really do share one recovery path, a multi-catch is the honest way to express that — Kotlin doesn't have multi-catch syntax, so catch each specific type and delegate to one shared handler, or catch the narrowest common supertype that is still *specific* (e.g. `IOException`, not `Exception`).\n\nThree things to scrutinize while you're in here:\n\n- **Is \"log and return a default value\" actually correct?** A silent `Profile.EMPTY` on failure hides the problem from every caller. If callers need to distinguish \"no profile\" from \"couldn't load,\" a `Result<Profile>`/sealed result type is the better fix — that's a design question to raise in the report, not something to assume is fine.\n- **Is the default value genuinely safe?** Returning `null`/empty where a caller assumes a populated object is a common source of downstream bugs; make sure the recovery value can't be mistaken for real data.\n- **Don't reach for the last-resort suppression.** A file-level `@Suppress(\"TooGenericExceptionCaught\")` (or a `detekt.yml` rule disable) would hide the same finding for every future occurrence in the file/module. If a specific catch genuinely must stay broad because the framework throws arbitrary types, scope the suppression to that single call site with a comment explaining *why* — never blanket-disable.\n\n**Step 3 — verify:**\n\n```bash\n./gradlew detekt\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n```\n\nAll exit 0. If you narrowed the catch set, add or confirm a test that exercises the recovered-from path (e.g. a faked `IOException` from the API returning `Profile.EMPTY`) so the new behavior is pinned.\n\n**Step 4 — report** the root cause in one sentence, e.g.:\n\n```\nFixed: TooGenericExceptionCaught in ProfileRepository.kt\n - Root cause: getProfile caught (e: Exception), swallowing programming\n errors alongside expected network failures\n - Narrowed to IOException/HttpException (the recoverable cases) with a\n shared handler; unexpected exceptions now propagate to crash reporting\n - detekt/assembleDebug/testDebugUnitTest all pass\n```\n\nNever resolve this by disabling the rule or slapping `@Suppress` on the file — that defeats the check that's doing its job.",
1831
+ "outputSha256": "25ea4550c61a176e7d34d2e3a98ecd7f0dc872728bbaa5d90d8e26a065ff48c9",
1832
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1833
+ "deterministic": [],
1834
+ "judge": {
1835
+ "verdict": "pass",
1836
+ "reason": "Answer narrows catch to concrete types (IOException, HttpException) with code shown, explicitly rejects @Suppress/detekt.yml disable as the fix ('Never resolve this by disabling the rule or slapping @Suppress'), and targets the flagged getProfile catch block. Minor: it allows a scoped single-call-site suppression as last resort, but the primary fix is narrowing, not suppression."
1837
+ },
1838
+ "passed": true
1839
+ },
1840
+ {
1841
+ "output": "Fix the underlying gap the rule is pointing at, not the rule. A `catch (e: Exception)` that logs and returns a default value is exactly what `TooGenericExceptionCaught` exists to flag: it treats every possible failure — a cancelled coroutine, a programming bug like an NPE or `IllegalStateException`, `OutOfMemoryError`'s cousins, an IO failure — as the same \"return a default\" case.\n\n**Root cause:** the catch is scoped to `Exception` because the author didn't decide which failures the repository can actually recover from. The fix is to make that decision explicit.\n\n**What to do, in order:**\n\n1. **Look at what the called code actually throws.** If `ProfileRepository` wraps a network/DB call, the recoverable failures are a small, known set — an HTTP/IO failure (`IOException`/a Retrofit `HttpException`/a serialization error), or a domain-specific error like a \"not found\" result. Catch those specific types, log each, and return the default for them:\n ```kotlin\n try {\n return remote.fetchProfile(id)\n } catch (e: IOException) {\n log.warn(\"profile fetch failed for $id\", e)\n return cachedProfileOrDefault(id)\n } catch (e: HttpException) {\n log.warn(\"profile fetch returned ${e.code()} for $id\", e)\n return cachedProfileOrDefault(id)\n }\n ```\n A specific catch makes the recovery policy readable and lets genuinely unexpected exceptions propagate to whatever Crashlytics/coroutine handler is meant to see them.\n\n2. **Check the concurrency case.** If this runs inside a coroutine, a broad `catch (e: Exception)` will also swallow `CancellationException`, which breaks structured concurrency — the coroutine looks like it \"recovered\" instead of cancelling. If cancellation really can arrive here, `catch (e: CancellationException) { throw e }` (or catching only non-cancellation types) is the correct move, and it's a real bug fix, not a style nicety.\n\n3. **If a catch-all genuinely is intended** — say this is a top-level boundary that must never let anything escape, like an application-level error handler — then a broad catch can be legitimate, but it should be narrow in scope and said out loud. In that case use a targeted `@Suppress(\"TooGenericExceptionCaught\")` on the single function with a comment explaining why the boundary must be total, rather than disabling the rule for the file or module.\n\n**What not to do:**\n- Don't add `@Suppress(\"TooGenericExceptionCaught\")` at the top of the file — that silences the check for every future catch in the file.\n- Don't disable the rule in `detekt.yml` — that turns it off project-wide for one occurrence.\n- Don't keep the broad catch and just swap the log line — the rule is about the recovery policy, not the logging.\n\n**Verify:** re-run `./gradlew detekt` (and `./gradlew assembleDebug`/`testDebugUnitTest` if the surrounding code changed), confirm it exits 0, and confirm the new catch set actually covers the failure modes the called API documents. If you added a suppression, it must be the single-site, commented, last-resort kind — and the report should say which failure types now propagate instead of returning a default, since that's a behavior change worth calling out explicitly.",
1842
+ "outputSha256": "c76ce7a3a7e246432c9afa1c310311f605702f74f4b8f7988d6779ea2205431c",
1843
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1844
+ "deterministic": [],
1845
+ "judge": {
1846
+ "verdict": "fail",
1847
+ "reason": "Pass criteria 1 and 2 hold (narrows to IOException/HttpException/CancellationException; rejects file-level @Suppress and detekt.yml disable). But step 3 explicitly recommends a targeted @Suppress(\"TooGenericExceptionCaught\") on the function as a legitimate fix, which is fail criterion 1 — recommending a suppression to make the finding disappear."
1848
+ },
1849
+ "passed": false
1850
+ },
1851
+ {
1852
+ "output": "`TooGenericExceptionCaught` is detekt telling you that catching `Exception` swallows everything indiscriminately — including bugs (a `NullPointerException`, an `IllegalStateException` from a bad assumption) that this code almost certainly didn't intend to treat as a normal, recoverable repository error. The fix is at the call site, not in the rule config.\n\nStart by asking what this block is actually for. A repository boundary is often where a specific set of *expected* failures should be caught and mapped to a default/empty result. So the shape of the fix is usually one of these:\n\n**1. Narrow the catch to the exceptions that are genuinely expected here.**\n\n```kotlin\ntry {\n profileApi.fetch(id)\n} catch (e: IOException) {\n // network/server failure — an expected, recoverable outcome for this call\n log.w(TAG, \"Failed to fetch profile $id\", e)\n defaultProfile\n}\n```\n\nIf the underlying call can fail in more than one specific way, catch each by name (`IOException`, a domain-specific `ProfileNotFoundException`, etc.) rather than reaching for the shared supertype. That single change is what satisfies the rule *and* is what it's asking for.\n\n**2. If the broad catch is load-bearing, prove it — don't assume it.**\n\nOnly widen back toward `Exception` (or `Throwable`) if you can state, in the code and in your PR description, exactly which additional failure modes must be treated as recoverable and why. If, say, a JSON deserialization library throws a grab-bag of runtime exceptions that all mean \"bad payload,\" consider catching those specific types, or converting them to one domain exception at the layer that talks to the library, so this catch can stay narrow.\n\n**3. Preserve what the block actually did.**\n\nThe current block logs and returns a default. The narrowed version must keep both behaviors — don't turn a recovered failure into a propagated crash, and don't turn it into a silent default with the logging dropped.\n\n**What not to do:**\n\n- Do not add `@Suppress(\"TooGenericExceptionCaught\")` on the file or the function. The finding is correct; suppressing it hides the next overly-broad catch too.\n- Do not disable the rule in `detekt.yml`. That turns off the check for the whole codebase to accommodate one file.\n- Do not silently change behavior while narrowing — e.g., letting an exception that used to be caught now propagate, without calling that out as an intentional change in your report.\n\n**Verify before calling it done:**\n\n```bash\n./gradlew assembleDebug\n./gradlew testDebugUnitTest\n./gradlew detekt\n```\n\nAll must exit 0. If there's a unit test covering the failure path, make sure it still asserts the default value comes back and the failure gets logged — and if there wasn't one, add a test for the specific exception you now catch, since narrowing the catch changed the contract of what this function survives.\n\nReport it as a root cause, not a green check: \"Narrowed `catch (e: Exception)` in `ProfileRepository.fetchProfile` to `catch (e: IOException)`, the only failure mode this call is expected to recover from; logging and default-value behavior preserved; detekt/assembleDebug/testDebugUnitTest pass.\"",
1853
+ "outputSha256": "e85f52cdb431fa45bb4932dde98d77b87319abab5d2303aeb352390a3d72789f",
1854
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1855
+ "deterministic": [],
1856
+ "judge": {
1857
+ "verdict": "pass",
1858
+ "reason": "Answer states the fix is to narrow the catch to specific expected exceptions (e.g. catch (e: IOException)) instead of broad Exception, explicitly rejects @Suppress(\"TooGenericExceptionCaught\") and detekt.yml disable, and targets the flagged catch block in ProfileRepository.fetchProfile while preserving logging/default behavior."
1859
+ },
1860
+ "passed": true
1861
+ },
1862
+ {
1863
+ "output": "`TooGenericExceptionCaught` is detekt telling you the catch is too broad to reason about: `catch (e: Exception)` swallows *every* failure mode the guarded block can produce, including ones the caller should hear about (a programming error like `IllegalStateException`, a cancellation like `CancellationException`). \"Just logs and returns a default value\" is the exact case the rule exists to catch — the default return makes the failure invisible to everyone upstream, and depending on what's inside the `try`, could even swallow Kotlin coroutine cancellation. So the fix is to tighten the catch to the exception types this code actually knows how to recover from, and let everything else propagate.\n\n**Recommended fix: narrow to the specific recoverable types**\n\nLook at what's inside the `try` — a network call, a `Room`/DB read, a file/JSON parse — and catch only the failure types that call for the default-value fallback, e.g.:\n\n```kotlin\nsuspend fun getProfile(userId: String): Profile {\n return try {\n api.fetchProfile(userId).toDomain()\n } catch (e: IOException) { // network failure -> default is intentional\n defaultProfile()\n } catch (e: SerializationException) { // malformed payload -> default is intentional\n log.warn(\"getProfile: falling back to default for $userId\", e)\n defaultProfile()\n }\n}\n```\n\nAnything else — an `IllegalStateException` from a genuine bug, a `CancellationException` — now propagates instead of being silently converted into a default profile, which is what you want: the broad catch was actively hiding bugs.\n\n**If the caller genuinely should treat \"operation failed\" as one outcome**\n\nModel it instead of swallowing it — return a `Result<Profile>`/sealed `ProfileResult` and let the caller decide on the fallback:\n\n```kotlin\nsuspend fun getProfile(userId: String): Result<Profile> = runCatching {\n api.fetchProfile(userId).toDomain()\n}\n```\n\nThis is only a fix if the catch site's *job* is to translate failure into an outcome rather than hide it — and note `runCatching` also catches `CancellationException`, so if this is suspend code on a cancellable path, use `runCatching { ... }.onFailure { if (it is CancellationException) throw it }` or a hand-written catch, not a bare `runCatching`.\n\n**What not to do**\n\n- Do not add `@Suppress(\"TooGenericExceptionCaught\")` on the function or file. That turns the rule off for every catch in the file, not just this one, and leaves the actual swallowed-failure bug in place — exactly the red flag this skill exists to prevent.\n- Do not disable the `TooGenericExceptionCaught` rule in `detekt.yml` to make this build green without discussing it in the report.\n- Do not \"fix\" it by keeping `catch (e: Exception)` and just adding a rethrow of `CancellationException` — that addresses one class of swallowed failure while still hiding every other programming error behind the default return.\n\n**Do not report this done until:**\n\n- `./gradlew detekt` exits 0 for this module, and `./gradlew assembleDebug`, `./gradlew testDebugUnitTest`, and `./gradlew lint` still pass.\n- The catch now names only the exception types whose failure genuinely maps to the default value; nothing else in `ProfileRepository.kt` changed.\n- If a narrowed catch changes behavior (a failure that used to return a default now surfaces as an exception), add or update a unit test asserting the new propagation, rather than deleting/skipping an existing test to stay green.\n- The report states the root cause in one sentence, e.g.: *\"`TooGenericExceptionCaught` on `getProfile`: `catch (e: Exception)` converted every failure — including programming errors and coroutine cancellation — into a default profile; narrowed to `IOException`/`SerializationException` so only recoverable failures fall back and the rest propagate. detekt/assembleDebug/testDebugUnitTest/lint all pass.\"*",
1864
+ "outputSha256": "1d83ae1f05d7608cb92ef37e27337d6c94cb720acfce487ed36d3d24762486d8",
1865
+ "promptSha256": "322945bb6e75583a4abb809c460b54d923f841625b0a693aa1a9561bc7fd6f37",
1866
+ "deterministic": [],
1867
+ "judge": {
1868
+ "verdict": "pass",
1869
+ "reason": "Answer narrows the catch to specific types (IOException, SerializationException) with concrete code, explicitly rejects @Suppress(\"TooGenericExceptionCaught\") and detekt.yml disable as fixes, and targets the flagged getProfile catch block in ProfileRepository.kt. No fail criteria met."
1870
+ },
1871
+ "passed": true
1872
+ }
1873
+ ]
1874
+ }
1875
+ ],
1876
+ "verdict": "fail",
1877
+ "scope": "bundled",
1878
+ "skillDigest": "f635f45b88d72eec78b5b2be5713d1439d47637b699357d8eb1a4ae935d8a5c9",
1879
+ "catalogDigest": "97f9af01aafac82ae21a63c6af2a2f24fcfe067dc32a7cfdcde9a69a91fa9aae",
1880
+ "judgePromptVersion": "2026-09-25.1",
1881
+ "runner": "deepseek",
1882
+ "model": "deepseek-chat",
1883
+ "runnerPromptVersion": "2026-09-25.1",
1884
+ "recordedAt": "2026-09-25T18:33:51.196Z",
1885
+ "judge": "deepseek",
1886
+ "judgeModel": "deepseek-chat"
1887
+ }
1888
+ ]
1889
+ }