Skip to content

Commit b2b6fbc

Browse files
committed
Merge branch '2.1-improve-undefined-var-warning' into v2.1
2 parents 91e99fa + 776ac1e commit b2b6fbc

6 files changed

Lines changed: 275 additions & 44 deletions

File tree

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
2-
"status": "open",
3-
"dependencies": [],
4-
"blocks": [],
5-
"target_branch": "v2.1"
2+
"status" : "closed",
3+
"resolution" : "implemented",
4+
"dependencies" : [ ],
5+
"blocks" : [ ],
6+
"target_branch" : "v2.1"
67
}

.cat/issues/v2/v2.1/improve-undefined-var-warning/plan.md

Lines changed: 93 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,54 +15,117 @@ cmd > "${SESSION_DIR}/squash-complete-${ISSUE_ID}"
1515
```
1616

1717
## Expected vs Actual
18-
- **Expected:** `WARNING: Cannot verify Bash redirect to variable-expanded path: "${SESSION_DIR}/squash-complete-${ISSUE_ID}"\n\nUndefined variables: SESSION_DIR, ISSUE_ID\n...`
18+
- **Expected:** `WARNING: Cannot verify Bash redirect to variable-expanded path: "${SESSION_DIR}/squash-complete-${ISSUE_ID}"\n\nUndefined variable(s): SESSION_DIR, ISSUE_ID\n...`
1919
- **Actual:** `WARNING: Cannot verify Bash redirect to variable-expanded path: "${SESSION_DIR}/squash-complete-${ISSUE_ID}"\n\nOne or more variables in the path could not be resolved.\n...`
2020

2121
## Root Cause
2222
`ShellParser.expandEnvVars()` returns `null` on the first undefined variable but does not report which
2323
variable failed. The caller in `BlockWorktreeIsolationViolation` has no way to name the undefined
2424
variable(s) without re-scanning the target string.
2525

26+
The fix adds `ShellParser.findUndefinedVars()` which performs a second pass to collect the names,
27+
called only when `expandEnvVars` already returned `null`.
28+
2629
## Risk Assessment
2730
- **Risk Level:** LOW
28-
- **Regression Risk:** Only changes the text of a warning message; no logic changes
29-
- **Mitigation:** Add a unit test for the new `findUndefinedVars` method
31+
- **Regression Risk:** Only changes the text of a warning message and adds a new method. No logic changes to existing methods.
32+
- **Mitigation:** Unit tests for `findUndefinedVars`; full `mvn verify` confirms no regressions.
3033

3134
## Files to Modify
3235
- `client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java` — add `findUndefinedVars(String, Function<String,String>)` method
33-
- `client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java` — replace "One or more variables..." with a list of the undefined variable names
34-
- `client/src/test/java/io/github/cowwoc/cat/client/test/ShellParserTest.java` (or equivalent) — add tests for `findUndefinedVars`
35-
36-
## Test Cases
37-
- [ ] Path with one undefined variable → list shows that one variable name
38-
- [ ] Path with two undefined variables → list shows both names in order
39-
- [ ] Path with all variables defined → returns empty list (no warning triggered)
36+
- `client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java` — replace "One or more variables..." with specific variable names from `findUndefinedVars`
37+
- `client/src/test/java/io/github/cowwoc/cat/client/test/ShellParserTest.java` — add tests for `findUndefinedVars`
4038

4139
## Pre-conditions
4240
- [ ] All dependent issues are closed
4341

4442
## Jobs
4543

46-
### Job 1: Add findUndefinedVars to ShellParser
47-
- Add `public static List<String> findUndefinedVars(String target, Function<String, String> envLookup)` to `ShellParser`
48-
- Scan `target` for `${VAR}` and `$VAR` references using `ENV_VAR_EXPAND_PATTERN`
49-
- For each match, call `envLookup`; if it returns `null`, add the variable name to the result list
50-
- Return the list (preserving order of appearance, no deduplication needed)
51-
- Files: `client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java`
52-
53-
### Job 2: Update warning message in BlockWorktreeIsolationViolation
54-
- After `expandEnvVars` returns `null`, call `ShellParser.findUndefinedVars(target, mergedLookup)` to get the undefined names
55-
- Replace "One or more variables in the path could not be resolved." with "Undefined variable(s): ${names joined by ", "}"
56-
- Files: `client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java`
57-
58-
### Job 3: Add tests
59-
- Add unit tests for `findUndefinedVars`:
60-
- One undefined variable
61-
- Two undefined variables
62-
- All defined (empty result)
63-
- Files: test source file for `ShellParser`
44+
### Job 1
45+
Steps must be executed sequentially (each step depends on the previous):
46+
47+
1. **Write failing tests first** (TDD) in `client/src/test/java/io/github/cowwoc/cat/client/test/ShellParserTest.java`:
48+
- `findUndefinedVars_oneUndefinedVariable` — path `"${SESSION_DIR}/file"` with no env lookup hits → returns `["SESSION_DIR"]`
49+
- `findUndefinedVars_twoUndefinedVariables` — path `"${SESSION_DIR}/squash-complete-${ISSUE_ID}"` with no env lookup hits → returns `["SESSION_DIR", "ISSUE_ID"]`
50+
- `findUndefinedVars_allDefined` — path `"${SESSION_DIR}/file"` with lookup returning `/tmp` for `SESSION_DIR` → returns empty list
51+
- Tests call `ShellParser.findUndefinedVars(target, envLookup)` with a lambda for `envLookup`
52+
- Tests will fail to compile until Step 2 adds the method
53+
54+
2. **Add `findUndefinedVars` to `ShellParser`** in `client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java`:
55+
- Signature: `public static List<String> findUndefinedVars(String target, Function<String, String> envLookup)`
56+
- Javadoc: document parameters, return value, NullPointerException for null inputs
57+
- Body: use `ENV_VAR_EXPAND_PATTERN` (already defined in the class) to scan `target`:
58+
```java
59+
List<String> undefined = new ArrayList<>();
60+
Matcher varMatcher = ENV_VAR_EXPAND_PATTERN.matcher(target);
61+
while (varMatcher.find())
62+
{
63+
String varName;
64+
if (varMatcher.group(1) != null)
65+
varName = varMatcher.group(1);
66+
else
67+
varName = varMatcher.group(2);
68+
if (envLookup.apply(varName) == null)
69+
undefined.add(varName);
70+
}
71+
return undefined;
72+
```
73+
- Add `requireThat` validation for `target` and `envLookup` (same pattern as `expandEnvVars`)
74+
- Place the method directly after the existing `expandEnvVars(String, Function<String,String>)` method
75+
- The `List` import is already present in the file (`import java.util.List;`); verify before adding imports
76+
77+
3. **Update warning message** in `client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java`:
78+
- Find the block at line ~162 where `expanded == null` is handled
79+
- After `String expanded = ShellParser.expandEnvVars(target, mergedLookup);` returns null, call:
80+
```java
81+
List<String> undefinedVars = ShellParser.findUndefinedVars(target, mergedLookup);
82+
String undefinedList = String.join(", ", undefinedVars);
83+
```
84+
- Build the second line of the warning from `undefinedList`:
85+
- If `undefinedList` is non-empty (the common case): use `"Undefined variable(s): " + undefinedList`
86+
- If `undefinedList` is empty (e.g., the `$` comes from `$(...)` command substitution, which the pattern does not match): fall back to `"One or more variables in the path could not be resolved."`
87+
- Implement this as:
88+
```java
89+
String variableLine;
90+
if (undefinedList.isEmpty())
91+
variableLine = "One or more variables in the path could not be resolved.";
92+
else
93+
variableLine = "Undefined variable(s): " + undefinedList;
94+
```
95+
- The full updated message template (replacing lines 162-175) should be:
96+
```java
97+
List<String> undefinedVars = ShellParser.findUndefinedVars(target, mergedLookup);
98+
String undefinedList = String.join(", ", undefinedVars);
99+
String variableLine;
100+
if (undefinedList.isEmpty())
101+
variableLine = "One or more variables in the path could not be resolved.";
102+
else
103+
variableLine = "Undefined variable(s): " + undefinedList;
104+
String message = """
105+
WARNING: Cannot verify Bash redirect to variable-expanded path: %s
106+
107+
%s
108+
Variables must be defined earlier in the same script as a simple literal assignment, e.g.:
109+
VAR="/absolute/path"
110+
cmd > "${VAR}"
111+
112+
Variables set via command substitution ($(...)) or unset variables cannot be resolved statically.
113+
If this targets a path outside your worktree, it bypasses worktree isolation.
114+
Use the Edit or Write tools with an explicit absolute path instead:
115+
Use: %s/plugin/file.txt
116+
Not: $UNSET_VAR/plugin/file.txt""".formatted(target, variableLine, context.absoluteWorktreePath());
117+
```
118+
- Add `import java.util.List;` if not already present (check existing imports first)
119+
120+
4. **Run `mvn -f client/pom.xml verify -e`** and confirm exit code 0
121+
122+
5. **Commit all changes** in a single commit:
123+
- Stage: `ShellParser.java`, `BlockWorktreeIsolationViolation.java`, `ShellParserTest.java`, `index.json`
124+
- Commit type: `bugfix:`
125+
- Message: `bugfix: name undefined variables in worktree isolation warning`
126+
- Update `index.json` in same commit: `status: "closed"`, `resolution: "implemented"`
64127

65128
## Post-conditions
66-
- [ ] Warning message names the specific undefined variable(s) (e.g., `Undefined variable(s): SESSION_DIR, ISSUE_ID`)
67-
- [ ] `ShellParser.findUndefinedVars` unit tests pass
129+
- [ ] Warning message names the specific undefined variable(s) (e.g., `Undefined variable(s): SESSION_DIR, ISSUE_ID`) instead of the generic "One or more variables..."
130+
- [ ] `ShellParser.findUndefinedVars` tests pass: one undefined var, two undefined vars, all defined
68131
- [ ] `mvn -f client/pom.xml verify -e` exits 0 with no new failures

client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,7 @@ public static String expandEnvVars(String target, Function<String, String> envLo
177177
while (varMatcher.find())
178178
{
179179
result.append(target, lastEnd, varMatcher.start());
180-
// group(1) is the name from ${VAR}; group(2) is the name from $VAR
181-
String varName;
182-
if (varMatcher.group(1) != null)
183-
varName = varMatcher.group(1);
184-
else
185-
varName = varMatcher.group(2);
180+
String varName = extractVarName(varMatcher);
186181
String value = envLookup.apply(varName);
187182
if (value == null)
188183
return null;
@@ -193,6 +188,52 @@ public static String expandEnvVars(String target, Function<String, String> envLo
193188
return result.toString();
194189
}
195190

191+
/**
192+
* Returns the names of all {@code $VAR} and {@code ${VAR}} references in {@code target} whose
193+
* values are not present in {@code envLookup}.
194+
* <p>
195+
* This method performs a second-pass scan used when {@link #expandEnvVars(String, Function)}
196+
* already returned {@code null}, to report which specific variable(s) could not be resolved.
197+
*
198+
* @param target the string containing variable references to inspect
199+
* @param envLookup a function mapping variable names to their values; returns {@code null} if
200+
* the variable is unset
201+
* @return a list of variable names for which {@code envLookup} returned {@code null}, in the
202+
* order they appear in {@code target}; empty if all variables are defined
203+
* @throws NullPointerException if {@code target} or {@code envLookup} are null
204+
*/
205+
public static List<String> findUndefinedVars(String target, Function<String, String> envLookup)
206+
{
207+
requireThat(target, "target").isNotNull();
208+
requireThat(envLookup, "envLookup").isNotNull();
209+
List<String> undefined = new ArrayList<>();
210+
Matcher varMatcher = ENV_VAR_EXPAND_PATTERN.matcher(target);
211+
while (varMatcher.find())
212+
{
213+
String varName = extractVarName(varMatcher);
214+
if (envLookup.apply(varName) == null)
215+
undefined.add(varName);
216+
}
217+
return undefined;
218+
}
219+
220+
/**
221+
* Extracts the variable name from a matcher positioned on a match of
222+
* {@code ENV_VAR_EXPAND_PATTERN}.
223+
* <p>
224+
* Group 1 captures the name from the {@code ${VAR}} form; group 2 captures it from
225+
* the {@code $VAR} form. Exactly one of the two groups is non-null on every match.
226+
*
227+
* @param matcher a matcher that has just found a match
228+
* @return the captured variable name
229+
*/
230+
private static String extractVarName(Matcher matcher)
231+
{
232+
if (matcher.group(1) != null)
233+
return matcher.group(1);
234+
return matcher.group(2);
235+
}
236+
196237
/**
197238
* Scans {@code script} for simple literal variable assignments and returns them as a map.
198239
* <p>

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.ArrayList;
1919
import java.util.List;
2020
import java.util.Map;
21+
import java.util.StringJoiner;
2122
import java.util.function.Function;
2223
import java.util.regex.Matcher;
2324
import java.util.regex.Pattern;
@@ -159,10 +160,22 @@ public Result check(ClaudeHook scope)
159160
String expanded = ShellParser.expandEnvVars(target, mergedLookup);
160161
if (expanded == null)
161162
{
163+
List<String> undefinedVars = ShellParser.findUndefinedVars(target, mergedLookup);
164+
if (undefinedVars.isEmpty())
165+
{
166+
// expandEnvVars returned null only when a variable is undefined, so this list
167+
// cannot be empty. A non-empty list here means the pattern missed a $(...) form.
168+
throw new AssertionError(
169+
"findUndefinedVars returned empty list after expandEnvVars returned null for: " + target);
170+
}
171+
StringJoiner joiner = new StringJoiner(", ");
172+
for (String varName : undefinedVars)
173+
joiner.add(varName);
174+
String variableLine = "Undefined variable(s): " + joiner;
162175
String message = """
163176
WARNING: Cannot verify Bash redirect to variable-expanded path: %s
164177
165-
One or more variables in the path could not be resolved.
178+
%s
166179
Variables must be defined earlier in the same script as a simple literal assignment, e.g.:
167180
VAR="/absolute/path"
168181
cmd > "${VAR}"
@@ -171,7 +184,7 @@ Variables set via command substitution ($(...)) or unset variables cannot be res
171184
If this targets a path outside your worktree, it bypasses worktree isolation.
172185
Use the Edit or Write tools with an explicit absolute path instead:
173186
Use: %s/plugin/file.txt
174-
Not: $UNSET_VAR/plugin/file.txt""".formatted(target, context.absoluteWorktreePath());
187+
Not: $UNSET_VAR/plugin/file.txt""".formatted(target, variableLine, context.absoluteWorktreePath());
175188
return Result.block(message);
176189
}
177190
// Replacing the variable reference with the concrete path lets the worktree isolation

client/src/test/java/io/github/cowwoc/cat/client/test/BlockWorktreeIsolationViolationTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ public void variableExpansionDollarSignIsBlocked() throws IOException
563563
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
564564

565565
requireThat(result.blocked(), "blocked").isTrue();
566-
requireThat(result.reason(), "reason").contains("could not be resolved");
566+
requireThat(result.reason(), "reason").contains("Undefined variable(s): BWIV_TEST_UNDEFINED_ENV_VAR");
567567
requireThat(result.reason(), "reason").contains(
568568
worktreeDir.toAbsolutePath().normalize().toString());
569569
}
@@ -935,7 +935,7 @@ public void redirectRemainsBlockedWhenVariableAssignedViaCommandSubstitution() t
935935
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
936936

937937
requireThat(result.blocked(), "blocked").isTrue();
938-
requireThat(result.reason(), "reason").contains("could not be resolved");
938+
requireThat(result.reason(), "reason").contains("Undefined variable(s): OUT");
939939
}
940940
finally
941941
{

0 commit comments

Comments
 (0)