claude-dev-env 8.74.1 → 8.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/correction/SKILL.md +19 -16
- package/.agents/skills/correction/references/filing.md +1 -1
- package/.agents/skills/correction/scripts/correction_task_card.py +194 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/config/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_task_card_constants/config/constants.py +38 -0
- package/.agents/skills/correction/scripts/test_correction_task_card.py +142 -0
- 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
|
@@ -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"
|
package/scripts/followup_cli.py
CHANGED
|
@@ -12,8 +12,8 @@ work. This command reads those records back.
|
|
|
12
12
|
``list`` names what is outstanding, ``ingest`` adds the diagnostics from a
|
|
13
13
|
policy-lint JSON report, ``brief`` writes the task an agent works from, and
|
|
14
14
|
``clear`` empties the ledger once the follow-up pull request carries the
|
|
15
|
-
fixes, and ``dedupe``
|
|
16
|
-
|
|
15
|
+
fixes, and ``dedupe`` moves a legacy ``smells.jsonl`` into one file per
|
|
16
|
+
finding and removes duplicate finding files.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
@@ -62,11 +62,10 @@ if _hooks_directory not in sys.path:
|
|
|
62
62
|
from followup_ledger import (
|
|
63
63
|
FollowupFinding,
|
|
64
64
|
all_recorded_findings,
|
|
65
|
-
deduplicate_ledger,
|
|
66
|
-
followup_ledger_path,
|
|
67
65
|
head_commit,
|
|
68
66
|
record_followup_finding,
|
|
69
67
|
)
|
|
68
|
+
from followup_ledger_maintenance import clear_recorded_findings, normalize_ledger
|
|
70
69
|
from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
|
|
71
70
|
|
|
72
71
|
|
|
@@ -307,26 +306,26 @@ def _run_clear(repository_root: Path) -> int:
|
|
|
307
306
|
Returns:
|
|
308
307
|
SUCCESS_EXIT_CODE.
|
|
309
308
|
"""
|
|
310
|
-
|
|
311
|
-
followup_ledger_path(repository_root).unlink(missing_ok=True)
|
|
312
|
-
except OSError:
|
|
313
|
-
return SUCCESS_EXIT_CODE
|
|
309
|
+
clear_recorded_findings(repository_root)
|
|
314
310
|
return SUCCESS_EXIT_CODE
|
|
315
311
|
|
|
316
312
|
|
|
317
313
|
def _run_dedupe(repository_root: Path, stdout: TextIO) -> int:
|
|
318
|
-
"""
|
|
314
|
+
"""Move every record to the file its tracking key names.
|
|
319
315
|
|
|
320
316
|
Args:
|
|
321
317
|
repository_root: The repository whose ledger to clean.
|
|
322
|
-
stdout: The stream the removed
|
|
318
|
+
stdout: The stream the migrated and removed counts go to.
|
|
323
319
|
|
|
324
320
|
Returns:
|
|
325
321
|
SUCCESS_EXIT_CODE.
|
|
326
322
|
"""
|
|
327
|
-
|
|
323
|
+
normalization = normalize_ledger(repository_root)
|
|
328
324
|
stdout.write(
|
|
329
|
-
DEDUPE_RESULT_TEMPLATE.format(
|
|
325
|
+
DEDUPE_RESULT_TEMPLATE.format(
|
|
326
|
+
migrated_count=normalization.migrated_count,
|
|
327
|
+
removed_count=normalization.removed_count,
|
|
328
|
+
)
|
|
330
329
|
+ LINE_SEPARATOR
|
|
331
330
|
)
|
|
332
331
|
return SUCCESS_EXIT_CODE
|
|
@@ -134,7 +134,6 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
134
134
|
hooks/blocking/issue_close_handoff_gate.py -> exempt
|
|
135
135
|
hooks/blocking/verify_before_acting.py -> exempt
|
|
136
136
|
hooks/blocking/session_title_format_gate.py -> exempt
|
|
137
|
-
hooks/blocking/session_title_stop_gate.py -> exempt
|
|
138
137
|
hooks/blocking/artifact_dark_mode_gate.py -> exempt
|
|
139
138
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
140
139
|
|
|
@@ -70,7 +70,6 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
70
70
|
"blocking/issue_close_handoff_gate.py",
|
|
71
71
|
"blocking/verify_before_acting.py",
|
|
72
72
|
"blocking/session_title_format_gate.py",
|
|
73
|
-
"blocking/session_title_stop_gate.py",
|
|
74
73
|
"blocking/image_read_size_gate.py",
|
|
75
74
|
"blocking/artifact_dark_mode_gate.py",
|
|
76
75
|
"blocking/haiku_spawn_slim_gate.py",
|
|
@@ -9,7 +9,6 @@ from policy_lint.config.constants import (
|
|
|
9
9
|
|
|
10
10
|
CHECK_ID_CLAUDE_MD_ORPHANS = "claude-md-orphans"
|
|
11
11
|
CHECK_ID_ENV_VAR_DOCUMENTATION = "env-var-documentation"
|
|
12
|
-
CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES = "followup-ledger-duplicates"
|
|
13
12
|
CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
|
|
14
13
|
CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
|
|
15
14
|
CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
|
|
@@ -19,7 +18,6 @@ PRIVATE_TERMS_SKIPPED_FILE_NAME = "CHANGELOG.md"
|
|
|
19
18
|
ALL_CHECK_IDS = (
|
|
20
19
|
CHECK_ID_CLAUDE_MD_ORPHANS,
|
|
21
20
|
CHECK_ID_ENV_VAR_DOCUMENTATION,
|
|
22
|
-
CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
|
|
23
21
|
CHECK_ID_PACKAGE_INVENTORY,
|
|
24
22
|
CHECK_ID_PYTEST_TESTPATHS,
|
|
25
23
|
CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
@@ -31,7 +29,6 @@ SEVERITY_SMELL = "smell"
|
|
|
31
29
|
SEVERITY_BY_CHECK_ID: dict[str, str] = {
|
|
32
30
|
CHECK_ID_CLAUDE_MD_ORPHANS: SEVERITY_BREAKING,
|
|
33
31
|
CHECK_ID_ENV_VAR_DOCUMENTATION: SEVERITY_BREAKING,
|
|
34
|
-
CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES: SEVERITY_BREAKING,
|
|
35
32
|
CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
|
|
36
33
|
CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
|
|
37
34
|
CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
|
|
@@ -52,9 +49,6 @@ EMPTY_REPORT_TEXT = ""
|
|
|
52
49
|
TRACKED_MATCH_MESSAGE_TEMPLATE = "[{category}] {preview}"
|
|
53
50
|
CLAUDE_MD_MISSING_FILE_MESSAGE_TEMPLATE = "references missing file {filename}"
|
|
54
51
|
PACKAGE_INVENTORY_MESSAGE_TEMPLATE = "production file is absent from package inventory"
|
|
55
|
-
FOLLOWUP_LEDGER_DUPLICATE_MESSAGE_TEMPLATE = (
|
|
56
|
-
"{repeated_line_count} lines repeat an earlier line; run `cde followup dedupe`"
|
|
57
|
-
)
|
|
58
52
|
PYTEST_TESTPATH_MESSAGE_TEMPLATE = (
|
|
59
53
|
"test file is outside the package testpaths allowlist"
|
|
60
54
|
)
|
|
@@ -11,9 +11,6 @@ from repository_checks.config import constants as repository_constants
|
|
|
11
11
|
from repository_checks.env_var_documentation import (
|
|
12
12
|
collect_env_var_documentation_findings,
|
|
13
13
|
)
|
|
14
|
-
from repository_checks.followup_ledger_duplicates import (
|
|
15
|
-
collect_followup_ledger_duplicate_findings,
|
|
16
|
-
)
|
|
17
14
|
from repository_checks.models import RepositoryCheckReport, RepositoryFinding
|
|
18
15
|
from repository_checks.package_inventory import collect_package_inventory_findings
|
|
19
16
|
from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
|
|
@@ -84,10 +81,6 @@ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
|
|
|
84
81
|
repository_constants.CHECK_ID_ENV_VAR_DOCUMENTATION,
|
|
85
82
|
collect_env_var_documentation_findings,
|
|
86
83
|
),
|
|
87
|
-
(
|
|
88
|
-
repository_constants.CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
|
|
89
|
-
collect_followup_ledger_duplicate_findings,
|
|
90
|
-
),
|
|
91
84
|
(
|
|
92
85
|
repository_constants.CHECK_ID_PACKAGE_INVENTORY,
|
|
93
86
|
collect_package_inventory_findings,
|
|
@@ -17,7 +17,8 @@ from followup_cli import main
|
|
|
17
17
|
from followup_ledger import (
|
|
18
18
|
FollowupFinding,
|
|
19
19
|
all_recorded_findings,
|
|
20
|
-
|
|
20
|
+
finding_path,
|
|
21
|
+
followup_directory,
|
|
21
22
|
record_followup_finding,
|
|
22
23
|
)
|
|
23
24
|
|
|
@@ -307,17 +308,80 @@ def test_list_names_the_check_behind_each_finding(tmp_path: Path) -> None:
|
|
|
307
308
|
assert "f00dcafe" in output_text
|
|
308
309
|
|
|
309
310
|
|
|
310
|
-
def
|
|
311
|
+
def test_dedupe_moves_a_legacy_ledger_into_one_file_per_finding(
|
|
311
312
|
tmp_path: Path,
|
|
312
313
|
) -> None:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
first_line = json.dumps({"rule_id": "r1", "file_path": "a.py", "message": "m1"})
|
|
315
|
+
second_line = json.dumps({"rule_id": "r2", "file_path": "b.py", "message": "m2"})
|
|
316
|
+
legacy_path = followup_directory(tmp_path) / "smells.jsonl"
|
|
317
|
+
legacy_path.parent.mkdir(parents=True)
|
|
318
|
+
legacy_path.write_text(
|
|
319
|
+
f"{first_line}\n{second_line}\n{first_line}\n", encoding="utf-8"
|
|
320
|
+
)
|
|
316
321
|
|
|
317
322
|
exit_code, output_text = run_command(
|
|
318
323
|
["dedupe", "--repository-root", str(tmp_path)]
|
|
319
324
|
)
|
|
320
325
|
|
|
321
326
|
assert exit_code == 0
|
|
322
|
-
assert output_text ==
|
|
323
|
-
|
|
327
|
+
assert output_text == (
|
|
328
|
+
DEDUPE_RESULT_TEMPLATE.format(migrated_count=3, removed_count=0) + "\n"
|
|
329
|
+
)
|
|
330
|
+
assert not legacy_path.exists()
|
|
331
|
+
assert [each.rule_id for each in all_recorded_findings(tmp_path)] == ["r1", "r2"]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_dedupe_removes_a_misnamed_copy_of_a_recorded_finding(tmp_path: Path) -> None:
|
|
335
|
+
finding = FollowupFinding("r1", "a.py", "m1", "r1")
|
|
336
|
+
record_followup_finding(tmp_path, finding)
|
|
337
|
+
recorded_path = finding_path(tmp_path, finding)
|
|
338
|
+
copy_path = recorded_path.with_name("copy.json")
|
|
339
|
+
copy_path.write_text(recorded_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
340
|
+
|
|
341
|
+
exit_code, output_text = run_command(
|
|
342
|
+
["dedupe", "--repository-root", str(tmp_path)]
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
assert exit_code == 0
|
|
346
|
+
assert output_text == (
|
|
347
|
+
DEDUPE_RESULT_TEMPLATE.format(migrated_count=0, removed_count=1) + "\n"
|
|
348
|
+
)
|
|
349
|
+
assert not copy_path.exists()
|
|
350
|
+
assert all_recorded_findings(tmp_path) == (finding,)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_fix_followups_reads_the_brief_then_clears_every_finding(
|
|
354
|
+
tmp_path: Path,
|
|
355
|
+
) -> None:
|
|
356
|
+
report_path = tmp_path / "lint.json"
|
|
357
|
+
write_lint_report(
|
|
358
|
+
report_path,
|
|
359
|
+
[
|
|
360
|
+
{
|
|
361
|
+
"rule_id": "code-rules",
|
|
362
|
+
"check_id": "code-rules/constant-outside-config",
|
|
363
|
+
"message": "Constant TIMEOUT",
|
|
364
|
+
"location": {"path": "src/run.py"},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"rule_id": "test-pairing",
|
|
368
|
+
"message": "no paired test",
|
|
369
|
+
"location": {"path": "src/app.py"},
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
)
|
|
373
|
+
root_arguments = ["--repository-root", str(tmp_path)]
|
|
374
|
+
|
|
375
|
+
ingest_exit_code, _ = run_command(["ingest", str(report_path), *root_arguments])
|
|
376
|
+
_, brief_text = run_command(["brief", *root_arguments])
|
|
377
|
+
clear_exit_code, _ = run_command(["clear", *root_arguments])
|
|
378
|
+
_, brief_after_clear = run_command(["brief", *root_arguments])
|
|
379
|
+
|
|
380
|
+
assert ingest_exit_code == 0
|
|
381
|
+
assert "- [code-rules/constant-outside-config] src/run.py: Constant TIMEOUT" in (
|
|
382
|
+
brief_text
|
|
383
|
+
)
|
|
384
|
+
assert "- [test-pairing] src/app.py: no paired test" in brief_text
|
|
385
|
+
assert clear_exit_code == 0
|
|
386
|
+
assert brief_after_clear == "no follow-ups recorded\n"
|
|
387
|
+
assert not list(followup_directory(tmp_path).glob("*.json"))
|
|
@@ -98,12 +98,9 @@ def test_new_verify_before_acting_registration_is_clean(tmp_path: Path) -> None:
|
|
|
98
98
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
def
|
|
101
|
+
def test_new_session_title_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
102
102
|
current_document = _hook_document(
|
|
103
|
-
[
|
|
104
|
-
"hooks/blocking/session_title_format_gate.py",
|
|
105
|
-
"hooks/blocking/session_title_stop_gate.py",
|
|
106
|
-
],
|
|
103
|
+
["hooks/blocking/session_title_format_gate.py"],
|
|
107
104
|
'{"hooks": {}}',
|
|
108
105
|
)
|
|
109
106
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
@@ -422,10 +422,9 @@ def test_should_exit_zero_and_record_a_followup_when_only_warnings_exist(
|
|
|
422
422
|
lint_runner=lambda _request: warning_report,
|
|
423
423
|
repository_root=tmp_path,
|
|
424
424
|
)
|
|
425
|
-
ledger_path = tmp_path / ".claude" / "followups" / "smells.jsonl"
|
|
426
425
|
all_ledger_records = [
|
|
427
|
-
json.loads(
|
|
428
|
-
for
|
|
426
|
+
json.loads(each_path.read_text(encoding="utf-8"))
|
|
427
|
+
for each_path in (tmp_path / ".claude" / "followups").glob("*.json")
|
|
429
428
|
]
|
|
430
429
|
assert exit_code == 0
|
|
431
430
|
assert "paired test gap" in stdout_text
|
|
@@ -445,4 +444,4 @@ def test_should_leave_the_ledger_absent_when_every_finding_is_an_error(
|
|
|
445
444
|
),
|
|
446
445
|
repository_root=tmp_path,
|
|
447
446
|
)
|
|
448
|
-
assert not (tmp_path / ".claude" / "followups"
|
|
447
|
+
assert not list((tmp_path / ".claude" / "followups").glob("*.json"))
|
|
@@ -17,7 +17,6 @@ import repository_policy
|
|
|
17
17
|
from repository_checks.config.constants import (
|
|
18
18
|
CHECK_ID_CLAUDE_MD_ORPHANS,
|
|
19
19
|
CHECK_ID_ENV_VAR_DOCUMENTATION,
|
|
20
|
-
CHECK_ID_FOLLOWUP_LEDGER_DUPLICATES,
|
|
21
20
|
CHECK_ID_PACKAGE_INVENTORY,
|
|
22
21
|
CHECK_ID_PYTEST_TESTPATHS,
|
|
23
22
|
CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
@@ -51,8 +50,7 @@ def test_should_keep_stable_check_identifiers() -> None:
|
|
|
51
50
|
assert repository_policy.ALL_CHECK_IDS == (
|
|
52
51
|
CHECK_ID_CLAUDE_MD_ORPHANS,
|
|
53
52
|
CHECK_ID_ENV_VAR_DOCUMENTATION,
|
|
54
|
-
|
|
55
|
-
CHECK_ID_PACKAGE_INVENTORY,
|
|
53
|
+
CHECK_ID_PACKAGE_INVENTORY,
|
|
56
54
|
CHECK_ID_PYTEST_TESTPATHS,
|
|
57
55
|
CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
58
56
|
CHECK_ID_TRACKED_PRIVATE_TERMS,
|