claude-dev-env 8.4.3 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +73 -0
- package/hooks/followup_ledger.py +248 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +42 -0
- package/hooks/test_followup_ledger.py +171 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +105 -0
- package/scripts/dev_env_scripts_constants/followup_constants.py +89 -0
- package/scripts/followup_cli.py +347 -0
- package/scripts/policy_lint/adapter_support.py +8 -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 +270 -0
- package/scripts/test_validate_instruction_pairs.py +166 -1
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +151 -0
- package/scripts/validate_instruction_pairs.py +284 -47
|
@@ -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(
|
|
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(),
|