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.
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +154 -18
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +117 -0
- package/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +15 -8
- package/hooks/blocking/state_description_blocker.py +230 -90
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +23 -25
- package/hooks/blocking/test_state_description_blocker.py +43 -0
- package/hooks/followup_ledger.py +109 -4
- package/hooks/hooks_constants/followup_ledger_constants.py +15 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +5 -1
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +85 -0
- package/hooks/test_followup_ledger.py +92 -4
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +35 -2
- package/scripts/dev_env_scripts_constants/followup_constants.py +21 -4
- package/scripts/followup_cli.py +78 -4
- package/scripts/policy_lint/adapter_detectors.py +1 -1
- package/scripts/policy_lint/adapter_support.py +11 -15
- package/scripts/policy_lint/adapters.py +0 -1
- package/scripts/policy_lint/check_catalog.py +44 -0
- package/scripts/policy_lint/config/check_catalog_constants.py +59 -0
- package/scripts/policy_lint/model.py +4 -1
- package/scripts/test_followup_cli.py +116 -2
- package/scripts/test_validate_instruction_pairs.py +23 -1
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
- package/scripts/validate_instruction_pairs.py +9 -2
package/scripts/followup_cli.py
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|