Skip to content

Commit 39aa5b1

Browse files
committed
bugfix: change test_set_sha from git commit SHA to SHA-256 file content hash
1 parent 6c312ca commit 39aa5b1

8 files changed

Lines changed: 381 additions & 474 deletions

File tree

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

.cat/issues/v2/v2.1/fix-test-set-sha-to-file-content-hash/plan.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,202 @@ Clarify test_set_sha as SHA-256 of skill file content and require SPRT re-run on
1616
- [ ] `instruction-builder-agent/first-use.md` updated: `INSTRUCTION_DRAFT_SHA` computed as `sha256(skill_file)` not a git commit SHA; compression/rewrite passes explicitly require full SPRT re-run
1717
- [ ] `batch-write-agent/test-results.json` `test_set_sha` updated to actual SHA-256 of `first-use.md`
1818
- [ ] Tests passing, no regressions
19+
20+
## Research Findings
21+
22+
### Current Implementation
23+
24+
**`InstructionTestRunner.detectChanges()` (lines 309–468)**
25+
- First argument `old_skill_sha` validated as `[0-9a-f]{7,40}` (git commit SHA format)
26+
- Retrieves old skill content via `git rev-parse --show-toplevel` + `git show <sha>:<relpath>`
27+
- Writes old content to temp file, parses both old and new skill files
28+
- Compares frontmatter SHA-256 and body diff to produce `frontmatter_changed`, `body_changed`, `changed_ranges`
29+
- Calls `hasTransitiveDependencyChanged()` which runs `git diff --name-only <sha> -- <skillDir>`
30+
to detect changes to sibling `.md` files (companion files like `first-use.md`)
31+
- Returns JSON with fine-grained fields: `skill_changed`, `frontmatter_changed`, `body_changed`,
32+
`changed_ranges`, `all_test_case_ids`, `rerun_test_case_ids`, `carryforward_test_case_ids`,
33+
and `requires_unit_mapping` / `semantic_units_path_hint`
34+
35+
**`sha256File()` already exists** in `InstructionTestRunner` (line 1354): computes SHA-256 hex digest of a file.
36+
37+
**`instruction-builder-agent/first-use.md`**
38+
- Line ~303: "Store the SHA as `INSTRUCTION_DRAFT_SHA`" (refers to git commit SHA from subagent result)
39+
- Lines ~576-580: Context derivation code sets `INSTRUCTION_DRAFT_SHA="${DRAFT_COMMIT}"`
40+
- Line ~562-563: Invokes `detect-changes <INSTRUCTION_DRAFT_SHA> <INSTRUCTION_TEXT_PATH> "${TEST_DIR}"`
41+
- Line ~629: Second `detect-changes` invocation with same arg pattern
42+
- Line ~1646: "Store the returned commit SHA as `INSTRUCTION_DRAFT_SHA`" (after improvement commit)
43+
- Line ~1928: Compression commits instruction file with `refactor: accept final compression ...`
44+
but does NOT explicitly say to recompute `INSTRUCTION_DRAFT_SHA`
45+
46+
**`batch-write-agent/test-results.json`**
47+
- `"test_set_sha": "b40012f59"` — 9-char partial git commit SHA
48+
- SHA-256 of `plugin/skills/batch-write-agent/first-use.md`: `0babde5b6695c94703ce79903a36b3e32858ca30550ac025d3db88d7a0dc8fbd`
49+
50+
### New Design
51+
52+
The new `detectChanges()` compares SHA-256 of the current skill file against the provided SHA-256 hash:
53+
- If hashes match → `skill_changed=false`, all test cases carry forward
54+
- If hashes differ → `skill_changed=true`, all test cases rerun
55+
- No git operations needed; `hasTransitiveDependencyChanged()` removed
56+
- Fine-grained fields (`frontmatter_changed`, `body_changed`, `changed_ranges`, `requires_unit_mapping`) removed
57+
- Simplified output: `skill_changed`, `all_test_case_ids`, `rerun_test_case_ids`, `carryforward_test_case_ids`
58+
59+
**Trade-off**: Companion file changes (e.g., `compression-protocol.md`) are no longer automatically detected,
60+
since only the tracked file's SHA-256 is compared. This is acceptable per the issue scope.
61+
62+
## Jobs
63+
64+
### Job 1
65+
66+
TDD-first implementation in Java and plugin skill updates (all independent files).
67+
68+
- **Write failing tests** for new `detectChanges()` behavior BEFORE modifying production code:
69+
In `client/src/test/java/io/github/cowwoc/cat/hooks/test/InstructionTestRunnerTest.java`:
70+
- Remove all 5 existing `detectChanges*` tests that use git repos and git commit SHAs:
71+
`detectChangesNoChanges`, `detectChangesFrontmatterChanged`, `detectChangesBodyOnlyChanged`,
72+
`detectChangesTransitiveDependencyChanged`, `detectChangesNoTransitiveDependencyChange`
73+
- Add new tests (write failing first, then they pass after production code change):
74+
- `detectChanges_sha256Match_allCarriedForward()`: Create temp skill file with content,
75+
compute its SHA-256, call `detectChanges(sha256, path, testDir)``skill_changed=false`,
76+
all test case IDs in `carryforward_test_case_ids`, `rerun_test_case_ids` empty
77+
- `detectChanges_sha256Mismatch_allRerun()`: Create temp skill file, use a SHA-256 of
78+
different content (e.g., SHA-256 of empty string = `e3b0c44...` 64-char hex), call
79+
`detectChanges(wrongSha256, path, testDir)``skill_changed=true`,
80+
all test case IDs in `rerun_test_case_ids`, `carryforward_test_case_ids` empty
81+
- `detectChanges_invalidSha_shortString_throwsIllegalArgument()`: Call with 9-char hex string
82+
(old git SHA format) → throws `IllegalArgumentException` with message containing "64"
83+
- `detectChanges_invalidSha_notHex_throwsIllegalArgument()`: Call with 64-char string
84+
containing non-hex chars → throws `IllegalArgumentException`
85+
- Run build: `mvn -f client/pom.xml verify -e` — expect FAILURES (new tests fail, old tests deleted)
86+
87+
- **Update `InstructionTestRunner.detectChanges()` method** in
88+
`client/src/main/java/io/github/cowwoc/cat/hooks/skills/InstructionTestRunner.java`:
89+
- Update Javadoc (lines 309-323): Change description from git-SHA-based comparison to
90+
SHA-256 content hash comparison. Remove mention of transitive dependencies, `git show`,
91+
and "changed line ranges". New description:
92+
```
93+
Compares the SHA-256 content hash of the current skill file against the provided hash,
94+
and partitions test cases into rerun vs carry-forward.
95+
@param args [old_skill_sha256, new_skill_path, test_dir_path]
96+
@throws IOException if files cannot be read
97+
```
98+
- Change SHA validation (line 336) from `[0-9a-f]{7,40}` to `[0-9a-f]{64}` (exactly 64 chars).
99+
Update error message: `"invalid SHA-256 content hash format: '" + oldSha + "'. Expected 64 lowercase hex characters."`
100+
- Remove all git operations:
101+
- Lines 347-366: Remove `ProcessRunner.run(..., "git", "rev-parse", "--show-toplevel")` block
102+
- Lines 356-366: Remove relPath derivation
103+
- Lines 360-366: Remove `ProcessRunner.run(..., "git", "show", ...)` block
104+
- Replace old-content temp file logic (lines 368-468) with:
105+
```java
106+
String currentSha = sha256File(newSkillPath);
107+
boolean skillChanged = !currentSha.equals(oldSha);
108+
List<String> allTestCaseIds = readAllTestCaseIds(testDirPath);
109+
JsonMapper mapper = scope.getJsonMapper();
110+
ObjectNode result = mapper.createObjectNode();
111+
result.put("skill_changed", skillChanged);
112+
ArrayNode allIdsArray = mapper.createArrayNode();
113+
for (String id : allTestCaseIds)
114+
allIdsArray.add(id);
115+
result.set("all_test_case_ids", allIdsArray);
116+
if (skillChanged)
117+
{
118+
result.set("rerun_test_case_ids", allIdsArray.deepCopy());
119+
result.set("carryforward_test_case_ids", mapper.createArrayNode());
120+
}
121+
else
122+
{
123+
result.set("rerun_test_case_ids", mapper.createArrayNode());
124+
result.set("carryforward_test_case_ids", allIdsArray.deepCopy());
125+
result.put("semantic_units_path_hint",
126+
"Run: skill-test-runner extract-units " + args[1]);
127+
}
128+
return compactJson(result);
129+
```
130+
- Remove `repoRoot`, `relPath` variables and their usages
131+
- Remove `oldTempFile`, `newTempFile`, `oldBodyFile`, `newBodyFile` and their try-finally blocks
132+
- Remove `frontmatterChanged`, `bodyChanged`, `transitiveDependencyChanged`, `changedRanges` variables
133+
- Remove `hasTransitiveDependencyChanged()` call
134+
135+
- **Remove `hasTransitiveDependencyChanged()` method** (lines 485-508) from `InstructionTestRunner.java`
136+
137+
- **Update `plugin/skills/instruction-builder-agent/first-use.md`**:
138+
1. Lines ~303-308: Change the paragraph that starts "The subagent returns `{"status": "success",
139+
"commit_sha": "<SHA>"}`. Store the SHA as `INSTRUCTION_DRAFT_SHA`" to:
140+
"The subagent returns `{"status": "success", "commit_sha": "<SHA>"}`. Compute `INSTRUCTION_DRAFT_SHA`
141+
as the SHA-256 content hash of `INSTRUCTION_TEXT_PATH`:
142+
```bash
143+
INSTRUCTION_DRAFT_SHA=$(sha256sum "${INSTRUCTION_TEXT_PATH}" | awk '{print $1}')
144+
```
145+
The instruction text is now on disk and committed, so subagents can read it via `cat <INSTRUCTION_TEXT_PATH>`."
146+
(Remove the `git show <SHA>:<INSTRUCTION_TEXT_PATH>` reference since git SHA is no longer tracked.)
147+
2. Lines ~576-580: Retain the `DRAFT_COMMIT` and `INSTRUCTION_TEXT_PATH` derivation lines (still
148+
needed to locate the instruction file). Only change the last line:
149+
- Keep: `DRAFT_COMMIT=$(git log --oneline --all | grep "write instruction draft" | head -1 | awk '{print $1}')`
150+
- Keep: `INSTRUCTION_TEXT_PATH=$(git show "${DRAFT_COMMIT}" --name-only --format='' | grep -v '^$' | head -1)`
151+
- Change: `INSTRUCTION_DRAFT_SHA="${DRAFT_COMMIT}"` →
152+
`INSTRUCTION_DRAFT_SHA=$(sha256sum "${INSTRUCTION_TEXT_PATH}" | awk '{print $1}')`
153+
Update the comment on the last line from `# Compute INSTRUCTION_DRAFT_SHA` to
154+
`# Compute INSTRUCTION_DRAFT_SHA as SHA-256 of the current instruction file content`.
155+
3. Line ~1646: Change "Store the returned commit SHA as `INSTRUCTION_DRAFT_SHA` before returning
156+
to Step 6." to "Compute `INSTRUCTION_DRAFT_SHA` as SHA-256 of the updated
157+
`INSTRUCTION_TEXT_PATH` and store it before returning to Step 6:
158+
```bash
159+
INSTRUCTION_DRAFT_SHA=$(sha256sum "${INSTRUCTION_TEXT_PATH}" | awk '{print $1}')
160+
```"
161+
Also update the continuation shortcut paragraph at lines ~1648-1653 that says
162+
"committed with new INSTRUCTION_DRAFT_SHA abc123" — change the example value
163+
`abc123` to a description of the new format, e.g.:
164+
"committed with new INSTRUCTION_DRAFT_SHA <64-char-sha256>"
165+
to clarify that `INSTRUCTION_DRAFT_SHA` is now a 64-character SHA-256 hex string, not a
166+
short git commit SHA.
167+
4. Lines ~631-648 (the output fields list and test-case-selection table):
168+
- Update the `skill_changed` field description: change from "whether the skill or any of its
169+
transitive dependencies changed since the last SPRT run. Transitive dependencies are all
170+
`.md` files co-located with the skill file..." to "whether the skill file content changed —
171+
`true` when the SHA-256 of the current file differs from the provided hash."
172+
- Remove `prior_test_case_ids` from the output fields list (this field no longer exists).
173+
- Add `rerun_test_case_ids` and `carryforward_test_case_ids` to the output fields list:
174+
- `rerun_test_case_ids`: test case IDs that must re-run (all IDs when `skill_changed=true`,
175+
empty when `skill_changed=false`)
176+
- `carryforward_test_case_ids`: test case IDs that carry forward from prior results (all IDs
177+
when `skill_changed=false`, empty when `skill_changed=true`)
178+
- Update the three-row test-case-selection table (currently references `prior_test_case_ids`).
179+
Replace it with a two-row table:
180+
| Condition | Which test cases to run |
181+
|-----------|------------------------|
182+
| `skill_changed: true` | **All** test cases (full SPRT re-run). |
183+
| `skill_changed: false` | Carry all results forward. Skip re-test entirely. |
184+
(The "new test cases exist" row is removed because this detection required `prior_test_case_ids`
185+
which is no longer output by `detect-changes`.)
186+
5. After compression commit (around line 1928, the `refactor: accept final compression` commit):
187+
Add a sentence: "Recompute `INSTRUCTION_DRAFT_SHA` as SHA-256 of the compressed file —
188+
any content change requires a fresh SHA:
189+
```bash
190+
INSTRUCTION_DRAFT_SHA=$(sha256sum "${INSTRUCTION_TEXT_PATH}" | awk '{print $1}')
191+
```
192+
This ensures the next `detect-changes` call correctly detects that the skill changed
193+
(full SPRT re-run required)."
194+
195+
- **Update `plugin/tests/skills/batch-write-agent/first-use/test-results.json`**:
196+
Change `"test_set_sha": "b40012f59"` to
197+
`"test_set_sha": "0babde5b6695c94703ce79903a36b3e32858ca30550ac025d3db88d7a0dc8fbd"`
198+
(SHA-256 of `plugin/skills/batch-write-agent/first-use.md`)
199+
200+
- **Run the full build** after all changes:
201+
```bash
202+
mvn -f client/pom.xml verify -e
203+
```
204+
All tests must pass. Fix any linter errors (Checkstyle, PMD) before committing.
205+
206+
- **Commit** all changes (client Java + plugin skill + test data) in a single commit:
207+
```
208+
bugfix: fix detect-changes to use SHA-256 content hash instead of git commit SHA
209+
```
210+
(Commit type `bugfix:` because it fixes a bug where git SHA was used as a proxy for file
211+
identity, which could miss changes and used an incorrect format for test_set_sha.)
212+
213+
- **Update index.json**: status=closed, progress=100%
214+
215+
## Commit Type
216+
217+
`bugfix:` (Java client + plugin skill files + test data all relate to the same fix)

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package io.github.cowwoc.cat.hooks;
88

99
import io.github.cowwoc.cat.hooks.skills.EmpiricalTestRunner;
10+
import io.github.cowwoc.cat.hooks.skills.InstructionTestRunner;
1011
import io.github.cowwoc.cat.hooks.util.IssueDiscovery;
1112
import tools.jackson.databind.json.JsonMapper;
1213

@@ -35,6 +36,7 @@ public final class SharedSecrets
3536
private static PostToolUseFailureHookAccess postToolUseFailureHookAccess;
3637
private static IssueDiscoveryAccess issueDiscoveryAccess;
3738
private static EmpiricalTestRunnerAccess empiricalTestRunnerAccess;
39+
private static InstructionTestRunnerAccess instructionTestRunnerAccess;
3840

3941
private SharedSecrets()
4042
{
@@ -168,6 +170,33 @@ public static void removeTestWorktree(Path baseRepo, Path worktreePath)
168170
empiricalTestRunnerAccess.removeTestWorktree(baseRepo, worktreePath);
169171
}
170172

173+
/**
174+
* Registers the access object for {@link InstructionTestRunner}.
175+
*
176+
* @param access the access object
177+
* @throws NullPointerException if {@code access} is null
178+
*/
179+
public static void setInstructionTestRunnerAccess(InstructionTestRunnerAccess access)
180+
{
181+
requireThat(access, "access").isNotNull();
182+
instructionTestRunnerAccess = access;
183+
}
184+
185+
/**
186+
* Computes the SHA-256 hex digest of the given bytes.
187+
*
188+
* @param bytes the bytes to hash
189+
* @return lowercase hex SHA-256 digest
190+
* @throws NullPointerException if {@code bytes} is null
191+
*/
192+
public static String sha256Bytes(byte[] bytes)
193+
{
194+
requireThat(bytes, "bytes").isNotNull();
195+
if (instructionTestRunnerAccess == null)
196+
initialize(InstructionTestRunner.class);
197+
return instructionTestRunnerAccess.sha256Bytes(bytes);
198+
}
199+
171200
/**
172201
* Initializes a class. If the class is already initialized, this method has no effect.
173202
*
@@ -233,6 +262,21 @@ public interface IssueDiscoveryAccess
233262
String getIssueStatus(String content, Path indexPath, JsonMapper mapper) throws IOException;
234263
}
235264

265+
/**
266+
* Provides access to {@link InstructionTestRunner} cryptographic helpers.
267+
*/
268+
@FunctionalInterface
269+
public interface InstructionTestRunnerAccess
270+
{
271+
/**
272+
* Computes the SHA-256 hex digest of the given bytes.
273+
*
274+
* @param bytes the bytes to hash
275+
* @return lowercase hex SHA-256 digest
276+
*/
277+
String sha256Bytes(byte[] bytes);
278+
}
279+
236280
/**
237281
* Provides access to {@link EmpiricalTestRunner} trial worktree management.
238282
*/

0 commit comments

Comments
 (0)