claude-dev-env 8.54.9 → 8.56.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.
Files changed (33) hide show
  1. package/bin/install.agents-home.test.mjs +2 -0
  2. package/bin/install.codex-rules.test.mjs +4 -0
  3. package/bin/install.cursor-rules.test.mjs +1 -0
  4. package/bin/install.mjs +19 -20
  5. package/bin/install.profile-settings.test.mjs +77 -0
  6. package/bin/install.profiles.test.mjs +2 -2
  7. package/bin/install.prune.test.mjs +2 -0
  8. package/bin/install.question-presentation.test.mjs +2 -0
  9. package/bin/install.settings-defaults.test.mjs +8 -3
  10. package/bin/install.shared-settings.test.mjs +1 -0
  11. package/bin/install.test.mjs +7 -3
  12. package/bin/install.transaction.test.mjs +14 -0
  13. package/bin/install.uninstall-transaction.test.mjs +4 -0
  14. package/bin/install.update-keeps-hooks.test.mjs +2 -0
  15. package/bin/merge_profile_settings.mjs +372 -0
  16. package/bin/merge_profile_settings.test.mjs +208 -0
  17. package/docs/templates/artifact-page/README.md +25 -0
  18. package/docs/templates/artifact-page/template.html +465 -0
  19. package/hooks/features/session-context.md +4 -1
  20. package/hooks/hooks.json +10 -0
  21. package/hooks/hooks_constants/artifact_template_pointer_constants.py +27 -0
  22. package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +8 -0
  23. package/hooks/session/artifact_template_pointer.py +102 -0
  24. package/hooks/session/test_artifact_template_pointer.py +65 -0
  25. package/installable-surfaces.manifest.json +2 -1
  26. package/package.json +3 -2
  27. package/profile-settings/config/__init__.py +1 -0
  28. package/profile-settings/config/session_title_gate_constants.py +15 -0
  29. package/profile-settings/session_title_status_gate.py +77 -0
  30. package/profile-settings/test_session_title_status_gate.py +101 -0
  31. package/scripts/dev_env_scripts_constants/private_term_constants.py +8 -4
  32. package/scripts/private_term_scan.py +15 -3
  33. package/scripts/tests/test_private_term_scan.py +81 -8
@@ -0,0 +1,65 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _SESSION_DIR = Path(__file__).resolve().parent
7
+ _HOOKS_ROOT = _SESSION_DIR.parent
8
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
9
+ if each_sys_path_entry not in sys.path:
10
+ sys.path.insert(0, each_sys_path_entry)
11
+
12
+ import artifact_template_pointer
13
+ from hooks_constants.artifact_template_pointer_constants import ARTIFACT_TEMPLATE_PATH
14
+
15
+ HOOK_SCRIPT = Path(__file__).resolve().parent / "artifact_template_pointer.py"
16
+
17
+
18
+ def _payload(tool_name: str, tool_input: object) -> dict[str, object]:
19
+ return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
20
+
21
+
22
+ def _run_hook(payload: dict[str, object]) -> str:
23
+ completed = subprocess.run(
24
+ [sys.executable, str(HOOK_SCRIPT)],
25
+ input=json.dumps(payload),
26
+ capture_output=True,
27
+ text=True,
28
+ check=True,
29
+ )
30
+ return completed.stdout
31
+
32
+
33
+ def test_artifact_quickstart_gets_the_template_pointer() -> None:
34
+ hook_output = json.loads(_run_hook(_payload("Artifact", {"action": "quickstart", "intent": "other"})))
35
+ specific_output = hook_output["hookSpecificOutput"]
36
+ assert specific_output["hookEventName"] == "PreToolUse"
37
+ assert str(ARTIFACT_TEMPLATE_PATH) in specific_output["additionalContext"]
38
+ assert "permissionDecision" not in specific_output
39
+
40
+
41
+ def test_artifact_design_skill_gets_the_template_pointer() -> None:
42
+ hook_output = artifact_template_pointer.decide_hook_output(
43
+ _payload("Skill", {"skill": "artifact-design"})
44
+ )
45
+ assert hook_output is not None
46
+ assert str(ARTIFACT_TEMPLATE_PATH) in hook_output["hookSpecificOutput"]["additionalContext"]
47
+
48
+
49
+ def test_artifact_publish_stays_quiet() -> None:
50
+ assert _run_hook(_payload("Artifact", {"action": "publish", "file_path": "page.html"})) == ""
51
+
52
+
53
+ def test_other_skill_stays_quiet() -> None:
54
+ assert artifact_template_pointer.decide_hook_output(_payload("Skill", {"skill": "pr-lifecycle"})) is None
55
+
56
+
57
+ def test_tool_input_that_is_not_an_object_stays_quiet() -> None:
58
+ assert artifact_template_pointer.decide_hook_output(_payload("Artifact", "quickstart")) is None
59
+
60
+
61
+ def test_empty_stdin_stays_quiet() -> None:
62
+ completed = subprocess.run(
63
+ [sys.executable, str(HOOK_SCRIPT)], input="", capture_output=True, text=True, check=True
64
+ )
65
+ assert completed.stdout == ""
@@ -10,7 +10,8 @@
10
10
  "system-prompts",
