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.
- package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/docs/second-claude-account.md +8 -1
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
- package/hooks/session/skill_loaded_reminder.py +199 -0
- package/hooks/session/test_skill_loaded_reminder.py +206 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +51 -6
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +28 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- 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.
|
|
25
|
-
CHANGED_RUNNER_ADDRESS = "10.
|
|
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)
|