claude-dev-env 8.6.0 → 8.7.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 (106) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
  3. package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
  4. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +155 -18
  7. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +130 -0
  8. package/bin/install.test.mjs +25 -0
  9. package/docs/high-trust-agent-delivery.md +132 -0
  10. package/hooks/advisory/refactor_guard.py +2 -2
  11. package/hooks/advisory/test_refactor_guard.py +21 -0
  12. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  13. package/hooks/blocking/code_rules_shared.py +4 -7
  14. package/hooks/blocking/conftest.py +22 -0
  15. package/hooks/blocking/state_description_blocker.py +232 -90
  16. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  17. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +27 -54
  18. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  19. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  20. package/hooks/blocking/test_code_rules_shared.py +18 -0
  21. package/hooks/blocking/test_conftest.py +35 -0
  22. package/hooks/blocking/test_state_description_blocker.py +59 -0
  23. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  24. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  25. package/hooks/git-hooks/test_config.py +10 -3
  26. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  27. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  28. package/hooks/git-hooks/verification_notice_state.py +3 -1
  29. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  30. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  31. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  32. package/hooks/hooks_constants/state_description_blocker_constants.py +2 -1
  33. package/hooks/hooks_constants/test_state_description_blocker_constants.py +87 -0
  34. package/hooks/validators/exempt_paths.py +3 -18
  35. package/hooks/validators/test_exempt_paths.py +20 -0
  36. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  37. package/package.json +1 -1
  38. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  39. package/rules/archiving-agent-config.md +10 -0
  40. package/rules/code-standards.md +1 -0
  41. package/rules/correction-lens.md +134 -0
  42. package/rules/flag-non-breaking-findings.md +11 -0
  43. package/rules/no-contrast-framing.md +68 -0
  44. package/rules/review-closure-is-a-check.md +44 -0
  45. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  46. package/scripts/agent_merge_check.py +381 -0
  47. package/scripts/check.ps1 +12 -1
  48. package/scripts/ci/install-playtest.mjs +291 -0
  49. package/scripts/ci/install-playtest.test.mjs +84 -0
  50. package/scripts/ci/installer-lifecycle.mjs +11 -30
  51. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  52. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  53. package/scripts/ci/scratch-home-install.mjs +73 -0
  54. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  55. package/scripts/codex_compat_materializer.py +33 -16
  56. package/scripts/contrast_framing.py +101 -0
  57. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  58. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  59. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  60. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  61. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  62. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  63. package/scripts/durable_post_lint.py +30 -4
  64. package/scripts/followup_cli.py +6 -10
  65. package/scripts/merge_gate_checks.py +236 -0
  66. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  67. package/scripts/policy_lint/adapter_detectors.py +1 -1
  68. package/scripts/policy_lint/adapter_support.py +3 -14
  69. package/scripts/policy_lint/adapters.py +30 -1
  70. package/scripts/policy_lint/config/constants.py +2 -0
  71. package/scripts/policy_lint/registry.py +5 -0
  72. package/scripts/repository_checks/config/constants.py +12 -0
  73. package/scripts/repository_checks/config/policy_document.py +28 -2
  74. package/scripts/repository_checks/followups.py +54 -0
  75. package/scripts/repository_checks/match_exemptions.py +67 -0
  76. package/scripts/repository_checks/models.py +46 -2
  77. package/scripts/repository_checks/reporting.py +23 -7
  78. package/scripts/repository_checks/tracked_secrets.py +2 -2
  79. package/scripts/repository_policy.py +4 -2
  80. package/scripts/review_closure.py +134 -0
  81. package/scripts/review_closure_github.py +345 -0
  82. package/scripts/review_closure_model.py +360 -0
  83. package/scripts/test_agent_merge_check.py +317 -0
  84. package/scripts/test_durable_post_lint.py +46 -0
  85. package/scripts/test_followup_cli.py +40 -0
  86. package/scripts/test_merge_gate_checks.py +92 -0
  87. package/scripts/tests/test_code_review_constants.py +78 -0
  88. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  89. package/scripts/tests/test_contrast_framing.py +117 -0
  90. package/scripts/tests/test_followups.py +88 -0
  91. package/scripts/tests/test_match_exemptions.py +291 -0
  92. package/scripts/tests/test_models.py +67 -2
  93. package/scripts/tests/test_package_inventory.py +6 -4
  94. package/scripts/tests/test_pester_suites_run.py +53 -0
  95. package/scripts/tests/test_policy_document.py +39 -1
  96. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  97. package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
  98. package/scripts/tests/test_reporting.py +18 -0
  99. package/scripts/tests/test_repository_policy.py +43 -1
  100. package/scripts/tests/test_review_closure.py +126 -0
  101. package/scripts/tests/test_review_closure_github.py +240 -0
  102. package/scripts/tests/test_review_closure_model.py +299 -0
  103. package/scripts/tests/test_tracked_secrets.py +42 -0
  104. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  105. package/scripts/repository_checks/email_exemptions.py +0 -39
  106. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -27,7 +27,8 @@ publish_plan_max_positional_arguments = 3