11
11
  "scripts",
12
12
  "_shared",
13
- "audit-rubrics"
13
+ "audit-rubrics",
14
+ "profile-settings"
14
15
  ],
15
16
  "root_files": [
16
17
  "AGENTS.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.54.9",
3
+ "version": "8.56.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "cde": "bin/cde.mjs"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test \"bin/*.test.mjs\" \".agents/skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\" \"scripts/ci/*.test.mjs\""
12
+ "test": "node --test --test-concurrency=1 \"bin/*.test.mjs\" \".agents/skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\" \"scripts/ci/*.test.mjs\""
13
13
  },
14
14
  "files": [
15
15
  "bin/",
@@ -25,6 +25,7 @@
25
25
  "settings.json",
26
26
  "installable-surfaces.manifest.json",
27
27
  "_shared/",
28
+ "profile-settings/",
28
29
  "audit-rubrics/",
29
30
  "codex-rules/",
30
31
  "pstack-codex-models.md",
@@ -0,0 +1 @@
1
+ """Constants package installed beside the Stop hook gate."""
@@ -0,0 +1,15 @@
1
+ """Constants for the session-title Stop hook gate."""
2
+
3
+ ALL_TITLE_TOOL_NAMES = frozenset(
4
+ {
5
+ "mcp__claude-code-remote__set_session_title",
6
+ "mcp__ccd_session_mgmt__set_session_title",
7
+ }
8
+ )
9
+ INSTRUCTION = (
10
+ "Call set_session_title (session id from get_session) before you stop. "
11
+ 'Title = "<emoji> <brief task name>", one line in a narrow sidebar. '
12
+ "\U0001f6a9 = Jon must act (question, approval, a step only he can do). "
13
+ "✅ = fully done (merged or nothing left). "
14
+ "⏳ = work, CI, or a merge queue still running and nothing waits on Jon."
15
+ )
@@ -0,0 +1,77 @@
1
+ """Stop hook that blocks until the session title is set with a status emoji."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import TextIO
6
+
7
+ from config.session_title_gate_constants import ALL_TITLE_TOOL_NAMES, INSTRUCTION
8
+
9
+
10
+ def _content_blocks(entry: dict) -> list:
11
+ content = entry.get("message", {}).get("content")
12
+ if isinstance(content, str):
13
+ return [{"type": "text", "text": content}]
14
+ return content if isinstance(content, list) else []
15
+
16
+
17
+ def _is_user_text(entry: dict) -> bool:
18
+ if entry.get("type") != "user":
19
+ return False
20
+ return any(
21
+ block.get("type") == "text" and block.get("text", "").strip()
22
+ for block in _content_blocks(entry)
23
+ if isinstance(block, dict)
24
+ )
25
+
26
+
27
+ def _is_title_call(entry: dict) -> bool:
28
+ if entry.get("type") != "assistant":
29
+ return False
30
+ return any(
31
+ block.get("type") == "tool_use" and block.get("name") in ALL_TITLE_TOOL_NAMES
32
+ for block in _content_blocks(entry)
33
+ if isinstance(block, dict)
34
+ )
35
+
36
+
37
+ def title_set_since_last_user_text(all_transcript_lines: list[str]) -> bool:
38
+ """Report whether a title call follows the newest user text in the transcript.
39
+
40
+ Args:
41
+ all_transcript_lines: The transcript's JSONL lines, oldest first.
42
+
43
+ Returns:
44
+ True when a set_session_title call comes after the last user text.
45
+ """
46
+ for each_line in reversed(all_transcript_lines):
47
+ if not each_line.strip():
48
+ continue
49
+ entry = json.loads(each_line)
50
+ if _is_title_call(entry):
51
+ return True
52
+ if _is_user_text(entry):
53
+ return False
54
+ return False
55
+
56
+
57
+ def main(decision_stream: TextIO) -> None:
58
+ """Block the stop with the title instruction when no title follows the last user text.
59
+
60
+ Args:
61
+ decision_stream: The stream that receives the Stop hook decision.
62
+ """
63
+ try:
64
+ payload = json.load(sys.stdin)
65
+ if payload.get("stop_hook_active"):
66
+ return
67
+ with open(payload["transcript_path"], encoding="utf-8") as transcript_file:
68
+ all_transcript_lines = transcript_file.readlines()
69
+ if title_set_since_last_user_text(all_transcript_lines):
70
+ return
71
+ except (OSError, KeyError, ValueError):
72
+ return
73
+ decision_stream.write(json.dumps({"decision": "block", "reason": INSTRUCTION}))
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main(sys.stdout)
@@ -0,0 +1,101 @@
1
+ """Run the installed session-title gate as the Stop hook runs it."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ GATE_SOURCE_DIRECTORY = Path(__file__).resolve().parent
10
+ GATE_FILE_NAME = "session_title_status_gate.py"
11
+ ALL_GATE_RELATIVE_PATHS = (
12
+ Path(GATE_FILE_NAME),
13
+ Path("config") / "__init__.py",
14
+ Path("config") / "session_title_gate_constants.py",
15
+ )
16
+ TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
17
+
18
+
19
+ def _install_gate(claude_directory: Path) -> Path:
20
+ for each_relative_path in ALL_GATE_RELATIVE_PATHS:
21
+ target_path = claude_directory / each_relative_path
22
+ target_path.parent.mkdir(parents=True, exist_ok=True)
23
+ shutil.copyfile(GATE_SOURCE_DIRECTORY / each_relative_path, target_path)
24
+ return claude_directory / GATE_FILE_NAME
25
+
26
+
27
+ def _user_text_entry(text: str) -> dict:
28
+ return {"type": "user", "message": {"content": text}}
29
+
30
+
31
+ def _title_call_entry() -> dict:
32
+ return {
33
+ "type": "assistant",
34
+ "message": {
35
+ "content": [{"type": "tool_use", "name": TITLE_TOOL_NAME, "input": {}}]
36
+ },
37
+ }
38
+
39
+
40
+ def _run_gate(
41
+ tmp_path: Path, all_entries: list[dict], is_stop_hook_active: bool = False
42
+ ) -> str:
43
+ gate_path = _install_gate(tmp_path / ".claude")
44
+ transcript_path = tmp_path / "transcript.jsonl"
45
+ transcript_path.write_text(
46
+ "\n".join(json.dumps(each_entry) for each_entry in all_entries) + "\n\n",
47
+ encoding="utf-8",
48
+ )
49
+ payload = {
50
+ "transcript_path": str(transcript_path),
51
+ "stop_hook_active": is_stop_hook_active,
52
+ }
53
+ gate_run = subprocess.run(
54
+ [sys.executable, str(gate_path)],
55
+ input=json.dumps(payload),
56
+ capture_output=True,
57
+ text=True,
58
+ encoding="utf-8",
59
+ check=True,
60
+ )
61
+ return gate_run.stdout
62
+
63
+
64
+ def should_allow_stop_when_title_follows_last_user_text(tmp_path: Path) -> None:
65
+ assert (
66
+ _run_gate(tmp_path, [_user_text_entry("fix the bug"), _title_call_entry()])
67
+ == ""
68
+ )
69
+
70
+
71
+ def should_block_stop_when_user_text_follows_last_title(tmp_path: Path) -> None:
72
+ gate_output = _run_gate(
73
+ tmp_path, [_title_call_entry(), _user_text_entry("one more thing")]
74
+ )
75
+
76
+ decision = json.loads(gate_output)
77
+ assert decision["decision"] == "block"
78
+ assert "set_session_title" in decision["reason"]
79
+
80
+
81
+ def should_allow_stop_when_stop_hook_is_already_active(tmp_path: Path) -> None:
82
+ assert (
83
+ _run_gate(tmp_path, [_user_text_entry("fix the bug")], is_stop_hook_active=True)
84
+ == ""
85
+ )
86
+
87
+
88
+ def should_allow_stop_when_transcript_is_missing(tmp_path: Path) -> None:
89
+ gate_path = _install_gate(tmp_path / ".claude")
90
+ payload = {"transcript_path": str(tmp_path / "absent.jsonl")}
91
+
92
+ gate_run = subprocess.run(
93
+ [sys.executable, str(gate_path)],
94
+ input=json.dumps(payload),
95
+ capture_output=True,
96
+ text=True,
97
+ encoding="utf-8",
98
+ check=True,
99
+ )
100
+
101
+ assert gate_run.stdout == ""
@@ -26,6 +26,10 @@ class PrivateTermDigest:
26
26
  sha256: str
27
27
 
28
28
 
29
+ OWNER_HANDLE_DIGEST = PrivateTermDigest(
30
+ length=7,
31
+ sha256="d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
32
+ )
29
33
  ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
30
34
  {
31
35
  PrivateTermDigest(
@@ -36,16 +40,16 @@ ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
36
40
  length=16,
37
41
  sha256="182cb82e3e973d041b8527659064153f8c4af79503bd198508d3655d9a932fdf",
38
42
  ),
39
- PrivateTermDigest(
40
- length=7,
41
- sha256="d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
42
- ),
43
+ OWNER_HANDLE_DIGEST,
43
44
  PrivateTermDigest(
44
45
  length=7,
45
46
  sha256="968e2d5b08687bf42997461cbdef6c844eabbf04f440cee888c95b864c2a4bcc",
46
47
  ),
47
48
  }
48
49
  )
