claude-dev-env 8.74.0 → 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 +179 -116
- 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 +26 -6
- 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 +151 -55
- 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",
|
|
@@ -15,7 +20,12 @@ __all__ = [
|
|
|
15
20
|
"SEVERITY_KEY",
|
|
16
21
|
"ORIGIN_COMMIT_KEY",
|
|
17
22
|
"ABSENT_ORIGIN_COMMIT",
|
|
18
|
-
"
|
|
23
|
+
"GIT_HEAD_FILE_NAME",
|
|
24
|
+
"GIT_DIRECTORY_FILE_PREFIX",
|
|
25
|
+
"GIT_COMMON_DIRECTORY_FILE_NAME",
|
|
26
|
+
"GIT_PACKED_REFERENCES_FILE_NAME",
|
|
27
|
+
"GIT_PACKED_REFERENCES_COMMENT_PREFIX",
|
|
28
|
+
"GIT_PACKED_REFERENCES_PEELED_PREFIX",
|
|
19
29
|
"GIT_DIRECTORY_NAME",
|
|
20
30
|
"GIT_REFERENCE_PREFIX",
|
|
21
31
|
"SEVERITY_BREAKING",
|
|
@@ -23,9 +33,14 @@ __all__ = [
|
|
|
23
33
|
"ALL_SEVERITY_NAMES",
|
|
24
34
|
]
|
|
25
35
|
|
|
26
|
-
|
|
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
|
|
27
41
|
LEDGER_ENCODING: str = "utf-8"
|
|
28
|
-
|
|
42
|
+
LEDGER_CREATE_MODE: str = "x"
|
|
43
|
+
LEDGER_JSON_INDENT: int = 2
|
|
29
44
|
LEDGER_IGNORE_FILE_NAME: str = ".gitignore"
|
|
30
45
|
LEDGER_IGNORE_TEXT: str = "*\n"
|
|
31
46
|
|
|
@@ -38,7 +53,12 @@ ORIGIN_COMMIT_KEY: str = "origin_commit"
|
|
|
38
53
|
|
|
39
54
|
ABSENT_ORIGIN_COMMIT: str = ""
|
|
40
55
|
GIT_DIRECTORY_NAME: str = ".git"
|
|
41
|
-
|
|
56
|
+
GIT_HEAD_FILE_NAME: str = "HEAD"
|
|
57
|
+
GIT_DIRECTORY_FILE_PREFIX: str = "gitdir: "
|
|
58
|
+
GIT_COMMON_DIRECTORY_FILE_NAME: str = "commondir"
|
|
59
|
+
GIT_PACKED_REFERENCES_FILE_NAME: str = "packed-refs"
|
|
60
|
+
GIT_PACKED_REFERENCES_COMMENT_PREFIX: str = "#"
|
|
61
|
+
GIT_PACKED_REFERENCES_PEELED_PREFIX: str = "^"
|
|
42
62
|
GIT_REFERENCE_PREFIX: str = "ref: "
|
|
43
63
|
|
|
44
64
|
SEVERITY_BREAKING: str = "breaking"
|
|
@@ -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",
|
|
@@ -191,57 +192,152 @@ def test_head_commit_is_empty_outside_a_repository(tmp_path: Path) -> None:
|
|
|
191
192
|
assert head_commit(tmp_path) == ""
|
|
192
193
|
|
|
193
194
|
|
|
194
|
-
def
|
|
195
|
-
|
|
195
|
+
def _committed_repository(repository_root: Path) -> str:
|
|
196
|
+
subprocess.run(["git", "init", "--quiet", str(repository_root)], check=True)
|
|
197
|
+
subprocess.run(
|
|
198
|
+
[
|
|
199
|
+
"git",
|
|
200
|
+
"-C",
|
|
201
|
+
str(repository_root),
|
|
202
|
+
"-c",
|
|
203
|
+
"user.name=t",
|
|
204
|
+
"-c",
|
|
205
|
+
"user.email=t@t",
|
|
206
|
+
"commit",
|
|
207
|
+
"--quiet",
|
|
208
|
+
"--allow-empty",
|
|
209
|
+
"-m",
|
|
210
|
+
"start",
|
|
211
|
+
],
|
|
212
|
+
check=True,
|
|
213
|
+
)
|
|
214
|
+
return subprocess.run(
|
|
215
|
+
["git", "-C", str(repository_root), "rev-parse", "HEAD"],
|
|
216
|
+
check=True,
|
|
217
|
+
capture_output=True,
|
|
218
|
+
text=True,
|
|
219
|
+
).stdout.strip()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_head_commit_reads_a_linked_worktree_branch(tmp_path: Path) -> None:
|
|
223
|
+
main_root = tmp_path / "main"
|
|
224
|
+
worktree_root = tmp_path / "linked"
|
|
225
|
+
expected_commit = _committed_repository(main_root)
|
|
226
|
+
subprocess.run(
|
|
227
|
+
[
|
|
228
|
+
"git",
|
|
229
|
+
"-C",
|
|
230
|
+
str(main_root),
|
|
231
|
+
"worktree",
|
|
232
|
+
"add",
|
|
233
|
+
"--quiet",
|
|
234
|
+
"-b",
|
|
235
|
+
"feature",
|
|
236
|
+
str(worktree_root),
|
|
237
|
+
],
|
|
238
|
+
check=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
assert head_commit(worktree_root) == expected_commit
|
|
196
242
|
|
|
197
243
|
|
|
198
|
-
def
|
|
244
|
+
def test_head_commit_reads_a_packed_branch_from_a_linked_worktree(
|
|
199
245
|
tmp_path: Path,
|
|
200
246
|
) -> None:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
247
|
+
main_root = tmp_path / "main"
|
|
248
|
+
worktree_root = tmp_path / "linked"
|
|
249
|
+
expected_commit = _committed_repository(main_root)
|
|
250
|
+
subprocess.run(
|
|
251
|
+
[
|
|
252
|
+
"git",
|
|
253
|
+
"-C",
|
|
254
|
+
str(main_root),
|
|
255
|
+
"worktree",
|
|
256
|
+
"add",
|
|
257
|
+
"--quiet",
|
|
258
|
+
"-b",
|
|
259
|
+
"feature",
|
|
260
|
+
str(worktree_root),
|
|
261
|
+
],
|
|
262
|
+
check=True,
|
|
263
|
+
)
|
|
264
|
+
subprocess.run(
|
|
265
|
+
["git", "-C", str(main_root), "pack-refs", "--all"], check=True
|
|
207
266
|
)
|
|
208
267
|
|
|
209
|
-
|
|
268
|
+
assert not (main_root / ".git" / "refs" / "heads" / "feature").exists()
|
|
269
|
+
assert head_commit(worktree_root) == expected_commit
|
|
210
270
|
|
|
211
|
-
all_ledger_lines = ledger_path.read_text(encoding="utf-8").splitlines()
|
|
212
|
-
assert all_ledger_lines[:2] == [first_line, second_line]
|
|
213
|
-
assert [json.loads(each_line)["rule_id"] for each_line in all_ledger_lines] == [
|
|
214
|
-
"r1",
|
|
215
|
-
"r2",
|
|
216
|
-
"r3",
|
|
217
|
-
]
|
|
218
271
|
|
|
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")
|
|
219
276
|
|
|
220
|
-
|
|
221
|
-
tmp_path
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
277
|
+
assert finding_path(tmp_path, first) == finding_path(tmp_path, later)
|
|
278
|
+
assert finding_path(tmp_path, first) != finding_path(tmp_path, other)
|
|
279
|
+
|
|
280
|
+
|
|
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
|
+
)
|
|
228
297
|
|
|
229
|
-
record_followup_finding(tmp_path, finding)
|
|
230
298
|
|
|
231
|
-
|
|
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())
|
|
310
|
+
|
|
311
|
+
|
|
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")
|
|
232
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")
|
|
233
329
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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)
|
|
238
334
|
|
|
239
|
-
removed_count = deduplicate_ledger(tmp_path)
|
|
240
335
|
|
|
241
|
-
|
|
242
|
-
|
|
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")
|
|
243
340
|
|
|
341
|
+
record_followup_finding(tmp_path, FollowupFinding("r1", "a.py", "m1", "r1"))
|
|
244
342
|
|
|
245
|
-
|
|
246
|
-
assert deduplicate_ledger(tmp_path) == 0
|
|
247
|
-
assert not followup_ledger_path(tmp_path).exists()
|
|
343
|
+
assert ignore_path.read_text(encoding="utf-8") == "!*.json\n"
|