claude-dev-env 8.5.0 → 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
+ });
@@ -18,14 +18,20 @@ Run `cde followup brief --repository-root <root>`.
18
18
  When it prints `no follow-ups recorded`, report that and stop. There is no
19
19
  work.
20
20
 
21
- Otherwise the brief lists one line per finding, each carrying the rule
22
- identifier, the file, and the message.
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.
23
24
 
24
25
  ## 2. Group the findings
25
26
 
26
- Group the lines by rule identifier. A rule with several findings takes one
27
+ Group the lines by check identifier. A check with several findings takes one
27
28
  pass over every file it names, so the same fix shape lands once.
28
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
+
29
35
  Read each named file before editing it. A recorded finding names the state of
30
36
  the file at the moment the gate ran, so confirm the finding still holds.
31
37
  Keep a finding the current file still carries. Drop the rest.
@@ -41,14 +47,15 @@ finding names a generated file, regenerate it with the repository's tooling.
41
47
 
42
48
  ## 4. Open the pull request
43
49
 
44
- Branch from the repository's default branch. Commit each rule group on its
45
- own, with a message naming the rule identifier and the files.
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.
46
52
 
47
53
  Open the pull request as a draft. Write the body as:
48
54
 
49
55
  - One `Before:` paragraph saying which findings stood open.
50
56
  - One `After:` paragraph saying what the tree now holds.
51
- - A short `How` paragraph naming the rule groups and the files each touched.
57
+ - A short `How` paragraph naming the groups, the files each touched, and the
58
+ revisions the findings were recorded against.
52
59
 
53
60
  Push, then confirm the required checks report on the branch head.
54
61
 
@@ -62,5 +69,5 @@ A finding the pull request left alone goes back in the ledger with
62
69
 
63
70
  ## Report
64
71
 
65
- Name the pull request link, the rule groups fixed, the findings dropped as no
66
- longer present, and anything left in the ledger.
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.
@@ -22,12 +22,20 @@ if _hooks_directory not in sys.path:
22
22
  sys.path.insert(0, _hooks_directory)
23
23
 
24
24
  from hooks_constants.followup_ledger_constants import (
25
- FILE_PATH_KEY,
25
+ ABSENT_ORIGIN_COMMIT,
26
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,
27
32
  LEDGER_APPEND_MODE,
28
33
  LEDGER_ENCODING,
29
34
  MESSAGE_KEY,
35
+ ORIGIN_COMMIT_KEY,
30
36
  RULE_ID_KEY,
37
+ SEVERITY_KEY,
38
+ SEVERITY_SMELL,
31
39
  )
32
40
 
33
41
 
@@ -35,14 +43,33 @@ class FollowupFinding(NamedTuple):
35
43
  """One non-breaking finding a later pull request resolves.
36
44
 
37
45
  Attributes:
38
- rule_id: Stable identifier of the check that raised the finding.
46
+ rule_id: Identifier of the rule that raised the finding.
39
47
  file_path: Repository-relative path the finding names.
40
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.
41
54
  """
42
55
 
43
56
  rule_id: str
44
57
  file_path: str
45
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)
46
73
 
47
74
 
48
75
  def followup_ledger_path(repository_root: Path) -> Path:
@@ -69,7 +96,11 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
69
96
  repository_root: The repository whose ledger receives the finding.
70
97
  finding: The non-breaking finding to record.
71
98
  """
72
- if finding in all_recorded_findings(repository_root):
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:
73
104
  return
74
105
 
75
106
  ledger_path = followup_ledger_path(repository_root)
@@ -78,6 +109,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
78
109
  RULE_ID_KEY: finding.rule_id,
79
110
  FILE_PATH_KEY: finding.file_path,
80
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,
81
115
  }
82
116
  )
83
117
  try:
@@ -140,4 +174,75 @@ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
140
174
  return None
141
175
  if not isinstance(message, str):
142
176
  return None
143
- return FollowupFinding(rule_id, file_path, message)
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
@@ -9,6 +9,13 @@ __all__ = [
9
9
  "RULE_ID_KEY",
10
10
  "FILE_PATH_KEY",
11
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",
12
19
  "SEVERITY_BREAKING",
13
20
  "SEVERITY_SMELL",
14
21
  "ALL_SEVERITY_NAMES",
@@ -21,6 +28,14 @@ LEDGER_APPEND_MODE: str = "a"
21
28
  RULE_ID_KEY: str = "rule_id"
22
29
  FILE_PATH_KEY: str = "file_path"
23
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: "
24
39
 
25
40
  SEVERITY_BREAKING: str = "breaking"
26
41
  SEVERITY_SMELL: str = "smell"
@@ -12,6 +12,7 @@ from followup_ledger import (
12
12
  FollowupFinding,
13
13
  all_recorded_findings,
14
14
  followup_ledger_path,
15
+ head_commit,
15
16
  record_followup_finding,
16
17
  )
17
18
 
@@ -21,6 +22,7 @@ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) ->
21
22
  rule_id="instruction-filename",
22
23
  file_path="docs/Claude.md",
23
24
  message="Use the canonical filename: docs/Claude.md",
25
+ check_id="instruction-filename",
24
26
  )
25
27
 
26
28
  record_followup_finding(tmp_path, finding)
@@ -29,8 +31,12 @@ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) ->
29
31
 
30
32
 
31
33
  def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -> None:
32
- first = FollowupFinding("instruction-filename", "docs/Claude.md", "wrong name")
33
- second = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
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
+ )
34
40
 
35
41
  record_followup_finding(tmp_path, first)
36
42
  record_followup_finding(tmp_path, second)
@@ -39,7 +45,9 @@ def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -
39
45
 
40
46
 
41
47
  def test_record_followup_finding_records_a_repeat_once(tmp_path: Path) -> None:
42
- finding = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
48
+ finding = FollowupFinding(
49
+ "instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
50
+ )
43
51
 
44
52
  record_followup_finding(tmp_path, finding)
45
53
  record_followup_finding(tmp_path, finding)
@@ -52,7 +60,9 @@ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None
52
60
 
53
61
 
54
62
  def test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
55
- finding = FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
63
+ finding = FollowupFinding(
64
+ "instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
65
+ )
56
66
  record_followup_finding(tmp_path, finding)
57
67
  ledger_path = followup_ledger_path(tmp_path)
58
68
  with ledger_path.open("a", encoding="utf-8") as ledger_file:
@@ -81,3 +91,81 @@ def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> Non
81
91
  )
82
92
 
83
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.5.0",
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": {
@@ -34,13 +34,39 @@ because the ledger holds them until a pull request does.
34
34
  ## The ledger
35
35
 
36
36
  A recorded finding lands in `.claude/followups/smells.jsonl` at the
37
- repository root, one JSON object per line carrying the rule identifier, the
38
- repository-relative path, and the message. `hooks/followup_ledger.py` writes
37
+ repository root, one JSON object per line. `hooks/followup_ledger.py` writes
39
38
  and reads it. Every write there is fail-safe, so a ledger failure leaves the
40
39
  gate's decision unchanged.
41
40
 
41
+ | Field | What it carries |
42
+ |---|---|
43
+ | `rule_id` | The rule that raised the finding |
44
+ | `check_id` | The single check behind it, which a severity table keys on |
45
+ | `file_path` | The repository-relative path the finding names |
46
+ | `message` | The text a reader acts on |
47
+ | `severity` | The class the gate put it in |
48
+ | `origin_commit` | The revision checked out when it was recorded |
49
+
50
+ The origin commit groups a follow-up pull request by the change that raised
51
+ the findings. A smell seen again under a later revision keeps the revision
52
+ that first raised it, so one smell stays one record.
53
+
42
54
  The ledger is per-checkout state and stays out of the repository.
43
55
 
56
+ ## The check identifier
57
+
58
+ Most lint rules run one check, so their rule identifier already names it. The
59
+ `code-rules` and `validators` rules bundle many checks behind one identifier.
60
+ `scripts/policy_lint/check_catalog.py` resolves those to a `<rule>/<check>`
61
+ identifier, which `cde lint --format json` emits as `check_id` on every
62
+ diagnostic. A consumer partitions findings by that identifier rather than by
63
+ message text.
64
+
65
+ A message no catalog entry names resolves to `<rule>/unclassified`, which a
66
+ partition treats as blocking. A check whose wording moves reports louder
67
+ rather than going quiet, and the catalog's synchronization tests fail on the
68
+ same change.
69
+
44
70
  ## Reading and clearing it
45
71
 
46
72
  | Command | What it does |
@@ -49,6 +75,13 @@ The ledger is per-checkout state and stays out of the repository.
49
75
  | `cde followup ingest REPORT` | Records the diagnostics a policy-lint JSON report carries |
50
76
  | `cde followup brief` | Writes the task an agent fixes them from |
51
77
  | `cde followup clear` | Empties the ledger |
78
+ | `cde followup count` | Reports the backlog against the threshold |
79
+
80
+ `count` exits non-zero once the backlog passes
81
+ `FOLLOWUP_BACKLOG_THRESHOLD` in
82
+ `scripts/dev_env_scripts_constants/followup_constants.py`, so a scheduled job
83
+ escalates rather than opening one more quiet pull request. The number is the
84
+ repository's setting, and raising or lowering it is one edit there.
52
85
 
53
86
  The `/fix-followups` command drives the whole pass: it reads the brief, fixes
54
87
  each rule group, opens a draft pull request, and clears the ledger.
@@ -30,18 +30,29 @@ LIST_COMMAND_NAME: str = "list"
30
30
  INGEST_COMMAND_NAME: str = "ingest"
31
31
  BRIEF_COMMAND_NAME: str = "brief"
32
32
  CLEAR_COMMAND_NAME: str = "clear"
33
+ COUNT_COMMAND_NAME: str = "count"
33
34
  ALL_COMMAND_NAMES: tuple[str, ...] = (
34
35
  LIST_COMMAND_NAME,
35
36
  INGEST_COMMAND_NAME,
36
37
  BRIEF_COMMAND_NAME,
37
38
  CLEAR_COMMAND_NAME,
39
+ COUNT_COMMAND_NAME,
38
40
  )
39
41
 
40
42
  SUCCESS_EXIT_CODE: int = 0
41
43
  INVALID_INPUT_EXIT_CODE: int = 2
44
+ BACKLOG_EXCEEDED_EXIT_CODE: int = 1
45
+
46
+ FOLLOWUP_BACKLOG_THRESHOLD: int = 20
47
+ BACKLOG_WITHIN_TEMPLATE: str = "{finding_count} follow-ups recorded, threshold {threshold}"
48
+ BACKLOG_EXCEEDED_TEMPLATE: str = (
49
+ "{finding_count} follow-ups recorded, over the threshold of {threshold}. "
50
+ "Work the backlog down before the next change records more."
51
+ )
42
52
 
43
53
  DIAGNOSTICS_KEY: str = "diagnostics"
44
54
  DIAGNOSTIC_RULE_ID_KEY: str = "rule_id"
55
+ DIAGNOSTIC_CHECK_ID_KEY: str = "check_id"
45
56
  DIAGNOSTIC_MESSAGE_KEY: str = "message"
46
57
  DIAGNOSTIC_LOCATION_KEY: str = "location"
47
58
  LOCATION_PATH_KEY: str = "path"
@@ -51,11 +62,13 @@ LINE_SEPARATOR: str = "\n"
51
62
  EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
52
63
  UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
53
64
  USAGE_TEXT: str = (
54
- "Usage: followup_cli.py <list|ingest|brief|clear> [--repository-root PATH]\n"
65
+ "Usage: followup_cli.py <list|ingest|brief|clear|count> "
66
+ "[--repository-root PATH]\n"
55
67
  " list Name every recorded follow-up\n"
56
68
  " ingest REPORT Record every diagnostic in a policy-lint JSON report\n"
57
69
  " brief Write the task an agent works the follow-ups from\n"
58
- " clear Empty the ledger"
70
+ " clear Empty the ledger\n"
71
+ " count Report the backlog size against the threshold"
59
72
  )
60
73
 
61
74
  BRIEF_HEADER: str = (
@@ -64,9 +77,13 @@ BRIEF_HEADER: str = (
64
77
  "already shipped. Keep the fixes mechanical, touch no behavior, and run "
65
78
  "the repository's own checks before pushing."
66
79
  )
67
- BRIEF_FINDING_TEMPLATE: str = "- [{rule_id}] {file_path}: {message}"
80
+ BRIEF_FINDING_TEMPLATE: str = (
81
+ "- [{check_id}] {file_path}: {message} (recorded at {origin_commit})"
82
+ )
68
83
  BRIEF_FOOTER_TEMPLATE: str = (
69
84
  "Once the pull request is open, empty the ledger with "
70
85
  "`python {command_path} clear`."
71
86
  )
72
- LIST_FINDING_TEMPLATE: str = "{rule_id}\t{file_path}\t{message}"
87
+ LIST_FINDING_TEMPLATE: str = (
88
+ "{check_id}\t{file_path}\t{origin_commit}\t{message}"
89
+ )
@@ -26,6 +26,12 @@ from typing import TextIO
26
26
  from dev_env_scripts_constants.followup_constants import (
27
27
  ABSENT_LOCATION_PATH,
28
28
  ALL_COMMAND_NAMES,
29
+ BACKLOG_EXCEEDED_EXIT_CODE,
30
+ BACKLOG_EXCEEDED_TEMPLATE,
31
+ BACKLOG_WITHIN_TEMPLATE,
32
+ COUNT_COMMAND_NAME,
33
+ DIAGNOSTIC_CHECK_ID_KEY,
34
+ FOLLOWUP_BACKLOG_THRESHOLD,
29
35
  BRIEF_COMMAND_NAME,
30
36
  BRIEF_FINDING_TEMPLATE,
31
37
  BRIEF_FOOTER_TEMPLATE,
@@ -54,8 +60,10 @@ from followup_ledger import (
54
60
  FollowupFinding,
55
61
  all_recorded_findings,
56
62
  followup_ledger_path,
63
+ head_commit,
57
64
  record_followup_finding,
58
65
  )
66
+ from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
59
67
 
60
68
 
61
69
  def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace | None:
@@ -99,7 +107,8 @@ def _formatted_finding(finding_template: str, finding: FollowupFinding) -> str:
99
107
  """Fill one template with a finding's fields.
100
108
 
101
109
  Args:
102
- finding_template: A template naming rule_id, file_path, and message.
110
+ finding_template: A template naming any of rule_id, check_id,
111
+ severity, origin_commit, file_path, and message.
103
112
  finding: The finding whose fields fill the template.
104
113
 
105
114
  Returns:
@@ -107,6 +116,9 @@ def _formatted_finding(finding_template: str, finding: FollowupFinding) -> str:
107
116
  """
108
117
  return finding_template.format(
109
118
  rule_id=finding.rule_id,
119
+ check_id=finding.check_id or finding.rule_id,
120
+ severity=finding.severity,
121
+ origin_commit=finding.origin_commit,
110
122
  file_path=finding.file_path,
111
123
  message=finding.message,
112
124
  )
@@ -156,18 +168,25 @@ def _run_ingest(repository_root: Path, report_path: Path | None, stdout: TextIO)
156
168
  all_diagnostics = parsed_report.get(DIAGNOSTICS_KEY, [])
157
169
  if not isinstance(all_diagnostics, list):
158
170
  return SUCCESS_EXIT_CODE
171
+ origin_commit = head_commit(repository_root)
159
172
  for each_diagnostic in all_diagnostics:
160
- each_finding = _finding_from_diagnostic(each_diagnostic)
173
+ each_finding = _finding_from_diagnostic(each_diagnostic, origin_commit)
161
174
  if each_finding is not None:
162
175
  record_followup_finding(repository_root, each_finding)
163
176
  return SUCCESS_EXIT_CODE
164
177
 
165
178
 
166
- def _finding_from_diagnostic(diagnostic_by_key: object) -> FollowupFinding | None:
179
+ def _finding_from_diagnostic(
180
+ diagnostic_by_key: object, origin_commit: str
181
+ ) -> FollowupFinding | None:
167
182
  """Convert one lint diagnostic into a ledger finding.
