claude-dev-env 8.4.3 → 8.5.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.
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ """Read and brief the follow-up ledger of non-breaking findings.
3
+
4
+ A gate that finds a non-breaking smell records it rather than stopping the
5
+ work. This command reads those records back.
6
+
7
+ ::
8
+
9
+ $ cde followup list
10
+ instruction-git-mode CLAUDE.md Commit the instruction file with Git mode 100644
11
+
12
+ ``list`` names what is outstanding, ``ingest`` adds the diagnostics from a
13
+ policy-lint JSON report, ``brief`` writes the task an agent works from, and
14
+ ``clear`` empties the ledger once the follow-up pull request carries the
15
+ fixes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import TextIO
25
+
26
+ from dev_env_scripts_constants.followup_constants import (
27
+ ABSENT_LOCATION_PATH,
28
+ ALL_COMMAND_NAMES,
29
+ BRIEF_COMMAND_NAME,
30
+ BRIEF_FINDING_TEMPLATE,
31
+ BRIEF_FOOTER_TEMPLATE,
32
+ BRIEF_HEADER,
33
+ DIAGNOSTIC_LOCATION_KEY,
34
+ DIAGNOSTIC_MESSAGE_KEY,
35
+ DIAGNOSTIC_RULE_ID_KEY,
36
+ DIAGNOSTICS_KEY,
37
+ EMPTY_LEDGER_MESSAGE,
38
+ INGEST_COMMAND_NAME,
39
+ INVALID_INPUT_EXIT_CODE,
40
+ LINE_SEPARATOR,
41
+ LIST_COMMAND_NAME,
42
+ LIST_FINDING_TEMPLATE,
43
+ LOCATION_PATH_KEY,
44
+ SUCCESS_EXIT_CODE,
45
+ UNREADABLE_REPORT_TEMPLATE,
46
+ USAGE_TEXT,
47
+ )
48
+
49
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
50
+ if _hooks_directory not in sys.path:
51
+ sys.path.insert(0, _hooks_directory)
52
+
53
+ from followup_ledger import (
54
+ FollowupFinding,
55
+ all_recorded_findings,
56
+ followup_ledger_path,
57
+ record_followup_finding,
58
+ )
59
+
60
+
61
+ def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace | None:
62
+ """Parse the command arguments.
63
+
64
+ Args:
65
+ all_arguments: The argument list after the program name.
66
+
67
+ Returns:
68
+ The parsed arguments, or None when the command name is unknown.
69
+ """
70
+ if not all_arguments or all_arguments[0] not in ALL_COMMAND_NAMES:
71
+ return None
72
+ argument_parser = argparse.ArgumentParser(add_help=False)
73
+ argument_parser.add_argument("command", choices=ALL_COMMAND_NAMES)
74
+ argument_parser.add_argument("report_path", nargs="?", default=None)
75
+ argument_parser.add_argument("--repository-root", type=Path, default=Path.cwd())
76
+ return argument_parser.parse_args(all_arguments)
77
+
78
+
79
+ def _run_list(repository_root: Path, stdout: TextIO) -> int:
80
+ """Write one line per recorded finding.
81
+
82
+ Args:
83
+ repository_root: The repository whose ledger to read.
84
+ stdout: The stream the lines go to.
85
+
86
+ Returns:
87
+ SUCCESS_EXIT_CODE.
88
+ """
89
+ all_findings = all_recorded_findings(repository_root)
90
+ if not all_findings:
91
+ stdout.write(EMPTY_LEDGER_MESSAGE + LINE_SEPARATOR)
92
+ return SUCCESS_EXIT_CODE
93
+ for each_finding in all_findings:
94
+ stdout.write(_formatted_finding(LIST_FINDING_TEMPLATE, each_finding) + LINE_SEPARATOR)
95
+ return SUCCESS_EXIT_CODE
96
+
97
+
98
+ def _formatted_finding(finding_template: str, finding: FollowupFinding) -> str:
99
+ """Fill one template with a finding's fields.
100
+
101
+ Args:
102
+ finding_template: A template naming rule_id, file_path, and message.
103
+ finding: The finding whose fields fill the template.
104
+
105
+ Returns:
106
+ The filled line.
107
+ """
108
+ return finding_template.format(
109
+ rule_id=finding.rule_id,
110
+ file_path=finding.file_path,
111
+ message=finding.message,
112
+ )
113
+
114
+
115
+ def _parsed_lint_report(report_path: Path) -> dict[str, object] | None:
116
+ """Read one policy-lint JSON report into a mapping.
117
+
118
+ Args:
119
+ report_path: Path of the JSON report to read.
120
+
121
+ Returns:
122
+ The report mapping, or None when the file cannot be read as one.
123
+ """
124
+ try:
125
+ report_text = report_path.read_text(encoding="utf-8")
126
+ parsed_report = json.loads(report_text)
127
+ except (OSError, UnicodeError, json.JSONDecodeError):
128
+ return None
129
+ if not isinstance(parsed_report, dict):
130
+ return None
131
+ return parsed_report
132
+
133
+
134
+ def _run_ingest(repository_root: Path, report_path: Path | None, stdout: TextIO) -> int:
135
+ """Record every diagnostic a policy-lint JSON report carries.
136
+
137
+ Args:
138
+ repository_root: The repository whose ledger receives the findings.
139
+ report_path: Path of the JSON report to read.
140
+ stdout: The stream messages go to.
141
+
142
+ Returns:
143
+ SUCCESS_EXIT_CODE once the report is recorded, or
144
+ INVALID_INPUT_EXIT_CODE when the report cannot be read.
145
+ """
146
+ if report_path is None:
147
+ stdout.write(USAGE_TEXT + LINE_SEPARATOR)
148
+ return INVALID_INPUT_EXIT_CODE
149
+ parsed_report = _parsed_lint_report(report_path)
150
+ if parsed_report is None:
151
+ stdout.write(
152
+ UNREADABLE_REPORT_TEMPLATE.format(report_path=report_path) + LINE_SEPARATOR
153
+ )
154
+ return INVALID_INPUT_EXIT_CODE
155
+
156
+ all_diagnostics = parsed_report.get(DIAGNOSTICS_KEY, [])
157
+ if not isinstance(all_diagnostics, list):
158
+ return SUCCESS_EXIT_CODE
159
+ for each_diagnostic in all_diagnostics:
160
+ each_finding = _finding_from_diagnostic(each_diagnostic)
161
+ if each_finding is not None:
162
+ record_followup_finding(repository_root, each_finding)
163
+ return SUCCESS_EXIT_CODE
164
+
165
+
166
+ def _finding_from_diagnostic(diagnostic_by_key: object) -> FollowupFinding | None:
167
+ """Convert one lint diagnostic into a ledger finding.
168
+
169
+ Args:
170
+ diagnostic_by_key: One entry of the report's diagnostics list.
171
+
172
+ Returns:
173
+ The finding, or None when the entry carries no rule identifier and
174
+ message.
175
+ """
176
+ if not isinstance(diagnostic_by_key, dict):
177
+ return None
178
+ rule_id = diagnostic_by_key.get(DIAGNOSTIC_RULE_ID_KEY)
179
+ message = diagnostic_by_key.get(DIAGNOSTIC_MESSAGE_KEY)
180
+ if not isinstance(rule_id, str) or not isinstance(message, str):
181
+ return None
182
+ return FollowupFinding(rule_id, _diagnostic_file_path(diagnostic_by_key), message)
183
+
184
+
185
+ def _diagnostic_file_path(diagnostic_by_key: dict[str, object]) -> str:
186
+ """Return the path a diagnostic names, or an empty string.
187
+
188
+ Args:
189
+ diagnostic_by_key: One entry of the report's diagnostics list.
190
+
191
+ Returns:
192
+ The diagnostic's path, or ABSENT_LOCATION_PATH when it names none.
193
+ """
194
+ location_by_key = diagnostic_by_key.get(DIAGNOSTIC_LOCATION_KEY)
195
+ if not isinstance(location_by_key, dict):
196
+ return ABSENT_LOCATION_PATH
197
+ location_path = location_by_key.get(LOCATION_PATH_KEY)
198
+ if not isinstance(location_path, str):
199
+ return ABSENT_LOCATION_PATH
200
+ return location_path
201
+
202
+
203
+ def _run_brief(repository_root: Path, stdout: TextIO) -> int:
204
+ """Write the task an agent works the recorded follow-ups from.
205
+
206
+ Args:
207
+ repository_root: The repository whose ledger to read.
208
+ stdout: The stream the brief goes to.
209
+
210
+ Returns:
211
+ SUCCESS_EXIT_CODE.
212
+ """
213
+ all_findings = all_recorded_findings(repository_root)
214
+ if not all_findings:
215
+ stdout.write(EMPTY_LEDGER_MESSAGE + LINE_SEPARATOR)
216
+ return SUCCESS_EXIT_CODE
217
+
218
+ all_lines = [BRIEF_HEADER, ""]
219
+ all_lines.extend(
220
+ _formatted_finding(BRIEF_FINDING_TEMPLATE, each_finding)
221
+ for each_finding in all_findings
222
+ )
223
+ all_lines.append("")
224
+ all_lines.append(BRIEF_FOOTER_TEMPLATE.format(command_path=Path(__file__).resolve()))
225
+ stdout.write(LINE_SEPARATOR.join(all_lines) + LINE_SEPARATOR)
226
+ return SUCCESS_EXIT_CODE
227
+
228
+
229
+ def _run_clear(repository_root: Path) -> int:
230
+ """Empty the repository's ledger.
231
+
232
+ Args:
233
+ repository_root: The repository whose ledger to empty.
234
+
235
+ Returns:
236
+ SUCCESS_EXIT_CODE.
237
+ """
238
+ try:
239
+ followup_ledger_path(repository_root).unlink(missing_ok=True)
240
+ except OSError:
241
+ return SUCCESS_EXIT_CODE
242
+ return SUCCESS_EXIT_CODE
243
+
244
+
245
+ def main(all_arguments: list[str], stdout: TextIO = sys.stdout) -> int:
246
+ """Run one follow-up command.
247
+
248
+ Args:
249
+ all_arguments: The argument list after the program name.
250
+ stdout: The stream every message goes to.
251
+
252
+ Returns:
253
+ The command's exit code.
254
+ """
255
+ parsed_arguments = _parse_arguments(all_arguments)
256
+ if parsed_arguments is None:
257
+ stdout.write(USAGE_TEXT + LINE_SEPARATOR)
258
+ return INVALID_INPUT_EXIT_CODE
259
+
260
+ repository_root = parsed_arguments.repository_root.resolve()
261
+ if parsed_arguments.command == LIST_COMMAND_NAME:
262
+ return _run_list(repository_root, stdout)
263
+ if parsed_arguments.command == INGEST_COMMAND_NAME:
264
+ raw_report_path = parsed_arguments.report_path
265
+ report_path = None if raw_report_path is None else Path(raw_report_path)
266
+ return _run_ingest(repository_root, report_path, stdout)
267
+ if parsed_arguments.command == BRIEF_COMMAND_NAME:
268
+ return _run_brief(repository_root, stdout)
269
+ return _run_clear(repository_root)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,156 @@
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 followup_cli import main
11
+
12
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
13
+ if _hooks_directory not in sys.path:
14
+ sys.path.insert(0, _hooks_directory)
15
+
16
+ from followup_ledger import (
17
+ FollowupFinding,
18
+ all_recorded_findings,
19
+ record_followup_finding,
20
+ )
21
+
22
+
23
+ def run_command(all_arguments: list[str]) -> tuple[int, str]:
24
+ output_stream = io.StringIO()
25
+ exit_code = main(all_arguments, stdout=output_stream)
26
+ return exit_code, output_stream.getvalue()
27
+
28
+
29
+ def write_lint_report(
30
+ report_path: Path, all_diagnostics: list[dict[str, object]]
31
+ ) -> None:
32
+ report_path.write_text(
33
+ json.dumps(
34
+ {"schema_version": 1, "diagnostics": all_diagnostics, "failed_rules": []}
35
+ ),
36
+ encoding="utf-8",
37
+ )
38
+
39
+
40
+ def test_list_reports_nothing_outstanding_for_an_empty_ledger(tmp_path: Path) -> None:
41
+ exit_code, output_text = run_command(["list", "--repository-root", str(tmp_path)])
42
+
43
+ assert exit_code == 0
44
+ assert "no follow-ups recorded" in output_text
45
+
46
+
47
+ def test_list_names_each_recorded_finding(tmp_path: Path) -> None:
48
+ record_followup_finding(
49
+ tmp_path, FollowupFinding("instruction-git-mode", "CLAUDE.md", "wrong mode")
50
+ )
51
+
52
+ exit_code, output_text = run_command(["list", "--repository-root", str(tmp_path)])
53
+
54
+ assert exit_code == 0
55
+ assert "instruction-git-mode" in output_text
56
+ assert "CLAUDE.md" in output_text
57
+
58
+
59
+ def test_ingest_records_every_located_diagnostic(tmp_path: Path) -> None:
60
+ report_path = tmp_path / "lint.json"
61
+ write_lint_report(
62
+ report_path,
63
+ [
64
+ {
65
+ "rule_id": "line-length",
66
+ "severity": "error",
67
+ "message": "line runs past the limit",
68
+ "location": {"path": "src/app.py", "start_line": 4, "start_column": 1},
69
+ }
70
+ ],
71
+ )
72
+
73
+ exit_code, _ = run_command(
74
+ ["ingest", str(report_path), "--repository-root", str(tmp_path)]
75
+ )
76
+
77
+ assert exit_code == 0
78
+ assert all_recorded_findings(tmp_path) == (
79
+ FollowupFinding("line-length", "src/app.py", "line runs past the limit"),
80
+ )
81
+
82
+
83
+ def test_ingest_records_a_diagnostic_without_a_location(tmp_path: Path) -> None:
84
+ report_path = tmp_path / "lint.json"
85
+ write_lint_report(
86
+ report_path,
87
+ [
88
+ {
89
+ "rule_id": "inventory-drift",
90
+ "severity": "error",
91
+ "message": "inventory row missing",
92
+ "location": None,
93
+ }
94
+ ],
95
+ )
96
+
97
+ run_command(["ingest", str(report_path), "--repository-root", str(tmp_path)])
98
+
99
+ assert all_recorded_findings(tmp_path) == (
100
+ FollowupFinding("inventory-drift", "", "inventory row missing"),
101
+ )
102
+
103
+
104
+ def test_ingest_reports_an_unreadable_report(tmp_path: Path) -> None:
105
+ exit_code, output_text = run_command(
106
+ ["ingest", str(tmp_path / "absent.json"), "--repository-root", str(tmp_path)]
107
+ )
108
+
109
+ assert exit_code == 2
110
+ assert "absent.json" in output_text
111
+
112
+
113
+ def test_brief_names_every_recorded_finding_and_the_repository(tmp_path: Path) -> None:
114
+ record_followup_finding(
115
+ tmp_path, FollowupFinding("instruction-git-mode", "CLAUDE.md", "wrong mode")
116
+ )
117
+
118
+ exit_code, output_text = run_command(["brief", "--repository-root", str(tmp_path)])
119
+
120
+ assert exit_code == 0
121
+ assert "instruction-git-mode" in output_text
122
+ assert "wrong mode" in output_text
123
+ assert "draft pull request" in output_text
124
+
125
+
126
+ def test_brief_asks_for_no_work_on_an_empty_ledger(tmp_path: Path) -> None:
127
+ exit_code, output_text = run_command(["brief", "--repository-root", str(tmp_path)])
128
+
129
+ assert exit_code == 0
130
+ assert "no follow-ups recorded" in output_text
131
+
132
+
133
+ def test_clear_empties_the_ledger(tmp_path: Path) -> None:
134
+ record_followup_finding(
135
+ tmp_path, FollowupFinding("instruction-git-mode", "CLAUDE.md", "wrong mode")
136
+ )
137
+
138
+ exit_code, _ = run_command(["clear", "--repository-root", str(tmp_path)])
139
+
140
+ assert exit_code == 0
141
+ assert all_recorded_findings(tmp_path) == ()
142
+
143
+
144
+ def test_clear_on_an_empty_ledger_succeeds(tmp_path: Path) -> None:
145
+ exit_code, _ = run_command(["clear", "--repository-root", str(tmp_path)])
146
+
147
+ assert exit_code == 0
148
+
149
+
150
+ def test_an_unknown_command_reports_usage(tmp_path: Path) -> None:
151
+ exit_code, output_text = run_command(
152
+ ["stampede", "--repository-root", str(tmp_path)]
153
+ )
154
+
155
+ assert exit_code == 2
156
+ assert "Usage" in output_text
@@ -1,7 +1,21 @@
1
1
  import subprocess
2
2
  from pathlib import Path
3
3
 
4
- from validate_instruction_pairs import validate_repository
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
5
19
 
6
20
 
7
21
  def read_workflow(workflow_filename: str) -> str:
@@ -145,3 +159,132 @@ def test_release_please_short_circuit_exists_in_workflow() -> None:
145
159
  assert "chore(.*): release " in workflow_text
146
160
  assert "autorelease: pending" in workflow_text
147
161
  assert "skip-validation:" in workflow_text
162
+
163
+
164
+ def write_valid_pair(repository_root: Path) -> None:
165
+ (repository_root / "AGENTS.md").write_bytes(b"# Canonical guidance\n")
166
+ (repository_root / "CLAUDE.md").write_bytes(b"@AGENTS.md\n")
167
+ subprocess.run(
168
+ ["git", "add", "AGENTS.md", "CLAUDE.md"], cwd=repository_root, check=True
169
+ )
170
+
171
+
172
+ def make_import_text_wrong(repository_root: Path) -> None:
173
+ (repository_root / "CLAUDE.md").write_bytes(b"@AGENTS.md\n\nExtra guidance.\n")
174
+
175
+
176
+ def make_git_mode_executable(repository_root: Path) -> None:
177
+ subprocess.run(
178
+ ["git", "update-index", "--chmod=+x", "CLAUDE.md"],
179
+ cwd=repository_root,
180
+ check=True,
181
+ )
182
+
183
+
184
+ def test_a_wrong_import_text_is_a_breaking_finding(tmp_path: Path) -> None:
185
+ initialize_repository(tmp_path)
186
+ write_valid_pair(tmp_path)
187
+ make_import_text_wrong(tmp_path)
188
+
189
+ all_breaking = [
190
+ each_finding
191
+ for each_finding in all_instruction_findings(tmp_path)
192
+ if each_finding.severity == "breaking"
193
+ ]
194
+
195
+ assert [each_finding.rule_id for each_finding in all_breaking] == [
196
+ "instruction-import-text"
197
+ ]
198
+
199
+
200
+ def test_a_wrong_git_mode_is_a_smell_finding(tmp_path: Path) -> None:
201
+ initialize_repository(tmp_path)
202
+ write_valid_pair(tmp_path)
203
+ make_git_mode_executable(tmp_path)
204
+
205
+ all_smells = [
206
+ each_finding
207
+ for each_finding in all_instruction_findings(tmp_path)
208
+ if each_finding.severity == "smell"
209
+ ]
210
+
211
+ assert [each_finding.rule_id for each_finding in all_smells] == [
212
+ "instruction-git-mode"
213
+ ]
214
+
215
+
216
+ def test_run_gate_fails_on_a_breaking_finding(tmp_path: Path) -> None:
217
+ initialize_repository(tmp_path)
218
+ write_valid_pair(tmp_path)
219
+ make_import_text_wrong(tmp_path)
220
+
221
+ assert run_gate(tmp_path) == GATE_FAILED_EXIT_CODE
222
+
223
+
224
+ def test_run_gate_passes_a_smell_and_records_it(tmp_path: Path) -> None:
225
+ initialize_repository(tmp_path)
226
+ write_valid_pair(tmp_path)
227
+ make_git_mode_executable(tmp_path)
228
+
229
+ exit_code = run_gate(tmp_path)
230
+
231
+ assert exit_code == GATE_PASSED_EXIT_CODE
232
+ assert [
233
+ each_finding.rule_id for each_finding in all_recorded_findings(tmp_path)
234
+ ] == ["instruction-git-mode"]
235
+
236
+
237
+ def test_run_gate_records_a_smell_it_finds_beside_a_breaking_finding(
238
+ tmp_path: Path,
239
+ ) -> None:
240
+ initialize_repository(tmp_path)
241
+ write_valid_pair(tmp_path)
242
+ make_import_text_wrong(tmp_path)
243
+ make_git_mode_executable(tmp_path)
244
+
245
+ exit_code = run_gate(tmp_path)
246
+
247
+ assert exit_code == GATE_FAILED_EXIT_CODE
248
+ assert [
249
+ each_finding.rule_id for each_finding in all_recorded_findings(tmp_path)
250
+ ] == ["instruction-git-mode"]
251
+
252
+
253
+ def test_run_gate_passes_a_valid_tree_and_records_nothing(tmp_path: Path) -> None:
254
+ initialize_repository(tmp_path)
255
+ write_valid_pair(tmp_path)
256
+
257
+ exit_code = run_gate(tmp_path)
258
+
259
+ assert exit_code == GATE_PASSED_EXIT_CODE
260
+ assert all_recorded_findings(tmp_path) == ()
261
+
262
+
263
+ def read_sparse_checkout_paths() -> list[str]:
264
+ workflow_text = read_workflow("instruction-pairs-reusable.yml")
265
+ block_start = workflow_text.index("sparse-checkout: |")
266
+ block_end = workflow_text.index("sparse-checkout-cone-mode", block_start)
267
+ return [
268
+ each_line.strip()
269
+ for each_line in workflow_text[block_start:block_end].splitlines()
270
+ if each_line.strip().startswith("packages/")
271
+ ]
272
+
273
+
274
+ def test_sparse_checkout_carries_every_module_the_validator_imports() -> None:
275
+ package_root = Path(__file__).resolve().parents[1]
276
+ all_checked_out_paths = set(read_sparse_checkout_paths())
277
+
278
+ all_required_paths = {
279
+ "packages/claude-dev-env/scripts/validate_instruction_pairs.py",
280
+ "packages/claude-dev-env/scripts/subprocess_window_access.py",
281
+ "packages/claude-dev-env/scripts/dev_env_scripts_constants/__init__.py",
282
+ "packages/claude-dev-env/scripts/dev_env_scripts_constants/followup_constants.py",
283
+ "packages/claude-dev-env/hooks/followup_ledger.py",
284
+ "packages/claude-dev-env/hooks/hooks_constants/__init__.py",
285
+ "packages/claude-dev-env/hooks/hooks_constants/followup_ledger_constants.py",
286
+ }
287
+
288
+ assert all_required_paths <= all_checked_out_paths
289
+ for each_path in all_checked_out_paths:
290
+ assert (package_root.parents[1] / each_path).is_file()