claude-dev-env 8.8.0 → 8.9.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 (36) hide show
  1. package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
  2. package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
  3. package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
  4. package/docs/second-claude-account.md +8 -1
  5. package/hooks/hooks.json +25 -0
  6. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
  7. package/hooks/session/skill_loaded_reminder.py +199 -0
  8. package/hooks/session/test_skill_loaded_reminder.py +206 -0
  9. package/package.json +1 -1
  10. package/rules/durable-post-artifacts.md +26 -1
  11. package/rules/prefer-existing-tools.md +35 -0
  12. package/rules/review-closure-is-a-check.md +1 -1
  13. package/scripts/claude_account_choice.py +51 -6
  14. package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
  15. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
  16. package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
  17. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
  18. package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
  19. package/scripts/durable_post_lint.py +47 -1
  20. package/scripts/durable_post_lint_config/config/constants.py +1 -0
  21. package/scripts/gh_artifact_upload.py +56 -3
  22. package/scripts/private_term_scan.py +135 -0
  23. package/scripts/private_terms.py +87 -0
  24. package/scripts/repository_checks/config/constants.py +3 -0
  25. package/scripts/repository_checks/runner.py +7 -0
  26. package/scripts/repository_checks/tracked_private_terms.py +57 -0
  27. package/scripts/test_claude_account_choice.py +28 -0
  28. package/scripts/test_durable_post_lint.py +90 -0
  29. package/scripts/tests/test_gh_artifact_upload.py +75 -0
  30. package/scripts/tests/test_match_exemptions.py +2 -2
  31. package/scripts/tests/test_private_term_scan.py +176 -0
  32. package/scripts/tests/test_private_terms.py +59 -0
  33. package/scripts/tests/test_repository_policy.py +2 -0
  34. package/scripts/tests/test_runner.py +20 -0
  35. package/scripts/tests/test_tracked_private_terms.py +71 -0
  36. package/scripts/tests/test_tracked_secrets.py +1 -1
