Skip to content

Commit 01a057f

Browse files
committed
bugfix: scope test failure detection to known test runner commands and enforce single-lock-per-session
1 parent 93c74f5 commit 01a057f

29 files changed

Lines changed: 3923 additions & 2834 deletions

.cat/issues/v2/v2.1/fix-post-bash-hook-test-failure-false-positive/PLAN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ Explicitly exclude commands or output that are clearly diffs:
5959
- Don't pattern-match on `git diff` output
6060
- Don't pattern-match on rendered diff output from tools like `get-output get-diff`
6161

62+
## Assumptions
63+
64+
- A session holds at most one issue lock at a time. `EnforceWorktreePathIsolation` uses the
65+
session's single lock file to identify the active worktree path.
66+
6267
## Scope
6368

6469
### In scope
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# State
22

3-
- **Status:** open
4-
- **Resolution:** unresolved
5-
- **Progress:** 0%
3+
- **Status:** closed
4+
- **Resolution:** implemented
5+
- **Progress:** 100%
66
- **Dependencies:** []
77
- **Blocks:** []
8-
- **Target Branch:** v2.1
8+
- **Target Branch:** v2.1

.cat/retrospectives/mistakes-2026-03.json

Lines changed: 2806 additions & 2407 deletions
Large diffs are not rendered by default.

