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.
Files changed (39) hide show
  1. package/bin/cde.mjs +1 -1
  2. package/bin/cde.test.mjs +1 -1
  3. package/bin/install.mjs +1 -0
  4. package/bin/install.test.mjs +1 -0
  5. package/bin/merge_profile_settings.mjs +2 -3
  6. package/bin/merge_profile_settings.test.mjs +5 -5
  7. package/commands/fix-followups.md +5 -2
  8. package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
  9. package/docs/rule-guides/paired-test-coverage.md +1 -1
  10. package/docs/rule-guides/session-title.md +3 -3
  11. package/docs/templates/html-plan/README.md +1 -1
  12. package/hooks/features/conduct-gates.md +0 -3
  13. package/hooks/features/session-context.md +1 -1
  14. package/hooks/followup_ledger.py +179 -116
  15. package/hooks/followup_ledger_maintenance.py +127 -0
  16. package/hooks/hooks.json +0 -11
  17. package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
  18. package/hooks/hooks_constants/followup_ledger_constants.py +26 -6
  19. package/hooks/hooks_constants/session_title_constants.py +1 -29
  20. package/hooks/session/artifact_template_pointer.py +5 -4
  21. package/hooks/session/test_artifact_template_pointer.py +13 -0
  22. package/hooks/test_followup_ledger.py +151 -55
  23. package/hooks/test_followup_ledger_maintenance.py +107 -0
  24. package/package.json +1 -1
  25. package/rules/flag-non-breaking-findings.md +1 -1
  26. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  27. package/scripts/followup_cli.py +11 -12
  28. package/scripts/policy_lint/adapter_configuration.py +0 -1
  29. package/scripts/policy_lint/config/constants.py +0 -1
  30. package/scripts/repository_checks/config/constants.py +0 -6
  31. package/scripts/repository_checks/runner.py +0 -7
  32. package/scripts/test_followup_cli.py +71 -7
  33. package/scripts/tests/test_adapter_configuration.py +2 -5
  34. package/scripts/tests/test_cde_lint.py +3 -4
  35. package/scripts/tests/test_repository_policy.py +1 -3
  36. package/hooks/blocking/session_title_stop_gate.py +0 -217
  37. package/hooks/blocking/test_session_title_stop_gate.py +0 -295
  38. package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
  39. 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 = "Build this artifact page from the html-plan skill, following "
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
- "ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
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
- "LEDGER_APPEND_MODE",
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
- "ALL_GIT_HEAD_PATH_SEGMENTS",
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
- ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
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
- LEDGER_APPEND_MODE: str = "a"
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
- ALL_GIT_HEAD_PATH_SEGMENTS: tuple[str, ...] = (GIT_DIRECTORY_NAME, "HEAD")
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 and session_title_stop_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: point an agent about to build an artifact page at html-plan.
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
- naming ``docs/templates/html-plan/README.md`` as the default and
6
- ``docs/templates/artifact-page/README.md`` as the alternative when a
7
- session starts a new artifact page:
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
- deduplicate_ledger,
16
- deduplicated_lines,
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 test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
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
- ledger_path = followup_ledger_path(tmp_path)
71
- with ledger_path.open("a", encoding="utf-8") as ledger_file:
72
- ledger_file.write("{ not json\n")
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 test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path) -> None:
78
- record_followup_finding(
79
- tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
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
- all_lines = followup_ledger_path(tmp_path).read_text(encoding="utf-8").splitlines()
81
+ record_followup_finding(tmp_path, finding)
83
82
 
84
- assert len(all_lines) == 1
85
- assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
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 followup_ledger_path(tmp_path).is_file()
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
- ledger_path = followup_ledger_path(tmp_path)
137
- ledger_path.parent.mkdir(parents=True, exist_ok=True)
138
- ledger_path.write_text(
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 test_deduplicated_lines_keeps_the_first_of_each_exact_line_in_order() -> None:
195
- assert deduplicated_lines(["a", "b", "a", "c", "b"]) == ["a", "b", "c"]
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 test_record_followup_finding_removes_lines_a_union_merge_repeated(
244
+ def test_head_commit_reads_a_packed_branch_from_a_linked_worktree(
199
245
  tmp_path: Path,
200
246
  ) -> None:
201
- ledger_path = followup_ledger_path(tmp_path)
202
- ledger_path.parent.mkdir(parents=True)
203
- first_line = '{"rule_id": "r1", "file_path": "a.py", "message": "m1"}'
204
- second_line = '{"rule_id": "r2", "file_path": "b.py", "message": "m2"}'
205
- ledger_path.write_text(
206
- f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
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
- record_followup_finding(tmp_path, FollowupFinding("r3", "c.py", "m3", "r3"))
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
- def test_record_followup_finding_removes_a_repeat_of_an_already_recorded_finding(
221
- tmp_path: Path,
222
- ) -> None:
223
- finding = FollowupFinding("r1", "a.py", "m1", "r1")
224
- record_followup_finding(tmp_path, finding)
225
- ledger_path = followup_ledger_path(tmp_path)
226
- recorded_text = ledger_path.read_text(encoding="utf-8")
227
- ledger_path.write_text(recorded_text * 2, encoding="utf-8")
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
- assert ledger_path.read_text(encoding="utf-8") == recorded_text
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
- def test_deduplicate_ledger_reports_how_many_lines_it_removed(tmp_path: Path) -> None:
235
- ledger_path = followup_ledger_path(tmp_path)
236
- ledger_path.parent.mkdir(parents=True)
237
- ledger_path.write_text("x\ny\nx\nx\n", encoding="utf-8")
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
- assert removed_count == 2
242
- assert ledger_path.read_text(encoding="utf-8") == "x\ny\n"
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
- def test_deduplicate_ledger_leaves_an_absent_ledger_absent(tmp_path: Path) -> None:
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"