@@ -0,0 +1,87 @@
1
+ """Find private organization names in text without holding the names.
2
+
3
+ ``private_term_line_numbers`` reports where a name appears and never the
4
+ name, so a caller can print its result in a public log.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import bisect
10
+ import hashlib
11
+ from collections.abc import Collection
12
+
13
+ from dev_env_scripts_constants.private_term_constants import (
14
+ ALL_PRIVATE_TERM_DIGESTS,
15
+ PRIVATE_TERM_TEXT_ENCODING,
16
+ PrivateTermDigest,
17
+ )
18
+
19
+
20
+ def _normalized_text_and_line_starts(text: str) -> tuple[str, list[int]]:
21
+ all_normalized_lines: list[str] = []
22
+ all_line_starts: list[int] = []
23
+ offset = 0
24
+ for each_line in text.split("\n"):
25
+ normalized_line = "".join(
26
+ each_character
27
+ for each_character in each_line.lower()
28
+ if each_character.isascii() and each_character.isalnum()
29
+ )
30
+ all_line_starts.append(offset)
31
+ all_normalized_lines.append(normalized_line)
32
+ offset += len(normalized_line)
33
+ return "".join(all_normalized_lines), all_line_starts
34
+
35
+
36
+ def _matching_window_starts(
37
+ normalized_text: str, length: int, all_wanted_digests: set[str]
38
+ ) -> list[int]:
39
+ return [
40
+ each_start
41
+ for each_start in range(len(normalized_text) - length + 1)
42
+ if hashlib.sha256(
43
+ normalized_text[each_start : each_start + length].encode(
44
+ PRIVATE_TERM_TEXT_ENCODING
45
+ )
46
+ ).hexdigest()
47
+ in all_wanted_digests
48
+ ]
49
+
50
+
51
+ def private_term_line_numbers(
52
+ text: str,
53
+ all_term_digests: Collection[PrivateTermDigest] = ALL_PRIVATE_TERM_DIGESTS,
54
+ ) -> tuple[int, ...]:
55
+ """Return the sorted one-based line numbers where a private name starts.
56
+
57
+ Args:
58
+ text: Text to scan.
59
+ all_term_digests: Digests of the normalized names to find.
60
+
61
+ Returns:
62
+ Each line number once. A name that wraps onto the next line reports
63
+ the line where it starts.
64
+ """
65
+ normalized_text, all_line_starts = _normalized_text_and_line_starts(text)
66
+ all_found_line_numbers: set[int] = set()
67
+ for each_length in {each_digest.length for each_digest in all_term_digests}:
68
+ all_wanted_digests = {
69
+ each_digest.sha256
70
+ for each_digest in all_term_digests
71
+ if each_digest.length == each_length
72
+ }
73
+ all_found_line_numbers.update(
74
+ bisect.bisect_right(all_line_starts, each_start)
75
+ for each_start in _matching_window_starts(
76
+ normalized_text, each_length, all_wanted_digests
77
+ )
78
+ )
79
+ return tuple(sorted(all_found_line_numbers))
80
+
81
+
82
+ def names_private_term(
83
+ text: str,
84
+ all_term_digests: Collection[PrivateTermDigest] = ALL_PRIVATE_TERM_DIGESTS,
85
+ ) -> bool:
86
+ """Return whether the text names any private organization."""
87
+ return bool(private_term_line_numbers(text, all_term_digests))
@@ -12,6 +12,7 @@ CHECK_ID_ENV_VAR_DOCUMENTATION = "env-var-documentation"
12
12
  CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
13
13
  CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
14
14
  CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
15
+ CHECK_ID_TRACKED_PRIVATE_TERMS = "tracked-private-terms"
15
16
 
16
17
  ALL_CHECK_IDS = (
17
18
  CHECK_ID_CLAUDE_MD_ORPHANS,
@@ -19,6 +20,7 @@ ALL_CHECK_IDS = (
19
20
  CHECK_ID_PACKAGE_INVENTORY,
20
21
  CHECK_ID_PYTEST_TESTPATHS,
21
22
  CHECK_ID_TRACKED_PERSONAL_DATA,
23
+ CHECK_ID_TRACKED_PRIVATE_TERMS,
22
24
  )
23
25
 
24
26
  SEVERITY_BREAKING = "breaking"
@@ -29,6 +31,7 @@ SEVERITY_BY_CHECK_ID: dict[str, str] = {
29
31
  CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
30
32
  CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
31
33
  CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
34
+ CHECK_ID_TRACKED_PRIVATE_TERMS: SEVERITY_BREAKING,
32
35
  }
33
36
 
34
37
  SUCCESS_EXIT_CODE = 0
@@ -14,6 +14,9 @@ from repository_checks.env_var_documentation import (
14
14
  from repository_checks.models import RepositoryCheckReport, RepositoryFinding
15
15
  from repository_checks.package_inventory import collect_package_inventory_findings
16
16
  from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
17
+ from repository_checks.tracked_private_terms import (
18
+ collect_tracked_private_term_findings,
19
+ )
17
20
  from repository_checks.tracked_secrets import collect_tracked_secret_findings
18
21
 
19
22
  RepositoryCollector = Callable[[Path, Sequence[str]], list[RepositoryFinding]]
@@ -90,4 +93,8 @@ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
90
93
  repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
91
94
  collect_tracked_secret_findings,
92
95
  ),
96
+ (
97
+ repository_constants.CHECK_ID_TRACKED_PRIVATE_TERMS,
98
+ collect_tracked_private_term_findings,
99
+ ),
93
100
  )
@@ -0,0 +1,57 @@
1
+ """Tracked-file check for private organization names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from dev_env_scripts_constants.private_term_constants import (
9
+ ALL_PRIVATE_TERM_DIGESTS,
10
+ PRIVATE_TERM_MESSAGE_TEMPLATE,
11
+ )
12
+ from policy_lint.config import constants as policy_constants
13
+ from private_terms import private_term_line_numbers
14
+
15
+ from repository_checks.config import constants as repository_constants
16
+ from repository_checks.models import RepositoryFinding
17
+
18
+
19
+ def _read_text_or_none(absolute_path: Path) -> str | None:
20
+ if not absolute_path.is_file():
21
+ return None
22
+ try:
23
+ return absolute_path.read_text(encoding=policy_constants.UTF8_ENCODING)
24
+ except UnicodeDecodeError:
25
+ return None
26
+
27
+
28
+ def collect_tracked_private_term_findings(
29
+ repository_root: Path, all_tracked_paths: Sequence[str]
30
+ ) -> list[RepositoryFinding]:
31
+ """Return one finding for each tracked line that names a private organization.
32
+
33
+ Args:
34
+ repository_root: Git repository root.
35
+ all_tracked_paths: Repository-relative tracked paths.
36
+
37
+ Returns:
38
+ Findings that carry the path and line number, never the name.
39
+ """
40
+ all_findings: list[RepositoryFinding] = []
41
+ for each_relative_path in all_tracked_paths:
42
+ if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
43
+ continue
44
+ posix_relative_path = each_relative_path.replace(
45
+ repository_constants.WINDOWS_PATH_SEPARATOR, policy_constants.PATH_SEPARATOR
46
+ )
47
+ all_findings.extend(
48
+ RepositoryFinding(
49
+ repository_constants.CHECK_ID_TRACKED_PRIVATE_TERMS,
50
+ posix_relative_path,
51
+ PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
52
+ )
53
+ for each_line_number in private_term_line_numbers(
54
+ content, ALL_PRIVATE_TERM_DIGESTS
55
+ )
56
+ )
57
+ return all_findings
@@ -158,6 +158,32 @@ class TestDecisionPayload:
158
158
  }
