claude-dev-env 8.5.0 → 8.6.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.
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
- package/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +15 -8
- package/hooks/blocking/state_description_blocker.py +230 -90
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
- package/hooks/blocking/test_state_description_blocker.py +43 -0
- package/hooks/followup_ledger.py +109 -4
- package/hooks/hooks_constants/followup_ledger_constants.py +15 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
- package/hooks/test_followup_ledger.py +92 -4
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +35 -2
- package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
- package/scripts/followup_cli.py +78 -4
- package/scripts/policy_lint/adapter_detectors.py +1 -1
- package/scripts/policy_lint/adapter_support.py +11 -15
- package/scripts/policy_lint/adapters.py +0 -1
- package/scripts/policy_lint/check_catalog.py +44 -0
- package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
- package/scripts/policy_lint/model.py +4 -1
- package/scripts/test_followup_cli.py +116 -2
- package/scripts/test_validate_instruction_pairs.py +23 -1
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
- package/scripts/validate_instruction_pairs.py +9 -2
package/hooks/followup_ledger.py
CHANGED
|
@@ -22,12 +22,20 @@ if _hooks_directory not in sys.path:
|
|
|
22
22
|
sys.path.insert(0, _hooks_directory)
|
|
23
23
|
|
|
24
24
|
from hooks_constants.followup_ledger_constants import (
|
|
25
|
-
|
|
25
|
+
ABSENT_ORIGIN_COMMIT,
|
|
26
26
|
ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS,
|
|
27
|
+
ALL_GIT_HEAD_PATH_SEGMENTS,
|
|
28
|
+
CHECK_ID_KEY,
|
|
29
|
+
FILE_PATH_KEY,
|
|
30
|
+
GIT_DIRECTORY_NAME,
|
|
31
|
+
GIT_REFERENCE_PREFIX,
|
|
27
32
|
LEDGER_APPEND_MODE,
|
|
28
33
|
LEDGER_ENCODING,
|
|
29
34
|
MESSAGE_KEY,
|
|
35
|
+
ORIGIN_COMMIT_KEY,
|
|
30
36
|
RULE_ID_KEY,
|
|
37
|
+
SEVERITY_KEY,
|
|
38
|
+
SEVERITY_SMELL,
|
|
31
39
|
)
|
|
32
40
|
|
|
33
41
|
|
|
@@ -35,14 +43,33 @@ class FollowupFinding(NamedTuple):
|
|
|
35
43
|
"""One non-breaking finding a later pull request resolves.
|
|
36
44
|
|
|
37
45
|
Attributes:
|
|
38
|
-
rule_id:
|
|
46
|
+
rule_id: Identifier of the rule that raised the finding.
|
|
39
47
|
file_path: Repository-relative path the finding names.
|
|
40
48
|
message: The text a reader acts on.
|
|
49
|
+
check_id: Identifier of the single check behind the finding. An empty
|
|
50
|
+
value reads back as the rule identifier.
|
|
51
|
+
severity: The class the gate put the finding in.
|
|
52
|
+
origin_commit: The revision checked out when the finding was recorded,
|
|
53
|
+
which groups a follow-up pull request by the change that raised it.
|
|
41
54
|
"""
|
|
42
55
|
|
|
43
56
|
rule_id: str
|
|
44
57
|
file_path: str
|
|
45
58
|
message: str
|
|
59
|
+
check_id: str = ""
|
|
60
|
+
severity: str = SEVERITY_SMELL
|
|
61
|
+
origin_commit: str = ABSENT_ORIGIN_COMMIT
|
|
62
|
+
|
|
63
|
+
def tracking_key(self) -> tuple[str, str, str]:
|
|
64
|
+
"""Return the fields that make this finding the same one over time.
|
|
65
|
+
|
|
66
|
+
The origin commit stays out of the key, so a smell seen again under a
|
|
67
|
+
later revision keeps the revision that first raised it.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The check identifier, path, and message.
|
|
71
|
+
"""
|
|
72
|
+
return (self.check_id or self.rule_id, self.file_path, self.message)
|
|
46
73
|
|
|
47
74
|
|
|
48
75
|
def followup_ledger_path(repository_root: Path) -> Path:
|
|
@@ -69,7 +96,11 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
|
|
|
69
96
|
repository_root: The repository whose ledger receives the finding.
|
|
70
97
|
finding: The non-breaking finding to record.
|
|
71
98
|
"""
|
|
72
|
-
|
|
99
|
+
all_recorded_keys = {
|
|
100
|
+
each_finding.tracking_key()
|
|
101
|
+
for each_finding in all_recorded_findings(repository_root)
|
|
102
|
+
}
|
|
103
|
+
if finding.tracking_key() in all_recorded_keys:
|
|
73
104
|
return
|
|
74
105
|
|
|
75
106
|
ledger_path = followup_ledger_path(repository_root)
|
|
@@ -78,6 +109,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
|
|
|
78
109
|
RULE_ID_KEY: finding.rule_id,
|
|
79
110
|
FILE_PATH_KEY: finding.file_path,
|
|
80
111
|
MESSAGE_KEY: finding.message,
|
|
112
|
+
CHECK_ID_KEY: finding.check_id or finding.rule_id,
|
|
113
|
+
SEVERITY_KEY: finding.severity,
|
|
114
|
+
ORIGIN_COMMIT_KEY: finding.origin_commit,
|
|
81
115
|
}
|
|
82
116
|
)
|
|
83
117
|
try:
|
|
@@ -140,4 +174,75 @@ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
|
|
|
140
174
|
return None
|
|
141
175
|
if not isinstance(message, str):
|
|
142
176
|
return None
|
|
143
|
-
return FollowupFinding(
|
|
177
|
+
return FollowupFinding(
|
|
178
|
+
rule_id,
|
|
179
|
+
file_path,
|
|
180
|
+
message,
|
|
181
|
+
_text_field(parsed_record, CHECK_ID_KEY, rule_id),
|
|
182
|
+
_text_field(parsed_record, SEVERITY_KEY, SEVERITY_SMELL),
|
|
183
|
+
_text_field(parsed_record, ORIGIN_COMMIT_KEY, ABSENT_ORIGIN_COMMIT),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _text_field(
|
|
188
|
+
all_record_fields: dict[str, object], field_key: str, fallback: str
|
|
189
|
+
) -> str:
|
|
190
|
+
"""Read one text field of a ledger record.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
all_record_fields: The mapping one ledger line parsed into.
|
|
194
|
+
field_key: The key to read.
|
|
195
|
+
fallback: The value a record written before this field carried it takes.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The field's text, or the fallback.
|
|
199
|
+
"""
|
|
200
|
+
field_value = all_record_fields.get(field_key)
|
|
201
|
+
if isinstance(field_value, str) and field_value:
|
|
202
|
+
return field_value
|
|
203
|
+
return fallback
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def head_commit(repository_root: Path) -> str:
|
|
207
|
+
"""Return the revision one repository has checked out.
|
|
208
|
+
|
|
209
|
+
::
|
|
210
|
+
|
|
211
|
+
.git/HEAD holding "ref: refs/heads/main" -> the sha in refs/heads/main
|
|
212
|
+
.git/HEAD holding a sha -> that sha
|
|
213
|
+
no .git directory -> ""
|
|
214
|
+
|
|
215
|
+
The revision comes from the files git writes rather than from a git
|
|
216
|
+
process, so recording a finding starts no subprocess.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
repository_root: The repository whose revision to read.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The checked-out revision, or an empty string when none can be read.
|
|
223
|
+
"""
|
|
224
|
+
head_path = repository_root.joinpath(*ALL_GIT_HEAD_PATH_SEGMENTS)
|
|
225
|
+
head_text = _file_text(head_path)
|
|
226
|
+
if head_text is None:
|
|
227
|
+
return ABSENT_ORIGIN_COMMIT
|
|
228
|
+
if not head_text.startswith(GIT_REFERENCE_PREFIX):
|
|
229
|
+
return head_text
|
|
230
|
+
reference_path = repository_root / GIT_DIRECTORY_NAME / head_text[
|
|
231
|
+
len(GIT_REFERENCE_PREFIX) :
|
|
232
|
+
]
|
|
233
|
+
return _file_text(reference_path) or ABSENT_ORIGIN_COMMIT
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _file_text(file_path: Path) -> str | None:
|
|
237
|
+
"""Read one small git file into stripped text.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
file_path: The file to read.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The stripped text, or None when the file cannot be read.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
return file_path.read_text(encoding=LEDGER_ENCODING).strip()
|
|
247
|
+
except (OSError, UnicodeError):
|
|
248
|
+
return None
|
|
@@ -9,6 +9,13 @@ __all__ = [
|
|
|
9
9
|
"RULE_ID_KEY",
|
|
10
10
|
"FILE_PATH_KEY",
|
|
11
11
|
"MESSAGE_KEY",
|
|
12
|
+
"CHECK_ID_KEY",
|
|
13
|
+
"SEVERITY_KEY",
|
|
14
|
+
"ORIGIN_COMMIT_KEY",
|
|
15
|
+
"ABSENT_ORIGIN_COMMIT",
|
|
16
|
+
"ALL_GIT_HEAD_PATH_SEGMENTS",
|
|
17
|
+
"GIT_DIRECTORY_NAME",
|
|
18
|
+
"GIT_REFERENCE_PREFIX",
|
|
12
19
|
"SEVERITY_BREAKING",
|
|
13
20
|
"SEVERITY_SMELL",
|
|
14
21
|
"ALL_SEVERITY_NAMES",
|
|
@@ -21,6 +28,14 @@ LEDGER_APPEND_MODE: str = "a"
|
|
|
21
28
|
RULE_ID_KEY: str = "rule_id"
|
|
22
29
|
FILE_PATH_KEY: str = "file_path"
|
|
23
30
|
MESSAGE_KEY: str = "message"
|
|
31
|
+
CHECK_ID_KEY: str = "check_id"
|
|
32
|
+
SEVERITY_KEY: str = "severity"
|
|
33
|
+
ORIGIN_COMMIT_KEY: str = "origin_commit"
|
|
34
|
+
|
|
35
|
+
ABSENT_ORIGIN_COMMIT: str = ""
|
|
36
|
+
GIT_DIRECTORY_NAME: str = ".git"
|
|
37
|
+
ALL_GIT_HEAD_PATH_SEGMENTS: tuple[str, ...] = (GIT_DIRECTORY_NAME, "HEAD")
|
|
38
|
+
GIT_REFERENCE_PREFIX: str = "ref: "
|
|
24
39
|
|
|
25
40
|
SEVERITY_BREAKING: str = "breaking"
|
|
26
41
|
SEVERITY_SMELL: str = "smell"
|
|
@@ -40,12 +40,16 @@ ALL_COMMENT_TRANSITION_PATTERNS: list[Pattern[str]] = [
|
|
|
40
40
|
compile(r"\bas of\b", IGNORECASE),
|
|
41
41
|
]
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
BLOCK_COMMENT_OPEN_MARKER: str = "/*"
|
|
44
|
+
BLOCK_COMMENT_CLOSE_MARKER: str = "*/"
|
|
45
|
+
|
|
44
46
|
INLINE_CODE_PATTERN: Pattern[str] = compile(r"``[^`]+``|`[^`]+`")
|
|
45
47
|
DOUBLE_QUOTED_SPAN_PATTERN: Pattern[str] = compile(r'"[^"\n]*"')
|
|
46
48
|
TRIPLE_QUOTED_BLOCK_PATTERN: Pattern[str] = compile(
|
|
47
49
|
r'"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\''
|
|
48
50
|
)
|
|
51
|
+
DOUBLE_QUOTE_BODY_GROUP: int = 1
|
|
52
|
+
SINGLE_QUOTE_BODY_GROUP: int = 2
|
|
49
53
|
|
|
50
54
|
PYTHON_EXTENSION: str = ".py"
|
|
51
55
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Behavior tests for state_description_blocker configuration constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
11
|
+
|
|
12
|
+
from blocking.state_description_blocker import _extract_comment_lines
|
|
13
|
+
from hooks_constants.state_description_blocker_constants import (
|
|
14
|
+
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
15
|
+
ALL_COMMENT_TRANSITION_PATTERNS,
|
|
16
|
+
ALL_HASH_ONLY_EXTENSIONS,
|
|
17
|
+
ALL_MARKDOWN_EXTENSIONS,
|
|
18
|
+
BLOCK_COMMENT_CLOSE_MARKER,
|
|
19
|
+
BLOCK_COMMENT_OPEN_MARKER,
|
|
20
|
+
DOUBLE_QUOTE_BODY_GROUP,
|
|
21
|
+
SINGLE_QUOTE_BODY_GROUP,
|
|
22
|
+
TRIPLE_QUOTED_BLOCK_PATTERN,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_transition_patterns_match_their_own_phrase() -> None:
|
|
27
|
+
all_phrases = [
|
|
28
|
+
"instead of",
|
|
29
|
+
"previously",
|
|
30
|
+
"was previously",
|
|
31
|
+
"used to",
|
|
32
|
+
"no longer",
|
|
33
|
+
"switched to",
|
|
34
|
+
]
|
|
35
|
+
for each_phrase in all_phrases:
|
|
36
|
+
sentence = f"The field is {each_phrase} required."
|
|
37
|
+
assert any(
|
|
38
|
+
each_pattern.search(sentence) for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS
|
|
39
|
+
), each_phrase
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_transition_patterns_do_not_match_unrelated_prose() -> None:
|
|
43
|
+
sentence = "The path selects the scan strategy for the file."
|
|
44
|
+
assert not any(
|
|
45
|
+
each_pattern.search(sentence) for each_pattern in ALL_COMMENT_TRANSITION_PATTERNS
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_block_comment_markers_close_a_single_line_block_comment() -> None:
|
|
50
|
+
text = f"{BLOCK_COMMENT_OPEN_MARKER} note {BLOCK_COMMENT_CLOSE_MARKER}\ncode();"
|
|
51
|
+
all_comment_lines = _extract_comment_lines(text, ".js")
|
|
52
|
+
assert all_comment_lines == [
|
|
53
|
+
(1, f"{BLOCK_COMMENT_OPEN_MARKER} note {BLOCK_COMMENT_CLOSE_MARKER}")
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_block_comment_markers_carry_an_unclosed_comment_to_the_next_line() -> None:
|
|
58
|
+
text = f"{BLOCK_COMMENT_OPEN_MARKER} still open\nclosed here {BLOCK_COMMENT_CLOSE_MARKER}"
|
|
59
|
+
all_comment_lines = _extract_comment_lines(text, ".js")
|
|
60
|
+
assert all_comment_lines == [
|
|
61
|
+
(1, f"{BLOCK_COMMENT_OPEN_MARKER} still open"),
|
|
62
|
+
(2, f"closed here {BLOCK_COMMENT_CLOSE_MARKER}"),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_double_and_single_quote_body_groups_index_the_matching_quote_style() -> None:
|
|
67
|
+
double_quoted_match = TRIPLE_QUOTED_BLOCK_PATTERN.search('"""double body"""')
|
|
68
|
+
assert double_quoted_match is not None
|
|
69
|
+
assert double_quoted_match.group(DOUBLE_QUOTE_BODY_GROUP) == "double body"
|
|
70
|
+
assert double_quoted_match.group(SINGLE_QUOTE_BODY_GROUP) is None
|
|
71
|
+
|
|
72
|
+
single_quoted_match = TRIPLE_QUOTED_BLOCK_PATTERN.search("'''single body'''")
|
|
73
|
+
assert single_quoted_match is not None
|
|
74
|
+
assert single_quoted_match.group(SINGLE_QUOTE_BODY_GROUP) == "single body"
|
|
75
|
+
assert single_quoted_match.group(DOUBLE_QUOTE_BODY_GROUP) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_extension_sets_are_disjoint_from_markdown_extensions() -> None:
|
|
79
|
+
assert not (ALL_HASH_ONLY_EXTENSIONS & ALL_MARKDOWN_EXTENSIONS)
|
|
80
|
+
assert not (ALL_BLOCK_COMMENT_EXTENSIONS & ALL_MARKDOWN_EXTENSIONS)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_python_is_hash_only_and_never_block_comment() -> None:
|
|
84
|
+
assert ".py" in ALL_HASH_ONLY_EXTENSIONS
|
|
85
|
+
assert ".py" not in ALL_BLOCK_COMMENT_EXTENSIONS
|
|
@@ -12,6 +12,7 @@ from followup_ledger import (
|
|
|
12
12
|
FollowupFinding,
|
|
13
13
|
all_recorded_findings,
|
|
14
14
|
followup_ledger_path,
|
|
15
|
+
head_commit,
|
|
15
16
|
record_followup_finding,
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -21,6 +22,7 @@ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) ->
|
|
|
21
22
|
rule_id="instruction-filename",
|
|
22
23
|
file_path="docs/Claude.md",
|
|
23
24
|
message="Use the canonical filename: docs/Claude.md",
|
|
25
|
+
check_id="instruction-filename",
|
|
24
26
|
)
|
|
25
27
|
|
|
26
28
|
record_followup_finding(tmp_path, finding)
|
|
@@ -29,8 +31,12 @@ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) ->
|
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -> None:
|
|
32
|
-
first = FollowupFinding(
|
|
33
|
-
|
|
34
|
+
first = FollowupFinding(
|
|
35
|
+
"instruction-filename", "docs/Claude.md", "wrong name", "instruction-filename"
|
|
36
|
+
)
|
|
37
|
+
second = FollowupFinding(
|
|
38
|
+
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
39
|
+
)
|
|
34
40
|
|
|
35
41
|
record_followup_finding(tmp_path, first)
|
|
36
42
|
record_followup_finding(tmp_path, second)
|
|
@@ -39,7 +45,9 @@ def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -
|
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def test_record_followup_finding_records_a_repeat_once(tmp_path: Path) -> None:
|
|
42
|
-
finding = FollowupFinding(
|
|
48
|
+
finding = FollowupFinding(
|
|
49
|
+
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
50
|
+
)
|
|
43
51
|
|
|
44
52
|
record_followup_finding(tmp_path, finding)
|
|
45
53
|
record_followup_finding(tmp_path, finding)
|
|
@@ -52,7 +60,9 @@ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None
|
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
def test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
|
|
55
|
-
finding = FollowupFinding(
|
|
63
|
+
finding = FollowupFinding(
|
|
64
|
+
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
65
|
+
)
|
|
56
66
|
record_followup_finding(tmp_path, finding)
|
|
57
67
|
ledger_path = followup_ledger_path(tmp_path)
|
|
58
68
|
with ledger_path.open("a", encoding="utf-8") as ledger_file:
|
|
@@ -81,3 +91,81 @@ def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> Non
|
|
|
81
91
|
)
|
|
82
92
|
|
|
83
93
|
assert all_recorded_findings(tmp_path) == ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_recorded_finding_carries_its_check_severity_and_origin(
|
|
97
|
+
tmp_path: Path,
|
|
98
|
+
) -> None:
|
|
99
|
+
finding = FollowupFinding(
|
|
100
|
+
rule_id="code-rules",
|
|
101
|
+
file_path="scripts/run.py",
|
|
102
|
+
message="Line 3: Constant TIMEOUT - move to config/",
|
|
103
|
+
check_id="code-rules/constant-outside-config",
|
|
104
|
+
severity="smell",
|
|
105
|
+
origin_commit="abc1234",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
record_followup_finding(tmp_path, finding)
|
|
109
|
+
|
|
110
|
+
assert all_recorded_findings(tmp_path) == (finding,)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_record_written_without_the_tracking_fields_still_reads(
|
|
114
|
+
tmp_path: Path,
|
|
115
|
+
) -> None:
|
|
116
|
+
ledger_path = followup_ledger_path(tmp_path)
|
|
117
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
ledger_path.write_text(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"rule_id": "instruction-mode",
|
|
122
|
+
"file_path": "docs/AGENTS.md",
|
|
123
|
+
"message": "wrong mode",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
+ "\n",
|
|
127
|
+
encoding="utf-8",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
recorded_finding = all_recorded_findings(tmp_path)[0]
|
|
131
|
+
|
|
132
|
+
assert recorded_finding.check_id == "instruction-mode"
|
|
133
|
+
assert recorded_finding.severity == "smell"
|
|
134
|
+
assert recorded_finding.origin_commit == ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_one_smell_seen_under_a_later_commit_stays_one_record(tmp_path: Path) -> None:
|
|
138
|
+
first = FollowupFinding(
|
|
139
|
+
"code-rules", "run.py", "Line 3: x", "code-rules/x", "smell", "aaa1111"
|
|
140
|
+
)
|
|
141
|
+
same_smell_later = first._replace(origin_commit="bbb2222")
|
|
142
|
+
|
|
143
|
+
record_followup_finding(tmp_path, first)
|
|
144
|
+
record_followup_finding(tmp_path, same_smell_later)
|
|
145
|
+
|
|
146
|
+
assert all_recorded_findings(tmp_path) == (first,)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_head_commit_reads_the_checked_out_revision(tmp_path: Path) -> None:
|
|
150
|
+
git_directory = tmp_path / ".git"
|
|
151
|
+
(git_directory / "refs" / "heads").mkdir(parents=True)
|
|
152
|
+
(git_directory / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
|
153
|
+
(git_directory / "refs" / "heads" / "main").write_text(
|
|
154
|
+
"0123456789abcdef0123456789abcdef01234567\n", encoding="utf-8"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert head_commit(tmp_path) == "0123456789abcdef0123456789abcdef01234567"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_head_commit_reads_a_detached_revision(tmp_path: Path) -> None:
|
|
161
|
+
git_directory = tmp_path / ".git"
|
|
162
|
+
git_directory.mkdir()
|
|
163
|
+
(git_directory / "HEAD").write_text(
|
|
164
|
+
"0123456789abcdef0123456789abcdef01234567\n", encoding="utf-8"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert head_commit(tmp_path) == "0123456789abcdef0123456789abcdef01234567"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_head_commit_is_empty_outside_a_repository(tmp_path: Path) -> None:
|
|
171
|
+
assert head_commit(tmp_path) == ""
|
package/package.json
CHANGED
|
@@ -34,13 +34,39 @@ because the ledger holds them until a pull request does.
|
|
|
34
34
|
## The ledger
|
|
35
35
|
|
|
36
36
|
A recorded finding lands in `.claude/followups/smells.jsonl` at the
|
|
37
|
-
repository root, one JSON object per line
|
|
38
|
-
repository-relative path, and the message. `hooks/followup_ledger.py` writes
|
|
37
|
+
repository root, one JSON object per line. `hooks/followup_ledger.py` writes
|
|
39
38
|
and reads it. Every write there is fail-safe, so a ledger failure leaves the
|
|
40
39
|
gate's decision unchanged.
|
|
41
40
|
|
|
41
|
+
| Field | What it carries |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `rule_id` | The rule that raised the finding |
|
|
44
|
+
| `check_id` | The single check behind it, which a severity table keys on |
|
|
45
|
+
| `file_path` | The repository-relative path the finding names |
|
|
46
|
+
| `message` | The text a reader acts on |
|
|
47
|
+
| `severity` | The class the gate put it in |
|
|
48
|
+
| `origin_commit` | The revision checked out when it was recorded |
|
|
49
|
+
|
|
50
|
+
The origin commit groups a follow-up pull request by the change that raised
|
|
51
|
+
the findings. A smell seen again under a later revision keeps the revision
|
|
52
|
+
that first raised it, so one smell stays one record.
|
|
53
|
+
|
|
42
54
|
The ledger is per-checkout state and stays out of the repository.
|
|
43
55
|
|
|
56
|
+
## The check identifier
|
|
57
|
+
|
|
58
|
+
Most lint rules run one check, so their rule identifier already names it. The
|
|
59
|
+
`code-rules` and `validators` rules bundle many checks behind one identifier.
|
|
60
|
+
`scripts/policy_lint/check_catalog.py` resolves those to a `<rule>/<check>`
|
|
61
|
+
identifier, which `cde lint --format json` emits as `check_id` on every
|
|
62
|
+
diagnostic. A consumer partitions findings by that identifier rather than by
|
|
63
|
+
message text.
|
|
64
|
+
|
|
65
|
+
A message no catalog entry names resolves to `<rule>/unclassified`, which a
|
|
66
|
+
partition treats as blocking. A check whose wording moves reports louder
|
|
67
|
+
rather than going quiet, and the catalog's synchronization tests fail on the
|
|
68
|
+
same change.
|
|
69
|
+
|
|
44
70
|
## Reading and clearing it
|
|
45
71
|
|
|
46
72
|
| Command | What it does |
|
|
@@ -49,6 +75,13 @@ The ledger is per-checkout state and stays out of the repository.
|
|
|
49
75
|
| `cde followup ingest REPORT` | Records the diagnostics a policy-lint JSON report carries |
|
|
50
76
|
| `cde followup brief` | Writes the task an agent fixes them from |
|
|
51
77
|
| `cde followup clear` | Empties the ledger |
|
|
78
|
+
| `cde followup count` | Reports the backlog against the threshold |
|
|
79
|
+
|
|
80
|
+
`count` exits non-zero once the backlog passes
|
|
81
|
+
`FOLLOWUP_BACKLOG_THRESHOLD` in
|
|
82
|
+
`scripts/dev_env_scripts_constants/followup_constants.py`, so a scheduled job
|
|
83
|
+
escalates rather than opening one more quiet pull request. The number is the
|
|
84
|
+
repository's setting, and raising or lowering it is one edit there.
|
|
52
85
|
|
|
53
86
|
The `/fix-followups` command drives the whole pass: it reads the brief, fixes
|
|
54
87
|
each rule group, opens a draft pull request, and clears the ledger.
|
|
@@ -30,18 +30,29 @@ LIST_COMMAND_NAME: str = "list"
|
|
|
30
30
|
INGEST_COMMAND_NAME: str = "ingest"
|
|
31
31
|
BRIEF_COMMAND_NAME: str = "brief"
|
|
32
32
|
CLEAR_COMMAND_NAME: str = "clear"
|
|
33
|
+
COUNT_COMMAND_NAME: str = "count"
|
|
33
34
|
ALL_COMMAND_NAMES: tuple[str, ...] = (
|
|
34
35
|
LIST_COMMAND_NAME,
|
|
35
36
|
INGEST_COMMAND_NAME,
|
|
36
37
|
BRIEF_COMMAND_NAME,
|
|
37
38
|
CLEAR_COMMAND_NAME,
|
|
39
|
+
COUNT_COMMAND_NAME,
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
SUCCESS_EXIT_CODE: int = 0
|
|
41
43
|
INVALID_INPUT_EXIT_CODE: int = 2
|
|
44
|
+
BACKLOG_EXCEEDED_EXIT_CODE: int = 1
|
|
45
|
+
|
|
46
|
+
FOLLOWUP_BACKLOG_THRESHOLD: int = 20
|
|
47
|
+
BACKLOG_WITHIN_TEMPLATE: str = "{finding_count} follow-ups recorded, threshold {threshold}"
|
|
48
|
+
BACKLOG_EXCEEDED_TEMPLATE: str = (
|
|
49
|
+
"{finding_count} follow-ups recorded, over the threshold of {threshold}. "
|
|
50
|
+
"Work the backlog down before the next change records more."
|
|
51
|
+
)
|
|
42
52
|
|
|
43
53
|
DIAGNOSTICS_KEY: str = "diagnostics"
|
|
44
54
|
DIAGNOSTIC_RULE_ID_KEY: str = "rule_id"
|
|
55
|
+
DIAGNOSTIC_CHECK_ID_KEY: str = "check_id"
|
|
45
56
|
DIAGNOSTIC_MESSAGE_KEY: str = "message"
|
|
46
57
|
DIAGNOSTIC_LOCATION_KEY: str = "location"
|
|
47
58
|
LOCATION_PATH_KEY: str = "path"
|
|
@@ -51,11 +62,13 @@ LINE_SEPARATOR: str = "\n"
|
|
|
51
62
|
EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
|
|
52
63
|
UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
|
|
53
64
|
USAGE_TEXT: str = (
|
|
54
|
-
"Usage: followup_cli.py <list|ingest|brief|clear>
|
|
65
|
+
"Usage: followup_cli.py <list|ingest|brief|clear|count> "
|
|
66
|
+
"[--repository-root PATH]\n"
|
|
55
67
|
" list Name every recorded follow-up\n"
|
|
56
68
|
" ingest REPORT Record every diagnostic in a policy-lint JSON report\n"
|
|
57
69
|
" brief Write the task an agent works the follow-ups from\n"
|
|
58
|
-
" clear Empty the ledger"
|
|
70
|
+
" clear Empty the ledger\n"
|
|
71
|
+
" count Report the backlog size against the threshold"
|
|
59
72
|
)
|
|
60
73
|
|
|
61
74
|
BRIEF_HEADER: str = (
|
|
@@ -64,9 +77,13 @@ BRIEF_HEADER: str = (
|
|
|
64
77
|
"already shipped. Keep the fixes mechanical, touch no behavior, and run "
|
|
65
78
|
"the repository's own checks before pushing."
|
|
66
79
|
)
|
|
67
|
-
BRIEF_FINDING_TEMPLATE: str =
|
|
80
|
+
BRIEF_FINDING_TEMPLATE: str = (
|
|
81
|
+
"- [{check_id}] {file_path}: {message} (recorded at {origin_commit})"
|
|
82
|
+
)
|
|
68
83
|
BRIEF_FOOTER_TEMPLATE: str = (
|
|
69
84
|
"Once the pull request is open, empty the ledger with "
|
|
70
85
|
"`python {command_path} clear`."
|
|
71
86
|
)
|
|
72
|
-
LIST_FINDING_TEMPLATE: str =
|
|
87
|
+
LIST_FINDING_TEMPLATE: str = (
|
|
88
|
+
"{check_id}\t{file_path}\t{origin_commit}\t{message}"
|
|
89
|
+
)
|