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.
@@ -0,0 +1,105 @@
1
+ # Flag Non-Breaking Findings
2
+
3
+ **When this applies:** Writing or changing a local gate — a git hook, a
4
+ pre-commit entry, a Claude tool-use hook — and deciding what one of its checks
5
+ does when it finds something.
6
+
7
+ ## Rule
8
+
9
+ A gate blocks on a breaking finding and records a non-breaking one.
10
+
11
+ A **breaking** finding means the change is wrong: a bug, a secret in the
12
+ tree, a broken test, a syntax error, an instruction file that fails to
13
+ load. The gate fails and the work stops there.
14
+
15
+ A **smell** finding means the change reads poorly: a length limit, a naming
16
+ convention, a prose term, a comment rule, a structural preference. The gate
17
+ records it and lets the commit, the push, or the tool call proceed. A later
18
+ pull request carries the fix.
19
+
20
+ Every check a gate runs declares which of the two it is. A check that cannot
21
+ say which one it raises is a breaking check until someone decides otherwise.
22
+
23
+ ## Why
24
+
25
+ A smell that blocks a commit stops delivery for something the reader would
26
+ have fixed in the next pass anyway. The writer then reaches for the bypass
27
+ flag, and the bypass turns off the breaking checks beside it. The gate that
28
+ blocks on everything ends up enforcing nothing.
29
+
30
+ Recording the smell keeps both halves working. The breaking checks stay
31
+ sharp because nobody routes around them, and the smells still get fixed
32
+ because the ledger holds them until a pull request does.
33
+
34
+ ## The ledger
35
+
36
+ A recorded finding lands in `.claude/followups/smells.jsonl` at the
37
+ repository root, one JSON object per line. `hooks/followup_ledger.py` writes
38
+ and reads it. Every write there is fail-safe, so a ledger failure leaves the
39
+ gate's decision unchanged.
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
+
54
+ The ledger is per-checkout state and stays out of the repository.
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
+
70
+ ## Reading and clearing it
71
+
72
+ | Command | What it does |
73
+ |---|---|
74
+ | `cde followup list` | Names every recorded follow-up |
75
+ | `cde followup ingest REPORT` | Records the diagnostics a policy-lint JSON report carries |
76
+ | `cde followup brief` | Writes the task an agent fixes them from |
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.
85
+
86
+ The `/fix-followups` command drives the whole pass: it reads the brief, fixes
87
+ each rule group, opens a draft pull request, and clears the ledger.
88
+
89
+ ## Worked example
90
+
91
+ `scripts/validate_instruction_pairs.py` raises five findings and splits them
92
+ on this line. A missing governing `AGENTS.md`, an import text that differs,
93
+ and an instruction path that is not a regular file each stop the instructions
94
+ loading, so the gate fails. A non-canonical filename and a Git mode other than
95
+ 100644 leave the instructions loading, so the gate records them and passes.
96
+
97
+ `SEVERITY_BY_RULE_ID` in that module is the whole declaration. Add a check,
98
+ add its row.
99
+
100
+ ## Sibling rules
101
+
102
+ | Rule | Role |
103
+ |---|---|
104
+ | [`ci-owns-the-gate.md`](ci-owns-the-gate.md) | The full check suite runs once, on CI |
105
+ | [`git-workflow.md`](git-workflow.md) | A red required check blocks the branch |
@@ -0,0 +1,89 @@
1
+ """Constants for the follow-up ledger command and the instruction-pair gate.
2
+
3
+ Script-level scalar constants live in dev_env_scripts_constants alongside
4
+ timing.py and the other per-script constant modules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ FILENAME_RULE_ID: str = "instruction-filename"
10
+ REGULAR_FILE_RULE_ID: str = "instruction-regular-file"
11
+ GIT_MODE_RULE_ID: str = "instruction-git-mode"
12
+ MISSING_AGENTS_RULE_ID: str = "instruction-missing-agents"
13
+ IMPORT_TEXT_RULE_ID: str = "instruction-import-text"
14
+
15
+ SEVERITY_BREAKING: str = "breaking"
16
+ SEVERITY_SMELL: str = "smell"
17
+ SEVERITY_BY_RULE_ID: dict[str, str] = {
18
+ FILENAME_RULE_ID: SEVERITY_SMELL,
19
+ REGULAR_FILE_RULE_ID: SEVERITY_BREAKING,
20
+ GIT_MODE_RULE_ID: SEVERITY_SMELL,
21
+ MISSING_AGENTS_RULE_ID: SEVERITY_BREAKING,
22
+ IMPORT_TEXT_RULE_ID: SEVERITY_BREAKING,
23
+ }
24
+
25
+ GATE_PASSED_EXIT_CODE: int = 0
26
+ GATE_FAILED_EXIT_CODE: int = 1
27
+ RECORDED_SMELL_TEMPLATE: str = "recorded for follow-up: %s"
28
+
29
+ LIST_COMMAND_NAME: str = "list"
30
+ INGEST_COMMAND_NAME: str = "ingest"
31
+ BRIEF_COMMAND_NAME: str = "brief"
32
+ CLEAR_COMMAND_NAME: str = "clear"
33
+ COUNT_COMMAND_NAME: str = "count"
34
+ ALL_COMMAND_NAMES: tuple[str, ...] = (
35
+ LIST_COMMAND_NAME,
36
+ INGEST_COMMAND_NAME,
37
+ BRIEF_COMMAND_NAME,
38
+ CLEAR_COMMAND_NAME,
39
+ COUNT_COMMAND_NAME,
40
+ )
41
+
42
+ SUCCESS_EXIT_CODE: int = 0
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
+ )
52
+
53
+ DIAGNOSTICS_KEY: str = "diagnostics"
54
+ DIAGNOSTIC_RULE_ID_KEY: str = "rule_id"
55
+ DIAGNOSTIC_CHECK_ID_KEY: str = "check_id"
56
+ DIAGNOSTIC_MESSAGE_KEY: str = "message"
57
+ DIAGNOSTIC_LOCATION_KEY: str = "location"
58
+ LOCATION_PATH_KEY: str = "path"
59
+ ABSENT_LOCATION_PATH: str = ""
60
+
61
+ LINE_SEPARATOR: str = "\n"
62
+ EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
63
+ UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
64
+ USAGE_TEXT: str = (
65
+ "Usage: followup_cli.py <list|ingest|brief|clear|count> "
66
+ "[--repository-root PATH]\n"
67
+ " list Name every recorded follow-up\n"
68
+ " ingest REPORT Record every diagnostic in a policy-lint JSON report\n"
69
+ " brief Write the task an agent works the follow-ups from\n"
70
+ " clear Empty the ledger\n"
71
+ " count Report the backlog size against the threshold"
72
+ )
73
+
74
+ BRIEF_HEADER: str = (
75
+ "Fix every finding below in one change, then open a draft pull request "
76
+ "for it. Each finding is non-breaking, so the change that raised it "
77
+ "already shipped. Keep the fixes mechanical, touch no behavior, and run "
78
+ "the repository's own checks before pushing."
79
+ )
80
+ BRIEF_FINDING_TEMPLATE: str = (
81
+ "- [{check_id}] {file_path}: {message} (recorded at {origin_commit})"
82
+ )
83
+ BRIEF_FOOTER_TEMPLATE: str = (
84
+ "Once the pull request is open, empty the ledger with "
85
+ "`python {command_path} clear`."
86
+ )
87
+ LIST_FINDING_TEMPLATE: str = (
88
+ "{check_id}\t{file_path}\t{origin_commit}\t{message}"
89
+ )
@@ -0,0 +1,347 @@
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
+ 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,
35
+ BRIEF_COMMAND_NAME,
36
+ BRIEF_FINDING_TEMPLATE,
37
+ BRIEF_FOOTER_TEMPLATE,
38
+ BRIEF_HEADER,
39
+ DIAGNOSTIC_LOCATION_KEY,
40
+ DIAGNOSTIC_MESSAGE_KEY,
41
+ DIAGNOSTIC_RULE_ID_KEY,
42
+ DIAGNOSTICS_KEY,
43
+ EMPTY_LEDGER_MESSAGE,
44
+ INGEST_COMMAND_NAME,
45
+ INVALID_INPUT_EXIT_CODE,
46
+ LINE_SEPARATOR,
47
+ LIST_COMMAND_NAME,
48
+ LIST_FINDING_TEMPLATE,
49
+ LOCATION_PATH_KEY,
50
+ SUCCESS_EXIT_CODE,
51
+ UNREADABLE_REPORT_TEMPLATE,
52
+ USAGE_TEXT,
53
+ )
54
+
55
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
56
+ if _hooks_directory not in sys.path:
57
+ sys.path.insert(0, _hooks_directory)
58
+
59
+ from followup_ledger import (
60
+ FollowupFinding,
61
+ all_recorded_findings,
62
+ followup_ledger_path,
63
+ head_commit,
64
+ record_followup_finding,
65
+ )
66
+ from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
67
+
68
+
69
+ def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace | None:
70
+ """Parse the command arguments.
71
+
72
+ Args:
73
+ all_arguments: The argument list after the program name.
74
+
75
+ Returns:
76
+ The parsed arguments, or None when the command name is unknown.
77
+ """
78
+ if not all_arguments or all_arguments[0] not in ALL_COMMAND_NAMES:
79
+ return None
80
+ argument_parser = argparse.ArgumentParser(add_help=False)
81
+ argument_parser.add_argument("command", choices=ALL_COMMAND_NAMES)
82
+ argument_parser.add_argument("report_path", nargs="?", default=None)
83
+ argument_parser.add_argument("--repository-root", type=Path, default=Path.cwd())
84
+ return argument_parser.parse_args(all_arguments)
85
+
86
+
87
+ def _run_list(repository_root: Path, stdout: TextIO) -> int:
88
+ """Write one line per recorded finding.
89
+
90
+ Args:
91
+ repository_root: The repository whose ledger to read.
92
+ stdout: The stream the lines go to.
93
+
94
+ Returns:
95
+ SUCCESS_EXIT_CODE.
96
+ """
97
+ all_findings = all_recorded_findings(repository_root)
98
+ if not all_findings:
99
+ stdout.write(EMPTY_LEDGER_MESSAGE + LINE_SEPARATOR)
100
+ return SUCCESS_EXIT_CODE
101
+ for each_finding in all_findings:
102
+ stdout.write(_formatted_finding(LIST_FINDING_TEMPLATE, each_finding) + LINE_SEPARATOR)
103
+ return SUCCESS_EXIT_CODE
104
+
105
+
106
+ def _formatted_finding(finding_template: str, finding: FollowupFinding) -> str:
107
+ """Fill one template with a finding's fields.
108
+
109
+ Args:
110
+ finding_template: A template naming any of rule_id, check_id,
111
+ severity, origin_commit, file_path, and message.
112
+ finding: The finding whose fields fill the template.
113
+
114
+ Returns:
115
+ The filled line.
116
+ """
117
+ return finding_template.format(
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,
122
+ file_path=finding.file_path,
123
+ message=finding.message,
124
+ )
125
+
126
+
127
+ def _parsed_lint_report(report_path: Path) -> dict[str, object] | None:
128
+ """Read one policy-lint JSON report into a mapping.
129
+
130
+ Args:
131
+ report_path: Path of the JSON report to read.
132
+
133
+ Returns:
134
+ The report mapping, or None when the file cannot be read as one.
135
+ """
136
+ try:
137
+ report_text = report_path.read_text(encoding="utf-8")
138
+ parsed_report = json.loads(report_text)
139
+ except (OSError, UnicodeError, json.JSONDecodeError):
140
+ return None
141
+ if not isinstance(parsed_report, dict):
142
+ return None
143
+ return parsed_report
144
+
145
+
146
+ def _run_ingest(repository_root: Path, report_path: Path | None, stdout: TextIO) -> int:
147
+ """Record every diagnostic a policy-lint JSON report carries.
148
+
149
+ Args:
150
+ repository_root: The repository whose ledger receives the findings.
151
+ report_path: Path of the JSON report to read.
152
+ stdout: The stream messages go to.
153
+
154
+ Returns:
155
+ SUCCESS_EXIT_CODE once the report is recorded, or
156
+ INVALID_INPUT_EXIT_CODE when the report cannot be read.
157
+ """
158
+ if report_path is None:
159
+ stdout.write(USAGE_TEXT + LINE_SEPARATOR)
160
+ return INVALID_INPUT_EXIT_CODE
161
+ parsed_report = _parsed_lint_report(report_path)
162
+ if parsed_report is None:
163
+ stdout.write(
164
+ UNREADABLE_REPORT_TEMPLATE.format(report_path=report_path) + LINE_SEPARATOR
165
+ )
166
+ return INVALID_INPUT_EXIT_CODE
167
+
168
+ all_diagnostics = parsed_report.get(DIAGNOSTICS_KEY, [])
169
+ if not isinstance(all_diagnostics, list):
170
+ return SUCCESS_EXIT_CODE
171
+ origin_commit = head_commit(repository_root)
172
+ for each_diagnostic in all_diagnostics:
173
+ each_finding = _finding_from_diagnostic(each_diagnostic, origin_commit)
174
+ if each_finding is not None:
175
+ record_followup_finding(repository_root, each_finding)
176
+ return SUCCESS_EXIT_CODE
177
+
178
+
179
+ def _finding_from_diagnostic(
180
+ diagnostic_by_key: object, origin_commit: str
181
+ ) -> FollowupFinding | None:
182
+ """Convert one lint diagnostic into a ledger finding.
183
+
184
+ A report reaches this command once its blocking findings are settled, so
185
+ every diagnostic it carries records as a smell.
186
+
187
+ Args:
188
+ diagnostic_by_key: One entry of the report's diagnostics list.
189
+ origin_commit: The revision the repository has checked out.
190
+
191
+ Returns:
192
+ The finding, or None when the entry carries no rule identifier and
193
+ message.
194
+ """
195
+ if not isinstance(diagnostic_by_key, dict):
196
+ return None
197
+ rule_id = diagnostic_by_key.get(DIAGNOSTIC_RULE_ID_KEY)
198
+ message = diagnostic_by_key.get(DIAGNOSTIC_MESSAGE_KEY)
199
+ if not isinstance(rule_id, str) or not isinstance(message, str):
200
+ return None
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
226
+
227
+
228
+ def _diagnostic_file_path(diagnostic_by_key: dict[str, object]) -> str:
229
+ """Return the path a diagnostic names, or an empty string.
230
+
231
+ Args:
232
+ diagnostic_by_key: One entry of the report's diagnostics list.
233
+
234
+ Returns:
235
+ The diagnostic's path, or ABSENT_LOCATION_PATH when it names none.
236
+ """
237
+ location_by_key = diagnostic_by_key.get(DIAGNOSTIC_LOCATION_KEY)
238
+ if not isinstance(location_by_key, dict):
239
+ return ABSENT_LOCATION_PATH
240
+ location_path = location_by_key.get(LOCATION_PATH_KEY)
241
+ if not isinstance(location_path, str):
242
+ return ABSENT_LOCATION_PATH
243
+ return location_path
244
+
245
+
246
+ def _run_brief(repository_root: Path, stdout: TextIO) -> int:
247
+ """Write the task an agent works the recorded follow-ups from.
248
+
249
+ Args:
250
+ repository_root: The repository whose ledger to read.
251
+ stdout: The stream the brief goes to.
252
+
253
+ Returns:
254
+ SUCCESS_EXIT_CODE.
255
+ """
256
+ all_findings = all_recorded_findings(repository_root)
257
+ if not all_findings:
258
+ stdout.write(EMPTY_LEDGER_MESSAGE + LINE_SEPARATOR)
259
+ return SUCCESS_EXIT_CODE
260
+
261
+ all_lines = [BRIEF_HEADER, ""]
262
+ all_lines.extend(
263
+ _formatted_finding(BRIEF_FINDING_TEMPLATE, each_finding)
264
+ for each_finding in all_findings
265
+ )
266
+ all_lines.append("")
267
+ all_lines.append(BRIEF_FOOTER_TEMPLATE.format(command_path=Path(__file__).resolve()))
268
+ stdout.write(LINE_SEPARATOR.join(all_lines) + LINE_SEPARATOR)
269
+ return SUCCESS_EXIT_CODE
270
+
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
+
301
+ def _run_clear(repository_root: Path) -> int:
302
+ """Empty the repository's ledger.
303
+
304
+ Args:
305
+ repository_root: The repository whose ledger to empty.
306
+
307
+ Returns:
308
+ SUCCESS_EXIT_CODE.
309
+ """
310
+ try:
311
+ followup_ledger_path(repository_root).unlink(missing_ok=True)
312
+ except OSError:
313
+ return SUCCESS_EXIT_CODE
314
+ return SUCCESS_EXIT_CODE
315
+
316
+
317
+ def main(all_arguments: list[str], stdout: TextIO = sys.stdout) -> int:
318
+ """Run one follow-up command.
319
+
320
+ Args:
321
+ all_arguments: The argument list after the program name.
322
+ stdout: The stream every message goes to.
323
+
324
+ Returns:
325
+ The command's exit code.
326
+ """
327
+ parsed_arguments = _parse_arguments(all_arguments)
328
+ if parsed_arguments is None:
329
+ stdout.write(USAGE_TEXT + LINE_SEPARATOR)
330
+ return INVALID_INPUT_EXIT_CODE
331
+
332
+ repository_root = parsed_arguments.repository_root.resolve()
333
+ if parsed_arguments.command == LIST_COMMAND_NAME:
334
+ return _run_list(repository_root, stdout)
335
+ if parsed_arguments.command == INGEST_COMMAND_NAME:
336
+ raw_report_path = parsed_arguments.report_path
337
+ report_path = None if raw_report_path is None else Path(raw_report_path)
338
+ return _run_ingest(repository_root, report_path, stdout)
339
+ if parsed_arguments.command == BRIEF_COMMAND_NAME:
340
+ return _run_brief(repository_root, stdout)
341
+ if parsed_arguments.command == COUNT_COMMAND_NAME:
342
+ return _run_count(repository_root, stdout)
343
+ return _run_clear(repository_root)
344
+
345
+
346
+ if __name__ == "__main__":
347
+ raise SystemExit(main(sys.argv[1:]))
@@ -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(),