168
183
 
184
+ A report reaches this command once its blocking findings are settled, so
185
+ every diagnostic it carries records as a smell.
186
+
169
187
  Args:
170
188
  diagnostic_by_key: One entry of the report's diagnostics list.
189
+ origin_commit: The revision the repository has checked out.
171
190
 
172
191
  Returns:
173
192
  The finding, or None when the entry carries no rule identifier and
@@ -179,7 +198,31 @@ def _finding_from_diagnostic(diagnostic_by_key: object) -> FollowupFinding | Non
179
198
  message = diagnostic_by_key.get(DIAGNOSTIC_MESSAGE_KEY)
180
199
  if not isinstance(rule_id, str) or not isinstance(message, str):
181
200
  return None
182
- return FollowupFinding(rule_id, _diagnostic_file_path(diagnostic_by_key), message)
201
+ return FollowupFinding(
202
+ rule_id,
203
+ _diagnostic_file_path(diagnostic_by_key),
204
+ message,
205
+ _diagnostic_check_id(diagnostic_by_key, rule_id),
206
+ SEVERITY_SMELL,
207
+ origin_commit,
208
+ )
209
+
210
+
211
+ def _diagnostic_check_id(diagnostic_by_key: dict[str, object], rule_id: str) -> str:
212
+ """Return the check identifier a diagnostic names.
213
+
214
+ Args:
215
+ diagnostic_by_key: One entry of the report's diagnostics list.
216
+ rule_id: The rule the diagnostic carries.
217
+
218
+ Returns:
219
+ The diagnostic's check identifier, or the rule identifier when a
220
+ report written before that field carries none.
221
+ """
222
+ check_id = diagnostic_by_key.get(DIAGNOSTIC_CHECK_ID_KEY)
223
+ if isinstance(check_id, str) and check_id:
224
+ return check_id
225
+ return rule_id
183
226
 