159
159
 
160
160
 
161
+ class TestMetersPayload:
162
+ def should_name_no_meters_for_an_unread_account(self) -> None:
163
+ assert choice.meters_payload(None) is None
164
+
165
+ def should_carry_each_used_percent_and_reset_time(self) -> None:
166
+ read_meters = meters(
167
+ session_used=12.0,
168
+ weekly_used=34.0,
169
+ weekly_resets_in=timedelta(days=3),
170
+ session_resets_in=timedelta(hours=1),
171
+ )
172
+ assert choice.meters_payload(read_meters) == {
173
+ "session_used_percent": 12.0,
174
+ "session_resets_at": "2026-09-22T22:00:00+00:00",
175
+ "weekly_used_percent": 34.0,
176
+ "weekly_resets_at": "2026-09-25T21:00:00+00:00",
177
+ }
178
+
179
+ def should_name_no_reset_time_the_meter_left_out(self) -> None:
180
+ read_meters = meters(session_used=1.0, weekly_used=None, weekly_resets_in=None)
181
+ payload = choice.meters_payload(read_meters)
182
+ assert payload is not None
183
+ assert payload["weekly_used_percent"] is None
184
+ assert payload["weekly_resets_at"] is None
185
+
186
+
161
187
  class TestMain:
162
188
  def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
163
189
  self,
@@ -190,6 +216,8 @@ class TestMain:
190
216
  assert exit_code == 0
191
217
  assert printed["account"] == choice.CHOICE_SECOND
192
218
  assert printed["config_dir"] == str(second_home)
219
+ assert printed["meters"]["main"] is None
220
+ assert printed["meters"]["second"]["weekly_used_percent"] == 10.0
193
221
 