50
+ ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
51
+ {OWNER_HANDLE_DIGEST}
52
+ )
49
53
  PRIVATE_TERM_TEXT_ENCODING = "utf-8"
50
54
  PRIVATE_TERM_FINDING_CODE = "private-term"
51
55
  PRIVATE_TERM_MESSAGE_TEMPLATE = (
@@ -19,6 +19,7 @@ from collections.abc import Sequence
19
19
  from pathlib import Path
20
20
 
21
21
  from dev_env_scripts_constants.private_term_constants import (
22
+ ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS,
22
23
  ALL_EVENT_TEXT_FIELDS,
23
24
  ALL_PRIVATE_TERM_DIGESTS,
24
25
  COMMIT_FIELD_SEPARATOR,
@@ -34,18 +35,23 @@ from dev_env_scripts_constants.private_term_constants import (
34
35
  PRIVATE_TERM_MESSAGE_TEMPLATE,
35
36
  PRIVATE_TERM_TEXT_ENCODING,
36
37
  SHORT_SHA_LENGTH,
38
+ PrivateTermDigest,
37
39
  )
38
40
  from private_terms import private_term_line_numbers
39
41
 
40
42
 
41
- def _findings_for(label: str, text: str) -> list[str]:
43
+ def _findings_for(
44
+ label: str,
45
+ text: str,
46
+ all_term_digests: frozenset[PrivateTermDigest],
47
+ ) -> list[str]:
42
48
  return [
43
49
  FINDING_LINE_TEMPLATE.format(
44
50
  label=label,
45
51
  message=PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
46
52
  )
47
53
  for each_line_number in private_term_line_numbers(
48
- text, ALL_PRIVATE_TERM_DIGESTS
54
+ text, all_term_digests
49
55
  )
50
56
  ]
51
57
 
@@ -67,7 +73,9 @@ def scan_event(event_path: Path) -> list[str]:
67
73
  continue
68
74
  text = event_object.get(each_field)
69
75
  if isinstance(text, str):
70
- all_findings.extend(_findings_for(each_label, text))
76
+ all_findings.extend(
77
+ _findings_for(each_label, text, ALL_PRIVATE_TERM_DIGESTS)
78
+ )
71
79
  return all_findings
72
80
 
73
81
 
@@ -80,10 +88,12 @@ def _commit_findings(record: str) -> list[str]:
80
88
  *_findings_for(
81
89
  COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_IDENTITY_PART),
82
90
  re.sub(GITHUB_NOREPLY_ADDRESS_PATTERN, MASKED_ADDRESS, identity, flags=re.IGNORECASE),
91
+ ALL_PRIVATE_TERM_DIGESTS - ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS,
83
92
  ),
84
93
  *_findings_for(
85
94
  COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_MESSAGE_PART),
86
95
  message,
96
+ ALL_PRIVATE_TERM_DIGESTS,
87
97
  ),
