claude-dev-env 1.80.0 → 1.81.0
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.
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +54 -4
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +0 -3
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +9 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +2 -2
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +245 -1
- package/skills/autoconverge/workflow/converge.mjs +129 -23
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Builds <context> and <scope> from CLI args; <bug_categories>,
|
|
4
4
|
<rubric_reference>, and <constraints> come from the shared constants in
|
|
5
5
|
skills_pr_loop_constants; <comment_posting> and <output_format> are built
|
|
6
|
-
inline.
|
|
6
|
+
inline. <pr_description> carries the PR body text read from --pr-body-file,
|
|
7
|
+
and stays empty when that argument is absent or the file cannot be read.
|
|
7
8
|
|
|
8
9
|
Usage:
|
|
9
10
|
python scripts/build_audit_prompt.py --owner jl-cmd --repo claude-code-config --pr-number 422 --loop 1 --head-ref feat/branch --base-ref main --worktree-path <PATH> --run-temp-dir <PATH>
|
|
@@ -28,6 +29,38 @@ from skills_pr_loop_constants.path_resolver_constants import (
|
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def read_pr_body_text(pr_body_file: Path | None) -> str | None:
|
|
33
|
+
"""Read the PR description body from a file when given and readable.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
pr_body_file: Path to a file holding the PR description body, or None.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The file's text when the path is given and readable, otherwise None.
|
|
40
|
+
"""
|
|
41
|
+
if pr_body_file is None:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
return pr_body_file.read_text(encoding="utf-8")
|
|
45
|
+
except OSError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _append_pr_description(root: Element, pr_body_text: str | None) -> None:
|
|
50
|
+
"""Append a <pr_description> element carrying the PR body text.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
root: Root element the <pr_description> child is appended to.
|
|
54
|
+
pr_body_text: PR description body text, or None when unavailable.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
None.
|
|
58
|
+
"""
|
|
59
|
+
pr_description = SubElement(root, "pr_description")
|
|
60
|
+
if pr_body_text:
|
|
61
|
+
pr_description.text = pr_body_text
|
|
62
|
+
|
|
63
|
+
|
|
31
64
|
def build_audit_prompt_xml(
|
|
32
65
|
*,
|
|
33
66
|
owner: str,
|
|
@@ -38,6 +71,7 @@ def build_audit_prompt_xml(
|
|
|
38
71
|
base_ref: str,
|
|
39
72
|
worktree_path: Path,
|
|
40
73
|
run_temp_dir: Path,
|
|
74
|
+
pr_body_text: str | None = None,
|
|
41
75
|
) -> Element:
|
|
42
76
|
"""Build the complete AUDIT spawn prompt XML.
|
|
43
77
|
|
|
@@ -50,6 +84,7 @@ def build_audit_prompt_xml(
|
|
|
50
84
|
base_ref: Base branch ref.
|
|
51
85
|
worktree_path: Path to the git worktree.
|
|
52
86
|
run_temp_dir: Path to the run temp directory.
|
|
87
|
+
pr_body_text: PR description body text, or None when unavailable.
|
|
53
88
|
|
|
54
89
|
Returns:
|
|
55
90
|
Root <spawn_prompt> element.
|
|
@@ -72,6 +107,8 @@ def build_audit_prompt_xml(
|
|
|
72
107
|
f"bugs, and anti-patterns. Work in {worktree_path.as_posix()}."
|
|
73
108
|
)
|
|
74
109
|
|
|
110
|
+
_append_pr_description(root, pr_body_text)
|
|
111
|
+
|
|
75
112
|
bug_categories = SubElement(root, "bug_categories")
|
|
76
113
|
for each_category_id, each_category_label in ALL_AUDIT_CATEGORY_ENTRIES:
|
|
77
114
|
cat_elem = SubElement(bug_categories, "category", {"id": each_category_id})
|
|
@@ -110,6 +147,7 @@ def emit_audit_prompt(
|
|
|
110
147
|
base_ref: str,
|
|
111
148
|
worktree_path: Path,
|
|
112
149
|
run_temp_dir: Path,
|
|
150
|
+
pr_body_text: str | None = None,
|
|
113
151
|
) -> str:
|
|
114
152
|
"""Build and serialize the AUDIT spawn prompt to a pretty-printed XML string.
|
|
115
153
|
|
|
@@ -122,6 +160,7 @@ def emit_audit_prompt(
|
|
|
122
160
|
base_ref: Base branch ref.
|
|
123
161
|
worktree_path: Path to the git worktree.
|
|
124
162
|
run_temp_dir: Path to the run temp directory.
|
|
163
|
+
pr_body_text: PR description body text, or None when unavailable.
|
|
125
164
|
|
|
126
165
|
Returns:
|
|
127
166
|
Pretty-printed XML string.
|
|
@@ -135,6 +174,7 @@ def emit_audit_prompt(
|
|
|
135
174
|
base_ref=base_ref,
|
|
136
175
|
worktree_path=worktree_path,
|
|
137
176
|
run_temp_dir=run_temp_dir,
|
|
177
|
+
pr_body_text=pr_body_text,
|
|
138
178
|
)
|
|
139
179
|
return emit_pretty_xml(root)
|
|
140
180
|
|
|
@@ -157,6 +197,7 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
|
157
197
|
parser.add_argument("--base-ref", required=True)
|
|
158
198
|
parser.add_argument("--worktree-path", type=Path, required=True)
|
|
159
199
|
parser.add_argument("--run-temp-dir", type=Path, required=True)
|
|
200
|
+
parser.add_argument("--pr-body-file", type=Path, default=None)
|
|
160
201
|
return parser.parse_args(all_argv)
|
|
161
202
|
|
|
162
203
|
|
|
@@ -179,6 +220,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
179
220
|
base_ref=arguments.base_ref,
|
|
180
221
|
worktree_path=arguments.worktree_path,
|
|
181
222
|
run_temp_dir=arguments.run_temp_dir,
|
|
223
|
+
pr_body_text=read_pr_body_text(arguments.pr_body_file),
|
|
182
224
|
)
|
|
183
225
|
sys.stdout.write(xml_output)
|
|
184
226
|
return 0
|
|
@@ -7,7 +7,7 @@ Python package of named constants for the `pr-loop` shared scripts. All constant
|
|
|
7
7
|
| File | Role |
|
|
8
8
|
|---|---|
|
|
9
9
|
| `__init__.py` | Package marker. |
|
|
10
|
-
| `path_resolver_constants.py` | Path template strings and format constants: workspace directory naming, worktree directory name, diff patch filename pattern, outcome XML filename patterns,
|
|
10
|
+
| `path_resolver_constants.py` | Path template strings and format constants: workspace directory naming, worktree directory name, diff patch filename pattern, outcome XML filename patterns, fix status values, audit constraint texts, audit category entries, fix execution steps, and fix constraint texts. |
|
|
11
11
|
| `preflight_constants.py` | Constants used by `preflight_worktree.py` for exit codes and output marker strings. |
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -15,5 +15,5 @@ Python package of named constants for the `pr-loop` shared scripts. All constant
|
|
|
15
15
|
Scripts import directly from the package:
|
|
16
16
|
|
|
17
17
|
```python
|
|
18
|
-
from skills_pr_loop_constants.path_resolver_constants import
|
|
18
|
+
from skills_pr_loop_constants.path_resolver_constants import PER_PR_WORKSPACE_TEMPLATE
|
|
19
19
|
```
|
|
@@ -9,7 +9,6 @@ WORKTREE_DIRNAME = "worktree"
|
|
|
9
9
|
DIFF_PATCH_TEMPLATE = "loop-{loop}.patch"
|
|
10
10
|
OUTCOME_XML_TEMPLATE = ".bugteam-pr{number}-loop{loop}.outcomes.xml"
|
|
11
11
|
FIX_OUTCOME_XML_TEMPLATE = ".bugteam-pr{number}-loop{loop}.fix-outcomes.xml"
|
|
12
|
-
LOOP_STATE_FILENAME = "loop-state.json"
|
|
13
12
|
SLUGIFY_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]")
|
|
14
13
|
SLUGIFY_REPLACEMENT = "-"
|
|
15
14
|
MULTI_PR_SLUG_TEMPLATE = "{owner}-{repo}-pr-{number}"
|
|
@@ -47,6 +46,7 @@ ALL_AUDIT_CATEGORY_ENTRIES = [
|
|
|
47
46
|
("N", "Test-name scenario verifier"),
|
|
48
47
|
("O", "Docstring / fixture-prose vs implementation drift"),
|
|
49
48
|
("P", "Name / regex / word-list vs behavior-contract precision"),
|
|
49
|
+
("Q", "Cross-surface claim consistency (terminology, PR-description claims, message-vs-guard)"),
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
AUDIT_RUBRIC_REFERENCE_TEXT = (
|
|
@@ -77,9 +77,6 @@ ALL_FIX_CONSTRAINT_TEXTS = [
|
|
|
77
77
|
"forward slashes (e.g. C:/Users/...), even on Windows.",
|
|
78
78
|
]
|
|
79
79
|
|
|
80
|
-
XML_PRETTY_INDENT = " "
|
|
81
|
-
XML_SERIALIZE_ENCODING = "unicode"
|
|
82
|
-
XML_OUTPUT_ENCODING = "utf-8"
|
|
83
80
|
ALL_PYTHON_ONEXC_VERSION = (3, 12)
|
|
84
81
|
|
|
85
82
|
ALL_FINDING_BODY_ELEMENT_KEYS: tuple[str, ...] = (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests pinning build_audit_prompt's emitted A-
|
|
1
|
+
"""Tests pinning build_audit_prompt's emitted A-Q category taxonomy."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -18,7 +18,7 @@ from skills_pr_loop_constants.path_resolver_constants import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
_CATEGORY_RUBRICS_DIR = _SCRIPTS_DIR.parents[3] / "audit-rubrics" / "category_rubrics"
|
|
21
|
-
_HEADING_PATTERN = re.compile(r"^# Category ([A-
|
|
21
|
+
_HEADING_PATTERN = re.compile(r"^# Category ([A-Q]) — (.+)$")
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def _load_build_audit_prompt() -> ModuleType:
|
|
@@ -84,12 +84,12 @@ def test_context_and_scope_render_paths_with_forward_slashes() -> None:
|
|
|
84
84
|
assert "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree" in scope.text
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
def
|
|
87
|
+
def test_bug_categories_carry_ids_a_through_q_in_order() -> None:
|
|
88
88
|
root = _build_audit_root()
|
|
89
89
|
bug_categories = root.find("bug_categories")
|
|
90
90
|
assert bug_categories is not None
|
|
91
91
|
all_emitted_ids = [each_category.get("id") for each_category in bug_categories]
|
|
92
|
-
all_expected_ids = list("
|
|
92
|
+
all_expected_ids = list("ABCDEFGHIJKLMNOPQ")
|
|
93
93
|
assert all_emitted_ids == all_expected_ids
|
|
94
94
|
|
|
95
95
|
|
|
@@ -161,3 +161,99 @@ def test_prompt_skeleton_sub_bucket_counts_match_rubric_rows() -> None:
|
|
|
161
161
|
f"{each_letter}{each_walk_match.group(1)} but rubric has "
|
|
162
162
|
f"{sub_bucket_row_count} rows"
|
|
163
163
|
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_bug_categories_include_category_q() -> None:
|
|
167
|
+
root = _build_audit_root()
|
|
168
|
+
bug_categories = root.find("bug_categories")
|
|
169
|
+
assert bug_categories is not None
|
|
170
|
+
label_by_id = {
|
|
171
|
+
each_category.get("id"): each_category.text for each_category in bug_categories
|
|
172
|
+
}
|
|
173
|
+
assert label_by_id["Q"] == (
|
|
174
|
+
"Cross-surface claim consistency "
|
|
175
|
+
"(terminology, PR-description claims, message-vs-guard)"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_pr_description_carries_body_text_when_supplied() -> None:
|
|
180
|
+
root = build_audit_prompt.build_audit_prompt_xml(
|
|
181
|
+
owner="jl-cmd",
|
|
182
|
+
repo="claude-code-config",
|
|
183
|
+
pr_number=422,
|
|
184
|
+
loop=1,
|
|
185
|
+
head_ref="feat/branch",
|
|
186
|
+
base_ref="main",
|
|
187
|
+
worktree_path=Path("/tmp/bugteam-pr-422/worktree"),
|
|
188
|
+
run_temp_dir=Path("/tmp/bugteam-pr-422"),
|
|
189
|
+
pr_body_text="## Summary\nCloses the gate.",
|
|
190
|
+
)
|
|
191
|
+
pr_description = root.find("pr_description")
|
|
192
|
+
assert pr_description is not None
|
|
193
|
+
assert pr_description.text == "## Summary\nCloses the gate."
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_pr_description_empty_when_body_absent() -> None:
|
|
197
|
+
root = _build_audit_root()
|
|
198
|
+
pr_description = root.find("pr_description")
|
|
199
|
+
assert pr_description is not None
|
|
200
|
+
assert pr_description.text is None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_read_pr_body_text_returns_file_contents(tmp_path: Path) -> None:
|
|
204
|
+
body_file = tmp_path / "pr-body.md"
|
|
205
|
+
body_file.write_text("body from disk", encoding="utf-8")
|
|
206
|
+
assert build_audit_prompt.read_pr_body_text(body_file) == "body from disk"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_read_pr_body_text_returns_none_when_path_absent() -> None:
|
|
210
|
+
assert build_audit_prompt.read_pr_body_text(None) is None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_read_pr_body_text_returns_none_when_file_missing(tmp_path: Path) -> None:
|
|
214
|
+
missing_file = tmp_path / "does-not-exist.md"
|
|
215
|
+
assert build_audit_prompt.read_pr_body_text(missing_file) is None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_emit_audit_prompt_embeds_pr_body_text_in_pr_description() -> None:
|
|
219
|
+
xml_text = build_audit_prompt.emit_audit_prompt(
|
|
220
|
+
owner="jl-cmd",
|
|
221
|
+
repo="claude-code-config",
|
|
222
|
+
pr_number=422,
|
|
223
|
+
loop=1,
|
|
224
|
+
head_ref="feat/branch",
|
|
225
|
+
base_ref="main",
|
|
226
|
+
worktree_path=Path("/tmp/bugteam-pr-422/worktree"),
|
|
227
|
+
run_temp_dir=Path("/tmp/bugteam-pr-422"),
|
|
228
|
+
pr_body_text="body via emit",
|
|
229
|
+
)
|
|
230
|
+
assert "body via emit" in xml_text
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_parse_arguments_defaults_pr_body_file_to_none() -> None:
|
|
234
|
+
arguments = build_audit_prompt.parse_arguments([
|
|
235
|
+
"--owner", "jl-cmd",
|
|
236
|
+
"--repo", "claude-code-config",
|
|
237
|
+
"--pr-number", "422",
|
|
238
|
+
"--loop", "1",
|
|
239
|
+
"--head-ref", "feat/branch",
|
|
240
|
+
"--base-ref", "main",
|
|
241
|
+
"--worktree-path", "/tmp/wt",
|
|
242
|
+
"--run-temp-dir", "/tmp/rt",
|
|
243
|
+
])
|
|
244
|
+
assert arguments.pr_body_file is None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_parse_arguments_reads_pr_body_file_path() -> None:
|
|
248
|
+
arguments = build_audit_prompt.parse_arguments([
|
|
249
|
+
"--owner", "jl-cmd",
|
|
250
|
+
"--repo", "claude-code-config",
|
|
251
|
+
"--pr-number", "422",
|
|
252
|
+
"--loop", "1",
|
|
253
|
+
"--head-ref", "feat/branch",
|
|
254
|
+
"--base-ref", "main",
|
|
255
|
+
"--worktree-path", "/tmp/wt",
|
|
256
|
+
"--run-temp-dir", "/tmp/rt",
|
|
257
|
+
"--pr-body-file", "/tmp/body.md",
|
|
258
|
+
])
|
|
259
|
+
assert arguments.pr_body_file == Path("/tmp/body.md")
|
|
@@ -312,10 +312,10 @@ matches.
|
|
|
312
312
|
(hooks/rules that block those violation classes at Write/Edit time), resolves
|
|
313
313
|
any bot threads with a deferral note, and reports the deferral in
|
|
314
314
|
`standardsNote`.
|
|
315
|
-
- **Copilot gate:** request a Copilot review, poll up to
|
|
315
|
+
- **Copilot gate:** request a Copilot review, poll up to the configured cap; findings
|
|
316
316
|
route back into Converge. When Copilot is down or out of quota — it posts an
|
|
317
317
|
out-of-usage notice (the requester hit their quota) on the HEAD, or surfaces no
|
|
318
|
-
review at all after the cap — the gate logs a notice and the run marks the PR
|
|
318
|
+
review at all after the configured cap — the gate logs a notice and the run marks the PR
|
|
319
319
|
ready with the Copilot gate bypassed. `copilotNote` records the bypass.
|
|
320
320
|
- **Convergence check:** `check_convergence.py` is the authoritative gate; on a
|
|
321
321
|
full pass the workflow marks `draft=false`.
|
|
@@ -79,11 +79,11 @@ tracks CONVERGE passes only and is never the cap.
|
|
|
79
79
|
**COPILOT** gate:
|
|
80
80
|
|
|
81
81
|
- Request a Copilot review on HEAD (skipping a duplicate request), then poll up
|
|
82
|
-
to
|
|
82
|
+
to the configured cap, 360 seconds apart.
|
|
83
83
|
- Copilot findings → fix them and return to CONVERGE on the new HEAD.
|
|
84
84
|
- Copilot clean or approved → move to the convergence check.
|
|
85
|
-
- Copilot down or out of quota (an out-of-usage notice, or no review after
|
|
86
|
-
|
|
85
|
+
- Copilot down or out of quota (an out-of-usage notice, or no review after the
|
|
86
|
+
configured cap) → log a notice and move to the convergence check with the Copilot gate
|
|
87
87
|
bypassed.
|
|
88
88
|
|
|
89
89
|
**Convergence check**:
|
|
@@ -47,7 +47,7 @@ skill still runs teardown (revoke permissions, final report).
|
|
|
47
47
|
Bugbot gate is bypassed.
|
|
48
48
|
- **Copilot down or out of quota** — when Copilot posts an out-of-usage notice on
|
|
49
49
|
the current HEAD (the user who requested the review reached their quota limit)
|
|
50
|
-
rather than a code review, or surfaces no review at all after
|
|
50
|
+
rather than a code review, or surfaces no review at all after the configured cap, the
|
|
51
51
|
Copilot gate returns `down: true`. The run logs a notice, runs the convergence
|
|
52
52
|
check with `--copilot-down` (the Copilot review gate and the
|
|
53
53
|
pending-requested-reviews gate bypassed), and marks the PR ready. `copilotNote`
|
|
@@ -385,8 +385,8 @@ test('spawnStandardsFollowUp reports whether a hardening PR opened on every path
|
|
|
385
385
|
const body = lensPromptBody('spawnStandardsFollowUp');
|
|
386
386
|
const falseReturns = body.match(/hardeningPrOpened:\s*false/g) || [];
|
|
387
387
|
assert.ok(
|
|
388
|
-
falseReturns.length >=
|
|
389
|
-
'expected
|
|
388
|
+
falseReturns.length >= 3,
|
|
389
|
+
'expected every skip path (hardening PR already opened, no hardening staged, verify failed) to return hardeningPrOpened:false',
|
|
390
390
|
);
|
|
391
391
|
assert.match(
|
|
392
392
|
body,
|
|
@@ -129,6 +129,24 @@ test('a Copilot no-show after the poll cap returns a down result rather than a b
|
|
|
129
129
|
);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
test('the Copilot gate accepts a COMMENTED review with no inline findings as a clean pass', () => {
|
|
133
|
+
const copilotPrompt = functionBody('runCopilotGate');
|
|
134
|
+
assert.match(
|
|
135
|
+
copilotPrompt,
|
|
136
|
+
/COMMENTED with no inline findings/,
|
|
137
|
+
'expected the clean-pass branch to accept a COMMENTED review with no inline findings, matching the canonical ALL_COPILOT_CLEAN_REVIEW_STATES rule where a Copilot COMMENTED review is a clean state',
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the Copilot gate polls long enough to catch a normal-latency Copilot review', () => {
|
|
142
|
+
const budgetMatch = convergeSource.match(/copilotMaxPolls:\s*(\d+)/);
|
|
143
|
+
assert.notEqual(budgetMatch, null, 'expected copilotMaxPolls in CONFIG');
|
|
144
|
+
assert.ok(
|
|
145
|
+
Number(budgetMatch[1]) >= 6,
|
|
146
|
+
`expected copilotMaxPolls >= 6 (>= ~36 min at 360s per attempt) so a normal-latency Copilot review lands before the no-show fallback triggers, got ${budgetMatch[1]}`,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
132
150
|
test('runConvergenceCheck wires the --copilot-down flag from the copilotDown context', () => {
|
|
133
151
|
const checkConvergenceBody = functionBody('runConvergenceCheck');
|
|
134
152
|
assert.match(
|
|
@@ -295,7 +313,7 @@ test('a copilotDisabled run reaches FINALIZE with --copilot-down', () => {
|
|
|
295
313
|
const bypassBlock = convergeSource.slice(bypassStart, bypassStart + 800);
|
|
296
314
|
assert.match(bypassBlock, /copilotDown = true/, 'expected the bypass to set copilotDown');
|
|
297
315
|
assert.match(bypassBlock, /phase = 'FINALIZE'/, 'expected the bypass to advance to FINALIZE');
|
|
298
|
-
const convergenceCheckBody = functionBody('
|
|
316
|
+
const convergenceCheckBody = functionBody('runConvergenceCheck');
|
|
299
317
|
assert.match(
|
|
300
318
|
convergenceCheckBody,
|
|
301
319
|
/context\.copilotDown \? ' --copilot-down' : ''/,
|
|
@@ -303,3 +321,229 @@ test('a copilotDisabled run reaches FINALIZE with --copilot-down', () => {
|
|
|
303
321
|
);
|
|
304
322
|
});
|
|
305
323
|
|
|
324
|
+
function loadStandardsFollowUpDecision() {
|
|
325
|
+
return new Function(
|
|
326
|
+
`${functionBody('shouldOpenStandardsFollowUp')}\n` +
|
|
327
|
+
'return shouldOpenStandardsFollowUp;',
|
|
328
|
+
)();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
test('the deferred follow-up create runs exactly once across a three-round standards run', () => {
|
|
332
|
+
const shouldOpenStandardsFollowUp = loadStandardsFollowUpDecision();
|
|
333
|
+
let alreadyOpened = false;
|
|
334
|
+
let createCount = 0;
|
|
335
|
+
for (let round = 0; round < 3; round += 1) {
|
|
336
|
+
if (shouldOpenStandardsFollowUp(alreadyOpened)) {
|
|
337
|
+
createCount += 1;
|
|
338
|
+
alreadyOpened = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
assert.equal(
|
|
342
|
+
createCount,
|
|
343
|
+
1,
|
|
344
|
+
'expected the deferred follow-up issue and hardening PR to be created exactly once across a multi-round run',
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('shouldOpenStandardsFollowUp opens on the first standards-only round and skips afterward', () => {
|
|
349
|
+
const shouldOpenStandardsFollowUp = loadStandardsFollowUpDecision();
|
|
350
|
+
assert.equal(shouldOpenStandardsFollowUp(false), true);
|
|
351
|
+
assert.equal(shouldOpenStandardsFollowUp(true), false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('the run seeds the standards follow-up flags to false so the first standards-only round opens the follow-up', () => {
|
|
355
|
+
assert.match(
|
|
356
|
+
convergeSource,
|
|
357
|
+
/let hasStandardsFollowUpFiled = false/,
|
|
358
|
+
'expected a run-scoped hasStandardsFollowUpFiled flag seeded false',
|
|
359
|
+
);
|
|
360
|
+
assert.match(
|
|
361
|
+
convergeSource,
|
|
362
|
+
/let wasStandardsHardeningPrOpened = false/,
|
|
363
|
+
'expected a run-scoped wasStandardsHardeningPrOpened flag seeded false',
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('openStandardsFollowUpOnce gates spawnStandardsFollowUp behind the run-once flag and remembers the outcome', () => {
|
|
368
|
+
const onceBody = extractCallableSource('openStandardsFollowUpOnce');
|
|
369
|
+
assert.match(
|
|
370
|
+
onceBody,
|
|
371
|
+
/shouldOpenStandardsFollowUp\(hasStandardsFollowUpFiled\)/,
|
|
372
|
+
'expected the helper to consult the run-once decision on the current flag',
|
|
373
|
+
);
|
|
374
|
+
assert.match(
|
|
375
|
+
onceBody,
|
|
376
|
+
/await spawnStandardsFollowUp\(head, findings, sourceLabel, wasStandardsHardeningPrOpened\)/,
|
|
377
|
+
'expected the helper to pass the remembered hardening state into the spawn so an already-opened PR is never re-opened',
|
|
378
|
+
);
|
|
379
|
+
assert.match(
|
|
380
|
+
onceBody,
|
|
381
|
+
/hasStandardsFollowUpFiled = standardsOutcome\?\.followUpIssueFiled === true/,
|
|
382
|
+
'expected the helper to latch the flag only when the follow-up issue was actually filed so a transient failure retries on a later round',
|
|
383
|
+
);
|
|
384
|
+
assert.match(
|
|
385
|
+
onceBody,
|
|
386
|
+
/wasStandardsHardeningPrOpened = wasStandardsHardeningPrOpened \|\| standardsOutcome\?\.hardeningPrOpened === true/,
|
|
387
|
+
'expected the hardening guard to latch the moment a hardening PR opens and stay latched across rounds so a later issue-filing retry never re-opens it',
|
|
388
|
+
);
|
|
389
|
+
assert.match(
|
|
390
|
+
onceBody,
|
|
391
|
+
/return wasStandardsHardeningPrOpened/,
|
|
392
|
+
'expected the skip path to return the cached hardening outcome',
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('both standards-deferral call sites route the create through openStandardsFollowUpOnce', () => {
|
|
397
|
+
const onceCalls = convergeSource.match(/await openStandardsFollowUpOnce\(/g) || [];
|
|
398
|
+
assert.equal(
|
|
399
|
+
onceCalls.length,
|
|
400
|
+
2,
|
|
401
|
+
'expected the converge-round and copilot standards call sites to both defer to openStandardsFollowUpOnce',
|
|
402
|
+
);
|
|
403
|
+
const directCreates = convergeSource.match(/await spawnStandardsFollowUp\(/g) || [];
|
|
404
|
+
assert.equal(
|
|
405
|
+
directCreates.length,
|
|
406
|
+
1,
|
|
407
|
+
'expected spawnStandardsFollowUp to be invoked once from openStandardsFollowUpOnce, never directly at a call site',
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
function extractCallableSource(functionName) {
|
|
412
|
+
const asyncStart = convergeSource.indexOf(`async function ${functionName}(`);
|
|
413
|
+
const plainStart = convergeSource.indexOf(`function ${functionName}(`);
|
|
414
|
+
const declarationStart = asyncStart !== -1 ? asyncStart : plainStart;
|
|
415
|
+
assert.notEqual(declarationStart, -1, `expected ${functionName} to exist`);
|
|
416
|
+
const bodyStart = convergeSource.indexOf('{', declarationStart);
|
|
417
|
+
let depth = 0;
|
|
418
|
+
let index = bodyStart;
|
|
419
|
+
for (; index < convergeSource.length; index += 1) {
|
|
420
|
+
const character = convergeSource[index];
|
|
421
|
+
if (character === '{') {
|
|
422
|
+
depth += 1;
|
|
423
|
+
} else if (character === '}') {
|
|
424
|
+
depth -= 1;
|
|
425
|
+
if (depth === 0) {
|
|
426
|
+
index += 1;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return convergeSource.slice(declarationStart, index);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function loadStandardsFollowUpRuntime(recordedCalls, standardsEditResult) {
|
|
435
|
+
const runtimeSource =
|
|
436
|
+
'let hasStandardsFollowUpFiled = false;\n' +
|
|
437
|
+
'let wasStandardsHardeningPrOpened = false;\n' +
|
|
438
|
+
"let standardsFollowUpIssueUrl = '';\n" +
|
|
439
|
+
'async function runCodeEditorTask(taskName, context) {\n' +
|
|
440
|
+
' recordedCalls.push({ task: taskName, context });\n' +
|
|
441
|
+
" return taskName === 'standards-edit' ? standardsEditResult : {};\n" +
|
|
442
|
+
'}\n' +
|
|
443
|
+
'async function runVerifierTask() {\n' +
|
|
444
|
+
' return { passed: true };\n' +
|
|
445
|
+
'}\n' +
|
|
446
|
+
'function verdictPassed() {\n' +
|
|
447
|
+
' return true;\n' +
|
|
448
|
+
'}\n' +
|
|
449
|
+
'function log() {}\n' +
|
|
450
|
+
`${extractCallableSource('collectFindingThreadIds')}\n` +
|
|
451
|
+
`${extractCallableSource('findingsCarryThreads')}\n` +
|
|
452
|
+
`${extractCallableSource('shouldOpenStandardsFollowUp')}\n` +
|
|
453
|
+
`${extractCallableSource('spawnStandardsFollowUp')}\n` +
|
|
454
|
+
`${extractCallableSource('resolveStandardsThreadsForBatch')}\n` +
|
|
455
|
+
`${extractCallableSource('openStandardsFollowUpOnce')}\n` +
|
|
456
|
+
'return {\n' +
|
|
457
|
+
' openStandardsFollowUpOnce,\n' +
|
|
458
|
+
' guards: () => ({ hasStandardsFollowUpFiled, wasStandardsHardeningPrOpened, standardsFollowUpIssueUrl }),\n' +
|
|
459
|
+
'};';
|
|
460
|
+
return new Function('recordedCalls', 'standardsEditResult', runtimeSource)(
|
|
461
|
+
recordedCalls,
|
|
462
|
+
standardsEditResult,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
test('a second standards-only round never re-opens a hardening PR after the first round opened one but failed to file the issue', async () => {
|
|
467
|
+
const recordedCalls = [];
|
|
468
|
+
const issueFailedHardeningStaged = {
|
|
469
|
+
issueUrl: '',
|
|
470
|
+
hardeningEdited: true,
|
|
471
|
+
hardeningRepoPath: '/tmp/hardening',
|
|
472
|
+
hardeningBranch: 'harden-standards',
|
|
473
|
+
};
|
|
474
|
+
const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFailedHardeningStaged);
|
|
475
|
+
|
|
476
|
+
const firstRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'converge-round');
|
|
477
|
+
const secondRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'copilot');
|
|
478
|
+
|
|
479
|
+
const hardeningCommitCalls = recordedCalls.filter((call) => call.task === 'hardening-commit').length;
|
|
480
|
+
assert.equal(
|
|
481
|
+
hardeningCommitCalls,
|
|
482
|
+
1,
|
|
483
|
+
'expected the hardening PR to be committed exactly once even when the follow-up issue filing must retry on the second round',
|
|
484
|
+
);
|
|
485
|
+
assert.equal(firstRoundHardeningPr, true, 'expected the first round to open the hardening PR');
|
|
486
|
+
assert.equal(secondRoundHardeningPr, true, 'expected the second round to report the hardening PR as opened for this run');
|
|
487
|
+
assert.equal(
|
|
488
|
+
runtime.guards().wasStandardsHardeningPrOpened,
|
|
489
|
+
true,
|
|
490
|
+
'expected the hardening guard to stay latched across rounds',
|
|
491
|
+
);
|
|
492
|
+
assert.equal(
|
|
493
|
+
runtime.guards().hasStandardsFollowUpFiled,
|
|
494
|
+
false,
|
|
495
|
+
'expected the issue guard to stay clear so the filing keeps retrying',
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('a later standards-only round resolves its own review threads after the follow-up issue was already filed', async () => {
|
|
500
|
+
const recordedCalls = [];
|
|
501
|
+
const issueFiledNoHardening = {
|
|
502
|
+
issueUrl: 'https://github.com/jl-cmd/claude-code-config/issues/900',
|
|
503
|
+
hardeningEdited: false,
|
|
504
|
+
hardeningRepoPath: '',
|
|
505
|
+
hardeningBranch: '',
|
|
506
|
+
};
|
|
507
|
+
const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFiledNoHardening);
|
|
508
|
+
|
|
509
|
+
await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1, replyToCommentId: null }], 'converge-round');
|
|
510
|
+
await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'b.py', line: 2, replyToCommentId: 42 }], 'copilot');
|
|
511
|
+
|
|
512
|
+
const standardsEditCalls = recordedCalls.filter((call) => call.task === 'standards-edit');
|
|
513
|
+
assert.equal(standardsEditCalls.length, 1, 'expected the follow-up issue to be filed exactly once across the run');
|
|
514
|
+
|
|
515
|
+
const resolveCalls = recordedCalls.filter((call) => call.task === 'standards-resolve-threads');
|
|
516
|
+
assert.equal(resolveCalls.length, 1, 'expected the reuse-path round to resolve its own batch review threads');
|
|
517
|
+
assert.deepEqual(
|
|
518
|
+
resolveCalls[0].context.findings,
|
|
519
|
+
[{ file: 'b.py', line: 2, replyToCommentId: 42 }],
|
|
520
|
+
'expected the resolve step to receive the reuse-path batch findings so their threads get replied-to and resolved',
|
|
521
|
+
);
|
|
522
|
+
assert.equal(
|
|
523
|
+
resolveCalls[0].context.issueUrl,
|
|
524
|
+
'https://github.com/jl-cmd/claude-code-config/issues/900',
|
|
525
|
+
'expected the resolve step to reference the already-filed follow-up issue in its inline reply',
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('a reuse-path standards round carrying no review threads spawns no thread-resolution agent', async () => {
|
|
530
|
+
const recordedCalls = [];
|
|
531
|
+
const issueFiledNoHardening = {
|
|
532
|
+
issueUrl: 'https://github.com/jl-cmd/claude-code-config/issues/901',
|
|
533
|
+
hardeningEdited: false,
|
|
534
|
+
hardeningRepoPath: '',
|
|
535
|
+
hardeningBranch: '',
|
|
536
|
+
};
|
|
537
|
+
const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFiledNoHardening);
|
|
538
|
+
|
|
539
|
+
await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1, replyToCommentId: null }], 'converge-round');
|
|
540
|
+
await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'b.py', line: 2, replyToCommentId: null }], 'copilot');
|
|
541
|
+
|
|
542
|
+
const resolveCalls = recordedCalls.filter((call) => call.task === 'standards-resolve-threads');
|
|
543
|
+
assert.equal(
|
|
544
|
+
resolveCalls.length,
|
|
545
|
+
0,
|
|
546
|
+
'expected no thread-resolution agent when the reuse-path batch of in-memory findings carries no review threads',
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|