184
227
 
185
228
  def _diagnostic_file_path(diagnostic_by_key: dict[str, object]) -> str:
@@ -226,6 +269,35 @@ def _run_brief(repository_root: Path, stdout: TextIO) -> int:
226
269
  return SUCCESS_EXIT_CODE
227
270
 
228
271
 
272
+ def _run_count(repository_root: Path, stdout: TextIO) -> int:
273
+ """Report the backlog size against the threshold.
274
+
275
+ Args:
276
+ repository_root: The repository whose ledger to measure.
277
+ stdout: The stream the line goes to.
278
+
279
+ Returns:
280
+ SUCCESS_EXIT_CODE while the backlog sits at or under the threshold,
281
+ and BACKLOG_EXCEEDED_EXIT_CODE once it passes it.
282
+ """
283
+ finding_count = len(all_recorded_findings(repository_root))
284
+ if finding_count <= FOLLOWUP_BACKLOG_THRESHOLD:
285
+ stdout.write(
286
+ BACKLOG_WITHIN_TEMPLATE.format(
287
+ finding_count=finding_count, threshold=FOLLOWUP_BACKLOG_THRESHOLD
288
+ )
289
+ + LINE_SEPARATOR
290
+ )
291
+ return SUCCESS_EXIT_CODE
292
+ stdout.write(
293
+ BACKLOG_EXCEEDED_TEMPLATE.format(
294
+ finding_count=finding_count, threshold=FOLLOWUP_BACKLOG_THRESHOLD
295
+ )
296
+ + LINE_SEPARATOR
297
+ )
298
+ return BACKLOG_EXCEEDED_EXIT_CODE
299
+
300
+
229
301
  def _run_clear(repository_root: Path) -> int:
230
302
  """Empty the repository's ledger.
231
303
 
@@ -266,6 +338,8 @@ def main(all_arguments: list[str], stdout: TextIO = sys.stdout) -> int:
266
338
  return _run_ingest(repository_root, report_path, stdout)
267
339
  if parsed_arguments.command == BRIEF_COMMAND_NAME:
268
340
  return _run_brief(repository_root, stdout)
341
+ if parsed_arguments.command == COUNT_COMMAND_NAME:
342
+ return _run_count(repository_root, stdout)
269
343
  return _run_clear(repository_root)
270
344
 
271
345
 
@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable
4
4
  from pathlib import Path
5
5
  from types import ModuleType
6
6
 
7
+ from .check_catalog import check_id_for_message
7
8
  from .model import Diagnostic, Document, Location, Severity
8
9
 
9
10
  HookModuleLoader = Callable[[str], ModuleType]
@@ -69,7 +70,13 @@ def _diagnostics_for_messages(
69
70
  None if maybe_line is None else Location(document.path, maybe_line, 1)
70
71
  )
71
72
  all_diagnostics.append(
72
- Diagnostic(rule_id, Severity.ERROR, each_message, location)
73
+ Diagnostic(
74
+ rule_id,
75
+ Severity.ERROR,
76
+ each_message,
77
+ location,
78
+ check_id_for_message(rule_id, each_message),
79
+ )
73
80
  )
74
81
  return tuple(all_diagnostics)
75
82
 
@@ -0,0 +1,44 @@
1
+ """Name the check behind one finding of a bundling rule.
2
+
3
+ ::
4
+
5
+ rule "code-rules", "Line 3: Constant TIMEOUT - move to config/"
6
+ -> code-rules/constant-outside-config
7
+ rule "code-rules", "Line 4: something no entry names"
8
+ -> code-rules/unclassified
9
+ rule "rmtree-safety", any message
10
+ -> rmtree-safety
11
+
12
+ A consumer partitions findings into blocking and advisory ones by this
13
+ identifier. A message no catalog entry names resolves to the unclassified
14
+ identifier, which a partition treats as blocking, so a reworded message
15
+ reports louder rather than quieter.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .config.check_catalog_constants import (
21
+ ALL_BUNDLING_RULE_IDS,
22
+ ALL_CHECK_CATALOG_ENTRIES,
23
+ CHECK_ID_SEPARATOR,
24
+ UNCLASSIFIED_CHECK_NAME,
25
+ )
26
+
27
+
28
+ def check_id_for_message(rule_id: str, message: str) -> str:
29
+ """Return the check identifier one rule and message resolve to.
30
+
31
+ Args:
32
+ rule_id: The rule that raised the finding.
33
+ message: The finding's message text.
34
+
35
+ Returns:
36
+ The rule identifier for a rule that runs one check, or the rule
37
+ identifier joined to the name of the check the message names.
38
+ """
39
+ if rule_id not in ALL_BUNDLING_RULE_IDS:
40
+ return rule_id
41
+ for each_entry in ALL_CHECK_CATALOG_ENTRIES:
42
+ if each_entry.message_marker in message:
43
+ return rule_id + CHECK_ID_SEPARATOR + each_entry.check_name
44
+ return rule_id + CHECK_ID_SEPARATOR + UNCLASSIFIED_CHECK_NAME
@@ -0,0 +1,59 @@
1
+ """The message markers that name which check inside a bundling rule spoke.
2
+
3
+ Most lint rules run one check, so their rule identifier already names the
4
+ check. Two rules bundle many checks behind one identifier, and a consumer that
5
+ classifies a finding by severity needs to know which of them raised it. Each
6
+ entry below pairs a stable fragment of a check's message with the name that
7
+ identifies the check.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import NamedTuple
13
+
14
+
15
+ class CheckCatalogEntry(NamedTuple):
16
+ """One check inside a bundling rule, and the text that identifies it.
17
+
18
+ Attributes:
19
+ message_marker: A fragment the check's message always carries.
20
+ check_name: The name that identifies the check inside its rule.
21
+ """
22
+
23
+ message_marker: str
24
+ check_name: str
25
+
26
+
27
+ CHECK_ID_SEPARATOR: str = "/"
28
+ UNCLASSIFIED_CHECK_NAME: str = "unclassified"
29
+ ALL_BUNDLING_RULE_IDS: frozenset[str] = frozenset({"code-rules", "validators"})
30
+
31
+ ALL_CHECK_CATALOG_ENTRIES: tuple[CheckCatalogEntry, ...] = (
32
+ CheckCatalogEntry(
33
+ "exceeds blocking threshold - split into helpers", "function-length"
34
+ ),
35
+ CheckCatalogEntry(" - consider moving to config/", "function-local-constant"),
36
+ CheckCatalogEntry(" - move to config/", "constant-outside-config"),
37
+ CheckCatalogEntry(" - prefix with all_ (CODE_RULES §5)", "collection-name-prefix"),
38
+ CheckCatalogEntry(" - prefix with each_ (CODE_RULES §5)", "loop-variable-prefix"),
39
+ CheckCatalogEntry("Stuttering collection prefix", "stuttering-collection-prefix"),
40
+ CheckCatalogEntry("string separator", "unnamed-string-separator"),
41
+ CheckCatalogEntry("string magic value", "string-magic-value"),
42
+ CheckCatalogEntry(
43
+ "Block comment found - refactor to self-documenting code",
44
+ "block-comment-added",
45
+ ),
46
+ CheckCatalogEntry(
47
+ "Comment found - refactor to self-documenting code", "comment-added"
48
+ ),
49
+ CheckCatalogEntry("comment added:", "comment-diff"),
50
+ CheckCatalogEntry("docstring carries a", "docstring-runon-sentence"),
51
+ CheckCatalogEntry("summary runs", "docstring-prose-wall"),
52
+ CheckCatalogEntry(
53
+ "return type annotation (CODE_RULES §6)", "missing-return-annotation"
54
+ ),
55
+ CheckCatalogEntry(
56
+ "missing type annotation (CODE_RULES §6)", "missing-type-annotation"
57
+ ),
58
+ CheckCatalogEntry("Single-letter variable", "single-letter-variable"),
59
+ )
@@ -254,15 +254,18 @@ class Diagnostic:
254
254
  severity: Severity
255
255
  message: str
256
256
  location: Location | None = None
257
+ check_id: str | None = None
257
258
 
258
259
  def as_dict(self) -> dict[str, object]:
259
260
  """Return a JSON-compatible diagnostic.
