claude-dev-env 8.74.1 → 8.74.2
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/bin/cde.mjs +1 -1
- package/bin/cde.test.mjs +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/bin/merge_profile_settings.mjs +2 -3
- package/bin/merge_profile_settings.test.mjs +5 -5
- package/commands/fix-followups.md +5 -2
- package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
- package/docs/rule-guides/paired-test-coverage.md +1 -1
- package/docs/rule-guides/session-title.md +3 -3
- package/docs/templates/html-plan/README.md +1 -1
- package/hooks/features/conduct-gates.md +0 -3
- package/hooks/features/session-context.md +1 -1
- package/hooks/followup_ledger.py +106 -108
- package/hooks/followup_ledger_maintenance.py +127 -0
- package/hooks/hooks.json +0 -11
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
- package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
- package/hooks/hooks_constants/session_title_constants.py +1 -29
- package/hooks/session/artifact_template_pointer.py +5 -4
- package/hooks/session/test_artifact_template_pointer.py +13 -0
- package/hooks/test_followup_ledger.py +78 -59
- package/hooks/test_followup_ledger_maintenance.py +107 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
- package/scripts/followup_cli.py +11 -12
- package/scripts/policy_lint/adapter_configuration.py +0 -1
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/repository_checks/config/constants.py +0 -6
- package/scripts/repository_checks/runner.py +0 -7
- package/scripts/test_followup_cli.py +71 -7
- package/scripts/tests/test_adapter_configuration.py +2 -5
- package/scripts/tests/test_cde_lint.py +3 -4
- package/scripts/tests/test_repository_policy.py +1 -3
- package/hooks/blocking/session_title_stop_gate.py +0 -217
- package/hooks/blocking/test_session_title_stop_gate.py +0 -295
- package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
- package/scripts/tests/test_followup_ledger_duplicates.py +0 -58
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Clear and normalize the follow-up ledger directory.
|
|
2
|
+
|
|
3
|
+
``cde followup clear`` and ``cde followup dedupe`` run these. The gates that
|
|
4
|
+
record findings use only ``followup_ledger``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import NamedTuple
|
|
12
|
+
|
|
13
|
+
_hooks_directory = str(Path(__file__).resolve().parent)
|
|
14
|
+
if _hooks_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_directory)
|
|
16
|
+
|
|
17
|
+
from followup_ledger import (
|
|
18
|
+
all_finding_files,
|
|
19
|
+
all_record_paths,
|
|
20
|
+
finding_from_text,
|
|
21
|
+
finding_path,
|
|
22
|
+
followup_directory,
|
|
23
|
+
record_followup_finding,
|
|
24
|
+
)
|
|
25
|
+
from hooks_constants.followup_ledger_constants import (
|
|
26
|
+
LEDGER_ENCODING,
|
|
27
|
+
LEGACY_LEDGER_FILE_NAME,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clear_recorded_findings(repository_root: Path) -> None:
|
|
32
|
+
"""Remove every recorded finding and any legacy ledger file.
|
|
33
|
+
|
|
34
|
+
The directory and its ``.gitignore`` stay. A file that cannot be removed
|
|
35
|
+
is left in place.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
repository_root: The repository whose ledger to empty.
|
|
39
|
+
"""
|
|
40
|
+
directory = followup_directory(repository_root)
|
|
41
|
+
all_paths = [*all_record_paths(directory), directory / LEGACY_LEDGER_FILE_NAME]
|
|
42
|
+
for each_path in all_paths:
|
|
43
|
+
try:
|
|
44
|
+
each_path.unlink(missing_ok=True)
|
|
45
|
+
except OSError:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LedgerNormalization(NamedTuple):
|
|
50
|
+
"""What one ``normalize_ledger`` run changed.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
migrated_count: Legacy ledger lines written as finding files.
|
|
54
|
+
removed_count: Finding files removed as duplicates.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
migrated_count: int
|
|
58
|
+
removed_count: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_ledger(repository_root: Path) -> LedgerNormalization:
|
|
62
|
+
"""Move every record to the file its tracking key names.
|
|
63
|
+
|
|
64
|
+
A legacy ``smells.jsonl`` ledger is split into one file per finding and
|
|
65
|
+
removed. A finding file whose name does not match its tracking key moves
|
|
66
|
+
to the matching name, or is removed when that name already holds the
|
|
67
|
+
finding.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
repository_root: The repository whose ledger to normalize.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
How many legacy lines moved and how many duplicate files left.
|
|
74
|
+
"""
|
|
75
|
+
migrated_count = _migrate_legacy_ledger(repository_root)
|
|
76
|
+
all_pairs = all_finding_files(repository_root)
|
|
77
|
+
all_readable_paths = {each_path for each_path, _each_finding in all_pairs}
|
|
78
|
+
removed_count = sum(
|
|
79
|
+
_settle_finding_file(
|
|
80
|
+
each_path,
|
|
81
|
+
finding_path(repository_root, each_finding),
|
|
82
|
+
all_readable_paths,
|
|
83
|
+
)
|
|
84
|
+
for each_path, each_finding in all_pairs
|
|
85
|
+
)
|
|
86
|
+
return LedgerNormalization(migrated_count, removed_count)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _settle_finding_file(
|
|
90
|
+
current_path: Path, expected_path: Path, all_readable_paths: set[Path]
|
|
91
|
+
) -> int:
|
|
92
|
+
if current_path == expected_path:
|
|
93
|
+
return 0
|
|
94
|
+
try:
|
|
95
|
+
if expected_path in all_readable_paths:
|
|
96
|
+
current_path.unlink()
|
|
97
|
+
return 1
|
|
98
|
+
if not expected_path.exists():
|
|
99
|
+
current_path.rename(expected_path)
|
|
100
|
+
except OSError:
|
|
101
|
+
return 0
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _migrate_legacy_ledger(repository_root: Path) -> int:
|
|
106
|
+
legacy_path = followup_directory(repository_root) / LEGACY_LEDGER_FILE_NAME
|
|
107
|
+
try:
|
|
108
|
+
legacy_text = legacy_path.read_text(encoding=LEDGER_ENCODING)
|
|
109
|
+
except (OSError, UnicodeError):
|
|
110
|
+
return 0
|
|
111
|
+
all_legacy_findings = [
|
|
112
|
+
each_finding
|
|
113
|
+
for each_finding in map(finding_from_text, legacy_text.splitlines())
|
|
114
|
+
if each_finding is not None
|
|
115
|
+
]
|
|
116
|
+
for each_finding in all_legacy_findings:
|
|
117
|
+
record_followup_finding(repository_root, each_finding)
|
|
118
|
+
if not all(
|
|
119
|
+
finding_path(repository_root, each_finding).is_file()
|
|
120
|
+
for each_finding in all_legacy_findings
|
|
121
|
+
):
|
|
122
|
+
return 0
|
|
123
|
+
try:
|
|
124
|
+
legacy_path.unlink()
|
|
125
|
+
except OSError:
|
|
126
|
+
return 0
|
|
127
|
+
return len(all_legacy_findings)
|
package/hooks/hooks.json
CHANGED
|
@@ -332,17 +332,6 @@
|
|
|
332
332
|
]
|
|
333
333
|
}
|
|
334
334
|
],
|
|
335
|
-
"Stop": [
|
|
336
|
-
{
|
|
337
|
-
"hooks": [
|
|
338
|
-
{
|
|
339
|
-
"type": "command",
|
|
340
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_stop_gate.py",
|
|
341
|
-
"timeout": 10
|
|
342
|
-
}
|
|
343
|
-
]
|
|
344
|
-
}
|
|
345
|
-
],
|
|
346
335
|
"PostToolUse": [
|
|
347
336
|
{
|
|
348
337
|
"matcher": "Write|Edit",
|
|
@@ -26,7 +26,11 @@ ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
|
|
|
26
26
|
TEMPLATES_DIRECTORY = Path(__file__).resolve().parents[2] / "docs" / "templates"
|
|
27
27
|
ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
|
|
28
28
|
HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
|
|
29
|
-
POINTER_TEXT_PREFIX =
|
|
29
|
+
POINTER_TEXT_PREFIX = (
|
|
30
|
+
"Pick the visual by scope. For one flow or one comparison, draw an inline SVG"
|
|
31
|
+
" flowchart artifact with minimal text, using the artifact-diagramming skill."
|
|
32
|
+
" For a multi-part plan or evidence, build an html-plan page following "
|
|
33
|
+
)
|
|
30
34
|
POINTER_ALTERNATIVE_PREFIX = (
|
|
31
35
|
". When the request names the older artifact page template,"
|
|
32
36
|
" pick the one that fits the ask from "
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
-
"
|
|
6
|
+
"ALL_FOLLOWUP_DIRECTORY_SEGMENTS",
|
|
7
|
+
"LEGACY_LEDGER_FILE_NAME",
|
|
8
|
+
"FINDING_FILE_SUFFIX",
|
|
9
|
+
"FINDING_FILE_PATTERN",
|
|
10
|
+
"FINDING_FILE_NAME_HASH_LENGTH",
|
|
7
11
|
"LEDGER_ENCODING",
|
|
8
|
-
"
|
|
12
|
+
"LEDGER_CREATE_MODE",
|
|
13
|
+
"LEDGER_JSON_INDENT",
|
|
9
14
|
"LEDGER_IGNORE_FILE_NAME",
|
|
10
15
|
"LEDGER_IGNORE_TEXT",
|
|
11
16
|
"RULE_ID_KEY",
|
|
@@ -28,9 +33,14 @@ __all__ = [
|
|
|
28
33
|
"ALL_SEVERITY_NAMES",
|
|
29
34
|
]
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
ALL_FOLLOWUP_DIRECTORY_SEGMENTS: tuple[str, ...] = (".claude", "followups")
|
|
37
|
+
LEGACY_LEDGER_FILE_NAME: str = "smells.jsonl"
|
|
38
|
+
FINDING_FILE_SUFFIX: str = ".json"
|
|
39
|
+
FINDING_FILE_PATTERN: str = "*.json"
|
|
40
|
+
FINDING_FILE_NAME_HASH_LENGTH: int = 16
|
|
32
41
|
LEDGER_ENCODING: str = "utf-8"
|
|
33
|
-
|
|
42
|
+
LEDGER_CREATE_MODE: str = "x"
|
|
43
|
+
LEDGER_JSON_INDENT: int = 2
|
|
34
44
|
LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
|
|
35
45
|
LEDGER_IGNORE_TEXT: str = "*\n"
|
|
36
46
|
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
"""Constants for session_title_format_gate.py
|
|
1
|
+
"""Constants for session_title_format_gate.py."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
5
|
TITLE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__set_session_title$")
|
|
6
|
-
REMOTE_SESSION_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_REMOTE_SESSION_ID"
|
|
7
6
|
TITLE_INPUT_KEY = "title"
|
|
8
7
|
TOOL_NAME_KEY = "tool_name"
|
|
9
8
|
TOOL_INPUT_KEY = "tool_input"
|
|
10
|
-
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
11
|
-
STOP_HOOK_ACTIVE_KEY = "stop_hook_active"
|
|
12
9
|
FORMAT_GATE_EVENT_NAME = "PreToolUse"
|
|
13
|
-
STOP_GATE_EVENT_NAME = "Stop"
|
|
14
10
|
ALLOW_EXIT_CODE = 0
|
|
15
11
|
BLOCK_EXIT_CODE = 2
|
|
16
|
-
BLOCK_DECISION = "block"
|
|
17
|
-
DEFERRED_TOOL_ATTACHMENT_TYPES = frozenset({"deferred_tools_delta", "deferred_tools_record"})
|
|
18
|
-
HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE = "hook_blocking_error"
|
|
19
12
|
|
|
20
13
|
STATUS_PREFIX_PATTERN = re.compile("^(\U0001f6a9|\u2705|\u23f3)\ufe0f? (?=\\S)")
|
|
21
14
|
MAXIMUM_NAME_LENGTH = 25
|
|
@@ -38,24 +31,3 @@ FORMAT_RETRY_INSTRUCTION = (
|
|
|
38
31
|
" the main change or outcome in concrete nouns, such as"
|
|
39
32
|
" '\u23f3 Broker gate + replay rule'."
|
|
40
33
|
)
|
|
41
|
-
|
|
42
|
-
STOP_BLOCK_REASON = (
|
|
43
|
-
"Before you end this turn, set the session title to its current status."
|
|
44
|
-
" Call the session title tool ({tool_name}); when it asks for a session id,"
|
|
45
|
-
" get it from get_session with no session_id."
|
|
46
|
-
" Use the shape '<emoji> <name>'."
|
|
47
|
-
" Emoji: \U0001f6a9 when the user must act (a question, an approval, or a step only the user can do);"
|
|
48
|
-
" \u2705 when the work is done (merged, or nothing left);"
|
|
49
|
-
" \u23f3 while work, CI or a merge queue still runs and nothing waits on the user."
|
|
50
|
-
" Name: 25 characters or fewer, the main change or outcome in concrete nouns,"
|
|
51
|
-
" two parts joined with ' + ', sentence case, no end punctuation,"
|
|
52
|
-
" no dates, IDs, branch names or filler words."
|
|
53
|
-
" Rename it when the scope of the work has changed."
|
|
54
|
-
" The title is a silent step: make the call and end the turn."
|
|
55
|
-
" Say nothing to the user about the title, its emoji or this reminder."
|
|
56
|
-
" When the user already has your answer this turn, end with no text."
|
|
57
|
-
" Next time, set the title before your final reply."
|
|
58
|
-
)
|
|
59
|
-
REMOTE_TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
|
|
60
|
-
REMOTE_SERVER_TOOL_PREFIX = "mcp__claude-code-remote__"
|
|
61
|
-
UNKNOWN_TITLE_TOOL_NAME = "its name ends in __set_session_title"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook:
|
|
2
|
+
"""PreToolUse hook: route an agent about to build an artifact page by scope.
|
|
3
3
|
|
|
4
4
|
Registered on ``Artifact|Skill``. It adds one line of ``additionalContext``
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
when a session starts a new artifact page. One flow or one comparison is an
|
|
6
|
+
inline SVG flowchart with minimal text. A multi-part plan or evidence is an
|
|
7
|
+
html-plan page from ``docs/templates/html-plan/README.md``, and
|
|
8
|
+
``docs/templates/artifact-page/README.md`` stays the alternative:
|
|
8
9
|
|
|
9
10
|
::
|
|
10
11
|
|
|
@@ -44,6 +44,19 @@ def test_artifact_quickstart_gets_the_template_pointer() -> None:
|
|
|
44
44
|
assert "permissionDecision" not in specific_output
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_pointer_routes_a_simple_visual_to_a_flowchart_ahead_of_html_plan() -> None:
|
|
48
|
+
hook_output = artifact_template_pointer.decide_hook_output(
|
|
49
|
+
_payload("Artifact", {"action": "quickstart", "intent": "other"})
|
|
50
|
+
)
|
|
51
|
+
assert hook_output is not None
|
|
52
|
+
pointer_text = hook_output["hookSpecificOutput"]["additionalContext"]
|
|
53
|
+
assert pointer_text.index("one flow or one comparison") < pointer_text.index("flowchart")
|
|
54
|
+
assert pointer_text.index("flowchart") < pointer_text.index("multi-part plan or evidence")
|
|
55
|
+
assert pointer_text.index("multi-part plan or evidence") < pointer_text.index(
|
|
56
|
+
str(HTML_PLAN_TEMPLATE_README_PATH)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
def test_artifact_design_skill_gets_the_template_pointer() -> None:
|
|
48
61
|
hook_output = artifact_template_pointer.decide_hook_output(
|
|
49
62
|
_payload("Skill", {"skill": "artifact-design"})
|
|
@@ -12,9 +12,8 @@ if _hooks_directory not in sys.path:
|
|
|
12
12
|
from followup_ledger import (
|
|
13
13
|
FollowupFinding,
|
|
14
14
|
all_recorded_findings,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
followup_ledger_path,
|
|
15
|
+
finding_path,
|
|
16
|
+
followup_directory,
|
|
18
17
|
head_commit,
|
|
19
18
|
record_followup_finding,
|
|
20
19
|
)
|
|
@@ -62,27 +61,29 @@ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None
|
|
|
62
61
|
assert all_recorded_findings(tmp_path) == ()
|
|
63
62
|
|
|
64
63
|
|
|
65
|
-
def
|
|
64
|
+
def test_all_recorded_findings_skips_an_unreadable_file(tmp_path: Path) -> None:
|
|
66
65
|
finding = FollowupFinding(
|
|
67
66
|
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
68
67
|
)
|
|
69
68
|
record_followup_finding(tmp_path, finding)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
(followup_directory(tmp_path) / "damaged.json").write_text(
|
|
70
|
+
"{ not json\n", encoding="utf-8"
|
|
71
|
+
)
|
|
73
72
|
|
|
74
73
|
assert all_recorded_findings(tmp_path) == (finding,)
|
|
75
74
|
|
|
76
75
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
76
|
+
def test_record_followup_finding_writes_one_json_file_per_finding(
|
|
77
|
+
tmp_path: Path,
|
|
78
|
+
) -> None:
|
|
79
|
+
finding = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
|
|
81
80
|
|
|
82
|
-
|
|
81
|
+
record_followup_finding(tmp_path, finding)
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
assert
|
|
83
|
+
all_record_paths = list(followup_directory(tmp_path).glob("*.json"))
|
|
84
|
+
assert all_record_paths == [finding_path(tmp_path, finding)]
|
|
85
|
+
recorded = json.loads(all_record_paths[0].read_text(encoding="utf-8"))
|
|
86
|
+
assert recorded["rule_id"] == "instruction-mode"
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
|
|
@@ -98,7 +99,7 @@ def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
|
|
|
98
99
|
capture_output=True,
|
|
99
100
|
text=True,
|
|
100
101
|
)
|
|
101
|
-
assert
|
|
102
|
+
assert any(followup_directory(tmp_path).glob("*.json"))
|
|
102
103
|
assert status_result.stdout == ""
|
|
103
104
|
|
|
104
105
|
|
|
@@ -133,9 +134,9 @@ def test_a_recorded_finding_carries_its_check_severity_and_origin(
|
|
|
133
134
|
def test_a_record_written_without_the_tracking_fields_still_reads(
|
|
134
135
|
tmp_path: Path,
|
|
135
136
|
) -> None:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
record_path = followup_directory(tmp_path) / "older.json"
|
|
138
|
+
record_path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
record_path.write_text(
|
|
139
140
|
json.dumps(
|
|
140
141
|
{
|
|
141
142
|
"rule_id": "instruction-mode",
|
|
@@ -268,57 +269,75 @@ def test_head_commit_reads_a_packed_branch_from_a_linked_worktree(
|
|
|
268
269
|
assert head_commit(worktree_root) == expected_commit
|
|
269
270
|
|
|
270
271
|
|
|
271
|
-
def
|
|
272
|
-
|
|
272
|
+
def test_the_file_name_depends_on_the_tracking_key_alone(tmp_path: Path) -> None:
|
|
273
|
+
first = FollowupFinding("r1", "a.py", "m1", "r1", "smell", "aaa1111")
|
|
274
|
+
later = first._replace(origin_commit="bbb2222")
|
|
275
|
+
other = first._replace(message="m2")
|
|
273
276
|
|
|
277
|
+
assert finding_path(tmp_path, first) == finding_path(tmp_path, later)
|
|
278
|
+
assert finding_path(tmp_path, first) != finding_path(tmp_path, other)
|
|
274
279
|
|
|
275
|
-
def test_record_followup_finding_removes_lines_a_union_merge_repeated(
|
|
276
|
-
tmp_path: Path,
|
|
277
|
-
) -> None:
|
|
278
|
-
ledger_path = followup_ledger_path(tmp_path)
|
|
279
|
-
ledger_path.parent.mkdir(parents=True)
|
|
280
|
-
first_line = '{"rule_id": "r1", "file_path": "a.py", "message": "m1"}'
|
|
281
|
-
second_line = '{"rule_id": "r2", "file_path": "b.py", "message": "m2"}'
|
|
282
|
-
ledger_path.write_text(
|
|
283
|
-
f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
|
|
284
|
-
)
|
|
285
280
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
281
|
+
def _git(repository_root: Path, *all_arguments: str) -> subprocess.CompletedProcess:
|
|
282
|
+
return subprocess.run(
|
|
283
|
+
[
|
|
284
|
+
"git",
|
|
285
|
+
"-C",
|
|
286
|
+
str(repository_root),
|
|
287
|
+
"-c",
|
|
288
|
+
"user.name=t",
|
|
289
|
+
"-c",
|
|
290
|
+
"user.email=t@t",
|
|
291
|
+
*all_arguments,
|
|
292
|
+
],
|
|
293
|
+
check=False,
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
)
|
|
295
297
|
|
|
296
298
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
) ->
|
|
300
|
-
|
|
301
|
-
record_followup_finding(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
299
|
+
def _commit_finding_on_branch(
|
|
300
|
+
repository_root: Path, branch_name: str, finding: FollowupFinding
|
|
301
|
+
) -> set[str]:
|
|
302
|
+
_git(repository_root, "checkout", "--quiet", "-b", branch_name, "main")
|
|
303
|
+
record_followup_finding(repository_root, finding)
|
|
304
|
+
_git(repository_root, "add", "--all")
|
|
305
|
+
_git(repository_root, "commit", "--quiet", "-m", branch_name)
|
|
306
|
+
changed_text = _git(
|
|
307
|
+
repository_root, "diff", "--name-only", "main", branch_name
|
|
308
|
+
).stdout
|
|
309
|
+
return set(changed_text.split())
|
|
305
310
|
|
|
306
|
-
record_followup_finding(tmp_path, finding)
|
|
307
311
|
|
|
308
|
-
|
|
312
|
+
def test_two_parallel_writers_share_no_file_and_merge_cleanly(tmp_path: Path) -> None:
|
|
313
|
+
repository_root = tmp_path / "repo"
|
|
314
|
+
subprocess.run(
|
|
315
|
+
["git", "init", "--quiet", "-b", "main", str(repository_root)], check=True
|
|
316
|
+
)
|
|
317
|
+
ignore_path = followup_directory(repository_root) / ".gitignore"
|
|
318
|
+
ignore_path.parent.mkdir(parents=True)
|
|
319
|
+
ignore_path.write_text("!*.json\n", encoding="utf-8")
|
|
320
|
+
_git(repository_root, "add", "--all")
|
|
321
|
+
_git(repository_root, "commit", "--quiet", "-m", "start")
|
|
322
|
+
first = FollowupFinding("r1", "a.py", "m1", "r1")
|
|
323
|
+
second = FollowupFinding("r2", "b.py", "m2", "r2")
|
|
309
324
|
|
|
325
|
+
first_files = _commit_finding_on_branch(repository_root, "first", first)
|
|
326
|
+
second_files = _commit_finding_on_branch(repository_root, "second", second)
|
|
327
|
+
_git(repository_root, "checkout", "--quiet", "first")
|
|
328
|
+
merge_result = _git(repository_root, "merge", "--no-edit", "second")
|
|
310
329
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
330
|
+
assert first_files and second_files
|
|
331
|
+
assert not first_files & second_files
|
|
332
|
+
assert merge_result.returncode == 0, merge_result.stdout + merge_result.stderr
|
|
333
|
+
assert all_recorded_findings(repository_root) == (first, second)
|
|
315
334
|
|
|
316
|
-
removed_count = deduplicate_ledger(tmp_path)
|
|
317
335
|
|
|
318
|
-
|
|
319
|
-
|
|
336
|
+
def test_a_committed_ignore_file_is_left_as_it_stands(tmp_path: Path) -> None:
|
|
337
|
+
ignore_path = followup_directory(tmp_path) / ".gitignore"
|
|
338
|
+
ignore_path.parent.mkdir(parents=True)
|
|
339
|
+
ignore_path.write_text("!*.json\n", encoding="utf-8")
|
|
320
340
|
|
|
341
|
+
record_followup_finding(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
|
|
321
342
|
|
|
322
|
-
|
|
323
|
-
assert deduplicate_ledger(tmp_path) == 0
|
|
324
|
-
assert not followup_ledger_path(tmp_path).exists()
|
|
343
|
+
assert ignore_path.read_text(encoding="utf-8") == "!*.json\n"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Behavior tests for clearing and normalizing the follow-up ledger."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_hooks_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
if _hooks_directory not in sys.path:
|
|
9
|
+
sys.path.insert(0, _hooks_directory)
|
|
10
|
+
|
|
11
|
+
from followup_ledger import (
|
|
12
|
+
FollowupFinding,
|
|
13
|
+
all_recorded_findings,
|
|
14
|
+
finding_path,
|
|
15
|
+
followup_directory,
|
|
16
|
+
record_followup_finding,
|
|
17
|
+
)
|
|
18
|
+
from followup_ledger_maintenance import clear_recorded_findings, normalize_ledger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_clear_removes_every_finding_and_the_legacy_ledger_but_keeps_the_ignore(
|
|
22
|
+
tmp_path: Path,
|
|
23
|
+
) -> None:
|
|
24
|
+
record_followup_finding(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
|
|
25
|
+
record_followup_finding(tmp_path, FollowupFinding("r2", "b.py", "m2", "r2"))
|
|
26
|
+
legacy_path = followup_directory(tmp_path) / "smells.jsonl"
|
|
27
|
+
legacy_path.write_text("{}\n", encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
clear_recorded_findings(tmp_path)
|
|
30
|
+
|
|
31
|
+
assert all_recorded_findings(tmp_path) == ()
|
|
32
|
+
assert not legacy_path.exists()
|
|
33
|
+
assert (followup_directory(tmp_path) / ".gitignore").is_file()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_normalize_splits_a_legacy_ledger_and_keeps_the_first_origin(
|
|
37
|
+
tmp_path: Path,
|
|
38
|
+
) -> None:
|
|
39
|
+
first_record = {
|
|
40
|
+
"rule_id": "r1",
|
|
41
|
+
"file_path": "a.py",
|
|
42
|
+
"message": "m1",
|
|
43
|
+
"origin_commit": "aaa1111",
|
|
44
|
+
}
|
|
45
|
+
later_record = {**first_record, "origin_commit": "bbb2222"}
|
|
46
|
+
legacy_path = followup_directory(tmp_path) / "smells.jsonl"
|
|
47
|
+
legacy_path.parent.mkdir(parents=True)
|
|
48
|
+
legacy_path.write_text(
|
|
49
|
+
f"{json.dumps(first_record)}\n{json.dumps(later_record)}\nnot json\n",
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
normalization = normalize_ledger(tmp_path)
|
|
54
|
+
|
|
55
|
+
assert normalization.migrated_count == 2
|
|
56
|
+
assert normalization.removed_count == 0
|
|
57
|
+
assert not legacy_path.exists()
|
|
58
|
+
assert [each.origin_commit for each in all_recorded_findings(tmp_path)] == [
|
|
59
|
+
"aaa1111"
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_normalize_renames_a_misnamed_finding_file(tmp_path: Path) -> None:
|
|
64
|
+
finding = FollowupFinding("r1", "a.py", "m1", "r1")
|
|
65
|
+
record_followup_finding(tmp_path, finding)
|
|
66
|
+
expected_path = finding_path(tmp_path, finding)
|
|
67
|
+
misnamed_path = expected_path.with_name("misnamed.json")
|
|
68
|
+
expected_path.rename(misnamed_path)
|
|
69
|
+
|
|
70
|
+
normalization = normalize_ledger(tmp_path)
|
|
71
|
+
|
|
72
|
+
assert normalization.removed_count == 0
|
|
73
|
+
assert expected_path.is_file()
|
|
74
|
+
assert not misnamed_path.exists()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_normalize_keeps_the_legacy_ledger_when_a_finding_cannot_be_written(
|
|
78
|
+
tmp_path: Path,
|
|
79
|
+
) -> None:
|
|
80
|
+
legacy_path = followup_directory(tmp_path) / "smells.jsonl"
|
|
81
|
+
legacy_path.parent.mkdir(parents=True)
|
|
82
|
+
legacy_text = json.dumps({"rule_id": "r1", "file_path": "a.py", "message": "m1"})
|
|
83
|
+
legacy_path.write_text(legacy_text + "\n", encoding="utf-8")
|
|
84
|
+
blocked_path = finding_path(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
|
|
85
|
+
blocked_path.mkdir()
|
|
86
|
+
|
|
87
|
+
normalization = normalize_ledger(tmp_path)
|
|
88
|
+
|
|
89
|
+
assert normalization.migrated_count == 0
|
|
90
|
+
assert legacy_path.read_text(encoding="utf-8") == legacy_text + "\n"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_normalize_keeps_a_misnamed_finding_when_its_name_holds_a_damaged_file(
|
|
94
|
+
tmp_path: Path,
|
|
95
|
+
) -> None:
|
|
96
|
+
finding = FollowupFinding("r1", "a.py", "m1", "r1")
|
|
97
|
+
record_followup_finding(tmp_path, finding)
|
|
98
|
+
expected_path = finding_path(tmp_path, finding)
|
|
99
|
+
misnamed_path = expected_path.with_name("misnamed.json")
|
|
100
|
+
expected_path.rename(misnamed_path)
|
|
101
|
+
expected_path.write_text("{ damaged\n", encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
normalization = normalize_ledger(tmp_path)
|
|
104
|
+
|
|
105
|
+
assert normalization.removed_count == 0
|
|
106
|
+
assert misnamed_path.is_file()
|
|
107
|
+
assert all_recorded_findings(tmp_path) == (finding,)
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ paths:
|
|
|
13
13
|
|
|
14
14
|
**When:** Writing a local gate or changing a check's severity.
|
|
15
15
|
|
|
16
|
-
Give every check ID a breaking or smell severity row; style and structure findings are smells. Fail the gate and stop for a bug, secret, broken test, syntax error, or instruction file that cannot load. Record a smell in `.claude/followups
|
|
16
|
+
Give every check ID a breaking or smell severity row; style and structure findings are smells. Fail the gate and stop for a bug, secret, broken test, syntax error, or instruction file that cannot load. Record a smell as one file in `.claude/followups/`, proceed, and fix it later in one combined follow-up pull request per parent pull request, whose body names the parent as `Follow-up to #N`; ledger failure leaves the gate decision unchanged. Treat an unclassified check as breaking.
|
|
17
17
|
|
|
18
18
|
**Enforcement:** none.
|
|
19
19
|
|
|
@@ -63,7 +63,10 @@ ABSENT_LOCATION_PATH: str = ""
|
|
|
63
63
|
LINE_SEPARATOR: str = "\n"
|
|
64
64
|
EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
|
|
65
65
|
UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
|
|
66
|
-
DEDUPE_RESULT_TEMPLATE: str =
|
|
66
|
+
DEDUPE_RESULT_TEMPLATE: str = (
|
|
67
|
+
"moved {migrated_count} legacy ledger lines into finding files, "
|
|
68
|
+
"removed {removed_count} duplicate finding files"
|
|
69
|
+
)
|
|
67
70
|
USAGE_TEXT: str = (
|
|
68
71
|
"Usage: followup_cli.py <list|ingest|brief|clear|count|dedupe> "
|
|
69
72
|
"[--repository-root PATH]\n"
|