claude-dev-env 8.5.0 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cde.mjs +40 -6
- package/bin/cde.test.mjs +25 -0
- package/commands/fix-followups.md +15 -8
- package/hooks/followup_ledger.py +109 -4
- package/hooks/hooks_constants/followup_ledger_constants.py +15 -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_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 +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/validate_instruction_pairs.py +9 -2
package/bin/cde.mjs
CHANGED
|
@@ -7,16 +7,18 @@ import path from "node:path";
|
|
|
7
7
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
8
|
const lintScriptPath = path.join(packageRoot, "scripts", "cde_lint.py");
|
|
9
9
|
const verificationScriptPath = path.join(packageRoot, "scripts", "local_verification", "cli.py");
|
|
10
|
+
const followupScriptPath = path.join(packageRoot, "scripts", "followup_cli.py");
|
|
10
11
|
const posixSignalStatusOffset = 128;
|
|
11
12
|
const lintCommandName = "lint";
|
|
12
13
|
const verifyCommandName = "verify";
|
|
14
|
+
const followupCommandName = "followup";
|
|
13
15
|
const pythonOptionName = "--python";
|
|
14
16
|
const pythonEnvironmentName = "CDE_PYTHON";
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
export function createHelpText() {
|
|
18
20
|
return [
|
|
19
|
-
"Usage: cde <lint|verify> [options]",
|
|
21
|
+
"Usage: cde <lint|verify|followup> [options]",
|
|
20
22
|
"",
|
|
21
23
|
"Run the policy linter with cde lint. Source modes are mutually exclusive:",
|
|
22
24
|
" --files PATH [PATH ...] Current worktree files",
|
|
@@ -37,6 +39,13 @@ export function createHelpText() {
|
|
|
37
39
|
" --base SHA Trusted base revision",
|
|
38
40
|
" --output PATH JSON verification report",
|
|
39
41
|
"",
|
|
42
|
+
"Read the follow-up ledger of non-breaking findings with cde followup:",
|
|
43
|
+
" cde followup list Name every recorded follow-up",
|
|
44
|
+
" cde followup ingest PATH Record a policy-lint JSON report",
|
|
45
|
+
" cde followup brief Write the task an agent fixes them from",
|
|
46
|
+
" cde followup count Report the backlog against the threshold",
|
|
47
|
+
" cde followup clear Empty the ledger",
|
|
48
|
+
"",
|
|
40
49
|
"Python may also be selected with CDE_PYTHON.",
|
|
41
50
|
].join("\n");
|
|
42
51
|
}
|
|
@@ -75,6 +84,14 @@ export function buildVerifyCommand(interpreter, forwardedArguments) {
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
|
|
87
|
+
export function buildFollowupCommand(interpreter, forwardedArguments) {
|
|
88
|
+
return {
|
|
89
|
+
executable: interpreter,
|
|
90
|
+
arguments: [followupScriptPath, ...forwardedArguments],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
78
95
|
function takePythonOverride(allArguments) {
|
|
79
96
|
const remainingArguments = [];
|
|
80
97
|
let interpreterOverride;
|
|
@@ -170,12 +187,31 @@ export function runVerifyCommand(command, dependencies = {}) {
|
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
|
|
190
|
+
export function runFollowupCommand(command, dependencies = {}) {
|
|
191
|
+
return runChildCommand(command, "Unable to start the follow-up command.", dependencies);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
const buildCommandByName = {
|
|
196
|
+
[lintCommandName]: buildLintCommand,
|
|
197
|
+
[verifyCommandName]: buildVerifyCommand,
|
|
198
|
+
[followupCommandName]: buildFollowupCommand,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
const runCommandByName = {
|
|
203
|
+
[lintCommandName]: runLintCommand,
|
|
204
|
+
[verifyCommandName]: runVerifyCommand,
|
|
205
|
+
[followupCommandName]: runFollowupCommand,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
|
|
173
209
|
export async function main(argumentsList = process.argv.slice(2), dependencies = {}) {
|
|
174
210
|
if (argumentsList.length === 0 || argumentsList.includes("--help") || argumentsList.includes("-h")) {
|
|
175
211
|
process.stdout.write(`${createHelpText()}\n`);
|
|
176
212
|
return 0;
|
|
177
213
|
}
|
|
178
|
-
if (!
|
|
214
|
+
if (!Object.hasOwn(buildCommandByName, argumentsList[0])) {
|
|
179
215
|
process.stderr.write(`${createHelpText()}\n`);
|
|
180
216
|
return 2;
|
|
181
217
|
}
|
|
@@ -190,10 +226,8 @@ export async function main(argumentsList = process.argv.slice(2), dependencies =
|
|
|
190
226
|
process.stderr.write("No usable Python interpreter found. Use --python or CDE_PYTHON.\n");
|
|
191
227
|
return 2;
|
|
192
228
|
}
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const runCommand = dependencies.runCommand
|
|
196
|
-
?? (argumentsList[0] === lintCommandName ? runLintCommand : runVerifyCommand);
|
|
229
|
+
const command = buildCommandByName[argumentsList[0]](interpreter, remainingArguments);
|
|
230
|
+
const runCommand = dependencies.runCommand ?? runCommandByName[argumentsList[0]];
|
|
197
231
|
return await runCommand(command, dependencies);
|
|
198
232
|
}
|
|
199
233
|
|
package/bin/cde.test.mjs
CHANGED
|
@@ -268,3 +268,28 @@ test("reports a synchronous child start failure as invalid input", async () => {
|
|
|
268
268
|
assert.equal(exitCode, 2);
|
|
269
269
|
assert.deepEqual(messages, ["Unable to start the policy linter.\n"]);
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
test("dispatches followup to the follow-up ledger command", async () => {
|
|
274
|
+
let receivedCommand;
|
|
275
|
+
const exitCode = await main(
|
|
276
|
+
["followup", "--python", "python-test", "list"],
|
|
277
|
+
{
|
|
278
|
+
findPython: async () => "unused-python",
|
|
279
|
+
runCommand: async (command) => {
|
|
280
|
+
receivedCommand = command;
|
|
281
|
+
return 0;
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
assert.equal(exitCode, 0);
|
|
286
|
+
assert.equal(receivedCommand.executable, "python-test");
|
|
287
|
+
assert.match(receivedCommand.arguments[0], /followup_cli\.py$/);
|
|
288
|
+
assert.deepEqual(receivedCommand.arguments.slice(1), ["list"]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
test("help names the followup command", () => {
|
|
293
|
+
assert.match(createHelpText(), /cde <lint\|verify\|followup>/);
|
|
294
|
+
assert.match(createHelpText(), /cde followup count/);
|
|
295
|
+
});
|
|
@@ -18,14 +18,20 @@ Run `cde followup brief --repository-root <root>`.
|
|
|
18
18
|
When it prints `no follow-ups recorded`, report that and stop. There is no
|
|
19
19
|
work.
|
|
20
20
|
|
|
21
|
-
Otherwise the brief lists one line per finding, each carrying the
|
|
22
|
-
identifier, the file, and the
|
|
21
|
+
Otherwise the brief lists one line per finding, each carrying the check
|
|
22
|
+
identifier, the file, the message, and the revision the finding was recorded
|
|
23
|
+
against.
|
|
23
24
|
|
|
24
25
|
## 2. Group the findings
|
|
25
26
|
|
|
26
|
-
Group the lines by
|
|
27
|
+
Group the lines by check identifier. A check with several findings takes one
|
|
27
28
|
pass over every file it names, so the same fix shape lands once.
|
|
28
29
|
|
|
30
|
+
A finding whose check identifier ends in `unclassified` comes from a check the
|
|
31
|
+
catalog does not name yet. Fix it like any other, and add its entry to
|
|
32
|
+
`scripts/policy_lint/config/check_catalog_constants.py` with a sample in the
|
|
33
|
+
catalog's synchronization test.
|
|
34
|
+
|
|
29
35
|
Read each named file before editing it. A recorded finding names the state of
|
|
30
36
|
the file at the moment the gate ran, so confirm the finding still holds.
|
|
31
37
|
Keep a finding the current file still carries. Drop the rest.
|
|
@@ -41,14 +47,15 @@ finding names a generated file, regenerate it with the repository's tooling.
|
|
|
41
47
|
|
|
42
48
|
## 4. Open the pull request
|
|
43
49
|
|
|
44
|
-
Branch from the repository's default branch. Commit each
|
|
45
|
-
|
|
50
|
+
Branch from the repository's default branch. Commit each group on its own,
|
|
51
|
+
with a message naming the check identifier and the files.
|
|
46
52
|
|
|
47
53
|
Open the pull request as a draft. Write the body as:
|
|
48
54
|
|
|
49
55
|
- One `Before:` paragraph saying which findings stood open.
|
|
50
56
|
- One `After:` paragraph saying what the tree now holds.
|
|
51
|
-
- A short `How` paragraph naming the
|
|
57
|
+
- A short `How` paragraph naming the groups, the files each touched, and the
|
|
58
|
+
revisions the findings were recorded against.
|
|
52
59
|
|
|
53
60
|
Push, then confirm the required checks report on the branch head.
|
|
54
61
|
|
|
@@ -62,5 +69,5 @@ A finding the pull request left alone goes back in the ledger with
|
|
|
62
69
|
|
|
63
70
|
## Report
|
|
64
71
|
|
|
65
|
-
Name the pull request link, the
|
|
66
|
-
|
|
72
|
+
Name the pull request link, the groups fixed, the findings dropped because
|
|
73
|
+
the current file reads clean, and anything left in the ledger.
|
package/hooks/followup_ledger.py
CHANGED
|
@@ -22,12 +22,20 @@ if _hooks_directory not in sys.path:
|
|
|
22
22
|
sys.path.insert(0, _hooks_directory)
|
|
23
23
|
|
|
24
24
|
from hooks_constants.followup_ledger_constants import (
|
|
25
|
-
|
|
25
|
+
ABSENT_ORIGIN_COMMIT,
|
|
26
26
|
ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS,
|
|
27
|
+
ALL_GIT_HEAD_PATH_SEGMENTS,
|
|
28
|
+
CHECK_ID_KEY,
|
|
29
|
+
FILE_PATH_KEY,
|
|
30
|
+
GIT_DIRECTORY_NAME,
|
|
31
|
+
GIT_REFERENCE_PREFIX,
|
|
27
32
|
LEDGER_APPEND_MODE,
|
|
28
33
|
LEDGER_ENCODING,
|
|
29
34
|
MESSAGE_KEY,
|
|
35
|
+
ORIGIN_COMMIT_KEY,
|
|
30
36
|
RULE_ID_KEY,
|
|
37
|
+
SEVERITY_KEY,
|
|
38
|
+
SEVERITY_SMELL,
|
|
31
39
|
)
|
|
32
40
|
|
|
33
41
|
|
|
@@ -35,14 +43,33 @@ class FollowupFinding(NamedTuple):
|
|
|
35
43
|
"""One non-breaking finding a later pull request resolves.
|
|
36
44
|
|
|
37
45
|
Attributes:
|
|
38
|
-
rule_id:
|
|
46
|
+
rule_id: Identifier of the rule that raised the finding.
|
|
39
47
|
file_path: Repository-relative path the finding names.
|
|
40
48
|
message: The text a reader acts on.
|
|
49
|
+
check_id: Identifier of the single check behind the finding. An empty
|
|
50
|
+
value reads back as the rule identifier.
|
|
51
|
+
severity: The class the gate put the finding in.
|
|
52
|
+
origin_commit: The revision checked out when the finding was recorded,
|
|
53
|
+
which groups a follow-up pull request by the change that raised it.
|
|
41
54
|
"""
|
|
42
55
|
|
|
43
56
|
rule_id: str
|
|
44
57
|
file_path: str
|
|
45
58
|
message: str
|
|
59
|
+
check_id: str = ""
|
|
60
|
+
severity: str = SEVERITY_SMELL
|
|
61
|
+
origin_commit: str = ABSENT_ORIGIN_COMMIT
|
|
62
|
+
|
|
63
|
+
def tracking_key(self) -> tuple[str, str, str]:
|
|
64
|
+
"""Return the fields that make this finding the same one over time.
|
|
65
|
+
|
|
66
|
+
The origin commit stays out of the key, so a smell seen again under a
|
|
67
|
+
later revision keeps the revision that first raised it.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The check identifier, path, and message.
|
|
71
|
+
"""
|
|
72
|
+
return (self.check_id or self.rule_id, self.file_path, self.message)
|
|
46
73
|
|
|
47
74
|
|
|
48
75
|
def followup_ledger_path(repository_root: Path) -> Path:
|
|
@@ -69,7 +96,11 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
|
|
|
69
96
|
repository_root: The repository whose ledger receives the finding.
|
|
70
97
|
finding: The non-breaking finding to record.
|
|
71
98
|
"""
|
|
72
|
-
|
|
99
|
+
all_recorded_keys = {
|
|
100
|
+
each_finding.tracking_key()
|
|
101
|
+
for each_finding in all_recorded_findings(repository_root)
|
|
102
|
+
}
|
|
103
|
+
if finding.tracking_key() in all_recorded_keys:
|
|
73
104
|
return
|
|
74
105
|
|
|
75
106
|
ledger_path = followup_ledger_path(repository_root)
|
|
@@ -78,6 +109,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
|
|
|
78
109
|
RULE_ID_KEY: finding.rule_id,
|
|
79
110
|
FILE_PATH_KEY: finding.file_path,
|
|
80
111
|
MESSAGE_KEY: finding.message,
|
|
112
|
+
CHECK_ID_KEY: finding.check_id or finding.rule_id,
|
|
113
|
+
SEVERITY_KEY: finding.severity,
|
|
114
|
+
ORIGIN_COMMIT_KEY: finding.origin_commit,
|
|
81
115
|
}
|
|
82
116
|
)
|
|
83
117
|
try:
|
|
@@ -140,4 +174,75 @@ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
|
|
|
140
174
|
return None
|
|
141
175
|
if not isinstance(message, str):
|
|
142
176
|
return None
|
|
143
|
-
return FollowupFinding(
|
|
177
|
+
return FollowupFinding(
|
|
178
|
+
rule_id,
|
|
179
|
+
file_path,
|
|
180
|
+
message,
|
|
181
|
+
_text_field(parsed_record, CHECK_ID_KEY, rule_id),
|
|
182
|
+
_text_field(parsed_record, SEVERITY_KEY, SEVERITY_SMELL),
|
|
183
|
+
_text_field(parsed_record, ORIGIN_COMMIT_KEY, ABSENT_ORIGIN_COMMIT),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _text_field(
|
|
188
|
+
all_record_fields: dict[str, object], field_key: str, fallback: str
|
|
189
|
+
) -> str:
|
|
190
|
+
"""Read one text field of a ledger record.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
all_record_fields: The mapping one ledger line parsed into.
|
|
194
|
+
field_key: The key to read.
|
|
195
|
+
fallback: The value a record written before this field carried it takes.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The field's text, or the fallback.
|
|
199
|
+
"""
|
|
200
|
+
field_value = all_record_fields.get(field_key)
|
|
201
|
+
if isinstance(field_value, str) and field_value:
|
|
202
|
+
return field_value
|
|
203
|
+
return fallback
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def head_commit(repository_root: Path) -> str:
|
|
207
|
+
"""Return the revision one repository has checked out.
|
|
208
|
+
|
|
209
|
+
::
|
|
210
|
+
|
|
211
|
+
.git/HEAD holding "ref: refs/heads/main" -> the sha in refs/heads/main
|
|
212
|
+
.git/HEAD holding a sha -> that sha
|
|
213
|
+
no .git directory -> ""
|
|
214
|
+
|
|
215
|
+
The revision comes from the files git writes rather than from a git
|
|
216
|
+
process, so recording a finding starts no subprocess.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
repository_root: The repository whose revision to read.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The checked-out revision, or an empty string when none can be read.
|
|
223
|
+
"""
|
|
224
|
+
head_path = repository_root.joinpath(*ALL_GIT_HEAD_PATH_SEGMENTS)
|
|
225
|
+
head_text = _file_text(head_path)
|
|
226
|
+
if head_text is None:
|
|
227
|
+
return ABSENT_ORIGIN_COMMIT
|
|
228
|
+
if not head_text.startswith(GIT_REFERENCE_PREFIX):
|
|
229
|
+
return head_text
|
|
230
|
+
reference_path = repository_root / GIT_DIRECTORY_NAME / head_text[
|
|
231
|
+
len(GIT_REFERENCE_PREFIX) :
|
|
232
|
+
]
|
|
233
|
+
return _file_text(reference_path) or ABSENT_ORIGIN_COMMIT
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _file_text(file_path: Path) -> str | None:
|
|
237
|
+
"""Read one small git file into stripped text.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
file_path: The file to read.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The stripped text, or None when the file cannot be read.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
return file_path.read_text(encoding=LEDGER_ENCODING).strip()
|
|
247
|
+
except (OSError, UnicodeError):
|
|
248
|
+
return None
|
|
@@ -9,6 +9,13 @@ __all__ = [
|
|
|
9
9
|
"RULE_ID_KEY",
|
|
10
10
|
"FILE_PATH_KEY",
|
|
11
11
|
"MESSAGE_KEY",
|
|
12
|
+
"CHECK_ID_KEY",
|
|
13
|
+
"SEVERITY_KEY",
|
|
14
|
+
"ORIGIN_COMMIT_KEY",
|
|
15
|
+
"ABSENT_ORIGIN_COMMIT",
|
|
16
|
+
"ALL_GIT_HEAD_PATH_SEGMENTS",
|
|
17
|
+
"GIT_DIRECTORY_NAME",
|
|
18
|
+
"GIT_REFERENCE_PREFIX",
|
|
12
19
|
"SEVERITY_BREAKING",
|
|
13
20
|
"SEVERITY_SMELL",
|
|
14
21
|
"ALL_SEVERITY_NAMES",
|
|
@@ -21,6 +28,14 @@ LEDGER_APPEND_MODE: str = "a"
|
|
|
21
28
|
RULE_ID_KEY: str = "rule_id"
|
|
22
29
|
FILE_PATH_KEY: str = "file_path"
|
|
23
30
|
MESSAGE_KEY: str = "message"
|
|
31
|
+
CHECK_ID_KEY: str = "check_id"
|
|
32
|
+
SEVERITY_KEY: str = "severity"
|
|
33
|
+
ORIGIN_COMMIT_KEY: str = "origin_commit"
|
|
34
|
+
|
|
35
|
+
ABSENT_ORIGIN_COMMIT: str = ""
|
|
36
|
+
GIT_DIRECTORY_NAME: str = ".git"
|
|
37
|
+
ALL_GIT_HEAD_PATH_SEGMENTS: tuple[str, ...] = (GIT_DIRECTORY_NAME, "HEAD")
|
|
38
|
+
GIT_REFERENCE_PREFIX: str = "ref: "
|
|
24
39
|
|
|
25
40
|
SEVERITY_BREAKING: str = "breaking"
|
|
26
41
|
SEVERITY_SMELL: str = "smell"
|
|
@@ -12,6 +12,7 @@ from followup_ledger import (
|
|
|
12
12
|
FollowupFinding,
|
|
13
13
|
all_recorded_findings,
|
|
14
14
|
followup_ledger_path,
|
|
15
|
+
head_commit,
|
|
15
16
|
record_followup_finding,
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -21,6 +22,7 @@ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) ->
|
|
|
21
22
|
rule_id="instruction-filename",
|
|
22
23
|
file_path="docs/Claude.md",
|
|
23
24
|
message="Use the canonical filename: docs/Claude.md",
|
|
25
|
+
check_id="instruction-filename",
|
|
24
26
|
)
|
|
25
27
|
|
|
26
28
|
record_followup_finding(tmp_path, finding)
|
|
@@ -29,8 +31,12 @@ def test_record_followup_finding_appends_one_readable_record(tmp_path: Path) ->
|
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -> None:
|
|
32
|
-
first = FollowupFinding(
|
|
33
|
-
|
|
34
|
+
first = FollowupFinding(
|
|
35
|
+
"instruction-filename", "docs/Claude.md", "wrong name", "instruction-filename"
|
|
36
|
+
)
|
|
37
|
+
second = FollowupFinding(
|
|
38
|
+
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
39
|
+
)
|
|
34
40
|
|
|
35
41
|
record_followup_finding(tmp_path, first)
|
|
36
42
|
record_followup_finding(tmp_path, second)
|
|
@@ -39,7 +45,9 @@ def test_record_followup_finding_keeps_distinct_findings_apart(tmp_path: Path) -
|
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def test_record_followup_finding_records_a_repeat_once(tmp_path: Path) -> None:
|
|
42
|
-
finding = FollowupFinding(
|
|
48
|
+
finding = FollowupFinding(
|
|
49
|
+
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
50
|
+
)
|
|
43
51
|
|
|
44
52
|
record_followup_finding(tmp_path, finding)
|
|
45
53
|
record_followup_finding(tmp_path, finding)
|
|
@@ -52,7 +60,9 @@ def test_all_recorded_findings_is_empty_without_a_ledger(tmp_path: Path) -> None
|
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
def test_all_recorded_findings_skips_an_unreadable_line(tmp_path: Path) -> None:
|
|
55
|
-
finding = FollowupFinding(
|
|
63
|
+
finding = FollowupFinding(
|
|
64
|
+
"instruction-mode", "docs/AGENTS.md", "wrong mode", "instruction-mode"
|
|
65
|
+
)
|
|
56
66
|
record_followup_finding(tmp_path, finding)
|
|
57
67
|
ledger_path = followup_ledger_path(tmp_path)
|
|
58
68
|
with ledger_path.open("a", encoding="utf-8") as ledger_file:
|
|
@@ -81,3 +91,81 @@ def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> Non
|
|
|
81
91
|
)
|
|
82
92
|
|
|
83
93
|
assert all_recorded_findings(tmp_path) == ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_recorded_finding_carries_its_check_severity_and_origin(
|
|
97
|
+
tmp_path: Path,
|
|
98
|
+
) -> None:
|
|
99
|
+
finding = FollowupFinding(
|
|
100
|
+
rule_id="code-rules",
|
|
101
|
+
file_path="scripts/run.py",
|
|
102
|
+
message="Line 3: Constant TIMEOUT - move to config/",
|
|
103
|
+
check_id="code-rules/constant-outside-config",
|
|
104
|
+
severity="smell",
|
|
105
|
+
origin_commit="abc1234",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
record_followup_finding(tmp_path, finding)
|
|
109
|
+
|
|
110
|
+
assert all_recorded_findings(tmp_path) == (finding,)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_record_written_without_the_tracking_fields_still_reads(
|
|
114
|
+
tmp_path: Path,
|
|
115
|
+
) -> None:
|
|
116
|
+
ledger_path = followup_ledger_path(tmp_path)
|
|
117
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
ledger_path.write_text(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"rule_id": "instruction-mode",
|
|
122
|
+
"file_path": "docs/AGENTS.md",
|
|
123
|
+
"message": "wrong mode",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
+ "\n",
|
|
127
|
+
encoding="utf-8",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
recorded_finding = all_recorded_findings(tmp_path)[0]
|
|
131
|
+
|
|
132
|
+
assert recorded_finding.check_id == "instruction-mode"
|
|
133
|
+
assert recorded_finding.severity == "smell"
|
|
134
|
+
assert recorded_finding.origin_commit == ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_one_smell_seen_under_a_later_commit_stays_one_record(tmp_path: Path) -> None:
|
|
138
|
+
first = FollowupFinding(
|
|
139
|
+
"code-rules", "run.py", "Line 3: x", "code-rules/x", "smell", "aaa1111"
|
|
140
|
+
)
|
|
141
|
+
same_smell_later = first._replace(origin_commit="bbb2222")
|
|
142
|
+
|
|
143
|
+
record_followup_finding(tmp_path, first)
|
|
144
|
+
record_followup_finding(tmp_path, same_smell_later)
|
|
145
|
+
|
|
146
|
+
assert all_recorded_findings(tmp_path) == (first,)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_head_commit_reads_the_checked_out_revision(tmp_path: Path) -> None:
|
|
150
|
+
git_directory = tmp_path / ".git"
|
|
151
|
+
(git_directory / "refs" / "heads").mkdir(parents=True)
|
|
152
|
+
(git_directory / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
|
153
|
+
(git_directory / "refs" / "heads" / "main").write_text(
|
|
154
|
+
"0123456789abcdef0123456789abcdef01234567\n", encoding="utf-8"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert head_commit(tmp_path) == "0123456789abcdef0123456789abcdef01234567"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_head_commit_reads_a_detached_revision(tmp_path: Path) -> None:
|
|
161
|
+
git_directory = tmp_path / ".git"
|
|
162
|
+
git_directory.mkdir()
|
|
163
|
+
(git_directory / "HEAD").write_text(
|
|
164
|
+
"0123456789abcdef0123456789abcdef01234567\n", encoding="utf-8"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert head_commit(tmp_path) == "0123456789abcdef0123456789abcdef01234567"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_head_commit_is_empty_outside_a_repository(tmp_path: Path) -> None:
|
|
171
|
+
assert head_commit(tmp_path) == ""
|
package/package.json
CHANGED
|
@@ -34,13 +34,39 @@ because the ledger holds them until a pull request does.
|
|
|
34
34
|
## The ledger
|
|
35
35
|
|
|
36
36
|
A recorded finding lands in `.claude/followups/smells.jsonl` at the
|
|
37
|
-
repository root, one JSON object per line
|
|
38
|
-
repository-relative path, and the message. `hooks/followup_ledger.py` writes
|
|
37
|
+
repository root, one JSON object per line. `hooks/followup_ledger.py` writes
|
|
39
38
|
and reads it. Every write there is fail-safe, so a ledger failure leaves the
|
|
40
39
|
gate's decision unchanged.
|
|
41
40
|
|
|
41
|
+
| Field | What it carries |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `rule_id` | The rule that raised the finding |
|
|
44
|
+
| `check_id` | The single check behind it, which a severity table keys on |
|
|
45
|
+
| `file_path` | The repository-relative path the finding names |
|
|
46
|
+
| `message` | The text a reader acts on |
|
|
47
|
+
| `severity` | The class the gate put it in |
|
|
48
|
+
| `origin_commit` | The revision checked out when it was recorded |
|
|
49
|
+
|
|
50
|
+
The origin commit groups a follow-up pull request by the change that raised
|
|
51
|
+
the findings. A smell seen again under a later revision keeps the revision
|
|
52
|
+
that first raised it, so one smell stays one record.
|
|
53
|
+
|
|
42
54
|
The ledger is per-checkout state and stays out of the repository.
|
|
43
55
|
|
|
56
|
+
## The check identifier
|
|
57
|
+
|
|
58
|
+
Most lint rules run one check, so their rule identifier already names it. The
|
|
59
|
+
`code-rules` and `validators` rules bundle many checks behind one identifier.
|
|
60
|
+
`scripts/policy_lint/check_catalog.py` resolves those to a `<rule>/<check>`
|
|
61
|
+
identifier, which `cde lint --format json` emits as `check_id` on every
|
|
62
|
+
diagnostic. A consumer partitions findings by that identifier rather than by
|
|
63
|
+
message text.
|
|
64
|
+
|
|
65
|
+
A message no catalog entry names resolves to `<rule>/unclassified`, which a
|
|
66
|
+
partition treats as blocking. A check whose wording moves reports louder
|
|
67
|
+
rather than going quiet, and the catalog's synchronization tests fail on the
|
|
68
|
+
same change.
|
|
69
|
+
|
|
44
70
|
## Reading and clearing it
|
|
45
71
|
|
|
46
72
|
| Command | What it does |
|
|
@@ -49,6 +75,13 @@ The ledger is per-checkout state and stays out of the repository.
|
|
|
49
75
|
| `cde followup ingest REPORT` | Records the diagnostics a policy-lint JSON report carries |
|
|
50
76
|
| `cde followup brief` | Writes the task an agent fixes them from |
|
|
51
77
|
| `cde followup clear` | Empties the ledger |
|
|
78
|
+
| `cde followup count` | Reports the backlog against the threshold |
|
|
79
|
+
|
|
80
|
+
`count` exits non-zero once the backlog passes
|
|
81
|
+
`FOLLOWUP_BACKLOG_THRESHOLD` in
|
|
82
|
+
`scripts/dev_env_scripts_constants/followup_constants.py`, so a scheduled job
|
|
83
|
+
escalates rather than opening one more quiet pull request. The number is the
|
|
84
|
+
repository's setting, and raising or lowering it is one edit there.
|
|
52
85
|
|
|
53
86
|
The `/fix-followups` command drives the whole pass: it reads the brief, fixes
|
|
54
87
|
each rule group, opens a draft pull request, and clears the ledger.
|
|
@@ -30,18 +30,29 @@ LIST_COMMAND_NAME: str = "list"
|
|
|
30
30
|
INGEST_COMMAND_NAME: str = "ingest"
|
|
31
31
|
BRIEF_COMMAND_NAME: str = "brief"
|
|
32
32
|
CLEAR_COMMAND_NAME: str = "clear"
|
|
33
|
+
COUNT_COMMAND_NAME: str = "count"
|
|
33
34
|
ALL_COMMAND_NAMES: tuple[str, ...] = (
|
|
34
35
|
LIST_COMMAND_NAME,
|
|
35
36
|
INGEST_COMMAND_NAME,
|
|
36
37
|
BRIEF_COMMAND_NAME,
|
|
37
38
|
CLEAR_COMMAND_NAME,
|
|
39
|
+
COUNT_COMMAND_NAME,
|
|
38
40
|
)
|
|
39
41
|
|
|
40
42
|
SUCCESS_EXIT_CODE: int = 0
|
|
41
43
|
INVALID_INPUT_EXIT_CODE: int = 2
|
|
44
|
+
BACKLOG_EXCEEDED_EXIT_CODE: int = 1
|
|
45
|
+
|
|
46
|
+
FOLLOWUP_BACKLOG_THRESHOLD: int = 20
|
|
47
|
+
BACKLOG_WITHIN_TEMPLATE: str = "{finding_count} follow-ups recorded, threshold {threshold}"
|
|
48
|
+
BACKLOG_EXCEEDED_TEMPLATE: str = (
|
|
49
|
+
"{finding_count} follow-ups recorded, over the threshold of {threshold}. "
|
|
50
|
+
"Work the backlog down before the next change records more."
|
|
51
|
+
)
|
|
42
52
|
|
|
43
53
|
DIAGNOSTICS_KEY: str = "diagnostics"
|
|
44
54
|
DIAGNOSTIC_RULE_ID_KEY: str = "rule_id"
|
|
55
|
+
DIAGNOSTIC_CHECK_ID_KEY: str = "check_id"
|
|
45
56
|
DIAGNOSTIC_MESSAGE_KEY: str = "message"
|
|
46
57
|
DIAGNOSTIC_LOCATION_KEY: str = "location"
|
|
47
58
|
LOCATION_PATH_KEY: str = "path"
|
|
@@ -51,11 +62,13 @@ LINE_SEPARATOR: str = "\n"
|
|
|
51
62
|
EMPTY_LEDGER_MESSAGE: str = "no follow-ups recorded"
|
|
52
63
|
UNREADABLE_REPORT_TEMPLATE: str = "cannot read the lint report: {report_path}"
|
|
53
64
|
USAGE_TEXT: str = (
|
|
54
|
-
"Usage: followup_cli.py <list|ingest|brief|clear>
|
|
65
|
+
"Usage: followup_cli.py <list|ingest|brief|clear|count> "
|
|
66
|
+
"[--repository-root PATH]\n"
|
|
55
67
|
" list Name every recorded follow-up\n"
|
|
56
68
|
" ingest REPORT Record every diagnostic in a policy-lint JSON report\n"
|
|
57
69
|
" brief Write the task an agent works the follow-ups from\n"
|
|
58
|
-
" clear Empty the ledger"
|
|
70
|
+
" clear Empty the ledger\n"
|
|
71
|
+
" count Report the backlog size against the threshold"
|
|
59
72
|
)
|
|
60
73
|
|
|
61
74
|
BRIEF_HEADER: str = (
|
|
@@ -64,9 +77,13 @@ BRIEF_HEADER: str = (
|
|
|
64
77
|
"already shipped. Keep the fixes mechanical, touch no behavior, and run "
|
|
65
78
|
"the repository's own checks before pushing."
|
|
66
79
|
)
|
|
67
|
-
BRIEF_FINDING_TEMPLATE: str =
|
|
80
|
+
BRIEF_FINDING_TEMPLATE: str = (
|
|
81
|
+
"- [{check_id}] {file_path}: {message} (recorded at {origin_commit})"
|
|
82
|
+
)
|
|
68
83
|
BRIEF_FOOTER_TEMPLATE: str = (
|
|
69
84
|
"Once the pull request is open, empty the ledger with "
|
|
70
85
|
"`python {command_path} clear`."
|
|
71
86
|
)
|
|
72
|
-
LIST_FINDING_TEMPLATE: str =
|
|
87
|
+
LIST_FINDING_TEMPLATE: str = (
|
|
88
|
+
"{check_id}\t{file_path}\t{origin_commit}\t{message}"
|
|
89
|
+
)
|
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
|
|
|
@@ -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(),
|
|
@@ -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"
|
|
@@ -29,7 +29,7 @@ from dev_env_scripts_constants.followup_constants import (
|
|
|
29
29
|
REGULAR_FILE_RULE_ID,
|
|
30
30
|
SEVERITY_BY_RULE_ID,
|
|
31
31
|
)
|
|
32
|
-
from followup_ledger import FollowupFinding, record_followup_finding
|
|
32
|
+
from followup_ledger import FollowupFinding, head_commit, record_followup_finding
|
|
33
33
|
from hooks_constants.followup_ledger_constants import SEVERITY_BREAKING
|
|
34
34
|
|
|
35
35
|
class InstructionFinding(NamedTuple):
|
|
@@ -358,7 +358,14 @@ def _record_smell(repository_root: Path, finding: InstructionFinding) -> None:
|
|
|
358
358
|
"""
|
|
359
359
|
record_followup_finding(
|
|
360
360
|
repository_root,
|
|
361
|
-
FollowupFinding(
|
|
361
|
+
FollowupFinding(
|
|
362
|
+
finding.rule_id,
|
|
363
|
+
finding.file_path,
|
|
364
|
+
finding.message,
|
|
365
|
+
finding.rule_id,
|
|
366
|
+
finding.severity,
|
|
367
|
+
head_commit(repository_root),
|
|
368
|
+
),
|
|
362
369
|
)
|
|
363
370
|
logger.warning(RECORDED_SMELL_TEMPLATE, finding.message)
|
|
364
371
|
|