27
27
  publish_plan_failure_injector_position = 2
28
28
  frontmatter_unsupported_fields = ("tools", "model", "color", "disable-model-invocation")
29
29
  instruction_alias_filenames = frozenset({"AGENTS.md", "CLAUDE.md"})
30
- failure_blast_radius_rule_relative_path = "rules/failure-blast-radius.md"
30
+ codex_instruction_rule_relative_paths = ("rules/failure-blast-radius.md", "rules/correction-lens.md")
31
+ codex_instruction_source_separator = ", "
31
32
  codex_instruction_target_path = "AGENTS.md"
32
33
  codex_instruction_section_heading = "## Excerpt for repository-instruction sessions"
33
34
  codex_hook_manifest_source_path = "hooks/hooks.json"
@@ -502,41 +503,57 @@ def convert_agent(agent: ClaudeAgent) -> str:
502
503
  return content
503
504
 
504
505
 
505
- def render_codex_failure_blast_radius(rule_content: str) -> str:
506
- """Extract the repository-instruction contract from the canonical rule.
506
+ def render_codex_instruction_excerpt(rule_content: str, rule_relative_path: str) -> str:
507
+ """Extract one rule's repository-instruction contract.
508
+
509
+ A rule reaches a Codex session through the fenced block under its
510
+ "Excerpt for repository-instruction sessions" heading, and this returns
511
+ that block's own text.
507
512
 
508
513
  Args:
509
- rule_content: Canonical failure blast-radius rule text.
514
+ rule_content: Canonical rule text.
515
+ rule_relative_path: Repository-relative path of that rule, named in errors.
510
516
 
511
517
  Returns:
512
518
  The fenced repository-instruction excerpt with a trailing newline.
513
519
 
514
520
  Raises:
515
- MaterializerError: If the canonical rule lacks the required excerpt.
521
+ MaterializerError: If the rule lacks a complete excerpt.
516
522
  """
517
523
  heading_start = rule_content.find(codex_instruction_section_heading)
518
524
  if heading_start < 0:
519
- raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
525
+ raise MaterializerError(f"{rule_relative_path} requires a Codex excerpt")
520
526
  fence_start = rule_content.find("```", heading_start)
521
527
  if fence_start < 0:
522
- raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
528
+ raise MaterializerError(f"{rule_relative_path} requires a Codex excerpt")
523
529
  content_start = rule_content.find(line_separator, fence_start)
524
530
  fence_end = rule_content.find(line_separator + "```", content_start + 1)
525
531
  if content_start < 0 or fence_end < 0:
526
- raise MaterializerError("failure blast-radius rule requires a complete Codex excerpt")
532
+ raise MaterializerError(f"{rule_relative_path} requires a complete Codex excerpt")
527
533
  return rule_content[content_start + len(line_separator) : fence_end].rstrip() + line_separator
528
534
 
529
535
 
530
536
  def _build_codex_instruction_projection(config: MaterializerConfig) -> PlannedFile | None:
531
- """Build the managed AGENTS.md projection when the canonical rule is present."""
532
- source_path = config.source_root / failure_blast_radius_rule_relative_path
533
- if not source_path.exists() and not _is_reparse_point(source_path):
537
+ """Build the managed AGENTS.md projection from every projected rule present."""
538
+ all_present_rules: list[tuple[str, str]] = []
539
+ for each_relative_path in codex_instruction_rule_relative_paths:
540
+ candidate_path = config.source_root / each_relative_path
541
+ if not candidate_path.exists() and not _is_reparse_point(candidate_path):
542
+ continue
543
+ source_path = _validated_source_file(config, each_relative_path, each_relative_path)
544
+ rule_content = source_path.read_text(encoding="utf-8")
545
+ all_present_rules.append(
546
+ (each_relative_path, render_codex_instruction_excerpt(rule_content, each_relative_path))
547
+ )
548
+ if not all_present_rules:
534
549
  return None
535
- source_path = _validated_source_file(config, failure_blast_radius_rule_relative_path, "failure blast-radius rule")
536
- rule_content = source_path.read_text(encoding="utf-8")
537
- projected_content = render_codex_failure_blast_radius(rule_content)
550
+ projected_content = line_separator.join(
551
+ each_excerpt for _, each_excerpt in all_present_rules
552
+ )
538
553
  return PlannedFile(
539
- failure_blast_radius_rule_relative_path,
554
+ codex_instruction_source_separator.join(
555
+ each_path for each_path, _ in all_present_rules
556
+ ),
540
557
  codex_instruction_target_path,
541
558
  projected_content,
542
559
  hash_content(projected_content),
@@ -987,7 +1004,7 @@ def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
987
1004
  if _is_reparse_point(each_path):
988
1005
  raise MaterializerError(f"source reparse point is not allowed: {each_path}")
989
1006
  relative_source = each_path.relative_to(config.source_root).as_posix()
990
- if relative_source == failure_blast_radius_rule_relative_path:
1007
+ if relative_source in codex_instruction_rule_relative_paths:
991
1008
  _validate_containment(config.source_root, each_path)
992
1009
  continue
993
1010
  _validate_containment(config.source_root, each_path)
@@ -0,0 +1,101 @@
1
+ """Find contrast framing in authored prose.
2
+
3
+ A contrast defines its subject against a rejected alternative, so the reader
4
+ carries two readings where one would do.
5
+
6
+ ::
7
+
8
+ all_hits = find_contrast_framing("A diff regression, not shared code.")
9
+ ok: all_hits == [(1, 18, "trailing-comma-not")]
10
+ flag: find_contrast_framing("The function runs more than 30 lines.") == []
11
+
12
+ Code spans, fenced blocks, paths, and link targets are blanked before the
13
+ patterns run, so an example quoted in backticks stays quiet. A blockquote line
14
+ is skipped, since it carries someone else's words. A comma-``not`` match
15
+ reports only when no bare ``not`` already sits earlier in the line, so a
16
+ second one in a list stays quiet, and a list that opens with ``not`` keeps
17
+ even its first comma quiet. The pattern list lives in the constants module
18
+ beside this one, and the durable post linter reads the same list.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+
25
+ from banned_prose_words import prose_lines
26
+ from dev_env_scripts_constants.contrast_framing_constants import (
27
+ ALL_CONTRAST_FRAMING_FORMS,
28
+ CONTRAST_FRAMING_MESSAGE_TEMPLATE,
29
+ CONTRAST_FRAMING_QUOTED_LINE_LIMIT,
30
+ ContrastFramingForm,
31
+ )
32
+
33
+ _BARE_NOT = re.compile(r"\bnot\b", re.IGNORECASE)
34
+
35
+
36
+ def _reports(form: ContrastFramingForm, prose_line: str, match_start: int) -> bool:
37
+ if not form.repeats_as_a_list:
38
+ return True
39
+ return _BARE_NOT.search(prose_line[:match_start]) is None
40
+
41
+
42
+ def _line_hits(line_number: int, prose_line: str) -> list[tuple[int, int, str]]:
43
+ if prose_line.lstrip().startswith(">"):
44
+ return []
45
+ return [
46
+ (line_number, each_match.start() + 1, each_form.name)
47
+ for each_form in ALL_CONTRAST_FRAMING_FORMS
48
+ for each_match in each_form.pattern.finditer(prose_line)
49
+ if _reports(each_form, prose_line, each_match.start())
50
+ ]
51
+
52
+
53
+ def find_contrast_framing(document_text: str) -> list[tuple[int, int, str]]:
54
+ """Return each contrast-framing occurrence in authored prose.
55
+
56
+ Args:
57
+ document_text: Full document source.
58
+
59
+ Returns:
60
+ Ordered ``(line number, column, form name)`` triples, both 1-based.
61
+ """
62
+ all_hits: list[tuple[int, int, str]] = []
63
+ for each_line_number, each_prose_line in enumerate(prose_lines(document_text), 1):
64
+ all_hits.extend(_line_hits(each_line_number, each_prose_line))
65
+ return sorted(all_hits)
66
+
67
+
68
+ def form_named(form_name: str) -> ContrastFramingForm:
69
+ """Return the contrast form carrying one name.
70
+
71
+ Args:
72
+ form_name: Name reported by ``find_contrast_framing``.
73
+
74
+ Returns:
75
+ The matching form.
76
+
77
+ Raises:
78
+ KeyError: If no form carries that name.
79
+ """
80
+ for each_form in ALL_CONTRAST_FRAMING_FORMS:
81
+ if each_form.name == form_name:
82
+ return each_form
83
+ raise KeyError(form_name)
84
+
85
+
86
+ def describe_contrast_framing(source_line: str, form_name: str) -> str:
87
+ """Return the reader-facing message for one occurrence.
88
+
89
+ Args:
90
+ source_line: The document line the occurrence sits on.
91
+ form_name: Name of the form that matched.
92
+
93
+ Returns:
94
+ The message naming the form, the line it sits on, and the fix.
95
+ """
96
+ matched_form = form_named(form_name)
97
+ return CONTRAST_FRAMING_MESSAGE_TEMPLATE.format(
98
+ name=form_name,
99
+ text=source_line.strip()[:CONTRAST_FRAMING_QUOTED_LINE_LIMIT],
100
+ guidance=matched_form.guidance,
101
+ )
@@ -0,0 +1,121 @@
1
+ """Named constants for agent_merge_check, the agent merge check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ GITHUB_API_ROOT = "https://api.github.com"
6
+ GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"
7
+ PULL_REQUEST_ENDPOINT_TEMPLATE = "{api_root}/repos/{slug}/pulls/{number}"
8
+ REVIEW_THREADS_ENDPOINT_TEMPLATE = (
9
+ "{api_root}/repos/{slug}/pulls/{number}/ccr/review_threads"
10
+ )
11
+ ACCEPT_HEADER = "Accept"
12
+ GITHUB_ACCEPT_TYPE = "application/vnd.github+json"
13
+ AUTHORIZATION_HEADER = "Authorization"
14
+ BEARER_PREFIX = "Bearer "
15
+ CONTENT_TYPE_HEADER = "Content-Type"
16
+ JSON_CONTENT_TYPE = "application/json"
17
+ UTF8_ENCODING = "utf-8"
18
+ REQUEST_TIMEOUT_SECONDS = 30
19
+
20
+ ALL_TOKEN_ENVIRONMENT_VARIABLES = ("GH_TOKEN", "GITHUB_TOKEN")
21
+
22
+ DRAFT_KEY = "draft"
23
+ MERGEABLE_STATE_KEY = "mergeable_state"
24
+ NUMBER_KEY = "number"
25
+ TITLE_KEY = "title"
26
+ HEAD_KEY = "head"
27
+ SHA_KEY = "sha"
28
+
29
+ MERGEABLE_STATE_CLEAN = "clean"
30
+ MERGEABLE_STATE_BEHIND = "behind"
31
+ MERGEABLE_STATE_DIRTY = "dirty"
32
+ MERGEABLE_STATE_BLOCKED = "blocked"
33
+ MERGEABLE_STATE_UNSTABLE = "unstable"
34
+ MERGEABLE_STATE_UNKNOWN = "unknown"
35
+ SETTLE_ATTEMPT_COUNT = 5
36
+ SETTLE_WAIT_SECONDS = 3
37
+
38
+ DRAFT_HOLD_REASON = (
39
+ "The pull request is a draft, so it carries no verdict to merge on. "
40
+ "Mark it ready once its checks pass."
41
+ )
42
+ BEHIND_HOLD_REASON = (
43
+ "The head is behind the base branch and the branch rule requires an "
44
+ "up-to-date head. Merge the base branch into this one and push."
45
+ )
46
+ DIRTY_HOLD_REASON = (
47
+ "The head conflicts with the base branch. Merge the base branch into "
48
+ "this one, resolve the conflict, and push."
49
+ )
50
+ BLOCKED_HOLD_REASON = (
51
+ "A required status check is not passing on this head. Read the failing "
52
+ "check, fix it, and push."
53
+ )
54
+ UNSTABLE_HOLD_REASON = (
55
+ "A check on this head is failing or still running. Wait for it, and fix "
56
+ "it when it comes back red."
57
+ )
58
+ ALL_HOLD_REASONS_BY_STATE = {
59
+ MERGEABLE_STATE_BEHIND: BEHIND_HOLD_REASON,
60
+ MERGEABLE_STATE_DIRTY: DIRTY_HOLD_REASON,
61
+ MERGEABLE_STATE_BLOCKED: BLOCKED_HOLD_REASON,
62
+ MERGEABLE_STATE_UNSTABLE: UNSTABLE_HOLD_REASON,
63
+ }
64
+ UNKNOWN_STATE_HOLD_TEMPLATE = (
65
+ "GitHub reports the merge state as {state}, which this check does not "
66
+ "treat as ready. Read the pull request page."
67
+ )
68
+ UNRESOLVED_THREADS_HOLD_TEMPLATE = (
69
+ "{count} review thread(s) are open. Answer each one and resolve it."
70
+ )
71
+
72
+ MERGE_VERDICT_LABEL = "MERGE"
73
+ HOLD_VERDICT_LABEL = "HOLD"
74
+ VERDICT_LINE_TEMPLATE = "{label} {slug}#{number} {sha} :: {detail}"
75
+ SHORT_SHA_LENGTH = 7
76
+ READY_DETAIL = "green, no open review thread, ready for the agent to merge"
77
+
78
+ MERGE_EXIT_CODE = 0
79
+ HOLD_EXIT_CODE = 1
80
+ ERROR_EXIT_CODE = 2
81
+
82
+ NO_SIGN_IN_MESSAGE = "No GitHub token in the environment. Set GH_TOKEN or GITHUB_TOKEN."
83
+ SLUG_SEPARATOR = "/"
84
+ SLUG_ARGUMENT_HELP = "Repository as owner/name, such as jl-cmd/claude-dev-env."
85
+ NUMBER_ARGUMENT_HELP = "Pull request number."
86
+ COMMAND_DESCRIPTION = (
87
+ "Report whether a pull request is ready for the agent that drives it to merge it."
88
+ )
89
+
90
+ REVIEW_THREAD_PAGE_SIZE = 100
91
+ UNRESOLVED_THREAD_QUERY = """
92
+ query($owner: String!, $name: String!, $number: Int!, $pageSize: Int!) {
93
+ repository(owner: $owner, name: $name) {
94
+ pullRequest(number: $number) {
95
+ reviewThreads(first: $pageSize) {
96
+ nodes { isResolved isOutdated }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ """
102
+ QUERY_KEY = "query"
103
+ VARIABLES_KEY = "variables"
104
+ OWNER_VARIABLE = "owner"
105
+ NAME_VARIABLE = "name"
106
+ NUMBER_VARIABLE = "number"
107
+ PAGE_SIZE_VARIABLE = "pageSize"
108
+ DATA_KEY = "data"
109
+ REPOSITORY_KEY = "repository"
110
+ PULL_REQUEST_KEY = "pullRequest"
111
+ REVIEW_THREADS_KEY = "reviewThreads"
112
+ NODES_KEY = "nodes"
113
+ ALL_THREAD_NODE_KEYS = (
114
+ DATA_KEY,
115
+ REPOSITORY_KEY,
116
+ PULL_REQUEST_KEY,
117
+ REVIEW_THREADS_KEY,
118
+ NODES_KEY,
119
+ )
120
+ ALL_RESOLVED_KEYS = ("isResolved", "is_resolved", "resolved")
121
+ ALL_OUTDATED_KEYS = ("isOutdated", "is_outdated", "outdated")
@@ -0,0 +1,82 @@
1
+ """Constants for the contrast-framing detector.
2
+
3
+ Each form names one shape a sentence takes when it defines its subject
4
+ against a rejected reading::
5
+
6
+ "a diff regression, not shared code" -> trailing-comma-not
7
+ "park it rather than fix it" -> substitution-rather-than
8
+ "precision matters more than coverage" -> comparative-ranking
9
+ ok: "the function runs more than 30 lines"
10
+
11
+ The rule file ``rules/no-contrast-framing.md`` carries one row for each name
12
+ here, and a test holds the two in step.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ContrastFramingForm:
23
+ """One banned contrast form and the pattern that finds it."""
24
+
25
+ name: str
26
+ pattern: re.Pattern[str]
27
+ guidance: str
28
+ repeats_as_a_list: bool = False
29
+
30
+
31
+ ALL_CONTRAST_FRAMING_FORMS: tuple[ContrastFramingForm, ...] = (
32
+ ContrastFramingForm(
33
+ "trailing-comma-not",
34
+ re.compile(r",\s+not\s+[\w\"'`(]", re.IGNORECASE),
35
+ "Drop the clause after the comma and state what is true.",
36
+ repeats_as_a_list=True,
37
+ ),
38
+ ContrastFramingForm(
39
+ "corrective-it-is-not",
40
+ re.compile(
41
+ r"\b(?:it|that|this)(?:'s|\s+is|\s+was)\s+not\b[^.!?]*?,\s*"
42
+ r"(?:it|that|this)(?:'s|\s+is|\s+was)\b",
43
+ re.IGNORECASE,
44
+ ),
45
+ "State what it is and leave the rejected reading out.",
46
+ ),
47
+ ContrastFramingForm(
48
+ "substitution-rather-than",
49
+ re.compile(r"\brather than\b", re.IGNORECASE),
50
+ "Name the thing you chose and stop.",
51
+ ),
52
+ ContrastFramingForm(
53
+ "additive-not-just",
54
+ re.compile(r"\bnot (?:just|only|merely|simply)\b", re.IGNORECASE),
55
+ "Say the point the sentence is building toward.",
56
+ ),
57
+ ContrastFramingForm(
58
+ "comparative-ranking",
59
+ re.compile(
60
+ r"\b(?:matters?|counts?|helps?|weighs?)\s+more\s+than\b"
61
+ r"|\b(?:more|less)\s+(?:important|useful|valuable)\s+than\b"
62
+ r"|\bmore\s+than\s+(?:trying|attempting|hoping)\b",
63
+ re.IGNORECASE,
64
+ ),
65
+ "State the thing you want. Leave the ranking out.",
66
+ ),
67
+ ContrastFramingForm(
68
+ "substitution-as-opposed-to",
69
+ re.compile(r"\bas opposed to\b", re.IGNORECASE),
70
+ "Name the thing you chose and stop.",
71
+ ),
72
+ )
73
+
74
+ CONTRAST_FRAMING_MESSAGE_TEMPLATE: str = (
75
+ 'Contrast framing ({name}): "{text}". {guidance}'
76
+ )
77
+
78
+ CONTRAST_FRAMING_QUOTED_LINE_LIMIT: int = 120
79
+
80
+ CONTRAST_FRAMING_FINDING_CODE: str = "contrast-framing"
81
+
82
+ CONTRAST_FRAMING_RULE_DOCUMENT: str = "rules/no-contrast-framing.md"
@@ -0,0 +1,23 @@
1
+ """Names and values ``merge_gate_checks.py`` reads a merge policy with.
2
+
3
+ The fenced block marker and the ruleset field names describe a document and a
4
+ GitHub payload that both live outside this package, so they sit here rather
5
+ than inside the module that reads them.
6
+ """
7
+
8
+ REQUIRED_CHECK_BLOCK_INFO_STRING: str = "required-status-checks"
9
+ FENCE_MARKER: str = "```"
10
+ RULESET_API_TEMPLATE: str = (
11
+ "https://api.github.com/repos/{repository}/rules/branches/{branch}"
12
+ )
13
+ RULESET_API_ACCEPT_HEADER: str = "application/vnd.github+json"
14
+ RULESET_REQUEST_TIMEOUT_SECONDS: int = 30
15
+ REQUIRED_STATUS_CHECKS_RULE_TYPE: str = "required_status_checks"
16
+ RULE_TYPE_KEY: str = "type"
17
+ RULE_PARAMETERS_KEY: str = "parameters"
18
+ RULE_CONTEXT_KEY: str = "context"
19
+ ALL_GITHUB_TOKEN_VARIABLES: tuple[str, ...] = ("GITHUB_TOKEN", "GH_TOKEN")
20
+ MERGE_GATE_CLEAN_EXIT_CODE: int = 0
21
+ MERGE_GATE_MISSING_CHECK_EXIT_CODE: int = 1
22
+ JSON_INDENT_WIDTH: int = 2
23
+ UTF8_ENCODING: str = "utf-8"
@@ -0,0 +1,241 @@
1
+ """Constants for the review_closure script.
2
+
3
+ Per the project's configuration conventions, script-level scalar constants
4
+ live in dev_env_scripts_constants alongside timing.py.
5
+ """
6
+
7
+ GITHUB_API_ROOT: str = "https://api.github.com"
8
+ """Root of the GitHub REST API."""
9
+
10
+ GITHUB_GRAPHQL_ENDPOINT: str = "https://api.github.com/graphql"
11
+ """Endpoint that answers the review-thread query."""
12
+
13
+ PULL_REQUEST_ENDPOINT_TEMPLATE: str = "{api_root}/repos/{slug}/pulls/{number}"
14
+ """REST route carrying one pull request."""
15
+
16
+ REVIEW_THREADS_ENDPOINT_TEMPLATE: str = (
17
+ "{api_root}/repos/{slug}/pulls/{number}/ccr/review_threads"
18
+ )
19
+ """REST route a Claude Code session has for review threads."""
20
+
21
+ CHECK_RUNS_ENDPOINT_TEMPLATE: str = (
22
+ "{api_root}/repos/{slug}/commits/{sha}/check-runs?per_page={page_size}"
23
+ )
24
+ """REST route carrying the check runs reported on one commit."""
25
+
26
+ REVIEW_THREAD_PAGE_SIZE: int = 100
27
+ """How many review threads and comments one query asks for."""
28
+
29
+ CHECK_RUN_PAGE_SIZE: int = 100
30
+ """How many check runs one request asks for."""
31
+
32
+ REVIEW_THREAD_QUERY: str = """
33
+ query($owner: String!, $name: String!, $number: Int!, $pageSize: Int!) {
34
+ repository(owner: $owner, name: $name) {
35
+ pullRequest(number: $number) {
36
+ reviewThreads(first: $pageSize) {
37
+ nodes {
38
+ path
39
+ isResolved
40
+ isOutdated
41
+ comments(first: $pageSize) {
42
+ nodes {
43
+ body
44
+ author { login }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ """
53
+ """GraphQL query for the review threads on one pull request."""
54
+
55
+ ALL_THREAD_NODE_KEYS: tuple[str, ...] = (
56
+ "data",
57
+ "repository",
58
+ "pullRequest",
59
+ "reviewThreads",
60
+ "nodes",
61
+ )
62
+ """Path from the GraphQL answer down to the review-thread list."""
63
+
64
+ ALL_RESOLVED_KEYS: tuple[str, ...] = ("isResolved", "resolved", "is_resolved")
65
+ """Field names either route uses for a resolved review thread."""
66
+
67
+ ALL_OUTDATED_KEYS: tuple[str, ...] = ("isOutdated", "outdated", "is_outdated")
68
+ """Field names either route uses for a thread whose code has been replaced."""
69
+
70
+ ALL_COMMENT_LIST_KEYS: tuple[str, ...] = ("comments", "all_comments")
71
+ """Field names either route uses for a thread's comment list."""
72
+
73
+ COMMENT_NODES_KEY: str = "nodes"
74
+ """GraphQL wraps a comment list in this field."""
75
+
76
+ COMMENT_BODY_KEY: str = "body"
77
+ """Field carrying a comment's text."""
78
+
79
+ ALL_COMMENT_AUTHOR_KEYS: tuple[str, ...] = ("author", "user")
80
+ """Field names either route uses for a comment's author."""
81
+
82
+ AUTHOR_LOGIN_KEY: str = "login"
83
+ """Field carrying an author's GitHub login."""
84
+
85
+ THREAD_PATH_KEY: str = "path"
86
+ """Field carrying the file a review thread points at."""
87
+
88
+ UNNAMED_THREAD_SUBJECT: str = "a review thread"
89
+ """Subject text for a thread that names no file."""
90
+
91
+ RED_CIRCLE_MARKER: str = "\U0001f534"
92
+ """The red circle a review marks a blocking finding with."""
93
+
94
+ APPROVALS_CHECK_NAME: str = "Claude Approvals"
95
+ """Name of the check run whose rows carry blocking findings."""
96
+
97
+ ALL_BLOCKING_CONCLUSIONS: tuple[str, ...] = ("failure", "action_required")
98
+ """Check-run conclusions that mean a blocking row is open on this head."""
99
+
100
+ APPROVALS_OPEN_REASON: str = (
101
+ "the Claude Approvals check reports a blocking row on this commit"
102
+ )
103
+ """Why a failing approvals check leaves the pull request open."""
104
+
105
+ RED_CIRCLE_OPEN_REASON: str = (
106
+ "a blocking finding here carries no reply from the driving agent"
107
+ )
108
+ """Why a red-circle thread stays open until the agent answers it."""
109
+
110
+ UNANSWERED_OPEN_REASON: str = "unresolved, with no reply from the driving agent"
111
+ """Why an ordinary thread stays open."""
112
+
113
+ CHECK_RUNS_KEY: str = "check_runs"
114
+ """Field carrying the check-run list in the REST answer."""
115
+
116
+ CHECK_RUN_NAME_KEY: str = "name"
117
+ """Field carrying a check run's name."""
118
+
119
+ CHECK_RUN_CONCLUSION_KEY: str = "conclusion"
120
+ """Field carrying a check run's conclusion."""
121
+
122
+ HEAD_KEY: str = "head"
123
+ """Field carrying a pull request's head."""
124
+
125
+ SHA_KEY: str = "sha"
126
+ """Field carrying a commit identifier."""
127
+
128
+ NUMBER_KEY: str = "number"
129
+ """Field carrying a pull request number."""
130
+
131
+ USER_KEY: str = "user"
132
+ """Field carrying the account that opened a pull request."""
133
+
134
+ SHORT_SHA_LENGTH: int = 7
135
+ """How much of a commit identifier the verdict line prints."""
136
+
137
+ SLUG_SEPARATOR: str = "/"
138
+ """Separator between owner and name in a repository slug."""
139
+
140
+ QUERY_KEY: str = "query"
141
+ """GraphQL payload field carrying the query text."""
142
+
143
+ VARIABLES_KEY: str = "variables"
144
+ """GraphQL payload field carrying the query variables."""
145
+
146
+ OWNER_VARIABLE: str = "owner"
147
+ """GraphQL variable naming the repository owner."""
148
+
149
+ NAME_VARIABLE: str = "name"
150
+ """GraphQL variable naming the repository."""
151
+
152
+ NUMBER_VARIABLE: str = "number"
153
+ """GraphQL variable naming the pull request number."""
154
+
155
+ PAGE_SIZE_VARIABLE: str = "pageSize"
156
+ """GraphQL variable naming the page size."""
157
+
158
+ ALL_TOKEN_ENVIRONMENT_VARIABLES: tuple[str, ...] = (
159
+ "GITHUB_TOKEN",
160
+ "GH_TOKEN",
161
+ )
162
+ """Environment variables that carry a GitHub token, in the order read."""
163
+
164
+ NO_SIGN_IN_MESSAGE: str = (
165
+ "No GitHub token found. Set GITHUB_TOKEN or GH_TOKEN and run this again."
166
+ )
167
+ """What to print when no token is available."""
168
+
169
+ COMMAND_DESCRIPTION: str = (
170
+ "Report whether every review finding on a pull request head has been "
171
+ "answered by the agent driving it."
172
+ )
173
+ """Help text for the command."""
174
+
175
+ SLUG_ARGUMENT_HELP: str = "Repository as owner/name."
176
+ """Help text for the repository argument."""
177
+
178
+ NUMBER_ARGUMENT_HELP: str = "Pull request number."
179
+ """Help text for the pull request argument."""
180
+
181
+ DRIVER_LOGIN_ARGUMENT_HELP: str = (
182
+ "A login whose comments count as the driving agent's reply. Repeatable. "
183
+ "The account that opened the pull request always counts."
184
+ )
185
+ """Help text for the driver login argument."""
186
+
187
+ CLOSED_VERDICT_LABEL: str = "CLOSED"
188
+ """Verdict label for a pull request with every finding answered."""
189
+
190
+ OPEN_VERDICT_LABEL: str = "OPEN"
191
+ """Verdict label for a pull request with a finding still waiting."""
192
+
193
+ CLOSED_DETAIL: str = "every review finding on this commit is answered"
194
+ """Detail text on a closed verdict."""
195
+
196
+ OPEN_DETAIL_TEMPLATE: str = "{count} review finding(s) wait on the driving agent"
197
+ """Detail text on an open verdict."""
198
+
199
+ VERDICT_LINE_TEMPLATE: str = "{label} {slug}#{number} {sha} :: {detail}"
200
+ """Shape of the one verdict line this command prints."""
201
+
202
+ FINDING_LINE_TEMPLATE: str = " - {subject}: {reason}"
203
+ """Shape of each open-finding line printed under the verdict."""
204
+
205
+ CLOSED_EXIT_CODE: int = 0
206
+ """Exit status when every finding is answered."""
207
+
208
+ OPEN_EXIT_CODE: int = 1
209
+ """Exit status when a finding waits on the driving agent."""
210
+
211
+ ERROR_EXIT_CODE: int = 2
212
+ """Exit status when the pull request state could not be read."""
213
+
214
+ REQUEST_TIMEOUT_SECONDS: int = 30
215
+ """How long one GitHub request waits for an answer."""
216
+
217
+ REQUEST_FAILED_TEMPLATE: str = "{url} answered {status}"
218
+ """What to report when GitHub answers with something other than success."""
219
+
220
+ GET_METHOD: str = "GET"
221
+ """HTTP method for a read."""
222
+
223
+ POST_METHOD: str = "POST"
224
+ """HTTP method the GraphQL query uses."""
225
+
226
+ COMMENT_IDS_KEY: str = "comment_ids"
227
+ """Field the session route uses for a thread's comment identifiers."""
228
+
229
+ COMMENT_IDENTIFIER_KEY: str = "id"
230
+ """Field carrying a review comment's identifier."""
231
+
232
+ REVIEW_COMMENTS_ENDPOINT_TEMPLATE: str = (
233
+ "{api_root}/repos/{slug}/pulls/{number}/comments?per_page={page_size}&page={page}"
234
+ )
235
+ """REST route carrying one page of a pull request's review comments."""
236
+
237
+ REVIEW_COMMENT_PAGE_SIZE: int = 100
238
+ """How many review comments one page carries."""
239
+
240
+ MAX_REVIEW_COMMENT_PAGES: int = 20
241
+ """How many comment pages one run reads before it stops."""
@@ -0,0 +1,12 @@
1
+ """Behavioral tests for the host-aware code-review invoker constants module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dev_env_scripts_constants import code_review_constants as review_constants
6
+
7
+
8
+ def test_default_effort_is_a_known_token() -> None:
9
+ assert (
10
+ review_constants.DEFAULT_CODE_REVIEW_EFFORT
11
+ in review_constants.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
12
+ )