194
222
  def should_print_no_config_directory_when_the_job_waits(
195
223
  self,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import subprocess
6
7
  import sys
7
8
  from pathlib import Path
@@ -13,6 +14,7 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
13
14
  sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
14
15
 
15
16
  import durable_post_lint
17
+ from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
16
18
 
17
19
  SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
18
20
  VALID_PR_BODY = """## Why
@@ -506,3 +508,91 @@ def test_a_release_body_carrying_a_contrast_stays_accepted() -> None:
506
508
  )
507
509
 
508
510
  assert all_findings == ()
511
+
512
+
513
+ _PRIVATE_FIXTURE_TERM = "acmewidget"
514
+ _ALL_PRIVATE_FIXTURE_DIGESTS = frozenset(
515
+ {
516
+ PrivateTermDigest(
517
+ length=len(_PRIVATE_FIXTURE_TERM),
518
+ sha256=hashlib.sha256(_PRIVATE_FIXTURE_TERM.encode("utf-8")).hexdigest(),
519
+ )
520
+ }
521
+ )
522
+
523
+
524
+ @pytest.fixture
525
+ def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
526
+ monkeypatch.setattr(
527
+ durable_post_lint, "ALL_PRIVATE_TERM_DIGESTS", _ALL_PRIVATE_FIXTURE_DIGESTS
528
+ )
529
+
530
+
531
+ @pytest.mark.usefixtures("private_fixture_terms")
532
+ def test_a_body_naming_a_private_organization_reports_its_line() -> None:
533
+ all_findings = durable_post_lint.lint_durable_post(
534
+ action="pr-comment",
535
+ title=None,
536
+ body_text="Fixed.\nSee Acme-Widgets-Inc/tools#12.\n",
537
+ )
538
+
539
+ assert [each_finding.code for each_finding in all_findings] == ["private-term"]
540
+ assert "Line 2" in all_findings[0].message
541
+ assert "Acme" not in all_findings[0].message
542
+
543
+
544
+ @pytest.mark.usefixtures("private_fixture_terms")
545
+ def test_a_title_naming_a_private_organization_reports() -> None:
546
+ all_findings = durable_post_lint.lint_durable_post(
547
+ action="pr-create",
548
+ title="fix: handle the acme widgets upload",
549
+ body_text=VALID_PR_BODY,
550
+ )
551
+
552
+ assert [each_finding.code for each_finding in all_findings] == ["private-term"]
553
+
554
+
555
+ @pytest.mark.usefixtures("private_fixture_terms")
556
+ def test_a_post_inside_the_private_organization_may_name_it() -> None:
557
+ all_findings = durable_post_lint.lint_durable_post(
558
+ action="pr-comment",
559
+ title=None,
560
+ body_text="See Acme-Widgets-Inc/tools#12.\n",
561
+ repository="Acme-Widgets-Inc/tools",
562
+ )
563
+
564
+ assert all_findings == ()
565
+
566
+
567
+ @pytest.mark.usefixtures("private_fixture_terms")
568
+ def test_a_post_to_a_public_repository_may_not_name_it() -> None:
569
+ all_findings = durable_post_lint.lint_durable_post(
570
+ action="pr-comment",
571
+ title=None,
572
+ body_text="See Acme-Widgets-Inc/tools#12.\n",
573
+ repository="pat/public-tools",
574
+ )
575
+
576
+ assert [each_finding.code for each_finding in all_findings] == ["private-term"]
577
+
578
+
579
+ def test_cli_accepts_a_repository(tmp_path: Path) -> None:
580
+ body_file = tmp_path / "body.md"
581
+ body_file.write_text("Comment body.\n", encoding="utf-8")
582
+ completed_process = subprocess.run(
583
+ [
584
+ sys.executable,
585
+ str(SCRIPT_PATH),
586
+ "--action",
587
+ "pr-comment",
588
+ "--repository",
589
+ "pat/public-tools",
590
+ "--body-file",
591
+ str(body_file),
592
+ ],
593
+ check=False,
594
+ capture_output=True,
595
+ text=True,
596
+ )
597
+ assert completed_process.returncode == 0
598
+ assert completed_process.stderr == ""
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import shutil
6
7
  import subprocess
7
8
  import sys
8
9
  import types
@@ -26,6 +27,15 @@ _DEFAULT_READBACK_ASSET = {
26
27
  }
27
28
 
28
29
 
30
+ _REAL_WHICH = shutil.which
31
+ _REAL_RUN = subprocess.run
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def oxipng_on_path(monkeypatch: pytest.MonkeyPatch) -> None:
36
+ monkeypatch.setattr(shutil, "which", lambda _name: "oxipng")
37
+
38
+
29
39
  def _make_gh_stub(
30
40
  recorded_calls: list[list[str]],
31
41
  view_return_code: int,
@@ -203,3 +213,68 @@ def test_upload_artifact_missing_file_raises(monkeypatch: pytest.MonkeyPatch) ->
203
213
  monkeypatch.setattr(subprocess, "run", _make_gh_stub([], view_return_code=0))
204
214
  with pytest.raises(mod.ArtifactUploadError):
205
215
  mod.upload_artifact("does_not_exist_9f3a.png", "owner/repo")
216
+
217
+
218
+ def test_upload_artifact_sends_the_oxipng_shrunk_png(
219
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
220
+ ) -> None:
221
+ image_module = pytest.importorskip("PIL.Image")
222
+ if _REAL_WHICH("oxipng") is None:
223
+ pytest.skip("oxipng is not on PATH")
224
+ monkeypatch.setattr(shutil, "which", _REAL_WHICH)
225
+ source_file = tmp_path / "contact_sheet.png"
226
+ image_module.new("RGB", (64, 64), (200, 40, 40)).save(
227
+ source_file, format="PNG", compress_level=0
228
+ )
229
+ source_bytes = source_file.read_bytes()
230
+ all_uploaded_sizes: list[int] = []
231
+ gh_stub = _make_gh_stub([], view_return_code=0)
232
+
233
+ def gh_stub_beside_live_oxipng(
234
+ all_command_arguments: list[str], **keyword_arguments: object
235
+ ) -> object:
236
+ if all_command_arguments[0] != "gh":
237
+ return _REAL_RUN(all_command_arguments, **keyword_arguments)
238
+ if "upload" in all_command_arguments:
239
+ staged_path = Path(all_command_arguments[4])
240
+ all_uploaded_sizes.append(staged_path.stat().st_size)
241
+ with image_module.open(staged_path) as uploaded:
242
+ assert uploaded.convert("RGB").tobytes() == bytes((200, 40, 40)) * 4096
243
+ return gh_stub(all_command_arguments, **keyword_arguments)
244
+
245
+ monkeypatch.setattr(subprocess, "run", gh_stub_beside_live_oxipng)
246
+
247
+ mod.upload_artifact(str(source_file), "owner/repo")
248
+
249
+ assert all_uploaded_sizes and all_uploaded_sizes[0] < len(source_bytes)
250
+ assert source_file.read_bytes() == source_bytes
251
+
252
+
253
+ def test_upload_artifact_refuses_a_png_when_oxipng_is_missing(
254
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
255
+ ) -> None:
256
+ source_file = tmp_path / "out.png"
257
+ source_file.write_bytes(b"binary")
258
+ recorded_calls: list[list[str]] = []
259
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
260
+ monkeypatch.setattr(subprocess, "run", _make_gh_stub(recorded_calls, view_return_code=0))
261
+
262
+ with pytest.raises(mod.ArtifactUploadError, match="oxipng"):
263
+ mod.upload_artifact(str(source_file), "owner/repo")
264
+
265
+ assert not any("upload" in call for call in recorded_calls)
266
+
267
+
268
+ def test_optimize_png_losslessly_leaves_other_formats_alone(
269
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
270
+ ) -> None:
271
+ source_file = tmp_path / "trace.zip"
272
+ source_file.write_bytes(b"PK archive")
273
+ recorded_calls: list[list[str]] = []
274
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
275
+ monkeypatch.setattr(subprocess, "run", _make_gh_stub(recorded_calls, view_return_code=0))
276
+
277
+ mod.optimize_png_losslessly(source_file)
278
+
279
+ assert source_file.read_bytes() == b"PK archive"
280
+ assert recorded_calls == []
@@ -21,8 +21,8 @@ from repository_checks.tracked_secrets import collect_tracked_secret_findings
21
21
  CONTACT = "fixture-contact@company.io"
22
22
  CHANGED_CONTACT = "changed-contact@company.io"
23
23
  DIGEST = hashlib.sha256(CONTACT.encode()).hexdigest()
24
- RUNNER_ADDRESS = "10.83.21.14"
25
- CHANGED_RUNNER_ADDRESS = "10.83.21.15"
24
+ RUNNER_ADDRESS = "10.0.0.14"
25
+ CHANGED_RUNNER_ADDRESS = "10.0.0.15"
26
26
  RUNNER_ADDRESS_DIGEST = hashlib.sha256(RUNNER_ADDRESS.encode()).hexdigest()
27
27
 
28
28
 
@@ -0,0 +1,176 @@
1
+ """Behavior tests for the GitHub event private-term scan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+ from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
13
+
14
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
15
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
16
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
17
+
18
+ import private_term_scan
19
+
20
+ _FIXTURE_TERM = "acmewidget"
21
+ _CLEAN_IDENTITY = ("Claude", "noreply@example.com")
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
26
+ monkeypatch.setattr(
27
+ private_term_scan,
28
+ "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
+ ),
37
+ )
38
+
39
+
40
+ def _write_event(tmp_path: Path, event: dict[str, object]) -> Path:
41
+ event_path = tmp_path / "event.json"
42
+ event_path.write_text(json.dumps(event), encoding="utf-8")
43
+ return event_path
44
+
45
+
46
+ def _git(repository_root: Path, *all_arguments: str, name: str, email: str) -> str:
47
+ completed = subprocess.run(
48
+ ["git", "-C", str(repository_root), *all_arguments],
49
+ check=True,
50
+ capture_output=True,
51
+ text=True,
52
+ env={
53
+ "GIT_AUTHOR_NAME": name,
54
+ "GIT_AUTHOR_EMAIL": email,
55
+ "GIT_COMMITTER_NAME": name,
56
+ "GIT_COMMITTER_EMAIL": email,
57
+ "GIT_CONFIG_GLOBAL": "/dev/null",
58
+ "GIT_CONFIG_NOSYSTEM": "1",
59
+ "HOME": str(repository_root),
60
+ },
61
+ )
62
+ return completed.stdout.strip()
63
+
64
+
65
+ def _repository_with_commits(
66
+ tmp_path: Path, all_commits: list[tuple[str, str, str]]
67
+ ) -> tuple[Path, str, str]:
68
+ repository_root = tmp_path / "repo"
69
+ repository_root.mkdir()
70
+ _git(
71
+ repository_root, "init", "-q", name=_CLEAN_IDENTITY[0], email=_CLEAN_IDENTITY[1]
72
+ )
73
+ _git(
74
+ repository_root,
75
+ "commit",
76
+ "-q",
77
+ "--allow-empty",
78
+ "-m",
79
+ "base",
80
+ name=_CLEAN_IDENTITY[0],
81
+ email=_CLEAN_IDENTITY[1],
82
+ )
83
+ base = _git(repository_root, "rev-parse", "HEAD", name="x", email="x@example.com")
84
+ for each_message, each_name, each_email in all_commits:
85
+ _git(
86
+ repository_root,
87
+ "commit",
88
+ "-q",
89
+ "--allow-empty",
90
+ "-m",
91
+ each_message,
92
+ name=each_name,
93
+ email=each_email,
94
+ )
95
+ head = _git(repository_root, "rev-parse", "HEAD", name="x", email="x@example.com")
96
+ return repository_root, base, head
97
+
98
+
99
+ def test_should_flag_a_pull_request_body_line(tmp_path: Path) -> None:
100
+ event_path = _write_event(
101
+ tmp_path,
102
+ {
103
+ "pull_request": {
104
+ "title": "fix: one",
105
+ "body": "Why\nSee Acme-Widgets-Inc/x#1\n",
106
+ }
107
+ },
108
+ )
109
+ all_findings = private_term_scan.scan_event(event_path)
110
+ assert all_findings == [
111
+ "pull request body: Line 2 names a private organization. "
112
+ "Describe it in general terms and drop any link to it."
113
+ ]
114
+
115
+
116
+ def test_should_flag_a_pull_request_title(tmp_path: Path) -> None:
117
+ event_path = _write_event(
118
+ tmp_path, {"pull_request": {"title": "fix: acme widget upload", "body": None}}
119
+ )
120
+ assert private_term_scan.scan_event(event_path)[0].startswith("pull request title:")
121
+
122
+
123
+ @pytest.mark.parametrize(
124
+ ("event", "label"),
125
+ [
126
+ ({"comment": {"body": "by acme widgets"}}, "comment body"),
127
+ ({"review": {"body": "by acme widgets"}}, "review body"),
128
+ ({"issue": {"title": "clean", "body": "by acme widgets"}}, "issue body"),
129
+ ({"release": {"name": "v1", "body": "by acme widgets"}}, "release body"),
130
+ ],
131
+ )
132
+ def test_should_flag_each_event_text(
133
+ tmp_path: Path, event: dict[str, object], label: str
134
+ ) -> None:
135
+ all_findings = private_term_scan.scan_event(_write_event(tmp_path, event))
136
+ assert len(all_findings) == 1
137
+ assert all_findings[0].startswith(f"{label}: Line 1")
138
+
139
+
140
+ def test_should_pass_a_clean_event(tmp_path: Path) -> None:
141
+ event_path = _write_event(
142
+ tmp_path,
143
+ {"comment": {"body": "Looks good."}, "issue": {"title": "t", "body": None}},
144
+ )
145
+ assert private_term_scan.scan_event(event_path) == []
146
+
147
+
148
+ def test_should_flag_a_commit_message_and_a_commit_identity(tmp_path: Path) -> None:
149
+ repository_root, base, head = _repository_with_commits(
150
+ tmp_path,
151
+ [
152
+ ("fix: clean change", "Pat", "pat@acmewidgets.example.com"),
153
+ ("docs: note\n\nWritten for Acme Widgets.", *_CLEAN_IDENTITY),
154
+ ("chore: clean", *_CLEAN_IDENTITY),
155
+ ],
156
+ )
157
+ all_findings = private_term_scan.scan_commits(repository_root, f"{base}..{head}")
158
+ assert len(all_findings) == 2
159
+ assert all_findings[0].startswith("commit ")
160
+ assert " identity:" in all_findings[0]
161
+ assert " message: Line 3 " in all_findings[1]
162
+ assert all("acme" not in each_finding.lower() for each_finding in all_findings)
163
+
164
+
165
+ def test_should_print_findings_and_fail_from_the_command_line(
166
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
167
+ ) -> None:
168
+ event_path = _write_event(tmp_path, {"comment": {"body": "by acme widgets"}})
169
+ exit_code = private_term_scan.main(["--event-path", str(event_path)])
170
+ assert exit_code == 1
171
+ assert "comment body: Line 1" in capsys.readouterr().out
172
+
173
+
174
+ def test_should_pass_from_the_command_line_on_a_clean_event(tmp_path: Path) -> None:
175
+ event_path = _write_event(tmp_path, {"comment": {"body": "fine"}})
176
+ assert private_term_scan.main(["--event-path", str(event_path)]) == 0
@@ -0,0 +1,59 @@
1
+ """Behavior tests for the hashed private-term matcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
10
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
11
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
12
+
13
+ from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
14
+ from private_terms import names_private_term, private_term_line_numbers
15
+
16
+ _FIXTURE_TERM = "acmewidget"
17
+ _ALL_FIXTURE_DIGESTS = frozenset(
18
+ {
19
+ PrivateTermDigest(
20
+ length=len(_FIXTURE_TERM),
21
+ sha256=hashlib.sha256(_FIXTURE_TERM.encode("utf-8")).hexdigest(),
22
+ )
23
+ }
24
+ )
25
+
26
+
27
+ def test_should_report_the_line_of_a_spaced_spelling() -> None:
28
+ text = "first line\nBuilt for Acme Widgets, Inc. last year\nthird line\n"
29
+ assert private_term_line_numbers(text, _ALL_FIXTURE_DIGESTS) == (2,)
30
+
31
+
32
+ def test_should_report_a_hyphenated_owner_in_a_link() -> None:
33
+ text = "See https://github.com/Acme-Widgets-Inc/tools/pull/12 for detail."
34
+ assert private_term_line_numbers(text, _ALL_FIXTURE_DIGESTS) == (1,)
35
+
36
+
37
+ def test_should_report_an_email_domain() -> None:
38
+ text = "Co-authored-by: Pat <pat@ACMEWIDGETS.EXAMPLE.COM>"
39
+ assert private_term_line_numbers(text, _ALL_FIXTURE_DIGESTS) == (1,)
40
+
41
+
42
+ def test_should_report_a_term_split_across_a_line_break_at_its_first_line() -> None:
43
+ text = "one\nsold by Acme\nWidgets today\n"
44
+ assert private_term_line_numbers(text, _ALL_FIXTURE_DIGESTS) == (2,)
45
+
46
+
47
+ def test_should_report_each_line_once() -> None:
48
+ text = "acme widget and acme-widget\nclean\nAcmeWidget\n"
49
+ assert private_term_line_numbers(text, _ALL_FIXTURE_DIGESTS) == (1, 3)
50
+
51
+
52
+ def test_should_report_nothing_for_clean_text() -> None:
53
+ text = "An acme of widgets.\nWidget acme.\n"
54
+ assert private_term_line_numbers(text, _ALL_FIXTURE_DIGESTS) == ()
55
+ assert not names_private_term(text, _ALL_FIXTURE_DIGESTS)
56
+
57
+
58
+ def test_should_answer_whether_text_names_a_term() -> None:
59
+ assert names_private_term("Acme-Widgets-Inc", _ALL_FIXTURE_DIGESTS)