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 CHANGED
@@ -7,16 +7,18 @@ import path from "node:path";
7
7
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
8
  const lintScriptPath = path.join(packageRoot, "scripts", "cde_lint.py");
9
9
  const verificationScriptPath = path.join(packageRoot, "scripts", "local_verification", "cli.py");
10
+ const followupScriptPath = path.join(packageRoot, "scripts", "followup_cli.py");
10
11
  const posixSignalStatusOffset = 128;
11
12
  const lintCommandName = "lint";
12
13
  const verifyCommandName = "verify";
14
+ const followupCommandName = "followup";
13
15
  const pythonOptionName = "--python";
14
16
  const pythonEnvironmentName = "CDE_PYTHON";
15
17
 
16
18
 
17
19
  export function createHelpText() {
18
20
  return [
19
- "Usage: cde <lint|verify> [options]",
21
+ "Usage: cde <lint|verify|followup> [options]",
20
22
  "",
21
23
  "Run the policy linter with cde lint. Source modes are mutually exclusive:",
22
24
  " --files PATH [PATH ...] Current worktree files",
@@ -37,6 +39,13 @@ export function createHelpText() {
37
39
  " --base SHA Trusted base revision",
38
40
  " --output PATH JSON verification report",
39
41
  "",
42
+ "Read the follow-up ledger of non-breaking findings with cde followup:",
43
+ " cde followup list Name every recorded follow-up",
44
+ " cde followup ingest PATH Record a policy-lint JSON report",
45
+ " cde followup brief Write the task an agent fixes them from",
46
+ " cde followup count Report the backlog against the threshold",
47
+ " cde followup clear Empty the ledger",
48
+ "",
40
49
  "Python may also be selected with CDE_PYTHON.",
41
50
  ].join("\n");
42
51
  }
@@ -75,6 +84,14 @@ export function buildVerifyCommand(interpreter, forwardedArguments) {
75
84
  }
76
85
 
77
86
 
87
+ export function buildFollowupCommand(interpreter, forwardedArguments) {
88
+ return {
89
+ executable: interpreter,
90
+ arguments: [followupScriptPath, ...forwardedArguments],
91
+ };
92
+ }
93
+
94
+
78
95
  function takePythonOverride(allArguments) {
79
96
  const remainingArguments = [];
80
97
  let interpreterOverride;
@@ -170,12 +187,31 @@ export function runVerifyCommand(command, dependencies = {}) {
170
187
  }
171
188
 
172
189
 
190
+ export function runFollowupCommand(command, dependencies = {}) {
191
+ return runChildCommand(command, "Unable to start the follow-up command.", dependencies);
192
+ }
193
+
194
+
195
+ const buildCommandByName = {
196
+ [lintCommandName]: buildLintCommand,
197
+ [verifyCommandName]: buildVerifyCommand,
198
+ [followupCommandName]: buildFollowupCommand,
199
+ };
200
+
201
+
202
+ const runCommandByName = {
203
+ [lintCommandName]: runLintCommand,
204
+ [verifyCommandName]: runVerifyCommand,
205
+ [followupCommandName]: runFollowupCommand,
206
+ };
207
+
208
+
173
209
  export async function main(argumentsList = process.argv.slice(2), dependencies = {}) {
174
210
  if (argumentsList.length === 0 || argumentsList.includes("--help") || argumentsList.includes("-h")) {
175
211
  process.stdout.write(`${createHelpText()}\n`);
176
212
  return 0;
177
213
  }
178
- if (![lintCommandName, verifyCommandName].includes(argumentsList[0])) {
214
+ if (!Object.hasOwn(buildCommandByName, argumentsList[0])) {
179
215
  process.stderr.write(`${createHelpText()}\n`);
180
216
  return 2;
181
217
  }
@@ -190,10 +226,8 @@ export async function main(argumentsList = process.argv.slice(2), dependencies =
190
226
  process.stderr.write("No usable Python interpreter found. Use --python or CDE_PYTHON.\n");
191
227
  return 2;
192
228
  }
193
- const buildCommand = argumentsList[0] === lintCommandName ? buildLintCommand : buildVerifyCommand;
194
- const command = buildCommand(interpreter, remainingArguments);
195
- const runCommand = dependencies.runCommand
196
- ?? (argumentsList[0] === lintCommandName ? runLintCommand : runVerifyCommand);
229
+ const command = buildCommandByName[argumentsList[0]](interpreter, remainingArguments);
230
+ const runCommand = dependencies.runCommand ?? runCommandByName[argumentsList[0]];
197
231
  return await runCommand(command, dependencies);
198
232
  }
199
233
 
package/bin/cde.test.mjs CHANGED
@@ -268,3 +268,28 @@ test("reports a synchronous child start failure as invalid input", async () => {
268
268
  assert.equal(exitCode, 2);
269
269
  assert.deepEqual(messages, ["Unable to start the policy linter.\n"]);
270
270
  });
271
+
272
+
273
+ test("dispatches followup to the follow-up ledger command", async () => {
274
+ let receivedCommand;
275
+ const exitCode = await main(
276
+ ["followup", "--python", "python-test", "list"],
277
+ {
278
+ findPython: async () => "unused-python",
279
+ runCommand: async (command) => {
280
+ receivedCommand = command;
281
+ return 0;
282
+ },
283
+ },
284
+ );
285
+ assert.equal(exitCode, 0);
286
+ assert.equal(receivedCommand.executable, "python-test");
287
+ assert.match(receivedCommand.arguments[0], /followup_cli\.py$/);
288
+ assert.deepEqual(receivedCommand.arguments.slice(1), ["list"]);
289
+ });
290
+
291
+
292
+ test("help names the followup command", () => {
293
+ assert.match(createHelpText(), /cde <lint\|verify\|followup>/);
294
+ assert.match(createHelpText(), /cde followup count/);
295
+ });
@@ -0,0 +1,73 @@
1
+ ---
2
+ description: Fix every non-breaking finding the follow-up ledger holds and open a draft PR for it
3
+ argument-hint: [repository root, or blank for the current repository]
4
+ ---
5
+
6
+ Fix the non-breaking findings a local gate recorded rather than blocked on,
7
+ and deliver them as one draft pull request.
8
+
9
+ The repository root is `$ARGUMENTS`, or the current repository when that is
10
+ blank.
11
+
12
+ Use `rules/asd-ste100-language.md` for user-facing wording.
13
+
14
+ ## 1. Read the ledger
15
+
16
+ Run `cde followup brief --repository-root <root>`.
17
+
18
+ When it prints `no follow-ups recorded`, report that and stop. There is no
19
+ work.
20
+
21
+ Otherwise the brief lists one line per finding, each carrying the check
22
+ identifier, the file, the message, and the revision the finding was recorded
23
+ against.
24
+
25
+ ## 2. Group the findings
26
+
27
+ Group the lines by check identifier. A check with several findings takes one
28
+ pass over every file it names, so the same fix shape lands once.
29
+
30
+ A finding whose check identifier ends in `unclassified` comes from a check the
31
+ catalog does not name yet. Fix it like any other, and add its entry to
32
+ `scripts/policy_lint/config/check_catalog_constants.py` with a sample in the
33
+ catalog's synchronization test.
34
+
35
+ Read each named file before editing it. A recorded finding names the state of
36
+ the file at the moment the gate ran, so confirm the finding still holds.
37
+ Keep a finding the current file still carries. Drop the rest.
38
+
39
+ ## 3. Fix each group
40
+
41
+ Each finding is non-breaking, so the change that raised it already shipped.
42
+ Keep every fix mechanical. Touch no behavior, change no signature, and add no
43
+ abstraction.
44
+
45
+ Run the repository's own fast checks over the files you changed. Where a
46
+ finding names a generated file, regenerate it with the repository's tooling.
47
+
48
+ ## 4. Open the pull request
49
+
50
+ Branch from the repository's default branch. Commit each group on its own,
51
+ with a message naming the check identifier and the files.
52
+
53
+ Open the pull request as a draft. Write the body as:
54
+
55
+ - One `Before:` paragraph saying which findings stood open.
56
+ - One `After:` paragraph saying what the tree now holds.
57
+ - A short `How` paragraph naming the groups, the files each touched, and the
58
+ revisions the findings were recorded against.
59
+
60
+ Push, then confirm the required checks report on the branch head.
61
+
62
+ ## 5. Empty the ledger
63
+
64
+ Once the pull request is open, run
65
+ `cde followup clear --repository-root <root>`.
66
+
67
+ A finding the pull request left alone goes back in the ledger with
68
+ `cde followup ingest`, so the next pass picks it up.
69
+
70
+ ## Report
71
+
72
+ Name the pull request link, the groups fixed, the findings dropped because
73
+ the current file reads clean, and anything left in the ledger.
@@ -0,0 +1,248 @@
1
+ """Append-only ledger of non-breaking findings awaiting a follow-up fix.
2
+
3
+ A gate that finds a breaking defect stops the commit, the push, or the tool
4
+ call. A gate that finds a non-breaking smell records the finding here and lets
5
+ the work proceed. ``cde followup`` reads the ledger and briefs the agent that
6
+ fixes the recorded smells in their own pull request.
7
+
8
+ Every write is fail-safe. A ledger that cannot be created or appended to leaves
9
+ the calling gate's decision unchanged, so recording a finding never becomes a
10
+ new way for a gate to fail.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import NamedTuple
19
+
20
+ _hooks_directory = str(Path(__file__).resolve().parent)
21
+ if _hooks_directory not in sys.path:
22
+ sys.path.insert(0, _hooks_directory)
23
+
24
+ from hooks_constants.followup_ledger_constants import (
25
+ ABSENT_ORIGIN_COMMIT,
26
+ ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS,
27
+ ALL_GIT_HEAD_PATH_SEGMENTS,
28
+ CHECK_ID_KEY,
29
+ FILE_PATH_KEY,
30
+ GIT_DIRECTORY_NAME,
31
+ GIT_REFERENCE_PREFIX,
32
+ LEDGER_APPEND_MODE,
33
+ LEDGER_ENCODING,
34
+ MESSAGE_KEY,
35
+ ORIGIN_COMMIT_KEY,
36
+ RULE_ID_KEY,
37
+ SEVERITY_KEY,
38
+ SEVERITY_SMELL,
39
+ )
40
+
41
+
42
+ class FollowupFinding(NamedTuple):
43
+ """One non-breaking finding a later pull request resolves.
44
+
45
+ Attributes:
46
+ rule_id: Identifier of the rule that raised the finding.
47
+ file_path: Repository-relative path the finding names.
48
+ message: The text a reader acts on.
49
+ check_id: Identifier of the single check behind the finding. An empty
50
+ value reads back as the rule identifier.
51
+ severity: The class the gate put the finding in.
52
+ origin_commit: The revision checked out when the finding was recorded,
53
+ which groups a follow-up pull request by the change that raised it.
54
+ """
55
+
56
+ rule_id: str
57
+ file_path: str
58
+ message: str
59
+ check_id: str = ""
60
+ severity: str = SEVERITY_SMELL
61
+ origin_commit: str = ABSENT_ORIGIN_COMMIT
62
+
63
+ def tracking_key(self) -> tuple[str, str, str]:
64
+ """Return the fields that make this finding the same one over time.
65
+
66
+ The origin commit stays out of the key, so a smell seen again under a
67
+ later revision keeps the revision that first raised it.
68
+
69
+ Returns:
70
+ The check identifier, path, and message.
71
+ """
72
+ return (self.check_id or self.rule_id, self.file_path, self.message)
73
+
74
+
75
+ def followup_ledger_path(repository_root: Path) -> Path:
76
+ """Return the ledger path for one repository.
77
+
78
+ Args:
79
+ repository_root: The repository whose ledger to address.
80
+
81
+ Returns:
82
+ The absolute path of that repository's follow-up ledger.
83
+ """
84
+ return repository_root.joinpath(*ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS)
85
+
86
+
87
+ def record_followup_finding(repository_root: Path, finding: FollowupFinding) -> None:
88
+ """Append one finding to the repository's ledger, once.
89
+
90
+ A finding already present in the ledger is left alone, so a gate that runs
91
+ on every commit records a standing smell a single time. Every filesystem
92
+ error is swallowed, so a ledger failure leaves the caller's gate decision
93
+ unchanged.
94
+
95
+ Args:
96
+ repository_root: The repository whose ledger receives the finding.
97
+ finding: The non-breaking finding to record.
98
+ """
99
+ all_recorded_keys = {
100
+ each_finding.tracking_key()
101
+ for each_finding in all_recorded_findings(repository_root)
102
+ }
103
+ if finding.tracking_key() in all_recorded_keys:
104
+ return
105
+
106
+ ledger_path = followup_ledger_path(repository_root)
107
+ record_text = json.dumps(
108
+ {
109
+ RULE_ID_KEY: finding.rule_id,
110
+ FILE_PATH_KEY: finding.file_path,
111
+ MESSAGE_KEY: finding.message,
112
+ CHECK_ID_KEY: finding.check_id or finding.rule_id,
113
+ SEVERITY_KEY: finding.severity,
114
+ ORIGIN_COMMIT_KEY: finding.origin_commit,
115
+ }
116
+ )
117
+ try:
118
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
119
+ with ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
120
+ ledger_file.write(record_text + "\n")
121
+ except OSError:
122
+ return
123
+
124
+
125
+ def all_recorded_findings(repository_root: Path) -> tuple[FollowupFinding, ...]:
126
+ """Return every finding the repository's ledger holds, in record order.
127
+
128
+ An absent or unreadable ledger reads as empty, and a line that does not
129
+ parse into a complete record is skipped, so one damaged line never hides
130
+ the findings around it.
131
+
132
+ Args:
133
+ repository_root: The repository whose ledger to read.
134
+
135
+ Returns:
136
+ The recorded findings, in the order they were appended.
137
+ """
138
+ ledger_path = followup_ledger_path(repository_root)
139
+ try:
140
+ ledger_text = ledger_path.read_text(encoding=LEDGER_ENCODING)
141
+ except (OSError, UnicodeError):
142
+ return ()
143
+
144
+ all_findings: list[FollowupFinding] = []
145
+ for each_line in ledger_text.splitlines():
146
+ each_finding = _finding_from_line(each_line)
147
+ if each_finding is not None:
148
+ all_findings.append(each_finding)
149
+ return tuple(all_findings)
150
+
151
+
152
+ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
153
+ """Parse one ledger line into a finding.
154
+
155
+ Args:
156
+ ledger_line: One line of the ledger file.
157
+
158
+ Returns:
159
+ The parsed finding, or None when the line carries no complete record.
160
+ """
161
+ stripped_line = ledger_line.strip()
162
+ if not stripped_line:
163
+ return None
164
+ try:
165
+ parsed_record = json.loads(stripped_line)
166
+ except json.JSONDecodeError:
167
+ return None
168
+ if not isinstance(parsed_record, dict):
169
+ return None
170
+ rule_id = parsed_record.get(RULE_ID_KEY)
171
+ file_path = parsed_record.get(FILE_PATH_KEY)
172
+ message = parsed_record.get(MESSAGE_KEY)
173
+ if not isinstance(rule_id, str) or not isinstance(file_path, str):
174
+ return None
175
+ if not isinstance(message, str):
176
+ return None
177
+ return FollowupFinding(
178
+ rule_id,
179
+ file_path,
180
+ message,
181
+ _text_field(parsed_record, CHECK_ID_KEY, rule_id),
182
+ _text_field(parsed_record, SEVERITY_KEY, SEVERITY_SMELL),
183
+ _text_field(parsed_record, ORIGIN_COMMIT_KEY, ABSENT_ORIGIN_COMMIT),
184
+ )
185
+
186
+
187
+ def _text_field(
188
+ all_record_fields: dict[str, object], field_key: str, fallback: str
189
+ ) -> str:
190
+ """Read one text field of a ledger record.
191
+
192
+ Args:
193
+ all_record_fields: The mapping one ledger line parsed into.
194
+ field_key: The key to read.
195
+ fallback: The value a record written before this field carried it takes.
196
+
197
+ Returns:
198
+ The field's text, or the fallback.
199
+ """
200
+ field_value = all_record_fields.get(field_key)
201
+ if isinstance(field_value, str) and field_value:
202
+ return field_value
203
+ return fallback
204
+
205
+
206
+ def head_commit(repository_root: Path) -> str:
207
+ """Return the revision one repository has checked out.
208
+
209
+ ::
210
+
211
+ .git/HEAD holding "ref: refs/heads/main" -> the sha in refs/heads/main
212
+ .git/HEAD holding a sha -> that sha
213
+ no .git directory -> ""
214
+
215
+ The revision comes from the files git writes rather than from a git
216
+ process, so recording a finding starts no subprocess.
217
+
218
+ Args:
219
+ repository_root: The repository whose revision to read.
220
+
221
+ Returns:
222
+ The checked-out revision, or an empty string when none can be read.
223
+ """
224
+ head_path = repository_root.joinpath(*ALL_GIT_HEAD_PATH_SEGMENTS)
225
+ head_text = _file_text(head_path)
226
+ if head_text is None:
227
+ return ABSENT_ORIGIN_COMMIT
228
+ if not head_text.startswith(GIT_REFERENCE_PREFIX):
229
+ return head_text
230
+ reference_path = repository_root / GIT_DIRECTORY_NAME / head_text[
231
+ len(GIT_REFERENCE_PREFIX) :
232
+ ]
233
+ return _file_text(reference_path) or ABSENT_ORIGIN_COMMIT
234
+
235
+
236
+ def _file_text(file_path: Path) -> str | None:
237
+ """Read one small git file into stripped text.
238
+
239
+ Args:
240
+ file_path: The file to read.
241
+
242
+ Returns:
243
+ The stripped text, or None when the file cannot be read.
244
+ """
245
+ try:
246
+ return file_path.read_text(encoding=LEDGER_ENCODING).strip()
247
+ except (OSError, UnicodeError):
248
+ return None
@@ -0,0 +1,42 @@
1
+ """Constants for the non-breaking-finding follow-up ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS",
7
+ "LEDGER_ENCODING",
8
+ "LEDGER_APPEND_MODE",
9
+ "RULE_ID_KEY",
10
+ "FILE_PATH_KEY",
11
+ "MESSAGE_KEY",
12
+ "CHECK_ID_KEY",
13
+ "SEVERITY_KEY",
14
+ "ORIGIN_COMMIT_KEY",
15
+ "ABSENT_ORIGIN_COMMIT",
16
+ "ALL_GIT_HEAD_PATH_SEGMENTS",
17
+ "GIT_DIRECTORY_NAME",
18
+ "GIT_REFERENCE_PREFIX",
19
+ "SEVERITY_BREAKING",
20
+ "SEVERITY_SMELL",
21
+ "ALL_SEVERITY_NAMES",
22
+ ]
23
+
24
+ ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS: tuple[str, ...] = (".claude", "followups", "smells.jsonl")
25
+ LEDGER_ENCODING: str = "utf-8"
26
+ LEDGER_APPEND_MODE: str = "a"
27
+
28
+ RULE_ID_KEY: str = "rule_id"
29
+ FILE_PATH_KEY: str = "file_path"
30
+ MESSAGE_KEY: str = "message"
31
+ CHECK_ID_KEY: str = "check_id"
32
+ SEVERITY_KEY: str = "severity"
33
+ ORIGIN_COMMIT_KEY: str = "origin_commit"
34
+
35
+ ABSENT_ORIGIN_COMMIT: str = ""
36
+ GIT_DIRECTORY_NAME: str = ".git"
37
+ ALL_GIT_HEAD_PATH_SEGMENTS: tuple[str, ...] = (GIT_DIRECTORY_NAME, "HEAD")
38
+ GIT_REFERENCE_PREFIX: str = "ref: "
39
+
40
+ SEVERITY_BREAKING: str = "breaking"
41
+ SEVERITY_SMELL: str = "smell"
42
+ ALL_SEVERITY_NAMES: frozenset[str] = frozenset({SEVERITY_BREAKING, SEVERITY_SMELL})
@@ -0,0 +1,171 @@
1
+ """Behavior tests for the non-breaking-finding 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
+ followup_ledger_path,
15
+ head_commit,
16
+ record_followup_finding,
17
+ )
18
+
19
+
20
+ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) -> None:
21
+ finding = FollowupFinding(
22
+ rule_id="instruction-filename",
23
+ file_path="docs/Claude.md",
24
+ message="Use the canonical filename: docs/Claude.md",
25
+ check_id="instruction-filename",
26
+ )
27
+
28
+ record_followup_finding(tmp_path, finding)
29
+
30
+ assert all_recorded_findings(tmp_path) == (finding,)
31
+
32
+
33
+ def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -> None:
34
+ first = FollowupFinding(
35
+ "instruction-filename", "docs/Claude.md", "wrong name", "instruction-filename"
36
+ )
37
+ second = FollowupFinding(
38
+ "instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
39
+ )
40
+
41
+ record_followup_finding(tmp_path, first)
42
+ record_followup_finding(tmp_path, second)
43
+
44
+ assert all_recorded_findings(tmp_path) == (first, second)
45
+
46
+
47
+ def test_record_followup_finding_records_a_repeat_once(tmp_path: Path) -> None:
48
+ finding = FollowupFinding(
49
+ "instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
50
+ )
51
+
52
+ record_followup_finding(tmp_path, finding)
53
+ record_followup_finding(tmp_path, finding)
54
+
55
+ assert all_recorded_findings(tmp_path) == (finding,)
56
+
57
+
58
+ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None:
59
+ assert all_recorded_findings(tmp_path) == ()
60
+
61
+
62
+ def test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
63
+ finding = FollowupFinding(
64
+ "instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
65
+ )
66
+ record_followup_finding(tmp_path, finding)
67
+ ledger_path = followup_ledger_path(tmp_path)
68
+ with ledger_path.open("a", encoding="utf-8") as ledger_file:
69
+ ledger_file.write("{ not json\n")
70
+
71
+ assert all_recorded_findings(tmp_path) == (finding,)
72
+
73
+
74
+ def test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path) -> None:
75
+ record_followup_finding(
76
+ tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
77
+ )
78
+
79
+ all_lines = followup_ledger_path(tmp_path).read_text(encoding="utf-8").splitlines()
80
+
81
+ assert len(all_lines) == 1
82
+ assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
83
+
84
+
85
+ def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> None:
86
+ blocking_file = tmp_path / ".claude"
87
+ blocking_file.write_text("not a directory", encoding="utf-8")
88
+
89
+ record_followup_finding(
90
+ tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
91
+ )
92
+
93
+ assert all_recorded_findings(tmp_path) == ()
94
+
95
+
96
+ def test_a_recorded_finding_carries_its_check_severity_and_origin(
97
+ tmp_path: Path,
98
+ ) -> None:
99
+ finding = FollowupFinding(
100
+ rule_id="code-rules",
101
+ file_path="scripts/run.py",
102
+ message="Line 3: Constant TIMEOUT - move to config/",
103
+ check_id="code-rules/constant-outside-config",
104
+ severity="smell",
105
+ origin_commit="abc1234",
106
+ )
107
+
108
+ record_followup_finding(tmp_path, finding)
109
+
110
+ assert all_recorded_findings(tmp_path) == (finding,)
111
+
112
+
113
+ def test_a_record_written_without_the_tracking_fields_still_reads(
114
+ tmp_path: Path,
115
+ ) -> None:
116
+ ledger_path = followup_ledger_path(tmp_path)
117
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
118
+ ledger_path.write_text(
119
+ json.dumps(
120
+ {
121
+ "rule_id": "instruction-mode",
122
+ "file_path": "docs/AGENTS.md",
123
+ "message": "wrong mode",
124
+ }
125
+ )
126
+ + "\n",
127
+ encoding="utf-8",
128
+ )
129
+
130
+ recorded_finding = all_recorded_findings(tmp_path)[0]
131
+
132
+ assert recorded_finding.check_id == "instruction-mode"
133
+ assert recorded_finding.severity == "smell"
134
+ assert recorded_finding.origin_commit == ""
135
+
136
+
137
+ def test_one_smell_seen_under_a_later_commit_stays_one_record(tmp_path: Path) -> None:
138
+ first = FollowupFinding(
139
+ "code-rules", "run.py", "Line 3: x", "code-rules/x", "smell", "aaa1111"
140
+ )
141
+ same_smell_later = first._replace(origin_commit="bbb2222")
142
+
143
+ record_followup_finding(tmp_path, first)
144
+ record_followup_finding(tmp_path, same_smell_later)
145
+
146
+ assert all_recorded_findings(tmp_path) == (first,)
147
+
148
+
149
+ def test_head_commit_reads_the_checked_out_revision(tmp_path: Path) -> None:
150
+ git_directory = tmp_path / ".git"
151
+ (git_directory / "refs" / "heads").mkdir(parents=True)
152
+ (git_directory / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
153
+ (git_directory / "refs" / "heads" / "main").write_text(
154
+ "0123456789abcdef0123456789abcdef01234567\n", encoding="utf-8"
155
+ )
156
+
157
+ assert head_commit(tmp_path) == "0123456789abcdef0123456789abcdef01234567"
158
+
159
+
160
+ def test_head_commit_reads_a_detached_revision(tmp_path: Path) -> None:
161
+ git_directory = tmp_path / ".git"
162
+ git_directory.mkdir()
163
+ (git_directory / "HEAD").write_text(
164
+ "0123456789abcdef0123456789abcdef01234567\n", encoding="utf-8"
165
+ )
166
+
167
+ assert head_commit(tmp_path) == "0123456789abcdef0123456789abcdef01234567"
168
+
169
+
170
+ def test_head_commit_is_empty_outside_a_repository(tmp_path: Path) -> None:
171
+ assert head_commit(tmp_path) == ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.4.3",
3
+ "version": "8.6.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {