Skip to content

Commit e1be10f

Browse files
committed
refactor: thin main() methods, add run() delegates and MainTest for all non-hook CLI classes
1 parent a11a041 commit e1be10f

122 files changed

Lines changed: 6766 additions & 1588 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"status": "open", "dependencies": [], "blocks": []}
1+
{"status": "closed", "resolution": "implemented", "dependencies": [], "blocks": []}

.claude/rules/hooks.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ CAT has two categories of Java CLI tools, each with a different output contract:
1515
### Hook Handlers (PreToolUse, PostToolUse, etc.)
1616

1717
Hook handlers are invoked directly by Claude Code's hook execution engine. They must produce
18-
Claude Code's hook JSON format via `ClaudeHook`. Claude Code's hook engine parses this format.
18+
Claude Code's hook JSON format via `HookOutput`. Claude Code's hook engine parses this format.
1919

2020
**Standard hook JSON output fields:**
2121
- `decision` (string) — e.g., `"block"` to indicate a blocked operation
@@ -30,10 +30,10 @@ cause stderr to be fed to Claude as plain text, losing the structured error.
3030

3131
**Pattern for expected errors (IOException, IllegalArgumentException) in hook handlers:**
3232
```java
33-
// Good — use ClaudeHook, write to stdout, exit 0
33+
// Good — use HookOutput, write to stdout, exit 0
3434
catch (IOException e)
3535
{
36-
ClaudeHook hookOutput = new ClaudeHook(scope);
36+
HookOutput hookOutput = new HookOutput(scope);
3737
System.out.println(hookOutput.block(e.getMessage()));
3838
System.exit(0);
3939
}
@@ -57,7 +57,7 @@ by the **skill itself**, not by Claude Code's hook engine. These tools may use a
5757
schema (e.g., `{"status":"...", "message":"..."}`) that the skill Markdown defines and parses.
5858

5959
`{"status":"ERROR","message":"..."}` is correct for skill CLI tools — the skill parser reads `status`
60-
and `message` fields directly. `ClaudeHook.block()` would produce `{"decision":"block",...}` which
60+
and `message` fields directly. `HookOutput.block()` would produce `{"decision":"block",...}` which
6161
skill parsers do not recognize.
6262

6363
**Pattern for expected errors (IOException) in skill CLI tools:**
@@ -73,7 +73,7 @@ catch (IOException e)
7373

7474
**Pattern for unexpected errors (RuntimeException | AssertionError) in `main()`:**
7575

76-
Unexpected errors in `main()` must be caught, logged, and converted to a `ClaudeHook.block()` response on stdout.
76+
Unexpected errors in `main()` must be caught, logged, and converted to a `HookOutput.block()` response on stdout.
7777
They must NOT be rethrown, as non-zero exit or uncaught exceptions prevent Claude Code from parsing the JSON response.
7878

7979
```java
@@ -89,7 +89,7 @@ public static void main(String[] args)
8989
{
9090
Logger log = LoggerFactory.getLogger(ClassName.class);
9191
log.error("Unexpected error", e);
92-
System.out.println(new ClaudeHook(scope).block(
92+
System.out.println(new HookOutput(scope).block(
9393
Objects.toString(e.getMessage(), e.getClass().getSimpleName())));
9494
}
9595
}

.claude/rules/java.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,53 @@ public void displayWidthCalculatesFromConfiguration() throws IOException
20102010

20112011
**Guideline:** If your test would fail when run on a different machine with different configuration (but the code still works correctly), you're testing implementation details, not requirements.
20122012

2013+
### Test Through run(), Not Internal Methods
2014+
When a CLI class has a `run()` method that parses command-line arguments and delegates to business logic, tests should
2015+
exercise `run()` directly rather than calling the internal methods it delegates to. This ensures the argument parsing,
2016+
error handling, and exit codes are tested as a unit.
2017+
2018+
```java
2019+
// ✅ CORRECT: Test through run() — exercises arg parsing + business logic together
2020+
@Test
2021+
public void missingTypeReturnsOne() throws IOException
2022+
{
2023+
Path tempDir = Files.createTempDirectory("test-");
2024+
try (JvmScope scope = new TestClaudeTool(tempDir, tempDir))
2025+
{
2026+
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
2027+
PrintStream out = new PrintStream(buffer, true, StandardCharsets.UTF_8);
2028+
int result = GetAddOutput.run(scope, new String[]{"--name", "foo", "--version", "2.1"}, out);
2029+
requireThat(result, "result").isEqualTo(1);
2030+
}
2031+
finally
2032+
{
2033+
TestUtils.deleteDirectoryRecursively(tempDir);
2034+
}
2035+
}
2036+
2037+
// ❌ WRONG: Test internal method directly — skips arg parsing and exit code
2038+
@Test
2039+
public void missingTypeReturnsOne() throws IOException
2040+
{
2041+
Path tempDir = Files.createTempDirectory("test-");
2042+
try (JvmScope scope = new TestClaudeTool(tempDir, tempDir))
2043+
{
2044+
GetAddOutput output = new GetAddOutput(scope);
2045+
String result = output.getOutput(new String[]{"--name", "foo", "--version", "2.1"});
2046+
// Doesn't test run()'s arg parsing, error messages, or exit codes
2047+
}
2048+
finally
2049+
{
2050+
TestUtils.deleteDirectoryRecursively(tempDir);
2051+
}
2052+
}
2053+
```
2054+
2055+
**When internal method tests ARE acceptable:**
2056+
- The internal method has complex logic independent of arg parsing (e.g., formatting, calculation)
2057+
- The method is reused by multiple callers, not just `run()`
2058+
- Testing through `run()` would require impractical setup (live git repo, network access)
2059+
20132060
### Testability Over Convenience
20142061
If code cannot be tested in a thread-safe way (e.g., it reads from `System.in` or writes to `System.out`), ask the
20152062
user's permission to update the API to make it testable. For example, add a method overload that accepts an

client/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@
148148
<configuration>
149149
<!-- Force UTF-8 encoding for forked test JVM -->
150150
<argLine>-Dstdin.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8</argLine>
151+
<!-- Add main classes to flat classpath so surefire's class scanner can resolve
152+
cross-module supertype references (e.g. TestSkillOutput implements SkillOutput) -->
153+
<additionalClasspathElements>
154+
<additionalClasspathElement>${project.build.outputDirectory}</additionalClasspathElement>
155+
</additionalClasspathElements>
151156
<environmentVariables>
152157
<CLAUDE_PLUGIN_ROOT>${project.basedir}/../plugin</CLAUDE_PLUGIN_ROOT>
153158
</environmentVariables>

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@ private static String validateAgentId(JsonNode data)
175175
return value;
176176
}
177177

