claude-dev-env 8.6.1 → 8.8.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 (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,59 @@
1
+ """Report authored prose that defines its subject against a rejected reading.
2
+
3
+ A contrast leaves the reader holding two readings where one would do. The
4
+ detector lives in ``scripts/contrast_framing.py`` and reads its pattern list
5
+ from ``scripts/dev_env_scripts_constants/contrast_framing_constants.py``, the
6
+ same list the durable post linter reads.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from contrast_framing import describe_contrast_framing, find_contrast_framing
14
+
15
+ from .config import constants
16
+ from .model import Diagnostic, Document, Location, Severity
17
+
18
+
19
+ def accepts_authored_markdown(document: Document) -> bool:
20
+ """Return whether the document is Markdown an author wrote.
21
+
22
+ Args:
23
+ document: Candidate document.
24
+
25
+ Returns:
26
+ True for Markdown outside the generated documents.
27
+ """
28
+ if document.path.suffix.lower() not in constants.ALL_MARKDOWN_SUFFIXES:
29
+ return False
30
+ return document.path.name.lower() not in constants.ALL_GENERATED_DOCUMENT_NAMES
31
+
32
+
33
+ def contrast_framing_diagnostics(
34
+ document: Document, repository_root: Path
35
+ ) -> tuple[Diagnostic, ...]:
36
+ """Report each contrast-framing occurrence in one document.
37
+
38
+ Args:
39
+ document: Current document text and path.
40
+ repository_root: Request repository root, unused by this detector.
41
+
42
+ Returns:
43
+ Contrast-framing diagnostics in document order.
44
+ """
45
+ del repository_root
46
+ all_lines = document.text.splitlines()
47
+ return tuple(
48
+ Diagnostic(
49
+ constants.CONTRAST_FRAMING_RULE_ID,
50
+ Severity.ERROR,
51
+ describe_contrast_framing(
52
+ all_lines[each_line_number - 1], each_form_name
53
+ ),
54
+ Location(document.path, each_line_number, each_column),
55
+ )
56
+ for each_line_number, each_column, each_form_name in find_contrast_framing(
57
+ document.text
58
+ )
59
+ )
@@ -10,6 +10,7 @@ from shared_tree_paths import resolve_shared_scripts_directory
10
10
 
11
11
  from . import (
12
12
  adapter_configuration,
13
+ adapter_contrast_framing,
13
14
  adapter_detectors,
14
15
  adapter_pairing,
15
16
  adapter_retired_hook_prose,
@@ -438,6 +439,35 @@ def accepts_instruction_markdown(document: Document) -> bool:
438
439
  return adapter_retired_hook_prose.accepts_instruction_markdown(document)
439
440
 
440
441
 
442
+ def accepts_authored_markdown(document: Document) -> bool:
443
+ """Return whether the document is Markdown an author wrote.
444
+
445
+ Args:
446
+ document: Candidate document.
447
+
448
+ Returns:
449
+ True for Markdown outside the generated documents.
450
+ """
451
+ return adapter_contrast_framing.accepts_authored_markdown(document)
452
+
453
+
454
+ def contrast_framing_diagnostics(
455
+ document: Document, repository_root: Path
456
+ ) -> tuple[Diagnostic, ...]:
457
+ """Report prose that defines its subject against a rejected reading.
458
+
459
+ Args:
460
+ document: Current Markdown text and path.
461
+ repository_root: Request repository root.
462
+
463
+ Returns:
464
+ Contrast-framing diagnostics.
465
+ """
466
+ return adapter_contrast_framing.contrast_framing_diagnostics(
467
+ document, repository_root
468
+ )
469
+
470
+
441
471
  def retired_hook_prose_diagnostics(
442
472
  document: Document, repository_root: Path
443
473
  ) -> tuple[Diagnostic, ...]:
@@ -168,3 +168,5 @@ RETIRED_HOOK_PROSE_MESSAGE = (
168
168
  "reaches no tool call. Say what the reader faces now, or drop the "
169
169
  "present-tense action."
170
170
  )
171
+
172
+ CONTRAST_FRAMING_RULE_ID = "contrast-framing"
@@ -104,6 +104,11 @@ def _text_document_rules() -> tuple[model.Rule, ...]:
104
104
  adapters.accepts_workflow,
105
105
  adapters.workflow_substitution_diagnostics,
106
106
  ),
107
+ build_document_rule(
108
+ "contrast-framing",
109
+ adapters.accepts_authored_markdown,
110
+ adapters.contrast_framing_diagnostics,
111
+ ),
107
112
  build_document_rule(
108
113
  "retired-hook-prose",
109
114
  adapters.accepts_instruction_markdown,
@@ -21,6 +21,16 @@ ALL_CHECK_IDS = (
21
21
  CHECK_ID_TRACKED_PERSONAL_DATA,
22
22
  )
23
23
 
24
+ SEVERITY_BREAKING = "breaking"
25
+ SEVERITY_SMELL = "smell"
26
+ SEVERITY_BY_CHECK_ID: dict[str, str] = {
27
+ CHECK_ID_CLAUDE_MD_ORPHANS: SEVERITY_BREAKING,
28
+ CHECK_ID_ENV_VAR_DOCUMENTATION: SEVERITY_BREAKING,
29
+ CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
30
+ CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
31
+ CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
32
+ }
33
+
24
34
  SUCCESS_EXIT_CODE = 0
25
35
  FINDINGS_EXIT_CODE = 1
26
36
  FAILED_CHECK_EXIT_CODE = INCOMPLETE_EXIT_CODE
@@ -28,6 +38,7 @@ USAGE_EXIT_CODE = INVALID_INPUT_EXIT_CODE
28
38
 
29
39
  REPOSITORY_ROOT_FLAG = "--repository-root"
30
40
  FINDING_LINE_TEMPLATE = "{check_id}: {relative_path}: {message}"
41
+ ADVISORY_FINDING_LINE_TEMPLATE = "advisory: {check_id}: {relative_path}: {message}"
31
42
  RULE_FAILED_LINE_TEMPLATE = "error: rule failed: {check_id}"
32
43
  REPORT_LINE_SEPARATOR = "\n"
33
44
  EMPTY_REPORT_TEXT = ""
@@ -57,6 +68,7 @@ PACKAGE_INVENTORY_CONSTANTS_MODULE_NAME = (
57
68
  )
58
69
  PYTEST_TESTPATHS_MODULE_NAME = "blocking.pytest_testpaths_orphan_blocker"
59
70
  PII_SCANNER_MODULE_NAME = "blocking.pii_scanner"
71
+ FOLLOWUP_LEDGER_MODULE_NAME = "followup_ledger"
60
72
  REPOSITORY_EXEMPTION_MODULE_NAME = (
61
73
  "blocking.pii_prevention_blocker_parts.repository_exemption"
62
74
  )
@@ -1,6 +1,7 @@
1
1
  """Repository policy document and exception-entry schema."""
2
2
 
3
3
  import re
4
+ from typing import NamedTuple
4
5
 
5
6
  CONFIG_RELATIVE_PATH = "config/repository-policy.json"
6
7
  DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}")
@@ -11,9 +12,34 @@ FIRST_PRINTABLE_CODEPOINT = 32
11
12
  VERSION_FIELD = "version"
12
13
  SUPPORTED_DOCUMENT_VERSION = 1
13
14
  EMAIL_EXEMPTIONS_FIELD = "email_exemptions"
15
+ PRIVATE_IP_EXEMPTIONS_FIELD = "private_ip_exemptions"
14
16
  PATH_EXEMPTIONS_FIELD = "path_exemptions"
15
- ALL_EXEMPTION_FIELDS = frozenset({EMAIL_EXEMPTIONS_FIELD, PATH_EXEMPTIONS_FIELD})
16
- ALL_DOCUMENT_FIELDS = ALL_EXEMPTION_FIELDS | {VERSION_FIELD}
17
17
  EMAIL_EXEMPTION_SUBJECT = "Email exemption"
18
+ PRIVATE_IP_EXEMPTION_SUBJECT = "Private IP exemption"
18
19
  PATH_EXEMPTION_SUBJECT = "Path exemption"
19
20
  EMAIL_CATEGORY = "email"
21
+ PRIVATE_IP_CATEGORY = "private-ip"
22
+
23
+
24
+ class MatchExemptionFamily(NamedTuple):
25
+ """One scanner category cleared by a committed path and match digest."""
26
+
27
+ field_name: str
28
+ category: str
29
+ subject: str
30
+
31
+
32
+ EMAIL_EXEMPTION_FAMILY = MatchExemptionFamily(
33
+ EMAIL_EXEMPTIONS_FIELD, EMAIL_CATEGORY, EMAIL_EXEMPTION_SUBJECT
34
+ )
35
+ PRIVATE_IP_EXEMPTION_FAMILY = MatchExemptionFamily(
36
+ PRIVATE_IP_EXEMPTIONS_FIELD, PRIVATE_IP_CATEGORY, PRIVATE_IP_EXEMPTION_SUBJECT
37
+ )
38
+ ALL_MATCH_EXEMPTION_FAMILIES = (
39
+ EMAIL_EXEMPTION_FAMILY,
40
+ PRIVATE_IP_EXEMPTION_FAMILY,
41
+ )
42
+ ALL_EXEMPTION_FIELDS = frozenset(
43
+ each_family.field_name for each_family in ALL_MATCH_EXEMPTION_FAMILIES
44
+ ) | {PATH_EXEMPTIONS_FIELD}
45
+ ALL_DOCUMENT_FIELDS = ALL_EXEMPTION_FIELDS | {VERSION_FIELD}
@@ -0,0 +1,54 @@
1
+ """Record advisory committed-tree findings in the follow-up ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from types import ModuleType
7
+
8
+ from repository_checks.config.constants import (
9
+ ALL_FAIL_CLOSED_EXCEPTION_TYPES,
10
+ FOLLOWUP_LEDGER_MODULE_NAME,
11
+ SEVERITY_SMELL,
12
+ )
13
+ from repository_checks.hook_modules import load_hooks_module
14
+ from repository_checks.models import RepositoryCheckReport, RepositoryFinding
15
+
16
+
17
+ def record_smell_findings(repository_root: Path, report: RepositoryCheckReport) -> None:
18
+ """Append every advisory finding to the repository's follow-up ledger.
19
+
20
+ The ledger keeps the gate's decision unchanged, so a ledger that cannot be
21
+ loaded or written leaves the report's exit code as it stands.
22
+
23
+ Args:
24
+ repository_root: The repository whose ledger receives the findings.
25
+ report: The findings this run produced.
26
+ """
27
+ if not report.all_smell_findings:
28
+ return
29
+ try:
30
+ ledger = load_hooks_module(FOLLOWUP_LEDGER_MODULE_NAME)
31
+ origin_commit = ledger.head_commit(repository_root)
32
+ for each_finding in report.all_smell_findings:
33
+ _append_finding(ledger, repository_root, each_finding, origin_commit)
34
+ except ALL_FAIL_CLOSED_EXCEPTION_TYPES:
35
+ return
36
+
37
+
38
+ def _append_finding(
39
+ ledger: ModuleType,
40
+ repository_root: Path,
41
+ finding: RepositoryFinding,
42
+ origin_commit: str,
43
+ ) -> None:
44
+ ledger.record_followup_finding(
45
+ repository_root,
46
+ ledger.FollowupFinding(
47
+ finding.check_id,
48
+ finding.relative_path,
49
+ finding.message,
50
+ finding.check_id,
51
+ SEVERITY_SMELL,
52
+ origin_commit,
53
+ ),
54
+ )
@@ -0,0 +1,67 @@
1
+ """Load the scanner matches a repository owns through its policy document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from repository_checks.config.policy_document import (
8
+ ALL_MATCH_EXEMPTION_FAMILIES,
9
+ MatchExemptionFamily,
10
+ )
11
+ from repository_checks.policy_document import (
12
+ PolicyConfigurationRunFatal,
13
+ entry_path_and_digest,
14
+ read_policy_entries,
15
+ )
16
+
17
+
18
+ def load_match_exemptions(
19
+ repository_root: Path, family: MatchExemptionFamily
20
+ ) -> frozenset[tuple[str, str, str]]:
21
+ """Read one family of matches the repository owns.
22
+
23
+ ::
24
+
25
+ the email family over a document owning one address
26
+ ok: {("contacts.py", "email", "<digest of the address>")}
27
+
28
+ Args:
29
+ repository_root: Repository containing the optional policy configuration.
30
+ family: Document field, scanner category, and rejection subject to read.
31
+ Returns:
32
+ Exact path, scanner category, and digest identities.
33
+ Raises:
34
+ ValueError: Configuration contains invalid JSON or schema fields.
35
+ OSError: Configuration cannot be read.
36
+ """
37
+ exemptions: set[tuple[str, str, str]] = set()
38
+ for each_entry in read_policy_entries(repository_root, family.field_name):
39
+ relative_path, digest = entry_path_and_digest(each_entry, family.subject)
40
+ identity = (relative_path, family.category, digest)
41
+ if identity in exemptions:
42
+ raise PolicyConfigurationRunFatal(
43
+ f"Duplicate repository {family.subject.lower()}"
44
+ )
45
+ exemptions.add(identity)
46
+ return frozenset(exemptions)
47
+
48
+
49
+ def load_all_match_exemptions(
50
+ repository_root: Path,
51
+ ) -> frozenset[tuple[str, str, str]]:
52
+ """Read every family of matches the repository owns.
53
+
54
+ Args:
55
+ repository_root: Repository containing the optional policy configuration.
56
+ Returns:
57
+ Exact path, scanner category, and digest identities across all families.
58
+ Raises:
59
+ ValueError: Configuration contains invalid JSON or schema fields.
60
+ OSError: Configuration cannot be read.
61
+ """
62
+ return frozenset().union(
63
+ *(
64
+ load_match_exemptions(repository_root, each_family)
65
+ for each_family in ALL_MATCH_EXEMPTION_FAMILIES
66
+ )
67
+ )
@@ -7,10 +7,28 @@ from dataclasses import dataclass
7
7
  from repository_checks.config.constants import (
8
8
  FAILED_CHECK_EXIT_CODE,
9
9
  FINDINGS_EXIT_CODE,
10
+ SEVERITY_BREAKING,
11
+ SEVERITY_BY_CHECK_ID,
12
+ SEVERITY_SMELL,
10
13
  SUCCESS_EXIT_CODE,
11
14
  )
12
15
 
13
16
 
17
+ def severity_for_check_id(check_id: str) -> str:
18
+ """Return the severity a check identifier declares.
19
+
20
+ A check identifier absent from the table reads as breaking, so a check
21
+ added without a severity row fails the tree rather than passing quietly.
22
+
23
+ Args:
24
+ check_id: Stable identifier of the check that raised a finding.
25
+
26
+ Returns:
27
+ Either SEVERITY_BREAKING or SEVERITY_SMELL.
28
+ """
29
+ return SEVERITY_BY_CHECK_ID.get(check_id, SEVERITY_BREAKING)
30
+
31
+
14
32
  @dataclass(frozen=True)
15
33
  class RepositoryFinding:
16
34
  """One committed-tree finding with a stable check id and relative path."""
@@ -19,19 +37,45 @@ class RepositoryFinding:
19
37
  relative_path: str
20
38
  message: str
21
39
 
40
+ @property
41
+ def severity(self) -> str:
42
+ """Return the severity this finding's check identifier declares."""
43
+ return severity_for_check_id(self.check_id)
44
+
22
45
 
23
46
  @dataclass(frozen=True)
24
47
  class RepositoryCheckReport:
25
- """Sorted findings and the check ids that failed while they ran."""
48
+ """Sorted findings and the check ids that failed while they ran.
49
+
50
+ Splits its findings into the breaking ones and the advisory ones, and
51
+ derives the exit code from the breaking ones alone.
52
+ """
26
53
 
27
54
  all_findings: tuple[RepositoryFinding, ...]
28
55
  all_failed_check_ids: tuple[str, ...]
29
56
 
57
+ @property
58
+ def all_breaking_findings(self) -> tuple[RepositoryFinding, ...]:
59
+ """Return the findings that fail the tree."""
60
+ return self._findings_of_severity(SEVERITY_BREAKING)
61
+
62
+ @property
63
+ def all_smell_findings(self) -> tuple[RepositoryFinding, ...]:
64
+ """Return the findings that report and leave the tree passing."""
65
+ return self._findings_of_severity(SEVERITY_SMELL)
66
+
30
67
  @property
31
68
  def exit_code(self) -> int:
32
69
  """Return the process status for this report."""
33
70
  if self.all_failed_check_ids:
34
71
  return FAILED_CHECK_EXIT_CODE
35
- if self.all_findings:
72
+ if self.all_breaking_findings:
36
73
  return FINDINGS_EXIT_CODE
37
74
  return SUCCESS_EXIT_CODE
75
+
76
+ def _findings_of_severity(self, severity: str) -> tuple[RepositoryFinding, ...]:
77
+ return tuple(
78
+ each_finding
79
+ for each_finding in self.all_findings
80
+ if each_finding.severity == severity
81
+ )
@@ -3,12 +3,21 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from repository_checks.config import constants as repository_constants
6
- from repository_checks.models import RepositoryCheckReport
6
+ from repository_checks.models import RepositoryCheckReport, RepositoryFinding
7
7
 
8
8
 
9
9
  def render_report(report: RepositoryCheckReport) -> str:
10
10
  """Render findings and failed checks as newline-terminated text.
11
11
 
12
+ An advisory finding carries an ``advisory:`` prefix, so a reader of a
13
+ passing run can tell the reported line from one that failed the tree.
14
+
15
+ ::
16
+
17
+ claude-md-orphans: notes/CLAUDE.md: references missing file x.py
18
+ advisory: package-inventory: pipeline/seam.py: production file is
19
+ absent from package inventory
20
+
12
21
  Args:
13
22
  report: Findings and failed check identifiers.
14
23
 
@@ -16,12 +25,7 @@ def render_report(report: RepositoryCheckReport) -> str:
16
25
  The report body, or an empty string when the tree is clean.
17
26
  """
18
27
  all_lines = [
19
- repository_constants.FINDING_LINE_TEMPLATE.format(
20
- check_id=each_finding.check_id,
21
- relative_path=each_finding.relative_path,
22
- message=each_finding.message,
23
- )
24
- for each_finding in report.all_findings
28
+ _finding_line(each_finding) for each_finding in report.all_findings
25
29
  ]
26
30
  all_lines.extend(_failed_check_lines(report))
27
31
  if not all_lines:
@@ -32,6 +36,18 @@ def render_report(report: RepositoryCheckReport) -> str:
32
36
  )