260
261
 
261
262
  Returns:
262
- The diagnostic mapping.
263
+ The diagnostic mapping, whose check_id names the single check
264
+ behind the finding and falls back to the rule identifier.
263
265
  """
264
266
  return {
265
267
  "rule_id": self.rule_id,
268
+ "check_id": self.check_id or self.rule_id,
266
269
  "severity": self.severity.value,
267
270
  "message": self.message,
268
271
  "location": None if self.location is None else self.location.as_dict(),
@@ -7,6 +7,10 @@ import json
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
+ from dev_env_scripts_constants.followup_constants import (
11
+ BACKLOG_EXCEEDED_EXIT_CODE,
12
+ FOLLOWUP_BACKLOG_THRESHOLD,
13
+ )
10
14
  from followup_cli import main
11
15
 
12
16
  _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
@@ -76,7 +80,9 @@ def test_ingest_records_every_located_diagnostic(tmp_path: Path) -> None:
76
80
 
77
81
  assert exit_code == 0
78
82
  assert all_recorded_findings(tmp_path) == (
79
- FollowupFinding("line-length", "src/app.py", "line runs past the limit"),
83
+ FollowupFinding(
84
+ "line-length", "src/app.py", "line runs past the limit", "line-length"
85
+ ),
80
86
  )
81
87
 
82
88
 
@@ -97,7 +103,9 @@ def test_ingest_records_a_diagnostic_without_a_location(tmp_path: Path) -> None:
97
103
  run_command(["ingest", str(report_path), "--repository-root", str(tmp_path)])
98
104
 
99
105
  assert all_recorded_findings(tmp_path) == (
100
- FollowupFinding("inventory-drift", "", "inventory row missing"),
106
+ FollowupFinding(
107
+ "inventory-drift", "", "inventory row missing", "inventory-drift"
108
+ ),
101
109
  )
102
110
 
103
111
 
@@ -154,3 +162,109 @@ def test_an_unknown_command_reports_usage(tmp_path: Path) -> None:
154
162
 
155
163
  assert exit_code == 2
156
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
@@ -15,7 +15,8 @@ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
15
15
  if _hooks_directory not in sys.path:
16
16
  sys.path.insert(0, _hooks_directory)
17
17
 
18
- from followup_ledger import all_recorded_findings
18
+ from followup_ledger import all_recorded_findings, head_commit
19
+ from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
19
20
 
20
21
 
21
22
  def read_workflow(workflow_filename: str) -> str:
@@ -234,6 +235,27 @@ def test_run_gate_passes_a_smell_and_records_it(tmp_path: Path) -> None:
234
235
  ] == ["instruction-git-mode"]
235
236
 
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
+
237
259
  def test_run_gate_records_a_smell_it_finds_beside_a_breaking_finding(
238
260
  tmp_path: Path,
239
261
  ) -> None:
@@ -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"
@@ -29,7 +29,7 @@ from dev_env_scripts_constants.followup_constants import (
29
29
  REGULAR_FILE_RULE_ID,
30
30
  SEVERITY_BY_RULE_ID,
31
31
  )
32
- from followup_ledger import FollowupFinding, record_followup_finding
32
+ from followup_ledger import FollowupFinding, head_commit, record_followup_finding
33
33
  from hooks_constants.followup_ledger_constants import SEVERITY_BREAKING
34
34
 
35
35
  class InstructionFinding(NamedTuple):
@@ -358,7 +358,14 @@ def _record_smell(repository_root: Path, finding: InstructionFinding) -> None:
358
358
  """
359
359
  record_followup_finding(
360
360
  repository_root,
361
- FollowupFinding(finding.rule_id, finding.file_path, finding.message),
361
+ FollowupFinding(
362
+ finding.rule_id,
363
+ finding.file_path,
364
+ finding.message,
365
+ finding.rule_id,
366
+ finding.severity,
367
+ head_commit(repository_root),
368
+ ),
362
369
  )
363
370
  logger.warning(RECORDED_SMELL_TEMPLATE, finding.message)
364
371