claude-dev-env 1.64.3 → 1.65.1

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.
@@ -6,8 +6,10 @@ project, so a constant defined there is never proven dead by a single-file scan
6
6
  alone. This check resolves the enclosing package tree — the scan root — and
7
7
  flags an UPPER_SNAKE constant defined in the written module whose name appears
8
8
  in no ``.py`` module anywhere under that root: not as an imported name, not as a
9
- read, not as a re-export. That is the ``MEDIUM_TEXT``-style dead constant the
10
- CODE_RULES §9.8 dead-code rule targets, caught at Write/Edit time before the
9
+ read, not as a re-export. When a constant looks dead in the package tree, the
10
+ scan widens to the whole repository so a consumer in a sibling tree counts
11
+ before the constant is flagged. That is the ``MEDIUM_TEXT``-style dead constant
12
+ the CODE_RULES §9.8 dead-code rule targets, caught at Write/Edit time before the
11
13
  unused constant lands.
12
14
 
13
15
  The scan is deliberately conservative to keep false positives near zero:
@@ -18,11 +20,19 @@ The scan is deliberately conservative to keep false positives near zero:
18
20
  surface explicitly, so a name listed there is live by declaration and a name
19
21
  absent there is the author's stated intent, neither of which this check second
20
22
  guesses.
21
- - A constant is live when its name appears anywhere under the scan root
23
+ - A constant is live when its name appears anywhere the scan reaches
22
24
  imported, read, listed in ``__all__``, or referenced in a string annotation —
23
25
  in any ``.py`` module, including the constants module itself.
24
- - Test modules under the scan root still count as references, so a constant used
25
- only by a test stays live.
26
+ - When the package-tree scan leaves a constant unreferenced, the scan widens to
27
+ the repository root (the nearest ``.git`` ancestor) so a consumer in a sibling
28
+ tree of the same repository counts; a module outside any repository is judged
29
+ on the package-tree scan alone. The widened pass skips the package subtree the
30
+ first pass already covered, so no file is read twice.
31
+ - The combined file count of the package-tree and widened passes is bounded by a
32
+ cap, so a write under an unexpectedly large tree cannot stall the hook; a write
33
+ whose scan hits the cap is treated as "cannot prove dead" and flags nothing.
34
+ - Test modules under the scanned tree still count as references, so a constant
35
+ used only by a test stays live.
26
36
  """
27
37
 
28
38
  import ast
@@ -48,6 +58,7 @@ from hooks_constants.dead_module_constant_constants import ( # noqa: E402
48
58
  DEAD_MODULE_CONSTANT_GUIDANCE,
49
59
  DUNDER_ALL_NAME,
50
60
  DUNDER_INIT_FILENAME,
61
+ GIT_DIRECTORY_NAME,
51
62
  MAX_DEAD_MODULE_CONSTANT_ISSUES,
52
63
  MAX_SCAN_ROOT_FILE_COUNT,
53
64
  MINIMUM_UPPER_SNAKE_LENGTH,
@@ -197,46 +208,100 @@ def _scan_root_for_constants_module(file_path: str) -> Path:
197
208
  return enclosing_directory
198
209
 
199
210
 
211
+ def _is_under_directory(candidate_path: Path, ancestor_directory: Path) -> bool:
212
+ """Return whether a resolved path lies inside a resolved ancestor directory.
213
+
214
+ Args:
215
+ candidate_path: The resolved file path to test.
216
+ ancestor_directory: The resolved directory that may contain the path.
217
+
218
+ Returns:
219
+ True when ``candidate_path`` is the ancestor directory itself or a
220
+ descendant of it, False otherwise.
221
+ """
222
+ try:
223
+ candidate_path.relative_to(ancestor_directory)
224
+ except ValueError:
225
+ return False
226
+ return True
227
+
228
+
200
229
  def _all_referenced_names_under_root(
201
230
  scan_root: Path,
202
231
  written_path: Path,
203
232
  written_content: str,
204
- ) -> tuple[set[str], bool]:
205
- """Return referenced names under the scan root and whether the file cap was hit.
233
+ already_scanned_count: int = 0,
234
+ excluded_subtree: Path | None = None,
235
+ ) -> tuple[set[str], int, bool]:
236
+ """Return referenced names under the scan root, the running count, and a cap flag.
206
237
 
207
238
  The written module's on-disk text is replaced by ``written_content`` so the
208
- post-edit view is judged, never the stale disk copy. Sibling modules are
209
- read from disk. Reading stops after the configured file cap so a write under
210
- an unexpectedly large tree cannot stall the hook; the boolean signals the
211
- caller to treat that case as "cannot prove dead".
239
+ post-edit view is judged, never the stale disk copy. Sibling modules are read
240
+ from disk. Reading stops once the running file count exceeds the configured
241
+ cap so a write under an unexpectedly large tree cannot stall the hook; the
242
+ boolean signals the caller to treat that case as "cannot prove dead". When
243
+ ``excluded_subtree`` is supplied, every ``.py`` module under that directory is
244
+ skipped, so the widened repository scan never re-reads a file the
245
+ package-tree scan already covered.
212
246
 
213
247
  Args:
214
248
  scan_root: The directory tree to scan.
215
249
  written_path: The resolved path of the module being written.
216
250
  written_content: The post-edit text of the written module.
251
+ already_scanned_count: The file count accumulated by a prior pass, so the
252
+ cap bounds the combined work of the package-tree and widened passes.
253
+ excluded_subtree: A resolved directory whose ``.py`` modules are skipped,
254
+ or None to scan every file under the root.
217
255
 
218
256
  Returns:
219
- A (referenced_names, cap_was_hit) pair. The name set is the union across
220
- every scanned module; cap_was_hit is True when the scan stopped at the
221
- configured file cap before scanning the whole tree.
257
+ A (referenced_names, running_count, cap_was_hit) triple. The name set is
258
+ the union across every scanned module, unioned with the names the written
259
+ module itself references; running_count is the cumulative file count
260
+ including ``already_scanned_count``; cap_was_hit is True when the scan
261
+ stopped at the configured file cap before scanning the whole tree.
222
262
  """
223
263
  all_referenced_names = _referenced_names_in_source(written_content, load_only=True)
224
264
  written_path_key = os.path.normcase(str(written_path))
225
- scanned_file_count = 1
265
+ scanned_file_count = already_scanned_count
226
266
  for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
227
267
  if not each_path.is_file():
228
268
  continue
229
- if os.path.normcase(str(each_path.resolve())) == written_path_key:
269
+ resolved_path = each_path.resolve()
270
+ if os.path.normcase(str(resolved_path)) == written_path_key:
271
+ continue
272
+ if excluded_subtree is not None and _is_under_directory(resolved_path, excluded_subtree):
230
273
  continue
231
274
  scanned_file_count += 1
232
275
  if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
233
- return all_referenced_names, True
276
+ return all_referenced_names, scanned_file_count, True
234
277
  try:
235
278
  sibling_source = each_path.read_text(encoding="utf-8")
236
279
  except (OSError, UnicodeDecodeError):
237
280
  continue
238
281
  all_referenced_names |= _referenced_names_in_source(sibling_source)
239
- return all_referenced_names, False
282
+ return all_referenced_names, scanned_file_count, False
283
+
284
+
285
+ def _repository_root_for(written_path: Path) -> Path | None:
286
+ """Return the nearest ancestor directory that holds a ``.git`` entry.
287
+
288
+ Walks upward from the written module toward the filesystem root. A normal
289
+ checkout carries a ``.git`` directory and a git worktree carries a ``.git``
290
+ file; both satisfy ``exists()``. The repository root bounds the widened
291
+ cross-tree reference scan.
292
+
293
+ Args:
294
+ written_path: The resolved path of the constants module being written.
295
+
296
+ Returns:
297
+ The repository root directory, or ``None`` when no ancestor carries a
298
+ ``.git`` entry, so a module outside any repository triggers no widened
299
+ scan.
300
+ """
301
+ for each_ancestor in written_path.parents:
302
+ if (each_ancestor / GIT_DIRECTORY_NAME).exists():
303
+ return each_ancestor
304
+ return None
240
305
 
241
306
 
242
307
  def _module_is_exempt_from_constant_check(file_path: str) -> bool:
@@ -269,10 +334,14 @@ def check_dead_module_constants(
269
334
  Runs only on a dedicated constants module (``*_constants.py`` or a module
270
335
  under ``config/``); every other production module's file-global constants
271
336
  are governed by the use-count rule instead. A constant is dead when its name
272
- appears in no ``.py`` module anywhere under the enclosing package tree not
273
- imported, not read, not listed in an ``__all__`` literal, not named in a
274
- string annotation. A module declaring its own ``__all__`` is skipped so the
275
- author's explicit export surface is never second-guessed. Whole-file
337
+ appears in no ``.py`` module under the enclosing package tree, nor anywhere
338
+ in the repository the scan widens to when the package-tree scan leaves the
339
+ constant unreferenced not imported, not read, not listed in an ``__all__``
340
+ literal, not named in a string annotation. A module declaring its own
341
+ ``__all__`` is skipped so the author's explicit export surface is never
342
+ second-guessed. A scan whose combined package-tree and widened file count
343
+ exceeds the configured cap returns ``[]`` (cannot prove dead), bounding the
344
+ work so the blocking hook cannot stall under a large tree. Whole-file
276
345
  analysis runs against ``full_file_content`` when supplied so an Edit fragment
277
346
  is judged against the reconstructed post-edit file.
278
347
 
@@ -285,7 +354,9 @@ def check_dead_module_constants(
285
354
 
286
355
  Returns:
287
356
  One violation message per dead module-level constant, capped at the
288
- configured maximum.
357
+ configured maximum. Returns an empty list when the file is exempt, no
358
+ constant is defined, the module declares ``__all__``, the scan exceeds the
359
+ file cap, or a SyntaxError prevents parsing.
289
360
  """
290
361
  if _module_is_exempt_from_constant_check(file_path):
291
362
  return []
@@ -301,13 +372,29 @@ def check_dead_module_constants(
301
372
  return []
302
373
  scan_root = _scan_root_for_constants_module(file_path)
303
374
  written_path = Path(file_path).resolve()
304
- all_referenced_names, cap_was_hit = _all_referenced_names_under_root(
375
+ all_referenced_names, scanned_file_count, cap_was_hit = _all_referenced_names_under_root(
305
376
  scan_root,
306
377
  written_path,
307
378
  effective_content,
308
379
  )
309
380
  if cap_was_hit:
310
381
  return []
382
+ has_unreferenced_constant = any(
383
+ each_name not in all_referenced_names for each_name, _ in constant_definitions
384
+ )
385
+ if has_unreferenced_constant:
386
+ repository_root = _repository_root_for(written_path)
387
+ if repository_root is not None and repository_root != scan_root:
388
+ widened_names, _widened_count, widened_cap_was_hit = _all_referenced_names_under_root(
389
+ repository_root,
390
+ written_path,
391
+ effective_content,
392
+ already_scanned_count=scanned_file_count,
393
+ excluded_subtree=scan_root,
394
+ )
395
+ if widened_cap_was_hit:
396
+ return []
397
+ all_referenced_names |= widened_names
311
398
  issues: list[str] = []
312
399
  for each_name, each_line in constant_definitions:
313
400
  if each_name in all_referenced_names:
@@ -39,8 +39,13 @@ def neutral_root() -> Iterator[Path]:
39
39
  own ``tmp_path`` directory name embeds the test name, which would make every
40
40
  synthetic constants path look like a test file. A neutral ``mkdtemp`` root
41
41
  mirrors how a production constants module path looks.
42
+
43
+ A ``.git`` marker is planted at the root so the cross-tree widening resolves
44
+ the repository root to this synthetic tree, never an enclosing real
45
+ checkout, keeping every test bounded and deterministic.
42
46
  """
43
47
  neutral_directory = Path(tempfile.mkdtemp(prefix="deadconst-")).resolve()
48
+ (neutral_directory / ".git").mkdir()
44
49
  try:
45
50
  yield neutral_directory
46
51
  finally:
@@ -186,3 +191,86 @@ def test_is_skipped_on_a_constants_test_file(neutral_root: Path) -> None:
186
191
  test_constants_path.write_text(body, encoding="utf-8")
187
192
  issues = _check(body, str(test_constants_path))
188
193
  assert issues == [], f"Test files are exempt, got: {issues}"
194
+
195
+
196
+ def _build_cross_tree_repository(
197
+ repository_root: Path,
198
+ constants_body: str,
199
+ sibling_consumer_body: str,
200
+ ) -> Path:
201
+ config_directory = repository_root / "shared" / "theme_db" / "config"
202
+ config_directory.mkdir(parents=True)
203
+ constants_path = config_directory / "constants.py"
204
+ constants_path.write_text(constants_body, encoding="utf-8")
205
+ sibling_directory = repository_root / "cdp"
206
+ sibling_directory.mkdir(parents=True)
207
+ (sibling_directory / "tally.py").write_text(sibling_consumer_body, encoding="utf-8")
208
+ return constants_path
209
+
210
+
211
+ def test_does_not_flag_constant_used_only_in_a_sibling_tree(neutral_root: Path) -> None:
212
+ constants_body = 'CROSS_TREE_CONSTANT = "cross"\nLOCALLY_DEAD_CONSTANT = "dead"\n'
213
+ sibling_consumer_body = (
214
+ "from shared.theme_db.config.constants import CROSS_TREE_CONSTANT\n"
215
+ "\n"
216
+ "def tally() -> str:\n"
217
+ " return CROSS_TREE_CONSTANT\n"
218
+ )
219
+ constants_path = _build_cross_tree_repository(
220
+ neutral_root, constants_body, sibling_consumer_body
221
+ )
222
+ issues = _check(constants_body, str(constants_path))
223
+ assert not any("CROSS_TREE_CONSTANT" in each_issue for each_issue in issues), (
224
+ f"A constant consumed by a sibling tree in the repository must not be flagged, got: {issues}"
225
+ )
226
+ assert any("LOCALLY_DEAD_CONSTANT" in each_issue for each_issue in issues), (
227
+ f"A constant referenced nowhere in the repository stays flagged, got: {issues}"
228
+ )
229
+
230
+
231
+ def test_returns_empty_list_at_file_cap(
232
+ neutral_root: Path, monkeypatch: pytest.MonkeyPatch
233
+ ) -> None:
234
+ monkeypatch.setattr("code_rules_dead_module_constant.MAX_SCAN_ROOT_FILE_COUNT", 0)
235
+ constants_path = _build_constants_package(
236
+ neutral_root / "workflow",
237
+ CONSTANTS_BODY,
238
+ "def noop() -> None:\n pass\n",
239
+ )
240
+ issues = _check(CONSTANTS_BODY, str(constants_path))
241
+ assert issues == [], f"File cap hit must return [] (cannot prove dead), got: {issues}"
242
+
243
+
244
+ def test_widened_scan_reads_each_file_at_most_once(
245
+ neutral_root: Path, monkeypatch: pytest.MonkeyPatch
246
+ ) -> None:
247
+ constants_body = 'CROSS_TREE_CONSTANT = "cross"\nLOCALLY_DEAD_CONSTANT = "dead"\n'
248
+ sibling_consumer_body = (
249
+ "from shared.theme_db.config.constants import CROSS_TREE_CONSTANT\n"
250
+ "\n"
251
+ "def tally() -> str:\n"
252
+ " return CROSS_TREE_CONSTANT\n"
253
+ )
254
+ constants_path = _build_cross_tree_repository(
255
+ neutral_root, constants_body, sibling_consumer_body
256
+ )
257
+ package_tree_neighbor = constants_path.parent.parent / "neighbor.py"
258
+ package_tree_neighbor.write_text(
259
+ "def neighbor() -> int:\n return 1\n", encoding="utf-8"
260
+ )
261
+ read_counts: dict[str, int] = {}
262
+ original_read_text = Path.read_text
263
+
264
+ def counting_read_text(self: Path, *positional: object, **keyword: object) -> str:
265
+ normalized_key = os.path.normcase(str(self.resolve()))
266
+ read_counts[normalized_key] = read_counts.get(normalized_key, 0) + 1
267
+ return original_read_text(self, *positional, **keyword) # type: ignore[arg-type] # forwards args
268
+
269
+ monkeypatch.setattr(Path, "read_text", counting_read_text)
270
+ _check(constants_body, str(constants_path))
271
+ over_read_paths = {
272
+ each_path: each_count for each_path, each_count in read_counts.items() if each_count > 1
273
+ }
274
+ assert not over_read_paths, (
275
+ f"Widening must read each .py file at most once, got over-reads: {over_read_paths}"
276
+ )
@@ -10,6 +10,7 @@ DUNDER_INIT_FILENAME: str = "__init__.py"
10
10
  CONSTANTS_MODULE_SUFFIX: str = "_constants.py"
11
11
  CONFIG_DIRECTORY_SEGMENT: str = "config"
12
12
  DUNDER_ALL_NAME: str = "__all__"
13
+ GIT_DIRECTORY_NAME: str = ".git"
13
14
  MINIMUM_UPPER_SNAKE_LENGTH: int = 2
14
15
  MAX_DEAD_MODULE_CONSTANT_ISSUES: int = 25
15
16
  MAX_SCAN_ROOT_FILE_COUNT: int = 2000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.64.3",
3
+ "version": "1.65.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,185 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
+
10
+ function functionSource(functionName) {
11
+ const functionStart = convergeSource.indexOf(`function ${functionName}(`);
12
+ assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
13
+ const nextMatch = /\n(?:async )?function /.exec(convergeSource.slice(functionStart + 1));
14
+ const functionEnd =
15
+ nextMatch === null ? convergeSource.length : functionStart + 1 + nextMatch.index;
16
+ return convergeSource.slice(functionStart, functionEnd);
17
+ }
18
+
19
+ function constantLine(constantName) {
20
+ const matchedLine = convergeSource
21
+ .split('\n')
22
+ .find((eachLine) => eachLine.trimStart().startsWith(`const ${constantName} =`));
23
+ assert.ok(matchedLine, `expected ${constantName} to be declared`);
24
+ return matchedLine;
25
+ }
26
+
27
+ function schemaSource(schemaName, nextDeclaration) {
28
+ const schemaStart = convergeSource.indexOf(`const ${schemaName} = {`);
29
+ assert.notEqual(schemaStart, -1, `expected ${schemaName} to exist`);
30
+ const schemaEnd = convergeSource.indexOf(`const ${nextDeclaration}`, schemaStart);
31
+ assert.notEqual(schemaEnd, -1, `expected ${nextDeclaration} to follow ${schemaName}`);
32
+ return convergeSource.slice(schemaStart, schemaEnd);
33
+ }
34
+
35
+ const pureModule = new Function(
36
+ `${functionSource('commitNeedsCodeRecovery')}\n` + 'return { commitNeedsCodeRecovery };',
37
+ )();
38
+
39
+ const { commitNeedsCodeRecovery } = pureModule;
40
+
41
+ test('a dead commit agent (null result) does not need code recovery', () => {
42
+ assert.equal(commitNeedsCodeRecovery(null), false);
43
+ });
44
+
45
+ test('a pushed commit does not need code recovery even with the flag and detail set', () => {
46
+ assert.equal(
47
+ commitNeedsCodeRecovery({ pushed: true, blockedNeedingEdit: true, blockerDetail: 'CODE_RULES' }),
48
+ false,
49
+ );
50
+ });
51
+
52
+ test('a transient failure (flag false, empty detail) does not need code recovery', () => {
53
+ assert.equal(
54
+ commitNeedsCodeRecovery({ pushed: false, blockedNeedingEdit: false, blockerDetail: '' }),
55
+ false,
56
+ );
57
+ });
58
+
59
+ test('a code-edit block (flag true, concrete detail) needs code recovery', () => {
60
+ assert.equal(
61
+ commitNeedsCodeRecovery({
62
+ pushed: false,
63
+ blockedNeedingEdit: true,
64
+ blockerDetail: 'BLOCKED [code-rules]: collection param needs all_ prefix',
65
+ }),
66
+ true,
67
+ );
68
+ });
69
+
70
+ test('a flagged block with an empty detail does not need code recovery', () => {
71
+ assert.equal(
72
+ commitNeedsCodeRecovery({ pushed: false, blockedNeedingEdit: true, blockerDetail: '' }),
73
+ false,
74
+ );
75
+ });
76
+
77
+ test('a detail without the flag does not need code recovery', () => {
78
+ assert.equal(
79
+ commitNeedsCodeRecovery({ pushed: false, blockedNeedingEdit: false, blockerDetail: 'some text' }),
80
+ false,
81
+ );
82
+ });
83
+
84
+ test('FIX_SCHEMA declares blockedNeedingEdit and blockerDetail as properties', () => {
85
+ const fixSchema = schemaSource('FIX_SCHEMA', 'EDIT_SCHEMA');
86
+ assert.match(fixSchema, /blockedNeedingEdit:\s*\{[\s\S]*?type:\s*'boolean'/);
87
+ assert.match(fixSchema, /blockerDetail:\s*\{[\s\S]*?type:\s*'string'/);
88
+ });
89
+
90
+ test('FIX_SCHEMA requires blockedNeedingEdit and blockerDetail', () => {
91
+ const fixSchema = schemaSource('FIX_SCHEMA', 'EDIT_SCHEMA');
92
+ const requiredMatch = /required:\s*\[([^\]]*)\]/.exec(fixSchema);
93
+ assert.ok(requiredMatch, 'expected FIX_SCHEMA to carry a required array');
94
+ assert.match(requiredMatch[1], /blockedNeedingEdit/);
95
+ assert.match(requiredMatch[1], /blockerDetail/);
96
+ });
97
+
98
+ test('FIX_RECOVERY_MAX_ATTEMPTS is declared and bounds the recovery loop at 2', () => {
99
+ assert.match(constantLine('FIX_RECOVERY_MAX_ATTEMPTS'), /=\s*2\s*$/);
100
+ });
101
+
102
+ for (const commitFunctionName of ['commitVerifiedFixes', 'commitRepairFixes']) {
103
+ test(`${commitFunctionName} prompt separates an edit-requiring block from a transient failure`, () => {
104
+ const commitBody = functionSource(commitFunctionName);
105
+ assert.match(commitBody, /blockedNeedingEdit/, 'expected the edit-block flag to be set in the prompt');
106
+ assert.match(commitBody, /blockerDetail/, 'expected the verbatim blocker detail to be requested');
107
+ assert.match(
108
+ commitBody,
109
+ /code_rules_gate|CODE_RULES/,
110
+ 'expected the commit prompt to name the CODE_RULES commit gate as an edit-requiring block',
111
+ );
112
+ assert.match(
113
+ commitBody,
114
+ /transient/i,
115
+ 'expected the commit prompt to name the transient (non-code) failure case',
116
+ );
117
+ });
118
+ }
119
+
120
+ test('recoverCommitBlockEdit is a clean-coder edit step bound to the blocker detail and leaves changes uncommitted', () => {
121
+ const recoverBody = functionSource('recoverCommitBlockEdit');
122
+ assert.match(recoverBody, /agentType:\s*'clean-coder'/, 'expected the fixer to use clean-coder');
123
+ assert.match(recoverBody, /schema:\s*EDIT_SCHEMA/, 'expected the fixer to reuse EDIT_SCHEMA');
124
+ assert.match(recoverBody, /label:\s*`fix-recover:/, 'expected the fix-recover label');
125
+ assert.match(recoverBody, /blockerDetail/, 'expected the fixer prompt to consume the blocker detail');
126
+ assert.match(
127
+ recoverBody,
128
+ /only the (?:violation|finding|block)/i,
129
+ 'expected the fixer to be scoped to only the blocking violation',
130
+ );
131
+ assert.match(
132
+ recoverBody,
133
+ /do not commit and do not push|NO commit and NO push|Do NOT commit|leave .*uncommitted|uncommitted/i,
134
+ 'expected the fixer to leave its fix uncommitted for the re-verify and retry commit',
135
+ );
136
+ });
137
+
138
+ test('commitWithRecovery bounds the loop, re-verifies, and retries the commit on a code block', () => {
139
+ const recoveryBody = functionSource('commitWithRecovery');
140
+ assert.match(recoveryBody, /commitNeedsCodeRecovery\(/, 'expected the loop guard to call commitNeedsCodeRecovery');
141
+ assert.match(
142
+ recoveryBody,
143
+ /attempt\s*<\s*FIX_RECOVERY_MAX_ATTEMPTS/,
144
+ 'expected the loop to be bounded by FIX_RECOVERY_MAX_ATTEMPTS',
145
+ );
146
+ assert.match(recoveryBody, /runRecoverEdit\(/, 'expected the loop to spawn the recover-edit fixer');
147
+ assert.match(recoveryBody, /runVerify\(/, 'expected the loop to re-verify after the fixer edit');
148
+ assert.match(recoveryBody, /verdictPassed\(/, 'expected a fresh verdict to gate the retry commit');
149
+ assert.match(recoveryBody, /runCommit\(/, 'expected the loop to retry the commit');
150
+ const editGuardIndex = recoveryBody.search(/edited\s*!==\s*true/);
151
+ const verifyGateIndex = recoveryBody.search(/verdictPassed\(/);
152
+ assert.notEqual(editGuardIndex, -1, 'expected an early break when the fixer made no edit');
153
+ assert.ok(
154
+ editGuardIndex < verifyGateIndex,
155
+ 'expected the no-edit break to precede the re-verify gate',
156
+ );
157
+ const recoverEditIndex = recoveryBody.search(/runRecoverEdit\(/);
158
+ const reverifyIndex = recoveryBody.search(/runVerify\(/);
159
+ const retryCommitIndex = recoveryBody.lastIndexOf('runCommit(');
160
+ assert.ok(
161
+ recoverEditIndex < reverifyIndex && reverifyIndex < retryCommitIndex,
162
+ 'expected order recover-edit -> re-verify -> retry commit, so a verify/commit swap fails',
163
+ );
164
+ });
165
+
166
+ test('applyFixes routes its commit through commitWithRecovery wired to the fix-path steps', () => {
167
+ const applyFixesBody = functionSource('applyFixes');
168
+ assert.match(applyFixesBody, /commitWithRecovery\(/, 'expected applyFixes to call commitWithRecovery');
169
+ assert.match(applyFixesBody, /runCommit:\s*\(\)\s*=>\s*commitVerifiedFixes\(/);
170
+ assert.match(applyFixesBody, /runVerify:\s*\(\)\s*=>\s*verifyFixesInWorkingTree\(/);
171
+ assert.match(applyFixesBody, /runRecoverEdit:[\s\S]*?recoverCommitBlockEdit\(/);
172
+ });
173
+
174
+ test('repairConvergence routes its commit through commitWithRecovery wired to the repair-path steps', () => {
175
+ const repairBody = functionSource('repairConvergence');
176
+ assert.match(repairBody, /commitWithRecovery\(/, 'expected repairConvergence to call commitWithRecovery');
177
+ assert.match(repairBody, /runCommit:\s*\(\)\s*=>\s*commitRepairFixes\(/);
178
+ assert.match(repairBody, /runVerify:\s*\(\)\s*=>\s*verifyRepairChanges\(/);
179
+ assert.match(repairBody, /runRecoverEdit:[\s\S]*?recoverCommitBlockEdit\(/);
180
+ });
181
+
182
+ test('the round-loop fix-stalled blockers survive the recovery wiring', () => {
183
+ assert.match(convergeSource, /fix lens landed no push for/);
184
+ assert.match(convergeSource, /copilot fix lens landed no push for/);
185
+ });
@@ -101,8 +101,10 @@ const FIX_SCHEMA = {
101
101
  pushed: { type: 'boolean' },
102
102
  resolvedWithoutCommit: { type: 'boolean', description: 'true when every finding was already addressed so no code change was made, yet each finding thread was still resolved — the round advances rather than stalling' },
103
103
  summary: { type: 'string' },
104
+ blockedNeedingEdit: { type: 'boolean', description: 'true only when the commit or push was rejected by a commit-time hook or gate whose message requires a code change (for example a CODE_RULES violation the fix introduced), not a transient or auth failure' },
105
+ blockerDetail: { type: 'string', description: 'verbatim hook or gate rejection text naming the file and rule that must change, or an empty string when no edit-requiring block occurred' },
104
106
  },
105
- required: ['newSha', 'pushed', 'resolvedWithoutCommit', 'summary'],
107
+ required: ['newSha', 'pushed', 'resolvedWithoutCommit', 'summary', 'blockedNeedingEdit', 'blockerDetail'],
106
108
  }
107
109
 
108
110
  const EDIT_SCHEMA = {
@@ -434,6 +436,25 @@ function detectFixProgress(fixResult, priorHead, hadThreadBearingFinding) {
434
436
  return { progressed, newSha }
435
437
  }
436
438
 
439
+ /**
440
+ * Decide whether a commit step was blocked by a commit-time hook or gate that
441
+ * requires a code change, so the recovery loop should route back to a fixer. A
442
+ * null result, a successful push, a transient failure (blockedNeedingEdit
443
+ * false), or a flagged block carrying no detail all read as not-needing-recovery,
444
+ * so only a flagged block with a concrete message routes to the fixer.
445
+ * @param {object|null} commitResult the FIX_SCHEMA result, or null on agent failure
446
+ * @returns {boolean} true only when the commit needs a code-edit recovery pass
447
+ */
448
+ function commitNeedsCodeRecovery(commitResult) {
449
+ if (commitResult == null) return false
450
+ if (commitResult.pushed === true) return false
451
+ return (
452
+ commitResult.blockedNeedingEdit === true &&
453
+ typeof commitResult.blockerDetail === 'string' &&
454
+ commitResult.blockerDetail.length > 0
455
+ )
456
+ }
457
+
437
458
  /**
438
459
  * Decide whether a resolved HEAD SHA is safe to spawn lenses against. A dead
439
460
  * resolve-head agent or a malformed result yields a falsy SHA; spawning lenses
@@ -753,11 +774,68 @@ function commitVerifiedFixes(head, sourceLabel) {
753
774
  `Rules:\n` +
754
775
  `- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the commit would be blocked. Do not run a formatter, do not touch a test, do not re-fix anything — only commit and push what is already there.\n` +
755
776
  `- Make ONE commit for all the working-tree fixes, then push to the PR branch.\n\n` +
756
- `Return values: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false, and a one-line summary. If the commit or push is blocked or fails, return newSha=${head}, pushed=false, resolvedWithoutCommit=false, and a summary naming the failure.`,
777
+ `Return values:\n` +
778
+ `- On a successful push: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false, blockedNeedingEdit=false, blockerDetail="", and a one-line summary.\n` +
779
+ `- When a commit-time hook or gate (for example code_rules_gate, the CODE_RULES commit gate) rejects the commit because the fix needs a code change: keep the no-edit rule, return newSha=${head}, pushed=false, resolvedWithoutCommit=false, blockedNeedingEdit=true, blockerDetail=<the verbatim hook message naming the file and rule>, and a summary. A recovery fixer runs after you to clear it.\n` +
780
+ `- On a transient or non-code failure (auth, network, a non-fast-forward, a lock): newSha=${head}, pushed=false, resolvedWithoutCommit=false, blockedNeedingEdit=false, blockerDetail="", and a summary naming the failure.`,
757
781
  { label: `fix-commit:${sourceLabel}`, phase: 'Converge', schema: FIX_SCHEMA, agentType: 'clean-coder' },
758
782
  )
759
783
  }
760
784
 
785
+ /**
786
+ * Commit-recovery fixer: when a commit step is blocked by a commit-time hook or
787
+ * gate that requires a code change, one clean-coder fixes only that blocking
788
+ * violation test-first in the working tree and leaves it uncommitted, so the
789
+ * re-verify step can bind a fresh verdict and the retry commit can push. It does
790
+ * not re-open the original findings or touch GitHub threads — the edit step
791
+ * already handled those.
792
+ * @param {string} head PR HEAD SHA the fixes were raised against
793
+ * @param {string} blockerDetail verbatim hook/gate message naming the file and rule to change
794
+ * @param {string} sourceLabel short description of where the findings came from
795
+ * @param {number} attempt the 1-based recovery attempt number
796
+ * @returns {Promise<object>} EDIT_SCHEMA result
797
+ */
798
+ function recoverCommitBlockEdit(head, blockerDetail, sourceLabel, attempt) {
799
+ return convergeAgent(
800
+ `You are the COMMIT-RECOVERY fixer (attempt ${attempt}) for fixes (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. A prior commit step was blocked by a commit-time hook or gate that requires a code change. A separate verify step then a separate commit step run after you.\n\n` +
801
+ `The blocking hook or gate said:\n${blockerDetail}\n\n` +
802
+ `Rules:\n` +
803
+ `- Confirm the working tree is on the PR branch at HEAD ${head} with the prior fixes still present.\n` +
804
+ `- Fix ONLY the violation named above, test-first (failing test, then minimum code to pass) per CODE_RULES. Do not re-open the original findings, and do not touch GitHub review threads — the edit step already handled those.\n` +
805
+ `- Leave the corrected fixes in the working tree. Do NOT commit and do NOT push — the verify step re-binds a verdict and the commit step pushes after you.\n\n` +
806
+ `Return values: edited=true with a one-line summary when you changed code to clear the block; edited=false, resolvedWithoutCommit=false when the block cannot be cleared with a code change.`,
807
+ { label: `fix-recover:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
808
+ )
809
+ }
810
+
811
+ const FIX_RECOVERY_MAX_ATTEMPTS = 2
812
+
813
+ /**
814
+ * Run a commit step and, when it is blocked by a commit-time hook or gate that
815
+ * requires a code change, route back to a fixer: fix the blocking violation,
816
+ * re-verify so a fresh verdict binds the corrected surface, then retry the
817
+ * commit — bounded by FIX_RECOVERY_MAX_ATTEMPTS. The loop breaks early when the
818
+ * fixer makes no edit or the re-verify does not pass, returning the last commit
819
+ * result so the caller's existing no-push handling still applies. A transient
820
+ * failure never enters the loop (commitNeedsCodeRecovery is false), so an auth or
821
+ * network failure keeps the existing blocker path.
822
+ * @param {{runCommit: function, runVerify: function, runRecoverEdit: function}} steps the commit, re-verify, and recover-edit thunks
823
+ * @returns {Promise<object>} the final FIX_SCHEMA result
824
+ */
825
+ async function commitWithRecovery({ runCommit, runVerify, runRecoverEdit }) {
826
+ let commitResult = await runCommit()
827
+ let attempt = 0
828
+ while (commitNeedsCodeRecovery(commitResult) && attempt < FIX_RECOVERY_MAX_ATTEMPTS) {
829
+ attempt += 1
830
+ const recoverEdit = await runRecoverEdit(commitResult.blockerDetail, attempt)
831
+ if (recoverEdit?.edited !== true) break
832
+ const verifyTranscript = await runVerify()
833
+ if (!verdictPassed(verifyTranscript)) break
834
+ commitResult = await runCommit()
835
+ }
836
+ return commitResult
837
+ }
838
+
761
839
  /**
762
840
  * Fix lens: edit (clean-coder, no commit) -> verify (code-verifier emits a
763
841
  * verdict fence binding the working tree) -> commit (clean-coder, one commit +
@@ -780,6 +858,8 @@ async function applyFixes(head, findings, sourceLabel) {
780
858
  pushed: false,
781
859
  resolvedWithoutCommit: true,
782
860
  summary: editResult?.summary || 'fixes resolved without a code change',
861
+ blockedNeedingEdit: false,
862
+ blockerDetail: '',
783
863
  }
784
864
  }
785
865
  const verifyTranscript = await verifyFixesInWorkingTree(head, findings, sourceLabel)
@@ -789,9 +869,15 @@ async function applyFixes(head, findings, sourceLabel) {
789
869
  pushed: false,
790
870
  resolvedWithoutCommit: false,
791
871
  summary: `verify step did not pass the working-tree fixes for ${findings.length} finding(s) — not committing`,
872
+ blockedNeedingEdit: false,
873
+ blockerDetail: '',
792
874
  }
793
875
  }
794
- return commitVerifiedFixes(head, sourceLabel)
876
+ return commitWithRecovery({
877
+ runCommit: () => commitVerifiedFixes(head, sourceLabel),
878
+ runVerify: () => verifyFixesInWorkingTree(head, findings, sourceLabel),
879
+ runRecoverEdit: (detail, attempt) => recoverCommitBlockEdit(head, detail, sourceLabel, attempt),
880
+ })
795
881
  }
796
882
 
797
883
  /**
@@ -978,7 +1064,10 @@ function commitRepairFixes(head, wasRebased) {
978
1064
  `Rules:\n` +
979
1065
  `- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the push would be blocked. Do not run a formatter, do not re-fix anything — only commit and push what is already there.\n` +
980
1066
  `- Commit any uncommitted bot-thread fix in ONE commit (skip the commit when the working tree carries only already-committed rebase results). ${pushInstruction}\n\n` +
981
- `Return values: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false, and a one-line summary. If the commit or push is blocked or fails, return newSha=${head}, pushed=false, resolvedWithoutCommit=false, and a summary naming the failure.`,
1067
+ `Return values:\n` +
1068
+ `- On a successful push: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false, blockedNeedingEdit=false, blockerDetail="", and a one-line summary.\n` +
1069
+ `- When a commit-time hook or gate (for example code_rules_gate, the CODE_RULES commit gate) rejects the commit because the fix needs a code change: keep the no-edit rule, return newSha=${head}, pushed=false, resolvedWithoutCommit=false, blockedNeedingEdit=true, blockerDetail=<the verbatim hook message naming the file and rule>, and a summary. A recovery fixer runs after you to clear it.\n` +
1070
+ `- On a transient or non-code failure (auth, network, a non-fast-forward, a lock): newSha=${head}, pushed=false, resolvedWithoutCommit=false, blockedNeedingEdit=false, blockerDetail="", and a summary naming the failure.`,
982
1071
  { label: 'repair-commit', phase: 'Finalize', schema: FIX_SCHEMA, agentType: 'clean-coder' },
983
1072
  )
984
1073
  }
@@ -1006,6 +1095,8 @@ async function repairConvergence(head, failures) {
1006
1095
  pushed: false,
1007
1096
  resolvedWithoutCommit: true,
1008
1097
  summary: editResult?.summary || 'convergence gates resolved without a code change or rebase',
1098
+ blockedNeedingEdit: false,
1099
+ blockerDetail: '',
1009
1100
  }
1010
1101
  }
1011
1102
  const verifyTranscript = await verifyRepairChanges(head, failures)
@@ -1015,9 +1106,16 @@ async function repairConvergence(head, failures) {
1015
1106
  pushed: false,
1016
1107
  resolvedWithoutCommit: false,
1017
1108
  summary: `repair verify step did not pass the working-tree repair on HEAD ${head} — not pushing`,
1109
+ blockedNeedingEdit: false,
1110
+ blockerDetail: '',
1018
1111
  }
1019
1112
  }
1020
- return commitRepairFixes(head, editResult?.rebased === true)
1113
+ const wasRebased = editResult?.rebased === true
1114
+ return commitWithRecovery({
1115
+ runCommit: () => commitRepairFixes(head, wasRebased),
1116
+ runVerify: () => verifyRepairChanges(head, failures),
1117
+ runRecoverEdit: (detail, attempt) => recoverCommitBlockEdit(head, detail, 'repair', attempt),
1118
+ })
1021
1119
  }
1022
1120
 
1023
1121
  /**