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.
Files changed (39) hide show
  1. package/bin/cde.mjs +1 -1
  2. package/bin/cde.test.mjs +1 -1
  3. package/bin/install.mjs +1 -0
  4. package/bin/install.test.mjs +1 -0
  5. package/bin/merge_profile_settings.mjs +2 -3
  6. package/bin/merge_profile_settings.test.mjs +5 -5
  7. package/commands/fix-followups.md +5 -2
  8. package/docs/rule-guides/flag-non-breaking-findings.md +3 -5
  9. package/docs/rule-guides/paired-test-coverage.md +1 -1
  10. package/docs/rule-guides/session-title.md +3 -3
  11. package/docs/templates/html-plan/README.md +1 -1
  12. package/hooks/features/conduct-gates.md +0 -3
  13. package/hooks/features/session-context.md +1 -1
  14. package/hooks/followup_ledger.py +106 -108
  15. package/hooks/followup_ledger_maintenance.py +127 -0
  16. package/hooks/hooks.json +0 -11
  17. package/hooks/hooks_constants/artifact_template_pointer_constants.py +5 -1
  18. package/hooks/hooks_constants/followup_ledger_constants.py +14 -4
  19. package/hooks/hooks_constants/session_title_constants.py +1 -29
  20. package/hooks/session/artifact_template_pointer.py +5 -4
  21. package/hooks/session/test_artifact_template_pointer.py +13 -0
  22. package/hooks/test_followup_ledger.py +78 -59
  23. package/hooks/test_followup_ledger_maintenance.py +107 -0
  24. package/package.json +1 -1
  25. package/rules/flag-non-breaking-findings.md +1 -1
  26. package/scripts/dev_env_scripts_constants/followup_constants.py +4 -1
  27. package/scripts/followup_cli.py +11 -12
  28. package/scripts/policy_lint/adapter_configuration.py +0 -1
  29. package/scripts/policy_lint/config/constants.py +0 -1
  30. package/scripts/repository_checks/config/constants.py +0 -6
  31. package/scripts/repository_checks/runner.py +0 -7
  32. package/scripts/test_followup_cli.py +71 -7
  33. package/scripts/tests/test_adapter_configuration.py +2 -5
  34. package/scripts/tests/test_cde_lint.py +3 -4
  35. package/scripts/tests/test_repository_policy.py +1 -3
  36. package/hooks/blocking/session_title_stop_gate.py +0 -217
  37. package/hooks/blocking/test_session_title_stop_gate.py +0 -295
  38. package/scripts/repository_checks/followup_ledger_duplicates.py +0 -55
  39. 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 Remove exact repeated ledger lines",
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
 
@@ -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 plugin's own session-title stop gate does
12
- * that job, so the merge removes the profile gate's Stop hook and allow rule
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 PLUGIN_STOP_GATE_COMMAND = 'python3 /plugin/hooks/blocking/session_title_stop_gate.py';
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 the plugin gate stays', () => {
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: PLUGIN_STOP_GATE_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), [PLUGIN_STOP_GATE_COMMAND]);
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: PLUGIN_STOP_GATE_COMMAND, timeout: 30 }] }],
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/smells.jsonl` at the repository root, one JSON object per line. `hooks/followup_ledger.py` writes and reads it. Ledger writes are fail-safe, so a ledger failure leaves the gate's decision unchanged.
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 ledger is per-checkout state and stays out of the repository.
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` | Removes exact repeated lines and keeps the first of each |
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/smells.jsonl`, and exits zero when every finding is a warning. A later pull request adds the missing tests.
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; `hooks/blocking/session_title_stop_gate.py` blocks a turn end with no 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. When a Stop hook asks for the title after the answer went out, make the call and end the turn with no text.
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. `hooks/blocking/session_title_stop_gate.py` blocks the end of a turn that set no 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 new artifact page 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.
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` points an agent that starts an artifact page at `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.
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
@@ -1,20 +1,23 @@
1
- """Append-only ledger of non-breaking findings awaiting a follow-up fix.
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. ``cde followup`` reads the ledger and briefs the agent that
6
- fixes the recorded smells in their own pull request.
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 appended to leaves
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
- ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS,
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
- LEDGER_APPEND_MODE,
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 deduplicated_lines(all_lines: Sequence[str]) -> list[str]:
84
- """Return the lines with every exact repeat removed.
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
- all_lines: The ledger lines, in file order.
94
+ repository_root: The repository whose ledger to address.
91
95
 
92
96
  Returns:
93
- The lines in file order, each exact line once.
97
+ The absolute path of that repository's follow-up directory.
94
98
  """
95
- return list(dict.fromkeys(all_lines))
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
- def followup_ledger_path(repository_root: Path) -> Path:
130
- """Return the ledger path for one repository.
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 to address.
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 that repository's follow-up ledger.
113
+ The absolute path of the finding's file.
137
114
  """
138
- return repository_root.joinpath(*ALL_FOLLOWUP_LEDGER_PATH_SEGMENTS)
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
- """Append one finding to the repository's ledger, once.
122
+ """Write one finding to its own file in the repository's ledger, once.
143
123
 
144
- The ledger first loses every exact repeated line, so lines a
145
- ``merge=union`` merge wrote twice leave on the next write. A finding
146
- already present in the ledger is left alone, so a gate that runs on every
147
- commit records a standing smell a single time. The ledger
148
- directory carries a ``.gitignore`` matching every file in it, so the
149
- ledger stays out of ``git status`` in any repository. Every filesystem
150
- error is swallowed, so a ledger failure leaves the caller's gate decision
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
- deduplicate_ledger(repository_root)
158
- all_recorded_keys = {
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
- ledger_path.parent.mkdir(parents=True, exist_ok=True)
178
- ignore_path = ledger_path.parent / LEDGER_IGNORE_FILE_NAME
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 ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
182
- ledger_file.write(record_text + "\n")
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, in record order.
162
+ """Return every finding the repository's ledger holds, ordered by check.
189
163
 
190
- An absent or unreadable ledger reads as empty, and a line that does not
191
- parse into a complete record is skipped, so one damaged line never hides
192
- the findings around it.
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, in the order they were appended.
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
- ledger_text = ledger_path.read_text(encoding=LEDGER_ENCODING)
203
- except (OSError, UnicodeError):
204
- return ()
191
+ return sorted(directory.glob(FINDING_FILE_PATTERN))
192
+ except OSError:
193
+ return []
205
194
 
206
- all_findings: list[FollowupFinding] = []
207
- for each_line in ledger_text.splitlines():
208
- each_finding = _finding_from_line(each_line)
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
- all_findings.append(each_finding)
211
- return tuple(all_findings)
213
+ all_pairs.append((each_path, each_finding))
214
+ return all_pairs
212
215
 
213
216
 
214
- def _finding_from_line(ledger_line: str) -> FollowupFinding | None:
215
- """Parse one ledger line into a finding.
217
+ def finding_from_text(record_text: str) -> FollowupFinding | None:
218
+ """Parse one JSON record into a finding.
216
219
 
217
220
  Args:
218
- ledger_line: One line of the ledger file.
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 line carries no complete record.
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(stripped_line)
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(rule_id, str) or not isinstance(file_path, str):
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 ledger line parsed into.
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