claude-dev-env 8.5.0 → 8.6.1

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.
Files changed (29) hide show
  1. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
  2. package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
  3. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
  4. package/bin/cde.mjs +40 -6
  5. package/bin/cde.test.mjs +25 -0
  6. package/commands/fix-followups.md +15 -8
  7. package/hooks/blocking/state_description_blocker.py +230 -90
  8. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
  9. package/hooks/blocking/test_state_description_blocker.py +43 -0
  10. package/hooks/followup_ledger.py +109 -4
  11. package/hooks/hooks_constants/followup_ledger_constants.py +15 -0
  12. package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
  13. package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
  14. package/hooks/test_followup_ledger.py +92 -4
  15. package/package.json +1 -1
  16. package/rules/flag-non-breaking-findings.md +35 -2
  17. package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
  18. package/scripts/followup_cli.py +78 -4
  19. package/scripts/policy_lint/adapter_detectors.py +1 -1
  20. package/scripts/policy_lint/adapter_support.py +11 -15
  21. package/scripts/policy_lint/adapters.py +0 -1
  22. package/scripts/policy_lint/check_catalog.py +44 -0
  23. package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
  24. package/scripts/policy_lint/model.py +4 -1
  25. package/scripts/test_followup_cli.py +116 -2
  26. package/scripts/test_validate_instruction_pairs.py +23 -1
  27. package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
  28. package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
  29. package/scripts/validate_instruction_pairs.py +9 -2
@@ -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
 
@@ -318,7 +318,7 @@ def state_description_diagnostics(
318
318
  """
319
319
  absolute_path = adapter_support._document_path(repository_root, document)
320
320
  detector_module = load_module("blocking.state_description_blocker")
321
- all_messages = detector_module.find_violations(
321
+ all_messages = detector_module.find_violations_with_lines(
322
322
  document.text, absolute_path.as_posix()
323
323
  )
324
324
  return adapter_support._diagnostics_for_state_messages(document, all_messages)
@@ -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,28 +70,23 @@ 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
 
76
83
 
77
- def _source_line_for_phrase(source_text: str, phrase: str) -> int | None:
78
- normalized_phrase = phrase.casefold()
79
- for each_line_number, each_line in enumerate(source_text.splitlines(), 1):
80
- if normalized_phrase in each_line.casefold():
81
- return each_line_number
82
- return None
83
-
84
-
85
84
  def _diagnostics_for_state_messages(
86
- document: Document, all_messages: Iterable[str]
85
+ document: Document, all_messages: Iterable[tuple[str, int]]
87
86
  ) -> tuple[Diagnostic, ...]:
88
87
  all_diagnostics: list[Diagnostic] = []
89
- for each_message in all_messages:
90
- maybe_line = _source_line_for_phrase(document.text, each_message)
91
- location = (
92
- None if maybe_line is None else Location(document.path, maybe_line, 1)
93
- )
88
+ for each_message, each_line_number in all_messages:
89
+ location = Location(document.path, each_line_number, 1)
94
90
  all_diagnostics.append(
95
91
  Diagnostic("state-description", Severity.ERROR, each_message, location)
96
92
  )
@@ -21,7 +21,6 @@ from .model import Diagnostic, Document, DocumentSet
21
21
  _document_path = adapter_support._document_path
22
22
  _line_number = adapter_support._line_number
23
23
  _diagnostics_for_messages = adapter_support._diagnostics_for_messages
24
- _source_line_for_phrase = adapter_support._source_line_for_phrase
25
24
  _diagnostics_for_state_messages = adapter_support._diagnostics_for_state_messages
26
25
 
27
26
 
@@ -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"
@@ -296,7 +296,10 @@ def test_state_description_diagnostics_maps_and_scopes_phrases(
296
296
  adapters,
297
297
  "_hooks_module",
298
298
  lambda _module_name: types.SimpleNamespace(
299
- find_violations=lambda _text, _path: ["used to", "replaced by"]
299
+ find_violations_with_lines=lambda _text, _path: [
300
+ ("used to", 2),
301
+ ("replaced by", 1),
302
+ ]
300
303
  ),
301
304
  )
302
305
  source_document = Document(