.claude/rules/java.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,112 @@ else
275275
String command = commandNode != null ? commandNode.asString() : "";
276276
```
277277

278+
### Optional Unwrapping
279+
When an `Optional` is used only once — immediately in a single method chain like `map().orElse()` or `isPresent()` — inline the constructor call rather than storing it in a named variable:
280+
281+
```java
282+
// Good — no intermediate variable needed
283+
Path branchDir = WorktreeContext.forSession(
284+
scope.getCatWorkPath(), projectPath, scope.getJsonMapper(), sessionId).
285+
map(WorktreeContext::absoluteWorktreePath).
286+
orElse(projectPath);
287+
288+
// Avoid — intermediate variable adds no information
289+
Optional<WorktreeContext> context = WorktreeContext.forSession(
290+
scope.getCatWorkPath(), projectPath, scope.getJsonMapper(), sessionId);
291+
Path branchDir = context.map(WorktreeContext::absoluteWorktreePath).orElse(projectPath);
292+
```
293+
294+
When extracting a value from an `Optional` with a fallback, use `map().orElse()` instead of an `if (isPresent()) / get()`
295+
block:
296+
297+
```java
298+
// Good - concise, idiomatic
299+
return context.map(WorktreeContext::absoluteWorktreePath).orElse(projectPath);
300+
301+
// Avoid - verbose, error-prone
302+
if (context.isPresent())
303+
return context.get().absoluteWorktreePath();
304+
return projectPath;
305+
```
306+
307+
Branch on an `Optional` to select a **value**, not a code path. When both branches perform the same operation on
308+
different values, extract the `Optional` to a single variable and call the operation once:
309+
310+
```java
311+
// Good — extract the path, call the operation once
312+
Path branchDir = context.map(WorktreeContext::absoluteWorktreePath).orElse(projectPath);
313+
try
314+
{
315+
return GitCommands.getCurrentBranch(branchDir.toString());
316+
}
317+
catch (IOException _)
318+
{
319+
return null;
320+
}
321+
322+
// Avoid — same operation duplicated in both branches
323+
if (context.isEmpty())
324+
{
325+
try
326+
{
327+
return GitCommands.getCurrentBranch(projectPath.toString());
328+
}
329+
catch (IOException _)
330+
{
331+
return null;
332+
}
333+
}
334+
try
335+
{
336+
return GitCommands.getCurrentBranch(context.get().absoluteWorktreePath().toString());
337+
}
338+
catch (IOException _)
339+
{
340+
return null;
341+
}
342+
```
343+
344+
When only the **empty** case has work to do, return early on `isPresent()` rather than wrapping the work in an
345+
`isEmpty()` block. This avoids a gratuitous level of nesting:
346+
347+
```java
348+
// Good — early return eliminates nesting
349+
if (context.isPresent())
350+
return null;
351+
String target = extractCheckoutTarget(command);
352+
if (!isCheckoutFlag(target))
353+
return Result.block("Blocked: Cannot checkout '%s' in main worktree.".formatted(target));
354+
return null;
355+
356+
// Avoid — work buried inside isEmpty() block
357+
if (context.isEmpty())
358+
{
359+
String target = extractCheckoutTarget(command);
360+
if (!isCheckoutFlag(target))
361+
return Result.block("Blocked: Cannot checkout '%s' in main worktree.".formatted(target));
362+
}
363+
return null;
364+
```
365+
366+
When the present-case requires multiple statements (a block body), extract the value using `orElse(null)` and test
367+
for null rather than using a two-step `isEmpty()` check followed by `.get()`:
368+
369+
```java
370+
// Good — single null check, no isEmpty/get split
371+
WorktreeContext context = WorktreeContext.forSession(...).orElse(null);
372+
if (context == null)
373+
return Result.allow();
374+
// use context directly
375+
context.absoluteWorktreePath();
376+
377+
// Avoid — isEmpty check followed by separate .get() call
378+
Optional<WorktreeContext> contextOptional = WorktreeContext.forSession(...);
379+
if (contextOptional.isEmpty())
380+
return Result.allow();
381+
WorktreeContext context = contextOptional.get();
382+
```
383+
278384
### Null-First Conditionals
279385
When a conditional handles both the null and non-null case, test the null case first. This applies to explicit
280386
if/else and to early-return patterns:

client/src/main/java/io/github/cowwoc/cat/hooks/WorktreeContext.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.io.IOException;
1515
import java.nio.file.Files;
1616
import java.nio.file.Path;
17+
import java.util.Optional;
1718

1819
/**
1920
* Resolved worktree context for a session.
@@ -41,18 +42,18 @@ public record WorktreeContext(Path absoluteWorktreePath, Path absoluteProjectDir
4142
* Resolves the worktree context for a session by looking up the session's lock file and
4243
* deriving the worktree path.
4344
* <p>
44-
* Returns {@code null} if no active worktree is found for the session (no lock file or
45-
* worktree directory does not exist).
45+
* A session holds at most one active lock at a time. Returns an empty optional if no
46+
* active worktree is found for the session (no lock file or worktree directory does not exist).
4647
*
4748
* @param projectCatDir the project CAT directory ({@code {claudeProjectPath}/.cat/work/})
4849
* @param projectPath the project root directory
4950
* @param mapper the JSON mapper for reading lock files
5051
* @param sessionId the session ID to look up
51-
* @return the resolved worktree context, or {@code null} if no active worktree exists
52+
* @return the resolved worktree context, or empty if no active worktree exists
5253
* @throws NullPointerException if any parameter is null
5354
* @throws IllegalArgumentException if {@code sessionId} is blank
5455
*/
55-
public static WorktreeContext forSession(Path projectCatDir, Path projectPath, JsonMapper mapper,
56+
public static Optional<WorktreeContext> forSession(Path projectCatDir, Path projectPath, JsonMapper mapper,
5657
String sessionId)
5758
{
5859
requireThat(projectCatDir, "projectCatDir").isNotNull();
@@ -64,15 +65,15 @@ public static WorktreeContext forSession(Path projectCatDir, Path projectPath, J
6465
{
6566
String issueId = WorktreeLock.findIssueIdForSession(projectCatDir, mapper, sessionId);
6667
if (issueId == null)
67-
return null;
68+
return Optional.empty();
6869

6970
Path worktreePath = projectCatDir.resolve("worktrees").resolve(issueId);
7071
if (!Files.isDirectory(worktreePath))
71-
return null;
72+
return Optional.empty();
7273

7374
Path absoluteWorktreePath = worktreePath.toAbsolutePath().normalize();
7475
Path absoluteProjectDirectory = projectPath.toAbsolutePath().normalize();
75-
return new WorktreeContext(absoluteWorktreePath, absoluteProjectDirectory);
76+
return Optional.of(new WorktreeContext(absoluteWorktreePath, absoluteProjectDirectory));
7677
}
7778
catch (IOException e)
7879
{

client/src/main/java/io/github/cowwoc/cat/hooks/bash/BlockMainRebase.java

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import java.io.IOException;
1616
import java.nio.file.Path;
17+
import java.util.Optional;
1718
import java.util.regex.Matcher;
1819
import java.util.regex.Pattern;
1920

@@ -138,20 +139,18 @@ private Result checkCheckoutInMainWorktree(String command, String sessionId)
138139
}
139140
}
140141

141-
WorktreeContext context = WorktreeContext.forSession(
142+
Optional<WorktreeContext> context = WorktreeContext.forSession(
142143
scope.getCatWorkPath(), projectPath, scope.getJsonMapper(), sessionId);
143-
if (context == null)
144+
if (context.isPresent())
145+
return null;
146+
// No active worktree for this session — this is the main context; block checkout
147+
String target = extractCheckoutTarget(command);
148+
if (!isCheckoutFlag(target))
144149
{
145-
// No active worktree for this session — this is the main context; block checkout
146-
String target = extractCheckoutTarget(command);
147-
if (!isCheckoutFlag(target))
148-
{
149-
return Result.block(String.format(
150-
"Blocked: Cannot checkout '%s' in main worktree. Use issue worktrees instead.",
151-
target));
152-
}
150+
return Result.block(String.format(
151+
"Blocked: Cannot checkout '%s' in main worktree. Use issue worktrees instead.",
152+
target));
153153
}
154-
155154
return null;
156155
}
157156

@@ -209,24 +208,13 @@ private String getCurrentBranch(String command, String sessionId)
209208
}
210209

211210
// Use lock-based worktree context to determine branch
212-
WorktreeContext context = WorktreeContext.forSession(
213-
scope.getCatWorkPath(), projectPath, scope.getJsonMapper(), sessionId);
214-
if (context == null)
215-
{
216-
// No active worktree for this session — commands run in main context
217-
try
218-
{
219-
return GitCommands.getCurrentBranch(projectPath.toString());
220-
}
221-
catch (IOException _)
222-
{
223-
return null;
224-
}
225-
}
226-
// In a worktree — determine branch from worktree directory
211+
Path branchDir = WorktreeContext.forSession(
212+
scope.getCatWorkPath(), projectPath, scope.getJsonMapper(), sessionId).
213+
map(WorktreeContext::absoluteWorktreePath).
214+
orElse(projectPath);
227215
try
228216
{
229-
return GitCommands.getCurrentBranch(context.absoluteWorktreePath().toString());
217+
return GitCommands.getCurrentBranch(branchDir.toString());
230218
}
231219
catch (IOException _)
232220
{

client/src/main/java/io/github/cowwoc/cat/hooks/bash/BlockWorktreeIsolationViolation.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ public Result check(HookInput input)
9595
requireThat(workingDirectory, "workingDirectory").isNotNull();
9696
requireThat(sessionId, "sessionId").isNotBlank();
9797

98-
WorktreeContext context = WorktreeContext.forSession(scope.getCatWorkPath(), projectPath, mapper, sessionId);
98+
WorktreeContext context = WorktreeContext.forSession(
99+
scope.getCatWorkPath(), projectPath, mapper, sessionId).orElse(null);
99100
if (context == null)
100101
return Result.allow();
101102

client/src/main/java/io/github/cowwoc/cat/hooks/bash/post/DetectFailures.java

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,48 @@
1010
import io.github.cowwoc.cat.hooks.HookInput;
1111
import tools.jackson.databind.JsonNode;
1212

13+
import java.util.List;
1314
import java.util.regex.Pattern;
1415

1516
/**
1617
* Detect command failures and suggest learning from mistakes.
1718
* <p>
1819
* Trigger: PostToolUse for Bash
20+
* <p>
21+
* Pattern matching is scoped to known test runner commands to avoid false positives from arbitrary
22+
* Bash commands whose output happens to contain failure keywords (e.g., git diff showing diff content
23+
* with "FAILED" in a file, or cat reading a file with "Exception" in its text).
1924
*/
2025
public final class DetectFailures implements BashHandler
2126
{
2227
private static final Pattern FAILURE_PATTERN = Pattern.compile(
23-
"BUILD FAILED|FAILED|ERROR:|error:|Exception|FATAL|fatal:",
28+
"BUILD FAILED|FAILED|ERROR:|Exception|FATAL",
2429
Pattern.CASE_INSENSITIVE);
2530

31+
/**
32+
* Command prefixes that identify known test runners.
33+
* <p>
34+
* Pattern matching is applied only when the executed command starts with one of these prefixes.
35+
* Commands not in this list (e.g., git, cat, javac, get-output) are skipped to prevent false positives
36+
* from incidental failure keywords in diff output or file content.
37+
*/
38+
private static final List<String> TEST_RUNNER_PREFIXES = List.of(
39+
"mvn ",
40+
"mvnw ",
41+
"./mvnw ",
42+
"gradle ",
43+
"gradlew ",
44+
"./gradlew ",
45+
"bats ",
46+
"bats\t",
47+
"npm test",
48+
"npm run test",
49+
"yarn test",
50+
"pytest",
51+
"cargo test",
52+
"go test",
53+
"dotnet test");
54+
2655
/**
2756
* Creates a new handler for detecting command failures.
2857
*/
@@ -31,6 +60,28 @@ public DetectFailures()
3160
// Handler class
3261
}
3362

63+
/**
64+
* Checks whether the given command is a known test runner invocation.
65+
*
66+
* @param command the bash command string to check
67+
* @return true if the command starts with a known test runner prefix
68+
* @throws NullPointerException if {@code command} is null
69+
*/
70+
private static boolean isTestRunnerCommand(String command)
71+
{
72+
String normalized = command.strip();
73+
for (String prefix : TEST_RUNNER_PREFIXES)
74+
{
75+
// Prefix entries include a trailing delimiter (space or tab) to prevent startsWith() from
76+
// false-matching unrelated commands (e.g., "bats\t" avoids matching "batsman"). The trailing
77+
// delimiter also means startsWith() would miss an exact bare-command invocation like "bats"
78+
// (no arguments). prefix.strip() removes the delimiter to enable exact bare-command matching.
79+
if (normalized.startsWith(prefix) || normalized.equals(prefix.strip()))
80+
return true;
81+
}
82+
return false;
83+
}
84+
3485
@Override
3586
public Result check(HookInput input)
3687
{
@@ -40,6 +91,12 @@ public Result check(HookInput input)
4091
if (toolResult.isEmpty())
4192
return Result.allow();
4293

94+
// Only apply failure detection to known test runner commands to avoid false positives
95+
// from incidental keyword matches in diff output, file content, or unrelated command output.
96+
String command = input.getCommand();
97+
if (!isTestRunnerCommand(command))
98+
return Result.allow();
99+
43100
// Get exit code
44101
int exitCode = 0;
45102
JsonNode exitCodeNode = toolResult.get("exit_code");

client/src/main/java/io/github/cowwoc/cat/hooks/task/EnforceCollectAfterAgent.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.io.IOException;
1919
import java.nio.file.Files;
2020
import java.nio.file.Path;
21+
import java.util.Optional;
2122

2223
/**
2324
* Blocks Task and Skill tool calls when a pending-agent-result flag exists.
@@ -56,8 +57,9 @@ public Result check(JsonNode toolInput, String sessionId, String cwd)
5657
if (!Files.exists(flagPath))
5758
return Result.allow();
5859

59-
if (WorktreeContext.forSession(
60-
scope.getCatWorkPath(), scope.getProjectPath(), scope.getJsonMapper(), sessionId) == null)
60+
Optional<WorktreeContext> worktreeContext = WorktreeContext.forSession(
61+
scope.getCatWorkPath(), scope.getProjectPath(), scope.getJsonMapper(), sessionId);
62+
if (worktreeContext.isEmpty())
6163
{
6264
// No active worktree lock — flag is stale; clean up and allow
6365
try

client/src/main/java/io/github/cowwoc/cat/hooks/task/EnforceCommitBeforeSubagentSpawn.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public Result check(JsonNode toolInput, String sessionId, String cwd)
6969
try
7070
{
7171
context = WorktreeContext.forSession(scope.getCatWorkPath(), scope.getProjectPath(),
72-
scope.getJsonMapper(), sessionId);
72+
scope.getJsonMapper(), sessionId).orElse(null);
7373
}
7474
catch (RuntimeException _)
7575
{

0 commit comments

Comments
 (0)