178+
/**
179+
* Returns the path to the Claude environment file.
180+
* <p>
181+
* Subclasses must implement this to provide the env file path from {@code CLAUDE_ENV_FILE}
182+
* or an injected value.
183+
*
184+
* @return the path to the env file
185+
* @throws AssertionError if {@code CLAUDE_ENV_FILE} is not set in the environment
186+
* @throws IllegalStateException if this scope is closed
187+
*/
188+
@Override
189+
public abstract Path getEnvFile();
190+
178191
@Override
179192
public Path getProjectPath()
180193
{

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,29 @@ public abstract class AbstractClaudeTool extends AbstractJvmScope implements Cla
2525
private final String sessionId;
2626
private final Path projectPath;
2727
private final Path pluginRoot;
28+
private final Path envFile;
2829

2930
/**
3031
* Creates a new abstract Claude tool scope with the given environment values.
3132
*
3233
* @param sessionId the Claude session ID
3334
* @param projectPath the project's root directory path (must be absolute)
3435
* @param pluginRoot the Claude plugin root directory path (must be absolute)
36+
* @param envFile the path to the Claude environment file
3537
* @throws IllegalArgumentException if {@code sessionId} is blank, or if {@code projectPath} or
3638
* {@code pluginRoot} are not absolute paths
37-
* @throws NullPointerException if {@code projectPath} or {@code pluginRoot} are null
39+
* @throws NullPointerException if {@code projectPath}, {@code pluginRoot}, or {@code envFile} are null
3840
*/
39-
protected AbstractClaudeTool(String sessionId, Path projectPath, Path pluginRoot)
41+
protected AbstractClaudeTool(String sessionId, Path projectPath, Path pluginRoot, Path envFile)
4042
{
4143
requireThat(sessionId, "sessionId").isNotBlank();
4244
requireThat(projectPath, "projectPath").isNotNull().isAbsolute();
4345
requireThat(pluginRoot, "pluginRoot").isNotNull().isAbsolute();
46+
requireThat(envFile, "envFile").isNotNull();
4447
this.sessionId = sessionId;
4548
this.projectPath = projectPath;
4649
this.pluginRoot = pluginRoot;
50+
this.envFile = envFile;
4751
}
4852

4953
@Override
@@ -66,4 +70,11 @@ public Path getPluginRoot()
6670
ensureOpen();
6771
return pluginRoot;
6872
}
73+
74+
@Override
75+
public Path getEnvFile()
76+
{
77+
ensureOpen();
78+
return envFile;
79+
}
6980
}

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