33
37
 
34
38
 
39
+ def _finding_line(finding: RepositoryFinding) -> str:
40
+ if finding.severity == repository_constants.SEVERITY_SMELL:
41
+ line_template = repository_constants.ADVISORY_FINDING_LINE_TEMPLATE
42
+ else:
43
+ line_template = repository_constants.FINDING_LINE_TEMPLATE
44
+ return line_template.format(
45
+ check_id=finding.check_id,
46
+ relative_path=finding.relative_path,
47
+ message=finding.message,
48
+ )
49
+
50
+
35
51
  def _failed_check_lines(report: RepositoryCheckReport) -> list[str]:
36
52
  return [
37
53
  repository_constants.RULE_FAILED_LINE_TEMPLATE.format(check_id=each_check_id)
@@ -10,8 +10,8 @@ from types import ModuleType
10
10
  from policy_lint.config import constants as policy_constants
11
11
 
12
12
  from repository_checks.config import constants as repository_constants
13
- from repository_checks.email_exemptions import load_email_exemptions
14
13
  from repository_checks.hook_modules import load_hooks_module
14
+ from repository_checks.match_exemptions import load_all_match_exemptions
15
15
  from repository_checks.models import RepositoryFinding
16
16
  from repository_checks.path_exemptions import load_path_exemptions
17
17
 
@@ -31,7 +31,7 @@ def collect_tracked_secret_findings(
31
31
  scanner = load_hooks_module(repository_constants.PII_SCANNER_MODULE_NAME)
32
32
  exemption = load_hooks_module(repository_constants.REPOSITORY_EXEMPTION_MODULE_NAME)
33
33
  all_allowlisted_literals = exemption.repository_allowlisted_values(repository_root)
34
- all_exact_exemptions = load_email_exemptions(repository_root)
34
+ all_exact_exemptions = load_all_match_exemptions(repository_root)
35
35
  all_exact_exemptions |= repository_constants.ALL_TRACKED_SECRET_EXACT_EXEMPTIONS
36
36
  all_path_exemptions = load_path_exemptions(repository_root)
37
37
  all_findings: list[RepositoryFinding] = []
@@ -16,6 +16,7 @@ from repository_checks.config.constants import (
16
16
  REPOSITORY_ROOT_FLAG,
17
17
  USAGE_EXIT_CODE,
18
18
  )
19
+ from repository_checks.followups import record_smell_findings
19
20
  from repository_checks.reporting import render_report
20
21
  from repository_checks.runner import run_repository_checks
21
22
 
@@ -90,8 +91,8 @@ def main(
90
91
  stderr: Error stream.
91
92
 
92
93
  Returns:
93
- Zero when the tree is clean, one for findings, two for usage errors,
94
- and three when a check fails closed.
94
+ Zero when the tree carries no breaking finding, one for a breaking
95
+ finding, two for usage errors, and three when a check fails closed.
95
96
  """
96
97
  try:
97
98
  namespace = _build_parser().parse_args(list(all_arguments))
@@ -102,6 +103,7 @@ def main(
102
103
  if namespace.repository_root is not None:
103
104
  selected_root = Path(namespace.repository_root).resolve()
104
105
  report = run_repository_checks(selected_root)
106
+ record_smell_findings(selected_root, report)
105
107
  stdout.write(render_report(report))
106
108
  return report.exit_code
107
109
 
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Report whether every review finding on a pull request head is answered.
3
+
4
+ ::
5
+
6
+ python3 review_closure.py jl-cmd/claude-dev-env 1442
7
+ CLOSED jl-cmd/claude-dev-env#1442 ab845eb :: every review finding on this
8
+ commit is answered
9
+
10
+ A finding waits until the agent driving the pull request replies to it or
11
+ pushes the fix. Exit status is 0 for CLOSED, 1 for OPEN, and 2 when the pull
12
+ request state could not be read.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+
22
+ from dev_env_scripts_constants.review_closure_constants import (
23
+ CLOSED_EXIT_CODE,
24
+ COMMAND_DESCRIPTION,
25
+ DRIVER_LOGIN_ARGUMENT_HELP,
26
+ ERROR_EXIT_CODE,
27
+ FINDING_LINE_TEMPLATE,
28
+ NUMBER_ARGUMENT_HELP,
29
+ OPEN_EXIT_CODE,
30
+ SLUG_ARGUMENT_HELP,
31
+ )
32
+ from pr_verification.github_parsing import GitHubError
33
+ from review_closure_github import (
34
+ github_token,
35
+ read_approvals_conclusion,
36
+ read_pull_request,
37
+ read_review_threads,
38
+ )
39
+ from review_closure_model import (
40
+ OpenFinding,
41
+ all_open_findings,
42
+ driver_logins,
43
+ head_sha,
44
+ verdict_line,
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ClosureReport:
50
+ """What one run of this command found on a pull request."""
51
+
52
+ verdict: str
53
+ all_findings: tuple[OpenFinding, ...]
54
+
55
+
56
+ def build_parser() -> argparse.ArgumentParser:
57
+ """Build the command line this script accepts.
58
+
59
+ Returns:
60
+ A parser taking a repository slug, a pull request number, and any
61
+ number of extra driver logins.
62
+ """
63
+ parser = argparse.ArgumentParser(description=COMMAND_DESCRIPTION)
64
+ parser.add_argument("slug", help=SLUG_ARGUMENT_HELP)
65
+ parser.add_argument("number", type=int, help=NUMBER_ARGUMENT_HELP)
66
+ parser.add_argument(
67
+ "--driver-login",
68
+ action="append",
69
+ default=[],
70
+ help=DRIVER_LOGIN_ARGUMENT_HELP,
71
+ )
72
+ return parser
73
+
74
+
75
+ def closure_report(
76
+ slug: str, number: int, all_extra_logins: Sequence[str], token: str
77
+ ) -> ClosureReport:
78
+ """Read one pull request and judge the findings on its head.
79
+
80
+ Args:
81
+ slug: The repository as ``owner/name``.
82
+ number: The pull request number.
83
+ all_extra_logins: Logins that count as the driving agent beside the
84
+ account that opened the pull request.
85
+ token: The GitHub token the reads authenticate with.
86
+
87
+ Returns:
88
+ The verdict line and each finding that waits.
89
+
90
+ Raises:
91
+ GitHubError: The pull request state could not be read.
92
+ """
93
+ all_pull_request_fields = read_pull_request(slug, number, token)
94
+ all_findings = all_open_findings(
95
+ read_review_threads(slug, number, token),
96
+ driver_logins(all_pull_request_fields, all_extra_logins),
97
+ read_approvals_conclusion(slug, head_sha(all_pull_request_fields), token),
98
+ )
99
+ return ClosureReport(
100
+ verdict=verdict_line(slug, all_pull_request_fields, all_findings),
101
+ all_findings=all_findings,
102
+ )
103
+
104
+
105
+ def main(all_arguments: Sequence[str]) -> int:
106
+ """Print the review closure verdict for one pull request.
107
+
108
+ Args:
109
+ all_arguments: The command line, without the program name.
110
+
111
+ Returns:
112
+ Zero when every finding is answered, one when a finding waits, and
113
+ two when the pull request state could not be read.
114
+ """
115
+ parsed = build_parser().parse_args(all_arguments)
116
+ try:
117
+ report = closure_report(
118
+ parsed.slug, parsed.number, parsed.driver_login, github_token()
119
+ )
120
+ except GitHubError as failure:
121
+ print(failure, file=sys.stderr)
122
+ return ERROR_EXIT_CODE
123
+ print(report.verdict)
124
+ for each_finding in report.all_findings:
125
+ print(
126
+ FINDING_LINE_TEMPLATE.format(
127
+ subject=each_finding.subject, reason=each_finding.reason
128
+ )
129
+ )
130
+ return OPEN_EXIT_CODE if report.all_findings else CLOSED_EXIT_CODE
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main(sys.argv[1:]))