claude-dev-env 8.4.2 → 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.
- package/commands/fix-followups.md +66 -0
- package/hooks/followup_ledger.py +143 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +27 -0
- package/hooks/test_followup_ledger.py +83 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +72 -0
- package/scripts/dev_env_scripts_constants/followup_constants.py +72 -0
- package/scripts/followup_cli.py +273 -0
- package/scripts/test_followup_cli.py +156 -0
- package/scripts/test_validate_instruction_pairs.py +144 -1
- package/scripts/validate_instruction_pairs.py +277 -47
- package/scripts/windows/pstack/pstack-command-shim.ps1 +9 -2
- package/scripts/audit-hooks-cli.mjs +0 -59
- package/scripts/audit-hooks.mjs +0 -452
- package/scripts/tests/test-audit-hooks-cli.mjs +0 -150
- package/scripts/tests/test-audit-hooks.mjs +0 -497
|
@@ -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
|
-
|
|
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()
|