Lines changed: 93 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import java.nio.file.Files;
3434
import java.nio.file.Path;
3535

36+
import static io.github.cowwoc.requirements13.java.DefaultJavaValidators.requireThat;
37+
3638
/**
3739
* Exercises all handler code paths in a single JVM invocation for AOT training.
3840
* <p>
@@ -71,80 +73,103 @@ public static void main(String[] args) throws Exception
7173
try (AbstractClaudeHook scope = new MainClaudeHook())
7274
{
7375
System.setIn(originalIn);
76+
System.exit(run(scope));
77+
}
78+
}
7479

75-
// Hook handlers all accept the unified ClaudeHook scope
76-
new PreToolUseHook(scope).run(scope);
77-
new PostBashHook().run(scope);
78-
new PreReadHook(scope).run(scope);
79-
new PostReadHook(scope).run(scope);
80-
new PostToolUseHook(scope).run(scope);
81-
new UserPromptSubmitHook(scope).run(scope);
82-
new PreAskHook(scope).run(scope);
83-
new PreWriteHook(scope).run(scope);
84-
new PreIssueHook(scope).run(scope);
85-
new SessionEndHook(scope).run(scope);
86-
new SessionStartHook(scope, Path.of("/tmp/aot-training-env")).run(scope);
87-
new SubagentStartHook(scope).run(scope);
80+
/**
81+
* Exercises all hook handler and skill constructor code paths for AOT training.
82+
* <p>
83+
* SYNC: Keep handler list synchronized with HANDLERS array in hooks/build-jlink.sh.
84+
* When adding a new handler, update both locations:
85+
* <ul>
86+
* <li>Add launcher entry to HANDLERS array in build-jlink.sh</li>
87+
* <li>Add training invocation to this method</li>
88+
* </ul>
89+
*
90+
* @param scope the hook scope providing access to services and configuration
91+
* @throws NullPointerException if {@code scope} is null
92+
* @throws Exception if training fails
93+
* @return 0 on success, non-zero on failure
94+
*/
95+
@SuppressWarnings("ResultOfMethodCallIgnored")
96+
public static int run(AbstractClaudeHook scope) throws Exception
97+
{
98+
requireThat(scope, "scope").isNotNull();
8899

89-
// Skill handlers - construct to load class graphs.
90-
// Calling getOutput() would read the filesystem, which is unnecessary for training.
91-
// GetDiffOutput and GetCleanupOutput accept JvmScope (no session required).
92-
// GetStatusOutput and GetOutput require AbstractClaudeTool (session-aware); use referenceClass() instead.
93-
new GetDiffOutput(scope);
94-
new GetCleanupOutput(scope);
95-
referenceClass(GetStatusOutput.class);
96-
referenceClass(GetOutput.class);
100+
// Hook handlers all accept the unified ClaudeHook scope
101+
new PreToolUseHook(scope).run(scope);
102+
new PostBashHook().run(scope);
103+
new PreReadHook(scope).run(scope);
104+
new PostReadHook(scope).run(scope);
105+
new PostToolUseHook(scope).run(scope);
106+
new UserPromptSubmitHook(scope).run(scope);
107+
new PreAskHook(scope).run(scope);
108+
new PreWriteHook(scope).run(scope);
109+
new PreIssueHook(scope).run(scope);
110+
new SessionEndHook(scope).run(scope);
111+
new SessionStartHook(scope).run(scope);
112+
new SubagentStartHook(scope).run(scope);
97113

98-
// VerifyAudit training - create temp directory with plan.md for prepare() and minimal JSON for report()
99-
Path tempDir = Files.createTempDirectory("aot-training-");
100-
try
101-
{
102-
Path planFile = tempDir.resolve("plan.md");
103-
Files.writeString(planFile, """
104-
# Plan
105-
## Post-conditions
106-
- [ ] Test criterion
107-
## Files to Modify
108-
- test.md
109-
""");
114+
// Skill handlers - construct to load class graphs.
115+
// Calling getOutput() would read the filesystem, which is unnecessary for training.
116+
// GetDiffOutput and GetCleanupOutput accept JvmScope (no session required).
117+
// GetStatusOutput and GetOutput require AbstractClaudeTool (session-aware); use referenceClass() instead.
118+
new GetDiffOutput(scope);
119+
new GetCleanupOutput(scope);
120+
referenceClass(GetStatusOutput.class);
121+
referenceClass(GetOutput.class);
110122

111-
VerifyAudit audit = new VerifyAudit(scope);
112-
String prepareArgs = """
113-
{
114-
"issue_id": "aot-training",
115-
"issue_path": "%s",
116-
"worktree_path": "%s"
117-
}
118-
""".formatted(tempDir.toString(), tempDir.toString());
119-
audit.prepare(prepareArgs);
120-
audit.report("test-issue", "{\"criteria_results\": [], \"file_results\": {\"modify\": {}, \"delete\": {}}}");
121-
}
122-
finally
123-
{
124-
Files.deleteIfExists(tempDir.resolve("plan.md"));
125-
Files.deleteIfExists(tempDir);
126-
}
123+
// VerifyAudit training - create temp directory with plan.md for prepare() and minimal JSON for report()
124+
Path tempDir = Files.createTempDirectory("aot-training-");
125+
try
126+
{
127+
Path planFile = tempDir.resolve("plan.md");
128+
Files.writeString(planFile, """
129+
# Plan
130+
## Post-conditions
131+
- [ ] Test criterion
132+
## Files to Modify
133+
- test.md
134+
""");
127135

128-
// Reference arg-based classes to force class loading without invoking main()
129-
// (their main() calls System.exit on missing args)
130-
referenceClass(EnforceStatusOutput.class);
131-
referenceClass(TokenCounter.class);
132-
referenceClass(GetCheckpointOutput.class);
133-
referenceClass(GetIssueCompleteOutput.class);
134-
referenceClass(GetNextIssueOutput.class);
135-
referenceClass(SessionAnalyzer.class);
136-
referenceClass(ProgressBanner.class);
137-
referenceClass(EmpiricalTestRunner.class);
138-
referenceClass(WorkPrepare.class);
139-
referenceClass(MarkdownWrapper.class);
140-
referenceClass(BatchReader.class);
141-
referenceClass(GetSubagentStatusOutput.class);
142-
referenceClass(HookRegistrar.class);
143-
referenceClass(StatusAlignmentValidator.class);
144-
referenceClass(GetSkill.class);
145-
referenceClass(GetFile.class);
146-
referenceClass(Feedback.class);
136+
VerifyAudit audit = new VerifyAudit(scope);
137+
String prepareArgs = """
138+
{
139+
"issue_id": "aot-training",
140+
"issue_path": "%s",
141+
"worktree_path": "%s"
142+
}
143+
""".formatted(tempDir.toString(), tempDir.toString());
144+
audit.prepare(prepareArgs);
145+
audit.report("test-issue", "{\"criteria_results\": [], \"file_results\": {\"modify\": {}, \"delete\": {}}}");
146+
}
147+
finally
148+
{
149+
Files.deleteIfExists(tempDir.resolve("plan.md"));
150+
Files.deleteIfExists(tempDir);
147151
}
152+
153+
// Reference arg-based classes to force class loading without invoking main()
154+
// (their main() calls System.exit on missing args)
155+
referenceClass(EnforceStatusOutput.class);
156+
referenceClass(TokenCounter.class);
157+
referenceClass(GetCheckpointOutput.class);
158+
referenceClass(GetIssueCompleteOutput.class);
159+
referenceClass(GetNextIssueOutput.class);
160+
referenceClass(SessionAnalyzer.class);
161+
referenceClass(ProgressBanner.class);
162+
referenceClass(EmpiricalTestRunner.class);
163+
referenceClass(WorkPrepare.class);
164+
referenceClass(MarkdownWrapper.class);
165+
referenceClass(BatchReader.class);
166+
referenceClass(GetSubagentStatusOutput.class);
167+
referenceClass(HookRegistrar.class);
168+
referenceClass(StatusAlignmentValidator.class);
169+
referenceClass(GetSkill.class);
170+
referenceClass(GetFile.class);
171+
referenceClass(Feedback.class);
172+
return 0;
148173
}
149174

150175
/**

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import tools.jackson.databind.json.JsonMapper;
1111
import tools.jackson.databind.node.ObjectNode;
1212

13+
import java.nio.file.Path;
14+
1315
/**
1416
* A {@link JvmScope} for hook handler processes that combines the Claude session environment
1517
* (project path, plugin root, config dir), hook input data, and hook output building in a single
@@ -28,6 +30,15 @@ public interface ClaudeHook extends JvmScope
2830
*/
2931
String getSessionId();
3032

33+
/**
34+
* Returns the path to the Claude environment file.
35+
*
36+
* @return the path to the env file
37+
* @throws AssertionError if {@code CLAUDE_ENV_FILE} is not set in the environment
38+
* @throws IllegalStateException if this scope is closed
39+
*/
40+
Path getEnvFile();
41+
3142
// Hook input methods
3243

3344
/**

0 commit comments

Comments
 (0)