claude-dev-env 8.74.1 → 8.74.2
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 +1 -1
- package/bin/cde.test.mjs +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/bin/merge_profile_settings.mjs +2 -3
- package/bin/merge_profile_settings.test.mjs +5 -5
- package/commands/fix-followups.md +5 -2
- package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
- package/docs/rule-guides/paired-test-coverage.md +1 -1
- package/docs/rule-guides/session-title.md +3 -3
- package/docs/templates/html-plan/README.md +1 -1
- package/hooks/features/conduct-gates.md +0 -3
- package/hooks/features/session-context.md +1 -1
- package/hooks/followup_ledger.py +106 -108
- package/hooks/followup_ledger_maintenance.py +127 -0
- package/hooks/hooks.json +0 -11
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
- package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
- package/hooks/hooks_constants/session_title_constants.py +1 -29
- package/hooks/session/artifact_template_pointer.py +5 -4
- package/hooks/session/test_artifact_template_pointer.py +13 -0
- package/hooks/test_followup_ledger.py +78 -59
- package/hooks/test_followup_ledger_maintenance.py +107 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
- package/scripts/followup_cli.py +11 -12
- package/scripts/policy_lint/adapter_configuration.py +0 -1
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/repository_checks/config/constants.py +0 -6
- package/scripts/repository_checks/runner.py +0 -7
- package/scripts/test_followup_cli.py +71 -7
- package/scripts/tests/test_adapter_configuration.py +2 -5
- package/scripts/tests/test_cde_lint.py +3 -4
- package/scripts/tests/test_repository_policy.py +1 -3
- package/hooks/blocking/session_title_stop_gate.py +0 -217
- package/hooks/blocking/test_session_title_stop_gate.py +0 -295
- package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
- package/scripts/tests/test_followup_ledger_duplicates.py +0 -58
package/bin/cde.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function createHelpText() {
|
|
|
45
45
|
" cde followup brief Write the task an agent fixes them from",
|
|
46
46
|
" cde followup count Report the backlog against the threshold",
|
|
47
47
|
" cde followup clear Empty the ledger",
|
|
48
|
-
" cde followup dedupe
|
|
48
|
+
" cde followup dedupe Give each finding its own file, once",
|
|
49
49
|
"",
|
|
50
50
|
"Python may also be selected with CDE_PYTHON.",
|
|
51
51
|
].join("\n");
|
package/bin/cde.test.mjs
CHANGED
|
@@ -292,5 +292,5 @@ test("dispatches followup to the follow-up ledger command", async () => {
|
|
|
292
292
|
test("help names the followup command", () => {
|
|
293
293
|
assert.match(createHelpText(), /cde <lint\|verify\|followup>/);
|
|
294
294
|
assert.match(createHelpText(), /cde followup count/);
|
|
295
|
-
assert.match(createHelpText(), /cde followup dedupe/);
|
|
295
|
+
assert.match(createHelpText(), /cde followup dedupe +Give each finding its own file, once/);
|
|
296
296
|
});
|
package/bin/install.mjs
CHANGED
|
@@ -1255,6 +1255,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
|
|
|
1255
1255
|
'blocking/question_to_user_enforcer.py',
|
|
1256
1256
|
'blocking/session_handoff_blocker.py',
|
|
1257
1257
|
'blocking/artifact_look_gate.py',
|
|
1258
|
+
'blocking/session_title_stop_gate.py',
|
|
1258
1259
|
'session/issue_tracker_session_starter.py',
|
|
1259
1260
|
]);
|
|
1260
1261
|
|
package/bin/install.test.mjs
CHANGED
|
@@ -1231,6 +1231,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1231
1231
|
'blocking/question_to_user_enforcer.py',
|
|
1232
1232
|
'blocking/session_handoff_blocker.py',
|
|
1233
1233
|
'blocking/artifact_look_gate.py',
|
|
1234
|
+
'blocking/session_title_stop_gate.py',
|
|
1234
1235
|
'session/issue_tracker_session_starter.py',
|
|
1235
1236
|
]);
|
|
1236
1237
|
const shippedHooks = JSON.parse(
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* timestamped `.bak` beside it.
|
|
9
9
|
*
|
|
10
10
|
* Each run also retires the profile-level session-title Stop gate an earlier
|
|
11
|
-
* install copied to `~/.claude`. The
|
|
12
|
-
*
|
|
13
|
-
* from each file and deletes the gate files.
|
|
11
|
+
* install copied to `~/.claude`. The merge removes that gate's Stop hook and
|
|
12
|
+
* allow rule from each file and deletes the gate files.
|
|
14
13
|
*
|
|
15
14
|
* Run `node merge_profile_settings.mjs --dry-run` to list the changes for
|
|
16
15
|
* every profile on this machine without writing anything.
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const FIXED_NOW = new Date('2026-10-04T12:00:00.000Z');
|
|
19
19
|
const BACKUP_SUFFIX = '.2026-10-04T12-00-00-000Z.bak';
|
|
20
|
-
const
|
|
20
|
+
const OTHER_STOP_HOOK_COMMAND = 'python3 /plugin/hooks/lifecycle/other_stop_hook.py';
|
|
21
21
|
const PERMISSION_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
|
|
22
22
|
.find((eachEntry) => eachEntry.keyPath.join('.') === 'permissions.allow').items;
|
|
23
23
|
const AUTO_MODE_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
|
|
@@ -73,7 +73,7 @@ test('a missing settings.json is created with every declared entry and no Stop h
|
|
|
73
73
|
assert.deepEqual(outcome.gate.removedPaths, []);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test('the retired profile gate leaves settings.json and ~/.claude, and
|
|
76
|
+
test('the retired profile gate leaves settings.json and ~/.claude, and another Stop hook stays', () => {
|
|
77
77
|
const homeDirectory = makeHome();
|
|
78
78
|
const claudeDirectory = join(homeDirectory, '.claude');
|
|
79
79
|
const settingsPath = join(claudeDirectory, 'settings.json');
|
|
@@ -83,7 +83,7 @@ test('the retired profile gate leaves settings.json and ~/.claude, and the plugi
|
|
|
83
83
|
{
|
|
84
84
|
hooks: [
|
|
85
85
|
{ type: 'command', command: expandedRetiredGateCommand(homeDirectory), timeout: 10 },
|
|
86
|
-
{ type: 'command', command:
|
|
86
|
+
{ type: 'command', command: OTHER_STOP_HOOK_COMMAND },
|
|
87
87
|
],
|
|
88
88
|
},
|
|
89
89
|
{ hooks: [{ type: 'command', command: expandedRetiredGateCommand(homeDirectory), timeout: 10 }] },
|
|
@@ -96,7 +96,7 @@ test('the retired profile gate leaves settings.json and ~/.claude, and the plugi
|
|
|
96
96
|
const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
|
|
97
97
|
|
|
98
98
|
const mergedSettings = readSettings(settingsPath);
|
|
99
|
-
assert.deepEqual(stopCommands(mergedSettings), [
|
|
99
|
+
assert.deepEqual(stopCommands(mergedSettings), [OTHER_STOP_HOOK_COMMAND]);
|
|
100
100
|
assert.equal(mergedSettings.hooks.Stop.length, 1);
|
|
101
101
|
assert.equal(mergedSettings.permissions.allow.includes(RETIRED_SESSION_TITLE_GATE_PERMISSION), false);
|
|
102
102
|
assert.equal(outcome.files[0].removals.length, 3);
|
|
@@ -152,7 +152,7 @@ test('a partial file gains only the missing entries, keeps other keys, and is ba
|
|
|
152
152
|
const originalSettings = {
|
|
153
153
|
model: 'opus',
|
|
154
154
|
hooks: {
|
|
155
|
-
Stop: [{ hooks: [{ type: 'command', command:
|
|
155
|
+
Stop: [{ hooks: [{ type: 'command', command: OTHER_STOP_HOOK_COMMAND, timeout: 30 }] }],
|
|
156
156
|
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'python3 guard.py' }] }],
|
|
157
157
|
},
|
|
158
158
|
permissions: { allow: ['Read', 'mcp__claude-code-remote__set_session_title'], deny: ['Bash(rm:*)'] },
|
|
@@ -13,7 +13,8 @@ Use `docs/rule-guides/asd-ste100-language.md` for user-facing wording.
|
|
|
13
13
|
|
|
14
14
|
## 1. Read the ledger
|
|
15
15
|
|
|
16
|
-
Run `cde followup brief --repository-root <root>`.
|
|
16
|
+
Run `cde followup brief --repository-root <root>`. The ledger is the
|
|
17
|
+
`.claude/followups/` directory, one JSON file per finding.
|
|
17
18
|
|
|
18
19
|
When it prints `no follow-ups recorded`, report that and stop. There is no
|
|
19
20
|
work.
|
|
@@ -62,7 +63,9 @@ Push, then confirm the required checks report on the branch head.
|
|
|
62
63
|
## 5. Empty the ledger
|
|
63
64
|
|
|
64
65
|
Once the pull request is open, run
|
|
65
|
-
`cde followup clear --repository-root <root>`.
|
|
66
|
+
`cde followup clear --repository-root <root>`. It removes every finding file.
|
|
67
|
+
When the repository commits its ledger, commit those removals on the pull
|
|
68
|
+
request branch and push.
|
|
66
69
|
|
|
67
70
|
A finding the pull request left alone goes back in the ledger with
|
|
68
71
|
`cde followup ingest`, so the next pass picks it up.
|
|
@@ -10,7 +10,7 @@ A smell that blocks a commit stops delivery for something the reader could fix i
|
|
|
10
10
|
|
|
11
11
|
## The ledger
|
|
12
12
|
|
|
13
|
-
Recorded findings land in `.claude/followups
|
|
13
|
+
Recorded findings land in `.claude/followups/` at the repository root, one JSON file per finding. The file name is the first 16 hex digits of a SHA-256 hash over the check identifier, path, and message, so the same finding always writes the same file and two branches that record different findings never touch one file. `hooks/followup_ledger.py` writes and reads the directory. Ledger writes are fail-safe, so a ledger failure leaves the gate's decision unchanged.
|
|
14
14
|
|
|
15
15
|
| Field | What it carries |
|
|
16
16
|
|---|---|
|
|
@@ -21,7 +21,7 @@ Recorded findings land in `.claude/followups/smells.jsonl` at the repository roo
|
|
|
21
21
|
| `severity` | The class the gate put it in |
|
|
22
22
|
| `origin_commit` | The revision checked out when it was recorded |
|
|
23
23
|
|
|
24
|
-
The origin commit groups a follow-up pull request by the change that raised the findings. A smell seen again under a later revision keeps the revision that first raised it, so one smell stays one record. The
|
|
24
|
+
The origin commit groups a follow-up pull request by the change that raised the findings. A smell seen again under a later revision keeps the revision that first raised it, so one smell stays one record. The first write into a directory with no `.gitignore` adds one holding `*`, so the ledger stays out of the repository by default. A repository that commits its findings commits its own `.gitignore`. This repository commits one holding `!*.json`.
|
|
25
25
|
|
|
26
26
|
## The check identifier
|
|
27
27
|
|
|
@@ -38,12 +38,10 @@ A message with no catalog entry resolves to `<rule>/unclassified`, which a parti
|
|
|
38
38
|
| `cde followup brief` | Writes the task an agent fixes them from |
|
|
39
39
|
| `cde followup clear` | Empties the ledger |
|
|
40
40
|
| `cde followup count` | Reports the backlog against the threshold |
|
|
41
|
-
| `cde followup dedupe` |
|
|
41
|
+
| `cde followup dedupe` | Moves a legacy `smells.jsonl` into finding files and removes duplicate finding files |
|
|
42
42
|
|
|
43
43
|
`count` exits non-zero once the backlog passes `FOLLOWUP_BACKLOG_THRESHOLD` in `scripts/dev_env_scripts_constants/followup_constants.py`, so a scheduled job escalates. The number is the repository's setting.
|
|
44
44
|
|
|
45
|
-
`.gitattributes` marks the ledger `merge=union`, so a local `git merge` keeps both sides' appended lines without a conflict. GitHub's server-side merge ignores the attribute. Every ledger write drops exact repeated lines, and the `followup-ledger-duplicates` repository check fails a tree that still holds one.
|
|
46
|
-
|
|
47
45
|
The `/fix-followups` command reads the brief, fixes each rule group, opens a pull request, and clears the ledger.
|
|
48
46
|
|
|
49
47
|
## Worked example
|
|
@@ -17,7 +17,7 @@ When you add a public function to a module whose test suite already exercises th
|
|
|
17
17
|
|
|
18
18
|
Two complementary checks in `code_rules_paired_test.py` reach changed files through `code_rules_enforcer.py`, which the staged policy lint runs under its `code-rules` rule. No write-time hook runs them, so CI is where they report, against the merge base. The two checks cover the two write orders.
|
|
19
19
|
|
|
20
|
-
Both checks record smells, per [`flag-non-breaking-findings.md`](../../rules/flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it in `.claude/followups
|
|
20
|
+
Both checks record smells, per [`flag-non-breaking-findings.md`](../../rules/flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it as one file in `.claude/followups/`, and exits zero when every finding is a warning. A later pull request adds the missing tests.
|
|
21
21
|
|
|
22
22
|
`check_public_function_missing_paired_test` runs on a production Python write or edit and flags a public function when all of these hold:
|
|
23
23
|
|
|
@@ -6,7 +6,7 @@ Back to the [rules index](../../rules/index.md).
|
|
|
6
6
|
|
|
7
7
|
Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when the user must act, the check mark when the work is done, and the hourglass while work, CI, or a merge queue runs. The name has 25 characters or fewer, in sentence case, with concrete nouns and no IDs, dates, or branch names.
|
|
8
8
|
|
|
9
|
-
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title
|
|
9
|
+
**Enforcement:** `hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
10
10
|
|
|
11
11
|
**When this applies:** The session has a tool whose name ends in `__set_session_title`, such as `mcp__claude-code-remote__set_session_title` in a cloud session or `mcp__ccd_session_mgmt__set_session_title` in the desktop app.
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ Before each turn ends, set the title to `<emoji> <name>`. Use the red flag when
|
|
|
14
14
|
|
|
15
15
|
Before each turn ends, set the title to `<emoji> <name>`. Get the session id from `get_session` with no `session_id` when the tool asks for one.
|
|
16
16
|
|
|
17
|
-
Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it.
|
|
17
|
+
Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it.
|
|
18
18
|
|
|
19
19
|
## Status emoji
|
|
20
20
|
|
|
@@ -30,4 +30,4 @@ The name has 25 characters or fewer and names the main change or outcome in conc
|
|
|
30
30
|
|
|
31
31
|
## Enforcement
|
|
32
32
|
|
|
33
|
-
`hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
33
|
+
`hooks/blocking/session_title_format_gate.py` denies a malformed title.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# html-plan page template
|
|
2
2
|
|
|
3
|
-
Build a
|
|
3
|
+
Build a complex visual, such as a multi-part plan or evidence, from the html-plan skill. Load `house.css` from this folder after the plugin's `htmlplan.css`. The older [artifact page template](../artifact-page/README.md) stays available; use it when the request names it.
|
|
4
4
|
|
|
5
5
|
## Get the skill
|
|
6
6
|
|
|
@@ -8,7 +8,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
8
8
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
9
9
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
10
10
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
11
|
-
- `blocking/session_title_stop_gate.py` blocks a turn end once when no session title call succeeded in that turn.
|
|
12
11
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
13
12
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
14
13
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
@@ -20,7 +19,6 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
20
19
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
21
20
|
- `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
|
|
22
21
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
23
|
-
- `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
|
|
24
22
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
25
23
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
26
24
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -36,7 +34,6 @@ Preconditions:
|
|
|
36
34
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
37
35
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
38
36
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
39
|
-
- **Title at turn end.** Input is a remote session transcript whose last turn set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_stop_gate.py -q`. The adjacent test observes a block; a turn with a successful title call passes.
|
|
40
37
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
41
38
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
42
39
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
@@ -9,7 +9,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
9
9
|
- `session/working_style_prompt.py` injects the session's working style at session start.
|
|
10
10
|
- `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
|
|
11
11
|
- `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
|
|
12
|
-
- `session/artifact_template_pointer.py`
|
|
12
|
+
- `session/artifact_template_pointer.py` routes an agent that starts an artifact page by scope. One flow or one comparison is an inline SVG flowchart with minimal text. A multi-part plan or evidence is an html-plan page from `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
|
|
13
13
|
- `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
|
|
14
14
|
|
|
15
15
|
## When it fires
|
package/hooks/followup_ledger.py
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Ledger of non-breaking findings awaiting a follow-up fix, one file each.
|
|
2
2
|
|
|
3
3
|
A gate that finds a breaking defect stops the commit, the push, or the tool
|
|
4
4
|
call. A gate that finds a non-breaking smell records the finding here and lets
|
|
5
|
-
the work proceed.
|
|
6
|
-
|
|
5
|
+
the work proceed. Each finding is its own JSON file under
|
|
6
|
+
``.claude/followups/``, named by a hash of the finding, so parallel branches
|
|
7
|
+
that record different findings never edit the same file. ``cde followup``
|
|
8
|
+
reads the ledger and briefs the agent that fixes the recorded smells in their
|
|
9
|
+
own pull request.
|
|
7
10
|
|
|
8
|
-
Every write is fail-safe. A ledger that cannot be created or
|
|
11
|
+
Every write is fail-safe. A ledger that cannot be created or written to leaves
|
|
9
12
|
the calling gate's decision unchanged, so recording a finding never becomes a
|
|
10
13
|
new way for a gate to fail.
|
|
11
14
|
"""
|
|
12
15
|
|
|
13
16
|
from __future__ import annotations
|
|
14
17
|
|
|
18
|
+
import hashlib
|
|
15
19
|
import json
|
|
16
20
|
import sys
|
|
17
|
-
from collections.abc import Sequence
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
from typing import NamedTuple
|
|
20
23
|
|
|
@@ -24,9 +27,12 @@ if _hooks_directory not in sys.path:
|
|
|
24
27
|
|
|
25
28
|
from hooks_constants.followup_ledger_constants import (
|
|
26
29
|
ABSENT_ORIGIN_COMMIT,
|
|
27
|
-
|
|
30
|
+
ALL_FOLLOWUP_DIRECTORY_SEGMENTS,
|
|
28
31
|
CHECK_ID_KEY,
|
|
29
32
|
FILE_PATH_KEY,
|
|
33
|
+
FINDING_FILE_NAME_HASH_LENGTH,
|
|
34
|
+
FINDING_FILE_PATTERN,
|
|
35
|
+
FINDING_FILE_SUFFIX,
|
|
30
36
|
GIT_COMMON_DIRECTORY_FILE_NAME,
|
|
31
37
|
GIT_DIRECTORY_FILE_PREFIX,
|
|
32
38
|
GIT_DIRECTORY_NAME,
|
|
@@ -35,10 +41,11 @@ from hooks_constants.followup_ledger_constants import (
|
|
|
35
41
|
GIT_PACKED_REFERENCES_FILE_NAME,
|
|
36
42
|
GIT_PACKED_REFERENCES_PEELED_PREFIX,
|
|
37
43
|
GIT_REFERENCE_PREFIX,
|
|
38
|
-
|
|
44
|
+
LEDGER_CREATE_MODE,
|
|
39
45
|
LEDGER_ENCODING,
|
|
40
46
|
LEDGER_IGNORE_FILE_NAME,
|
|
41
47
|
LEDGER_IGNORE_TEXT,
|
|
48
|
+
LEDGER_JSON_INDENT,
|
|
42
49
|
MESSAGE_KEY,
|
|
43
50
|
ORIGIN_COMMIT_KEY,
|
|
44
51
|
RULE_ID_KEY,
|
|
@@ -80,151 +87,144 @@ class FollowupFinding(NamedTuple):
|
|
|
80
87
|
return (self.check_id or self.rule_id, self.file_path, self.message)
|
|
81
88
|
|
|
82
89
|
|
|
83
|
-
def
|
|
84
|
-
"""Return the
|
|
85
|
-
|
|
86
|
-
The first occurrence of each line keeps its place, so the ledger keeps its
|
|
87
|
-
record order after a ``merge=union`` merge writes both sides' lines.
|
|
90
|
+
def followup_directory(repository_root: Path) -> Path:
|
|
91
|
+
"""Return the directory that holds one repository's recorded findings.
|
|
88
92
|
|
|
89
93
|
Args:
|
|
90
|
-
|
|
94
|
+
repository_root: The repository whose ledger to address.
|
|
91
95
|
|
|
92
96
|
Returns:
|
|
93
|
-
The
|
|
97
|
+
The absolute path of that repository's follow-up directory.
|
|
94
98
|
"""
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def deduplicate_ledger(repository_root: Path) -> int:
|
|
99
|
-
"""Rewrite the repository's ledger without exact repeated lines.
|
|
100
|
-
|
|
101
|
-
An absent or unreadable ledger, and a ledger that cannot be rewritten, are
|
|
102
|
-
left as they are, so a ledger failure never becomes a gate failure.
|
|
99
|
+
return repository_root.joinpath(*ALL_FOLLOWUP_DIRECTORY_SEGMENTS)
|
|
103
100
|
|
|
104
|
-
Args:
|
|
105
|
-
repository_root: The repository whose ledger to clean.
|
|
106
|
-
|
|
107
|
-
Returns:
|
|
108
|
-
How many lines the rewrite removed.
|
|
109
|
-
"""
|
|
110
|
-
ledger_path = followup_ledger_path(repository_root)
|
|
111
|
-
try:
|
|
112
|
-
all_ledger_lines = ledger_path.read_text(encoding=LEDGER_ENCODING).splitlines()
|
|
113
|
-
except (OSError, UnicodeError):
|
|
114
|
-
return 0
|
|
115
|
-
all_unique_lines = deduplicated_lines(all_ledger_lines)
|
|
116
|
-
removed_line_count = len(all_ledger_lines) - len(all_unique_lines)
|
|
117
|
-
if not removed_line_count:
|
|
118
|
-
return 0
|
|
119
|
-
try:
|
|
120
|
-
ledger_path.write_text(
|
|
121
|
-
"".join(each_line + "\n" for each_line in all_unique_lines),
|
|
122
|
-
encoding=LEDGER_ENCODING,
|
|
123
|
-
)
|
|
124
|
-
except OSError:
|
|
125
|
-
return 0
|
|
126
|
-
return removed_line_count
|
|
127
101
|
|
|
102
|
+
def finding_path(repository_root: Path, finding: FollowupFinding) -> Path:
|
|
103
|
+
"""Return the file one finding is recorded in.
|
|
128
104
|
|
|
129
|
-
|
|
130
|
-
|
|
105
|
+
The file name is a hash of the finding's tracking key, so the same finding
|
|
106
|
+
always maps to the same file and two different findings never share one.
|
|
131
107
|
|
|
132
108
|
Args:
|
|
133
|
-
repository_root: The repository whose ledger
|
|
109
|
+
repository_root: The repository whose ledger holds the finding.
|
|
110
|
+
finding: The finding to locate.
|
|
134
111
|
|
|
135
112
|
Returns:
|
|
136
|
-
The absolute path of
|
|
113
|
+
The absolute path of the finding's file.
|
|
137
114
|
"""
|
|
138
|
-
|
|
115
|
+
key_text = json.dumps(list(finding.tracking_key()), ensure_ascii=True)
|
|
116
|
+
key_digest = hashlib.sha256(key_text.encode(LEDGER_ENCODING)).hexdigest()
|
|
117
|
+
file_name = key_digest[:FINDING_FILE_NAME_HASH_LENGTH] + FINDING_FILE_SUFFIX
|
|
118
|
+
return followup_directory(repository_root) / file_name
|
|
139
119
|
|
|
140
120
|
|
|
141
121
|
def record_followup_finding(repository_root: Path, finding: FollowupFinding) -> None:
|
|
142
|
-
"""
|
|
122
|
+
"""Write one finding to its own file in the repository's ledger, once.
|
|
143
123
|
|
|
144
|
-
The
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
ledger stays out of ``git status`` in any repository
|
|
150
|
-
error is swallowed, so a ledger
|
|
151
|
-
unchanged.
|
|
124
|
+
The file is created exclusively, so a finding already on disk keeps the
|
|
125
|
+
record that first raised it, and a gate that runs on every commit records
|
|
126
|
+
a standing smell a single time. Two writers recording different findings
|
|
127
|
+
write different files, so parallel branches never edit a shared file. The
|
|
128
|
+
first write in a directory with no ``.gitignore`` adds one matching every
|
|
129
|
+
file, so the ledger stays out of ``git status`` in any repository that
|
|
130
|
+
does not commit its own. Every filesystem error is swallowed, so a ledger
|
|
131
|
+
failure leaves the caller's gate decision unchanged.
|
|
152
132
|
|
|
153
133
|
Args:
|
|
154
134
|
repository_root: The repository whose ledger receives the finding.
|
|
155
135
|
finding: The non-breaking finding to record.
|
|
156
136
|
"""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
each_finding.tracking_key()
|
|
160
|
-
for each_finding in all_recorded_findings(repository_root)
|
|
161
|
-
}
|
|
162
|
-
if finding.tracking_key() in all_recorded_keys:
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
ledger_path = followup_ledger_path(repository_root)
|
|
166
|
-
record_text = json.dumps(
|
|
167
|
-
{
|
|
168
|
-
RULE_ID_KEY: finding.rule_id,
|
|
169
|
-
FILE_PATH_KEY: finding.file_path,
|
|
170
|
-
MESSAGE_KEY: finding.message,
|
|
171
|
-
CHECK_ID_KEY: finding.check_id or finding.rule_id,
|
|
172
|
-
SEVERITY_KEY: finding.severity,
|
|
173
|
-
ORIGIN_COMMIT_KEY: finding.origin_commit,
|
|
174
|
-
}
|
|
175
|
-
)
|
|
137
|
+
record_path = finding_path(repository_root, finding)
|
|
138
|
+
record_text = json.dumps(_record_fields(finding), indent=LEDGER_JSON_INDENT)
|
|
176
139
|
try:
|
|
177
|
-
|
|
178
|
-
ignore_path =
|
|
140
|
+
record_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
ignore_path = record_path.parent / LEDGER_IGNORE_FILE_NAME
|
|
179
142
|
if not ignore_path.exists():
|
|
180
143
|
ignore_path.write_text(LEDGER_IGNORE_TEXT, encoding=LEDGER_ENCODING)
|
|
181
|
-
with
|
|
182
|
-
|
|
144
|
+
with record_path.open(LEDGER_CREATE_MODE, encoding=LEDGER_ENCODING) as record_file:
|
|
145
|
+
record_file.write(record_text + "\n")
|
|
183
146
|
except OSError:
|
|
184
147
|
return
|
|
185
148
|
|
|
186
149
|
|
|
150
|
+
def _record_fields(finding: FollowupFinding) -> dict[str, str]:
|
|
151
|
+
return {
|
|
152
|
+
RULE_ID_KEY: finding.rule_id,
|
|
153
|
+
FILE_PATH_KEY: finding.file_path,
|
|
154
|
+
MESSAGE_KEY: finding.message,
|
|
155
|
+
CHECK_ID_KEY: finding.check_id or finding.rule_id,
|
|
156
|
+
SEVERITY_KEY: finding.severity,
|
|
157
|
+
ORIGIN_COMMIT_KEY: finding.origin_commit,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
187
161
|
def all_recorded_findings(repository_root: Path) -> tuple[FollowupFinding, ...]:
|
|
188
|
-
"""Return every finding the repository's ledger holds,
|
|
162
|
+
"""Return every finding the repository's ledger holds, ordered by check.
|
|
189
163
|
|
|
190
|
-
An absent
|
|
191
|
-
|
|
192
|
-
|
|
164
|
+
An absent directory reads as empty, and a file that does not parse into a
|
|
165
|
+
complete record is skipped, so one damaged file never hides the findings
|
|
166
|
+
around it.
|
|
193
167
|
|
|
194
168
|
Args:
|
|
195
169
|
repository_root: The repository whose ledger to read.
|
|
196
170
|
|
|
197
171
|
Returns:
|
|
198
|
-
The recorded findings,
|
|
172
|
+
The recorded findings, sorted by check identifier, path, and message.
|
|
173
|
+
"""
|
|
174
|
+
all_findings = [
|
|
175
|
+
each_finding
|
|
176
|
+
for _each_path, each_finding in all_finding_files(repository_root)
|
|
177
|
+
]
|
|
178
|
+
return tuple(sorted(all_findings, key=FollowupFinding.tracking_key))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def all_record_paths(directory: Path) -> list[Path]:
|
|
182
|
+
"""Return every finding file in one ledger directory, sorted by name.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
directory: The follow-up directory to list.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The finding file paths, or an empty list when none can be listed.
|
|
199
189
|
"""
|
|
200
|
-
ledger_path = followup_ledger_path(repository_root)
|
|
201
190
|
try:
|
|
202
|
-
|
|
203
|
-
except
|
|
204
|
-
return
|
|
191
|
+
return sorted(directory.glob(FINDING_FILE_PATTERN))
|
|
192
|
+
except OSError:
|
|
193
|
+
return []
|
|
205
194
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
195
|
+
|
|
196
|
+
def all_finding_files(repository_root: Path) -> list[tuple[Path, FollowupFinding]]:
|
|
197
|
+
"""Return each readable finding file with the finding it holds.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
repository_root: The repository whose ledger to read.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Path and finding pairs, sorted by file name.
|
|
204
|
+
"""
|
|
205
|
+
all_pairs: list[tuple[Path, FollowupFinding]] = []
|
|
206
|
+
for each_path in all_record_paths(followup_directory(repository_root)):
|
|
207
|
+
try:
|
|
208
|
+
record_text = each_path.read_text(encoding=LEDGER_ENCODING)
|
|
209
|
+
except (OSError, UnicodeError):
|
|
210
|
+
continue
|
|
211
|
+
each_finding = finding_from_text(record_text)
|
|
209
212
|
if each_finding is not None:
|
|
210
|
-
|
|
211
|
-
return
|
|
213
|
+
all_pairs.append((each_path, each_finding))
|
|
214
|
+
return all_pairs
|
|
212
215
|
|
|
213
216
|
|
|
214
|
-
def
|
|
215
|
-
"""Parse one
|
|
217
|
+
def finding_from_text(record_text: str) -> FollowupFinding | None:
|
|
218
|
+
"""Parse one JSON record into a finding.
|
|
216
219
|
|
|
217
220
|
Args:
|
|
218
|
-
|
|
221
|
+
record_text: One finding file's text, or one legacy ledger line.
|
|
219
222
|
|
|
220
223
|
Returns:
|
|
221
|
-
The parsed finding, or None when the
|
|
224
|
+
The parsed finding, or None when the text carries no complete record.
|
|
222
225
|
"""
|
|
223
|
-
stripped_line = ledger_line.strip()
|
|
224
|
-
if not stripped_line:
|
|
225
|
-
return None
|
|
226
226
|
try:
|
|
227
|
-
parsed_record = json.loads(
|
|
227
|
+
parsed_record = json.loads(record_text)
|
|
228
228
|
except json.JSONDecodeError:
|
|
229
229
|
return None
|
|
230
230
|
if not isinstance(parsed_record, dict):
|
|
@@ -232,9 +232,7 @@ def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
|
|
|
232
232
|
rule_id = parsed_record.get(RULE_ID_KEY)
|
|
233
233
|
file_path = parsed_record.get(FILE_PATH_KEY)
|
|
234
234
|
message = parsed_record.get(MESSAGE_KEY)
|
|
235
|
-
if not isinstance(
|
|
236
|
-
return None
|
|
237
|
-
if not isinstance(message, str):
|
|
235
|
+
if not all(isinstance(each, str) for each in (rule_id, file_path, message)):
|
|
238
236
|
return None
|
|
239
237
|
return FollowupFinding(
|
|
240
238
|
rule_id,
|
|
@@ -252,7 +250,7 @@ def _text_field(
|
|
|
252
250
|
"""Read one text field of a ledger record.
|
|
253
251
|
|
|
254
252
|
Args:
|
|
255
|
-
all_record_fields: The mapping one
|
|
253
|
+
all_record_fields: The mapping one record parsed into.
|
|
256
254
|
field_key: The key to read.
|
|
257
255
|
fallback: The value a record written before this field carried it takes.
|
|
258
256
|
|