claude-dev-env 8.4.3 → 8.6.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/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +73 -0
- package/hooks/followup_ledger.py +248 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +42 -0
- package/hooks/test_followup_ledger.py +171 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +105 -0
- package/scripts/dev_env_scripts_constants/followup_constants.py +89 -0
- package/scripts/followup_cli.py +347 -0
- package/scripts/policy_lint/adapter_support.py +8 -1
- package/scripts/policy_lint/check_catalog.py +44 -0
- package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
- package/scripts/policy_lint/model.py +4 -1
- package/scripts/test_followup_cli.py +270 -0
- package/scripts/test_validate_instruction_pairs.py +166 -1
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
- package/scripts/validate_instruction_pairs.py +284 -47
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Behavior tests for the follow-up command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dev_env_scripts_constants.followup_constants import (
|
|
11
|
+
BACKLOG_EXCEEDED_EXIT_CODE,
|
|
12
|
+
FOLLOWUP_BACKLOG_THRESHOLD,
|
|
13
|
+
)
|
|
14
|
+
from followup_cli import main
|
|
15
|
+
|
|
16
|
+
_hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
|
|
17
|
+
if _hooks_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, _hooks_directory)
|
|
19
|
+
|
|
20
|
+
from followup_ledger import (
|
|
21
|
+
FollowupFinding,
|
|
22
|
+
all_recorded_findings,
|
|
23
|
+
record_followup_finding,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_command(all_arguments: list[str]) -> tuple[int, str]:
|
|
28
|
+
output_stream = io.StringIO()
|
|
29
|
+
exit_code = main(all_arguments, stdout=output_stream)
|
|
30
|
+
return exit_code, output_stream.getvalue()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_lint_report(
|
|
34
|
+
report_path: Path, all_diagnostics: list[dict[str, object]]
|
|
35
|
+
) -> None:
|
|
36
|
+
report_path.write_text(
|
|
37
|
+
json.dumps(
|
|
38
|
+
{"schema_version": 1, "diagnostics": all_diagnostics, "failed_rules": []}
|
|
39
|
+
),
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_list_reports_nothing_outstanding_for_an_empty_ledger(tmp_path: Path) -> None:
|
|
45
|
+
exit_code, output_text = run_command(["list", "--repository-root", str(tmp_path)])
|
|
46
|
+
|
|
47
|
+
assert exit_code == 0
|
|
48
|
+
assert "no follow-ups recorded" in output_text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_list_names_each_recorded_finding(tmp_path: Path) -> None:
|
|
52
|
+
record_followup_finding(
|
|
53
|
+
tmp_path, FollowupFinding("instruction-git-mode", "CLAUDE.md", "wrong mode")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
exit_code, output_text = run_command(["list", "--repository-root", str(tmp_path)])
|
|
57
|
+
|
|
58
|
+
assert exit_code == 0
|
|
59
|
+
assert "instruction-git-mode" in output_text
|
|
60
|
+
assert "CLAUDE.md" in output_text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_ingest_records_every_located_diagnostic(tmp_path: Path) -> None:
|
|
64
|
+
report_path = tmp_path / "lint.json"
|
|
65
|
+
write_lint_report(
|
|
66
|
+
report_path,
|
|
67
|
+
[
|
|
68
|
+
{
|
|
69
|
+
"rule_id": "line-length",
|
|
70
|
+
"severity": "error",
|
|
71
|
+
"message": "line runs past the limit",
|
|
72
|
+
"location": {"path": "src/app.py", "start_line": 4, "start_column": 1},
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
exit_code, _ = run_command(
|
|
78
|
+
["ingest", str(report_path), "--repository-root", str(tmp_path)]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert exit_code == 0
|
|
82
|
+
assert all_recorded_findings(tmp_path) == (
|
|
83
|
+
FollowupFinding(
|
|
84
|
+
"line-length", "src/app.py", "line runs past the limit", "line-length"
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_ingest_records_a_diagnostic_without_a_location(tmp_path: Path) -> None:
|
|
90
|
+
report_path = tmp_path / "lint.json"
|
|
91
|
+
write_lint_report(
|
|
92
|
+
report_path,
|
|
93
|
+
[
|
|
94
|
+
{
|
|
95
|
+
"rule_id": "inventory-drift",
|
|
96
|
+
"severity": "error",
|
|
97
|
+
"message": "inventory row missing",
|
|
98
|
+
"location": None,
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
run_command(["ingest", str(report_path), "--repository-root", str(tmp_path)])
|
|
104
|
+
|
|
105
|
+
assert all_recorded_findings(tmp_path) == (
|
|
106
|
+
FollowupFinding(
|
|
107
|
+
"inventory-drift", "", "inventory row missing", "inventory-drift"
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_ingest_reports_an_unreadable_report(tmp_path: Path) -> None:
|
|
113
|
+
exit_code, output_text = run_command(
|
|
114
|
+
["ingest", str(tmp_path / "absent.json"), "--repository-root", str(tmp_path)]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert exit_code == 2
|
|
118
|
+
assert "absent.json" in output_text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_brief_names_every_recorded_finding_and_the_repository(tmp_path: Path) -> None:
|
|
122
|
+
record_followup_finding(
|
|
123
|
+
tmp_path, FollowupFinding("instruction-git-mode", "CLAUDE.md", "wrong mode")
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
exit_code, output_text = run_command(["brief", "--repository-root", str(tmp_path)])
|
|
127
|
+
|
|
128
|
+
assert exit_code == 0
|
|
129
|
+
assert "instruction-git-mode" in output_text
|
|
130
|
+
assert "wrong mode" in output_text
|
|
131
|
+
assert "draft pull request" in output_text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_brief_asks_for_no_work_on_an_empty_ledger(tmp_path: Path) -> None:
|
|
135
|
+
exit_code, output_text = run_command(["brief", "--repository-root", str(tmp_path)])
|
|
136
|
+
|
|
137
|
+
assert exit_code == 0
|
|
138
|
+
assert "no follow-ups recorded" in output_text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_clear_empties_the_ledger(tmp_path: Path) -> None:
|
|
142
|
+
record_followup_finding(
|
|
143
|
+
tmp_path, FollowupFinding("instruction-git-mode", "CLAUDE.md", "wrong mode")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
exit_code, _ = run_command(["clear", "--repository-root", str(tmp_path)])
|
|
147
|
+
|
|
148
|
+
assert exit_code == 0
|
|
149
|
+
assert all_recorded_findings(tmp_path) == ()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_clear_on_an_empty_ledger_succeeds(tmp_path: Path) -> None:
|
|
153
|
+
exit_code, _ = run_command(["clear", "--repository-root", str(tmp_path)])
|
|
154
|
+
|
|
155
|
+
assert exit_code == 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_an_unknown_command_reports_usage(tmp_path: Path) -> None:
|
|
159
|
+
exit_code, output_text = run_command(
|
|
160
|
+
["stampede", "--repository-root", str(tmp_path)]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
assert exit_code == 2
|
|
164
|
+
assert "Usage" in output_text
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_ingest_carries_the_check_identifier_a_diagnostic_names(
|
|
168
|
+
tmp_path: Path,
|
|
169
|
+
) -> None:
|
|
170
|
+
report_path = tmp_path / "lint.json"
|
|
171
|
+
write_lint_report(
|
|
172
|
+
report_path,
|
|
173
|
+
[
|
|
174
|
+
{
|
|
175
|
+
"rule_id": "code-rules",
|
|
176
|
+
"check_id": "code-rules/constant-outside-config",
|
|
177
|
+
"severity": "error",
|
|
178
|
+
"message": "Line 3: Constant TIMEOUT - move to config/",
|
|
179
|
+
"location": {"path": "scripts/run.py", "start_line": 3},
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
run_command(["ingest", str(report_path), "--repository-root", str(tmp_path)])
|
|
185
|
+
|
|
186
|
+
recorded_finding = all_recorded_findings(tmp_path)[0]
|
|
187
|
+
assert recorded_finding.check_id == "code-rules/constant-outside-config"
|
|
188
|
+
assert recorded_finding.severity == "smell"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_ingest_records_the_revision_the_repository_has_checked_out(
|
|
192
|
+
tmp_path: Path,
|
|
193
|
+
) -> None:
|
|
194
|
+
(tmp_path / ".git").mkdir()
|
|
195
|
+
(tmp_path / ".git" / "HEAD").write_text("f00dcafe\n", encoding="utf-8")
|
|
196
|
+
report_path = tmp_path / "lint.json"
|
|
197
|
+
write_lint_report(
|
|
198
|
+
report_path,
|
|
199
|
+
[
|
|
200
|
+
{
|
|
201
|
+
"rule_id": "code-rules",
|
|
202
|
+
"check_id": "code-rules/function-length",
|
|
203
|
+
"message": "Line 9: Function 'run' is 74 lines",
|
|
204
|
+
"location": {"path": "scripts/run.py", "start_line": 9},
|
|
205
|
+
}
|
|
206
|
+
],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
run_command(["ingest", str(report_path), "--repository-root", str(tmp_path)])
|
|
210
|
+
|
|
211
|
+
assert all_recorded_findings(tmp_path)[0].origin_commit == "f00dcafe"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_count_reports_an_empty_backlog(tmp_path: Path) -> None:
|
|
215
|
+
exit_code, output_text = run_command(["count", "--repository-root", str(tmp_path)])
|
|
216
|
+
|
|
217
|
+
assert exit_code == 0
|
|
218
|
+
assert "0" in output_text
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_count_passes_while_the_backlog_sits_under_the_threshold(
|
|
222
|
+
tmp_path: Path,
|
|
223
|
+
) -> None:
|
|
224
|
+
record_followup_finding(
|
|
225
|
+
tmp_path, FollowupFinding("code-rules", "run.py", "one smell", "code-rules/x")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
exit_code, output_text = run_command(["count", "--repository-root", str(tmp_path)])
|
|
229
|
+
|
|
230
|
+
assert exit_code == 0
|
|
231
|
+
assert "1" in output_text
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_count_escalates_once_the_backlog_passes_the_threshold(
|
|
235
|
+
tmp_path: Path,
|
|
236
|
+
) -> None:
|
|
237
|
+
for each_index in range(FOLLOWUP_BACKLOG_THRESHOLD + 1):
|
|
238
|
+
record_followup_finding(
|
|
239
|
+
tmp_path,
|
|
240
|
+
FollowupFinding(
|
|
241
|
+
"code-rules",
|
|
242
|
+
f"run{each_index}.py",
|
|
243
|
+
"one smell",
|
|
244
|
+
f"code-rules/x{each_index}",
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
exit_code, output_text = run_command(["count", "--repository-root", str(tmp_path)])
|
|
249
|
+
|
|
250
|
+
assert exit_code == BACKLOG_EXCEEDED_EXIT_CODE
|
|
251
|
+
assert str(FOLLOWUP_BACKLOG_THRESHOLD) in output_text
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_list_names_the_check_behind_each_finding(tmp_path: Path) -> None:
|
|
255
|
+
record_followup_finding(
|
|
256
|
+
tmp_path,
|
|
257
|
+
FollowupFinding(
|
|
258
|
+
"code-rules",
|
|
259
|
+
"run.py",
|
|
260
|
+
"one smell",
|
|
261
|
+
"code-rules/constant-outside-config",
|
|
262
|
+
"smell",
|
|
263
|
+
"f00dcafe",
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
_, output_text = run_command(["list", "--repository-root", str(tmp_path)])
|
|
268
|
+
|
|
269
|
+
assert "code-rules/constant-outside-config" in output_text
|
|
270
|
+
assert "f00dcafe" in output_text
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from validate_instruction_pairs import (
|
|
7
|
+
GATE_FAILED_EXIT_CODE,
|
|
8
|
+
GATE_PASSED_EXIT_CODE,
|
|
9
|
+
all_instruction_findings,
|
|
10
|
+
run_gate,
|
|
11
|
+
validate_repository,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
|
|
15
|
+
if _hooks_directory not in sys.path:
|
|
16
|
+
sys.path.insert(0, _hooks_directory)
|
|
17
|
+
|
|
18
|
+
from followup_ledger import all_recorded_findings, head_commit
|
|
19
|
+
from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
|
|
5
20
|
|
|
6
21
|
|
|
7
22
|
def read_workflow(workflow_filename: str) -> str:
|
|
@@ -145,3 +160,153 @@ def test_release_please_short_circuit_exists_in_workflow() -> None:
|
|
|
145
160
|
assert "chore(.*): release " in workflow_text
|
|
146
161
|
assert "autorelease: pending" in workflow_text
|
|
147
162
|
assert "skip-validation:" in workflow_text
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_valid_pair(repository_root: Path) -> None:
|
|
166
|
+
(repository_root / "AGENTS.md").write_bytes(b"# Canonical guidance\n")
|
|
167
|
+
(repository_root / "CLAUDE.md").write_bytes(b"@AGENTS.md\n")
|
|
168
|
+
subprocess.run(
|
|
169
|
+
["git", "add", "AGENTS.md", "CLAUDE.md"], cwd=repository_root, check=True
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def make_import_text_wrong(repository_root: Path) -> None:
|
|
174
|
+
(repository_root / "CLAUDE.md").write_bytes(b"@AGENTS.md\n\nExtra guidance.\n")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def make_git_mode_executable(repository_root: Path) -> None:
|
|
178
|
+
subprocess.run(
|
|
179
|
+
["git", "update-index", "--chmod=+x", "CLAUDE.md"],
|
|
180
|
+
cwd=repository_root,
|
|
181
|
+
check=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_a_wrong_import_text_is_a_breaking_finding(tmp_path: Path) -> None:
|
|
186
|
+
initialize_repository(tmp_path)
|
|
187
|
+
write_valid_pair(tmp_path)
|
|
188
|
+
make_import_text_wrong(tmp_path)
|
|
189
|
+
|
|
190
|
+
all_breaking = [
|
|
191
|
+
each_finding
|
|
192
|
+
for each_finding in all_instruction_findings(tmp_path)
|
|
193
|
+
if each_finding.severity == "breaking"
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
assert [each_finding.rule_id for each_finding in all_breaking] == [
|
|
197
|
+
"instruction-import-text"
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_a_wrong_git_mode_is_a_smell_finding(tmp_path: Path) -> None:
|
|
202
|
+
initialize_repository(tmp_path)
|
|
203
|
+
write_valid_pair(tmp_path)
|
|
204
|
+
make_git_mode_executable(tmp_path)
|
|
205
|
+
|
|
206
|
+
all_smells = [
|
|
207
|
+
each_finding
|
|
208
|
+
for each_finding in all_instruction_findings(tmp_path)
|
|
209
|
+
if each_finding.severity == "smell"
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
assert [each_finding.rule_id for each_finding in all_smells] == [
|
|
213
|
+
"instruction-git-mode"
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_run_gate_fails_on_a_breaking_finding(tmp_path: Path) -> None:
|
|
218
|
+
initialize_repository(tmp_path)
|
|
219
|
+
write_valid_pair(tmp_path)
|
|
220
|
+
make_import_text_wrong(tmp_path)
|
|
221
|
+
|
|
222
|
+
assert run_gate(tmp_path) == GATE_FAILED_EXIT_CODE
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_run_gate_passes_a_smell_and_records_it(tmp_path: Path) -> None:
|
|
226
|
+
initialize_repository(tmp_path)
|
|
227
|
+
write_valid_pair(tmp_path)
|
|
228
|
+
make_git_mode_executable(tmp_path)
|
|
229
|
+
|
|
230
|
+
exit_code = run_gate(tmp_path)
|
|
231
|
+
|
|
232
|
+
assert exit_code == GATE_PASSED_EXIT_CODE
|
|
233
|
+
assert [
|
|
234
|
+
each_finding.rule_id for each_finding in all_recorded_findings(tmp_path)
|
|
235
|
+
] == ["instruction-git-mode"]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_a_recorded_smell_carries_its_severity_and_the_checked_out_revision(
|
|
239
|
+
tmp_path: Path,
|
|
240
|
+
) -> None:
|
|
241
|
+
initialize_repository(tmp_path)
|
|
242
|
+
write_valid_pair(tmp_path)
|
|
243
|
+
make_git_mode_executable(tmp_path)
|
|
244
|
+
subprocess.run(
|
|
245
|
+
["git", "commit", "--quiet", "-m", "instruction pair"],
|
|
246
|
+
cwd=tmp_path,
|
|
247
|
+
check=True,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
run_gate(tmp_path)
|
|
251
|
+
|
|
252
|
+
recorded_finding = all_recorded_findings(tmp_path)[0]
|
|
253
|
+
assert recorded_finding.severity == SEVERITY_SMELL
|
|
254
|
+
assert recorded_finding.check_id == "instruction-git-mode"
|
|
255
|
+
assert recorded_finding.origin_commit == head_commit(tmp_path)
|
|
256
|
+
assert recorded_finding.origin_commit
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_run_gate_records_a_smell_it_finds_beside_a_breaking_finding(
|
|
260
|
+
tmp_path: Path,
|
|
261
|
+
) -> None:
|
|
262
|
+
initialize_repository(tmp_path)
|
|
263
|
+
write_valid_pair(tmp_path)
|
|
264
|
+
make_import_text_wrong(tmp_path)
|
|
265
|
+
make_git_mode_executable(tmp_path)
|
|
266
|
+
|
|
267
|
+
exit_code = run_gate(tmp_path)
|
|
268
|
+
|
|
269
|
+
assert exit_code == GATE_FAILED_EXIT_CODE
|
|
270
|
+
assert [
|
|
271
|
+
each_finding.rule_id for each_finding in all_recorded_findings(tmp_path)
|
|
272
|
+
] == ["instruction-git-mode"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_run_gate_passes_a_valid_tree_and_records_nothing(tmp_path: Path) -> None:
|
|
276
|
+
initialize_repository(tmp_path)
|
|
277
|
+
write_valid_pair(tmp_path)
|
|
278
|
+
|
|
279
|
+
exit_code = run_gate(tmp_path)
|
|
280
|
+
|
|
281
|
+
assert exit_code == GATE_PASSED_EXIT_CODE
|
|
282
|
+
assert all_recorded_findings(tmp_path) == ()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def read_sparse_checkout_paths() -> list[str]:
|
|
286
|
+
workflow_text = read_workflow("instruction-pairs-reusable.yml")
|
|
287
|
+
block_start = workflow_text.index("sparse-checkout: |")
|
|
288
|
+
block_end = workflow_text.index("sparse-checkout-cone-mode", block_start)
|
|
289
|
+
return [
|
|
290
|
+
each_line.strip()
|
|
291
|
+
for each_line in workflow_text[block_start:block_end].splitlines()
|
|
292
|
+
if each_line.strip().startswith("packages/")
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_sparse_checkout_carries_every_module_the_validator_imports() -> None:
|
|
297
|
+
package_root = Path(__file__).resolve().parents[1]
|
|
298
|
+
all_checked_out_paths = set(read_sparse_checkout_paths())
|
|
299
|
+
|
|
300
|
+
all_required_paths = {
|
|
301
|
+
"packages/claude-dev-env/scripts/validate_instruction_pairs.py",
|
|
302
|
+
"packages/claude-dev-env/scripts/subprocess_window_access.py",
|
|
303
|
+
"packages/claude-dev-env/scripts/dev_env_scripts_constants/__init__.py",
|
|
304
|
+
"packages/claude-dev-env/scripts/dev_env_scripts_constants/followup_constants.py",
|
|
305
|
+
"packages/claude-dev-env/hooks/followup_ledger.py",
|
|
306
|
+
"packages/claude-dev-env/hooks/hooks_constants/__init__.py",
|
|
307
|
+
"packages/claude-dev-env/hooks/hooks_constants/followup_ledger_constants.py",
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
assert all_required_paths <= all_checked_out_paths
|
|
311
|
+
for each_path in all_checked_out_paths:
|
|
312
|
+
assert (package_root.parents[1] / each_path).is_file()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Synchronization tests for the per-check identifier catalog.
|
|
2
|
+
|
|
3
|
+
Each sample below is a message copied from the check that raises it. A check
|
|
4
|
+
whose wording moves fails its sample here, so the catalog and the checks stay
|
|
5
|
+
in step.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from policy_lint.check_catalog import check_id_for_message
|
|
14
|
+
from policy_lint.config.check_catalog_constants import (
|
|
15
|
+
ALL_CHECK_CATALOG_ENTRIES,
|
|
16
|
+
UNCLASSIFIED_CHECK_NAME,
|
|
17
|
+
)
|
|
18
|
+
from policy_lint.model import Diagnostic, Severity
|
|
19
|
+
|
|
20
|
+
_BUNDLING_RULE_ID = "code-rules"
|
|
21
|
+
_HOOKS_DIRECTORY = Path(__file__).resolve().parents[2] / "hooks"
|
|
22
|
+
_TEST_FILE_PREFIX = "test_"
|
|
23
|
+
_PYTHON_GLOB = "*.py"
|
|
24
|
+
_SOURCE_ENCODING = "utf-8"
|
|
25
|
+
|
|
26
|
+
ALL_MESSAGE_SAMPLES: tuple[tuple[str, str], ...] = (
|
|
27
|
+
(
|
|
28
|
+
"Line 20: Function 'run' is 74 lines - exceeds blocking threshold - "
|
|
29
|
+
"split into helpers",
|
|
30
|
+
"function-length",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"Line 8: Function-local constant TIMEOUT - consider moving to config/",
|
|
34
|
+
"function-local-constant",
|
|
35
|
+
),
|
|
36
|
+
("Line 3: Constant TIMEOUT - move to config/", "constant-outside-config"),
|
|
37
|
+
(
|
|
38
|
+
"Line 5: Collection parameter paths - prefix with all_ (CODE_RULES §5)",
|
|
39
|
+
"collection-name-prefix",
|
|
40
|
+
),
|
|
41
|
+
(
|
|
42
|
+
"Line 6: loop variable 'path' - prefix with each_ (CODE_RULES §5)",
|
|
43
|
+
"loop-variable-prefix",
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"Line 7: Stuttering collection prefix 'all_all_paths'",
|
|
47
|
+
"stuttering-collection-prefix",
|
|
48
|
+
),
|
|
49
|
+
("Line 9: string separator '\\n' - name it", "unnamed-string-separator"),
|
|
50
|
+
("Line 10: string magic value 'utf-8'", "string-magic-value"),
|
|
51
|
+
(
|
|
52
|
+
"Line 11: Block comment found - refactor to self-documenting code",
|
|
53
|
+
"block-comment-added",
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"Line 12: Comment found - refactor to self-documenting code",
|
|
57
|
+
"comment-added",
|
|
58
|
+
),
|
|
59
|
+
("Line 13: TODO comment added: fix later", "comment-diff"),
|
|
60
|
+
(
|
|
61
|
+
"Line 14: module docstring carries a 44-word run-on sentence",
|
|
62
|
+
"docstring-runon-sentence",
|
|
63
|
+
),
|
|
64
|
+
("Line 15: module summary runs 9 prose lines", "docstring-prose-wall"),
|
|
65
|
+
(
|
|
66
|
+
"Line 16: parameter 'count' on 'run' missing type annotation (CODE_RULES §6)",
|
|
67
|
+
"missing-type-annotation",
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
"Line 17: function 'run' missing return type annotation (CODE_RULES §6)",
|
|
71
|
+
"missing-return-annotation",
|
|
72
|
+
),
|
|
73
|
+
("src/app.py:3: Single-letter variable 'p'", "single-letter-variable"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _all_hook_source_texts() -> tuple[str, ...]:
|
|
78
|
+
return tuple(
|
|
79
|
+
each_path.read_text(encoding=_SOURCE_ENCODING)
|
|
80
|
+
for each_path in _HOOKS_DIRECTORY.rglob(_PYTHON_GLOB)
|
|
81
|
+
if not each_path.name.startswith(_TEST_FILE_PREFIX)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize(("sample_message", "expected_name"), ALL_MESSAGE_SAMPLES)
|
|
86
|
+
def test_a_message_sample_resolves_to_its_check_name(
|
|
87
|
+
sample_message: str, expected_name: str
|
|
88
|
+
) -> None:
|
|
89
|
+
resolved_check_id = check_id_for_message(_BUNDLING_RULE_ID, sample_message)
|
|
90
|
+
|
|
91
|
+
assert resolved_check_id == f"{_BUNDLING_RULE_ID}/{expected_name}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_every_catalog_entry_carries_a_message_sample() -> None:
|
|
95
|
+
all_sampled_names = {each_sample[1] for each_sample in ALL_MESSAGE_SAMPLES}
|
|
96
|
+
all_catalog_names = {
|
|
97
|
+
each_entry.check_name for each_entry in ALL_CHECK_CATALOG_ENTRIES
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
assert all_catalog_names == all_sampled_names
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize("catalog_entry", ALL_CHECK_CATALOG_ENTRIES)
|
|
104
|
+
def test_each_catalog_marker_is_still_written_by_a_check(catalog_entry) -> None:
|
|
105
|
+
all_source_texts = _all_hook_source_texts()
|
|
106
|
+
|
|
107
|
+
assert any(
|
|
108
|
+
catalog_entry.message_marker in each_text for each_text in all_source_texts
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_every_catalog_check_name_is_distinct_from_its_neighbours() -> None:
|
|
113
|
+
all_names = [each_entry.check_name for each_entry in ALL_CHECK_CATALOG_ENTRIES]
|
|
114
|
+
|
|
115
|
+
assert len(set(all_names)) == len(all_names)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_an_unmatched_message_stays_unclassified() -> None:
|
|
119
|
+
resolved_check_id = check_id_for_message(
|
|
120
|
+
_BUNDLING_RULE_ID, "Line 4: a finding no catalog entry names"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert resolved_check_id == f"{_BUNDLING_RULE_ID}/{UNCLASSIFIED_CHECK_NAME}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_a_rule_outside_the_bundling_set_keeps_its_own_identifier() -> None:
|
|
127
|
+
resolved_check_id = check_id_for_message(
|
|
128
|
+
"rmtree-safety", "Line 9: shutil.rmtree with ignore_errors=True"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert resolved_check_id == "rmtree-safety"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_diagnostic_reports_its_rule_identifier_when_it_carries_no_check_name() -> (
|
|
135
|
+
None
|
|
136
|
+
):
|
|
137
|
+
diagnostic = Diagnostic("test-pairing", Severity.ERROR, "no matching test")
|
|
138
|
+
|
|
139
|
+
assert diagnostic.as_dict()["check_id"] == "test-pairing"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_a_diagnostic_reports_the_check_identifier_it_carries() -> None:
|
|
143
|
+
diagnostic = Diagnostic(
|
|
144
|
+
"code-rules",
|
|
145
|
+
Severity.ERROR,
|
|
146
|
+
"Line 3: Constant TIMEOUT - move to config/",
|
|
147
|
+
None,
|
|
148
|
+
"code-rules/constant-outside-config",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
assert diagnostic.as_dict()["check_id"] == "code-rules/constant-outside-config"
|