88
98
  ]
89
99
 
@@ -99,6 +109,8 @@ def scan_commits(repository_root: Path, commit_range: str) -> list[str]:
99
109
  One finding per commit identity or message line that names a private
100
110
  organization. An identity address ending in users.noreply.github.com
101
111
  is left out of the scan, since GitHub builds it from the account handle.
112
+ The owner's own handle passes in an identity and still fails in a
113
+ message.
102
114
  """
103
115
  log_text = subprocess.run(
104
116
  ["git", "-C", str(repository_root), "log", "--reverse", COMMIT_LOG_FORMAT, commit_range],
@@ -9,6 +9,7 @@ import sys
9
9
  from pathlib import Path
10
10
 
11
11
  import pytest
12
+ from dev_env_scripts_constants import private_term_constants
12
13
  from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
13
14
 
14
15
  _SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
@@ -18,7 +19,16 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
18
19
  import private_term_scan
19
20
 
20
21
  _FIXTURE_TERM = "acmewidget"
22
+ _FIXTURE_OWNER_HANDLE = "patowner"
21
23
  _CLEAN_IDENTITY = ("Claude", "noreply@example.com")
24
+ _OWNER_HANDLE = "ohcEnoJ"[::-1]
25
+ _OWNER_IDENTITY = (_OWNER_HANDLE, f"24366590+{_OWNER_HANDLE}@users.noreply.github.com")
26
+
27
+
28
+ def _fixture_digest(term: str) -> PrivateTermDigest:
29
+ return PrivateTermDigest(
30
+ length=len(term), sha256=hashlib.sha256(term.encode("utf-8")).hexdigest()
31
+ )
22
32
 
23
33
 
24
34
  @pytest.fixture(autouse=True)
@@ -26,14 +36,26 @@ def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
26
36
  monkeypatch.setattr(
27
37
  private_term_scan,
28
38
  "ALL_PRIVATE_TERM_DIGESTS",
29
- frozenset(
30
- {
31
- PrivateTermDigest(
32
- length=len(_FIXTURE_TERM),
33
- sha256=hashlib.sha256(_FIXTURE_TERM.encode("utf-8")).hexdigest(),
34
- )
35
- }
36
- ),
39
+ frozenset({_fixture_digest(_FIXTURE_TERM), _fixture_digest(_FIXTURE_OWNER_HANDLE)}),
40
+ )
41
+ monkeypatch.setattr(
42
+ private_term_scan,
43
+ "ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS",
44
+ frozenset({_fixture_digest(_FIXTURE_OWNER_HANDLE)}),
45
+ )
46
+
47
+
48
+ @pytest.fixture
49
+ def shipped_private_terms(monkeypatch: pytest.MonkeyPatch) -> None:
50
+ monkeypatch.setattr(
51
+ private_term_scan,
52
+ "ALL_PRIVATE_TERM_DIGESTS",
53
+ private_term_constants.ALL_PRIVATE_TERM_DIGESTS,
54
+ )
55
+ monkeypatch.setattr(
56
+ private_term_scan,
57
+ "ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS",
58
+ private_term_constants.ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS,
37
59
  )
38
60
 
39
61
 
@@ -190,6 +212,57 @@ def test_should_flag_a_private_name_beside_a_github_noreply_address(
190
212
  assert " identity:" in all_findings[0]
191
213
 
192
214
 
215
+ @pytest.mark.usefixtures("shipped_private_terms")
216
+ def test_should_accept_the_owner_handle_as_a_commit_identity(tmp_path: Path) -> None:
217
+ repository_root, base, head = _repository_with_commits(
218
+ tmp_path, [("feat: clean change", *_OWNER_IDENTITY)]
219
+ )
220
+ assert private_term_scan.scan_commits(repository_root, f"{base}..{head}") == []
221
+
222
+
223
+ @pytest.mark.usefixtures("shipped_private_terms")
224
+ def test_should_flag_the_owner_handle_in_a_commit_message(tmp_path: Path) -> None:
225
+ repository_root, base, head = _repository_with_commits(
226
+ tmp_path, [(f"docs: note\n\nWritten by {_OWNER_HANDLE}.", *_OWNER_IDENTITY)]
227
+ )
228
+ all_findings = private_term_scan.scan_commits(repository_root, f"{base}..{head}")
229
+ assert len(all_findings) == 1
230
+ assert " message: Line 3 " in all_findings[0]
231
+
232
+
233
+ def test_should_flag_a_private_name_in_the_message_of_an_owner_commit(
234
+ tmp_path: Path,
235
+ ) -> None:
236
+ repository_root, base, head = _repository_with_commits(
237
+ tmp_path,
238
+ [
239
+ (
240
+ "docs: note\n\nWritten for Acme Widgets.",
241
+ "Pat Owner",
242
+ "81234567+patowner@users.noreply.github.com",
243
+ )
244
+ ],
245
+ )
246
+ all_findings = private_term_scan.scan_commits(repository_root, f"{base}..{head}")
247
+ assert len(all_findings) == 1
248
+ assert " message: Line 3 " in all_findings[0]
249
+
250
+
251
+ def test_should_flag_a_private_name_in_a_pull_request_body_beside_an_accepted_handle(
252
+ tmp_path: Path,
253
+ ) -> None:
254
+ event_path = _write_event(
255
+ tmp_path,
256
+ {"pull_request": {"title": "fix: one", "body": "By Pat Owner\nFor Acme Widgets\n"}},
257
+ )
258
+ assert private_term_scan.scan_event(event_path) == [
259
+ "pull request body: Line 1 names a private organization. "
260
+ "Describe it in general terms and drop any link to it.",
261
+ "pull request body: Line 2 names a private organization. "
262
+ "Describe it in general terms and drop any link to it.",
263
+ ]
264
+
265
+
193
266
  def test_should_print_findings_and_fail_from_the_command_line(
194
267
  tmp_path: Path, capsys: pytest.CaptureFixture[str]
195
268
  ) -> None: