@homericintelligence/athena-opencode 0.5.0 → 0.5.2

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 (146) hide show
  1. package/README.md +30 -17
  2. package/package.json +2 -2
  3. package/plugin.js +20 -5
  4. package/skills/TECHNICAL_ENGLISH.md +78 -0
  5. package/skills/_cli.py +33 -22
  6. package/skills/_plugin.json +1 -0
  7. package/skills/_support/docs/dependency-resolution.md +203 -0
  8. package/skills/_support/docs/policies/development.md +131 -0
  9. package/skills/_support/docs/policies/evidence-integrity.md +40 -0
  10. package/skills/_support/docs/principles/README.md +565 -0
  11. package/skills/_support/docs/principles/details/p001-kiss.md +114 -0
  12. package/skills/_support/docs/principles/details/p002-yagni.md +109 -0
  13. package/skills/_support/docs/principles/details/p003-dry.md +126 -0
  14. package/skills/_support/docs/principles/details/p004-solid.md +165 -0
  15. package/skills/_support/docs/principles/details/p005-modularity.md +123 -0
  16. package/skills/_support/docs/principles/details/p006-principle-of-least-astonishment.md +115 -0
  17. package/skills/_support/docs/principles/details/p007-subtraction-over-addition.md +113 -0
  18. package/skills/_support/docs/principles/details/p008-understand-before-subtracting.md +115 -0
  19. package/skills/_support/docs/principles/details/p009-general-mechanisms-over-special-cases.md +108 -0
  20. package/skills/_support/docs/principles/details/p010-scope-fidelity.md +117 -0
  21. package/skills/_support/docs/principles/details/p011-minimal-coherent-change.md +128 -0
  22. package/skills/_support/docs/principles/details/p012-evidence-before-modification.md +115 -0
  23. package/skills/_support/docs/principles/details/p013-avoid-hasty-abstractions.md +112 -0
  24. package/skills/_support/docs/principles/details/p014-preserve-unrequested-behavior.md +110 -0
  25. package/skills/_support/docs/principles/details/p015-architecture-conformance.md +124 -0
  26. package/skills/_support/docs/principles/details/p016-separation-of-concerns.md +125 -0
  27. package/skills/_support/docs/principles/details/p017-high-cohesion-low-coupling.md +130 -0
  28. package/skills/_support/docs/principles/details/p018-information-hiding.md +128 -0
  29. package/skills/_support/docs/principles/details/p019-explicit-contracts.md +134 -0
  30. package/skills/_support/docs/principles/details/p020-executable-architecture.md +130 -0
  31. package/skills/_support/docs/principles/details/p021-evolutionary-and-reversible-design.md +126 -0
  32. package/skills/_support/docs/principles/details/p022-test-behavior-not-implementation.md +129 -0
  33. package/skills/_support/docs/principles/details/p023-parameterized-table-driven-testing.md +129 -0
  34. package/skills/_support/docs/principles/details/p024-boundary-value-testing.md +128 -0
  35. package/skills/_support/docs/principles/details/p025-property-based-testing-for-invariants.md +133 -0
  36. package/skills/_support/docs/principles/details/p026-regression-before-repair.md +134 -0
  37. package/skills/_support/docs/principles/details/p027-deterministic-and-hermetic-tests.md +128 -0
  38. package/skills/_support/docs/principles/details/p028-test-failure-paths.md +133 -0
  39. package/skills/_support/docs/principles/details/p029-generalize-error-policy-preserve-specific-cause.md +131 -0
  40. package/skills/_support/docs/principles/details/p030-nearest-responsible-error-boundary.md +133 -0
  41. package/skills/_support/docs/principles/details/p031-propagate-rather-than-swallow.md +131 -0
  42. package/skills/_support/docs/principles/details/p032-handle-once-preserve-causality.md +137 -0
  43. package/skills/_support/docs/principles/details/p033-state-safe-failure-semantics.md +140 -0
  44. package/skills/_support/docs/principles/details/p034-fail-fast.md +132 -0
  45. package/skills/_support/docs/principles/details/p035-fail-secure-fail-closed.md +137 -0
  46. package/skills/_support/docs/principles/details/p036-graceful-degradation.md +137 -0
  47. package/skills/_support/docs/principles/details/p037-idempotency-before-retry.md +139 -0
  48. package/skills/_support/docs/principles/details/p038-bounded-retry.md +145 -0
  49. package/skills/_support/docs/principles/details/p039-bounded-waiting.md +151 -0
  50. package/skills/_support/docs/principles/details/p040-bounded-resources.md +138 -0
  51. package/skills/_support/docs/principles/details/p041-backpressure-and-load-shedding.md +141 -0
  52. package/skills/_support/docs/principles/details/p042-fault-isolation-bulkheads.md +140 -0
  53. package/skills/_support/docs/principles/details/p043-circuit-breakers.md +148 -0
  54. package/skills/_support/docs/principles/details/p044-atomicity-where-possible.md +141 -0
  55. package/skills/_support/docs/principles/details/p045-compensation-where-atomicity-is-impossible.md +163 -0
  56. package/skills/_support/docs/principles/details/p046-resumability.md +138 -0
  57. package/skills/_support/docs/principles/details/p047-observability-is-part-of-correctness.md +134 -0
  58. package/skills/_support/docs/principles/details/p048-secure-by-design.md +132 -0
  59. package/skills/_support/docs/principles/details/p049-secure-by-default.md +126 -0
  60. package/skills/_support/docs/principles/details/p050-least-privilege.md +123 -0
  61. package/skills/_support/docs/principles/details/p051-complete-mediation.md +124 -0
  62. package/skills/_support/docs/principles/details/p052-separation-of-duties.md +129 -0
  63. package/skills/_support/docs/principles/details/p053-validate-at-trust-boundaries.md +126 -0
  64. package/skills/_support/docs/principles/details/p054-defense-in-depth.md +135 -0
  65. package/skills/_support/docs/principles/details/p055-minimize-attack-surface.md +133 -0
  66. package/skills/_support/docs/principles/details/p056-secrets-stay-out-of-code-and-context.md +137 -0
  67. package/skills/_support/docs/principles/details/p057-supply-chain-integrity.md +135 -0
  68. package/skills/_support/docs/principles/details/p058-bounded-agent-authority.md +137 -0
  69. package/skills/_support/docs/principles/details/p059-data-is-not-instruction.md +130 -0
  70. package/skills/_support/docs/principles/details/p060-constrain-sub-agents.md +136 -0
  71. package/skills/_support/docs/principles/details/p061-separate-decision-from-high-impact-execution.md +131 -0
  72. package/skills/_support/docs/principles/details/p062-human-approval-for-irreversible-or-high-risk-actions.md +129 -0
  73. package/skills/_support/docs/principles/details/p063-requirement-to-code-traceability.md +116 -0
  74. package/skills/_support/docs/principles/details/p064-requirement-to-test-traceability.md +116 -0
  75. package/skills/_support/docs/principles/details/p065-verify-before-claiming-completion.md +131 -0
  76. package/skills/_support/docs/principles/details/p066-preserve-existing-work.md +114 -0
  77. package/skills/_support/docs/principles/details/p067-no-test-cheating.md +126 -0
  78. package/skills/_support/docs/principles/details/p068-no-validation-bypass.md +119 -0
  79. package/skills/_support/docs/principles/details/p069-independent-review-for-high-risk-changes.md +129 -0
  80. package/skills/_support/docs/principles/details/p070-code-health-must-not-regress.md +116 -0
  81. package/skills/_support/docs/principles/details/p071-consistency-over-personal-preference.md +117 -0
  82. package/skills/_support/docs/principles/details/p072-technical-evidence-over-preference.md +122 -0
  83. package/skills/_support/docs/principles/details/p073-optimize-only-with-evidence.md +123 -0
  84. package/skills/_support/docs/principles/details/p074-prefer-existing-mechanisms.md +121 -0
  85. package/skills/_support/docs/principles/details/p075-make-invalid-states-hard-to-represent.md +128 -0
  86. package/skills/_support/docs/principles/details/p076-parse-then-validate-then-operate.md +133 -0
  87. package/skills/_support/docs/principles/details/p077-separate-policy-from-mechanism.md +112 -0
  88. package/skills/_support/docs/principles/details/p078-single-source-of-truth.md +131 -0
  89. package/skills/_support/docs/principles/details/p079-explicit-ownership-and-lifetimes.md +124 -0
  90. package/skills/_support/docs/principles/details/p080-make-concurrency-deliberate.md +122 -0
  91. package/skills/_support/docs/principles/details/p081-forward-progress-with-safety.md +125 -0
  92. package/skills/_support/docs/principles/details/p082-design-for-cancellation.md +133 -0
  93. package/skills/_support/docs/principles/details/p083-irreversible-actions-last.md +124 -0
  94. package/skills/_support/docs/principles/details/p084-prefer-local-reasoning.md +131 -0
  95. package/skills/_support/docs/principles/details/p085-explicit-is-better-than-implicit.md +123 -0
  96. package/skills/_support/docs/principles/details/p086-readability-counts.md +113 -0
  97. package/skills/_support/docs/principles/details/p087-comments-explain-why-code-explains-what.md +120 -0
  98. package/skills/_support/docs/principles/details/p088-delete-dead-code.md +125 -0
  99. package/skills/_support/docs/principles/details/p089-delete-obsolete-configuration-and-dependencies.md +125 -0
  100. package/skills/_support/docs/principles/details/p090-prefer-negative-code.md +134 -0
  101. package/skills/_support/docs/principles/details/p091-test-driven-development.md +149 -0
  102. package/skills/_support/docs/review/README.md +63 -0
  103. package/skills/_support/docs/review/behavior-first-testing.md +78 -0
  104. package/skills/_support/docs/review/common.md +400 -0
  105. package/skills/_support/docs/review/design-docs.md +74 -0
  106. package/skills/_support/docs/review/issue-planning.md +247 -0
  107. package/skills/_support/docs/review/language-routing.md +141 -0
  108. package/skills/_support/docs/review/repository-scorecard.md +170 -0
  109. package/skills/advise/SKILL.md +108 -74
  110. package/skills/advise/scripts/list_retrievable_skills.py +20 -8
  111. package/skills/advise/scripts/resolve_knowledge_checkout.py +533 -0
  112. package/skills/brainstorm/SKILL.md +163 -127
  113. package/skills/change-review/SKILL.md +91 -71
  114. package/skills/change-review/references/scope-resolution.md +69 -46
  115. package/skills/change-review/scripts/resolve_scope.py +144 -84
  116. package/skills/finalize-plan/SKILL.md +205 -139
  117. package/skills/git-worktrees/SKILL.md +100 -80
  118. package/skills/git-worktrees/scripts/prepare_worktree.py +34 -15
  119. package/skills/issue-review/SKILL.md +136 -79
  120. package/skills/learn/SKILL.md +359 -192
  121. package/skills/myrmidon-swarm/SKILL.md +126 -86
  122. package/skills/plan-issue/SKILL.md +122 -81
  123. package/skills/pr-review/SKILL.md +184 -116
  124. package/skills/pr-review/references/criteria.md +61 -37
  125. package/skills/pr-review/references/delivery.md +277 -101
  126. package/skills/pr-review/references/evidence.md +346 -149
  127. package/skills/pr-review/references/prevalidated.md +93 -61
  128. package/skills/pr-review/scripts/collect_evidence.py +312 -162
  129. package/skills/pr-review/scripts/deliver_go.py +701 -0
  130. package/skills/pr-review/scripts/diff_context.py +34 -15
  131. package/skills/pr-review/scripts/materialize_snapshot.py +141 -130
  132. package/skills/pr-review/scripts/pr_identity.py +27 -15
  133. package/skills/pr-review/scripts/resolve_pr.py +77 -41
  134. package/skills/realign/SKILL.md +516 -0
  135. package/skills/realign/references/aislop-integration.md +215 -0
  136. package/skills/realign/references/architecture-and-structure.md +271 -0
  137. package/skills/realign/references/control-flow-and-errors.md +344 -0
  138. package/skills/realign/references/tests-dependencies-and-security.md +261 -0
  139. package/skills/realign/scripts/resolve_assessment.py +1525 -0
  140. package/skills/repo-review/SKILL.md +202 -131
  141. package/skills/simplify/SKILL.md +174 -0
  142. package/skills/systematic-debugging/SKILL.md +212 -192
  143. package/skills/systematic-debugging/scripts/repository_evidence.py +26 -8
  144. package/skills/test-driven-development/SKILL.md +126 -96
  145. package/skills/tidy/SKILL.md +104 -59
  146. package/skills/tidy/scripts/run_tidy.py +54 -6
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """Collect GitHub PR metadata and immutable review evidence."""
2
+ """Collect GitHub pull-request metadata and immutable review evidence."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import importlib.util
6
7
  import json
8
+ import re
7
9
  import subprocess
8
10
  import sys
9
11
  import threading
@@ -12,10 +14,7 @@ from collections.abc import Sequence
12
14
  from dataclasses import dataclass, field
13
15
  from hashlib import sha256
14
16
  from pathlib import Path
15
- from typing import IO, Any
16
-
17
- if __package__ in {None, ""}:
18
- sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
17
+ from typing import IO, TYPE_CHECKING, Any
19
18
 
20
19
  from materialize_snapshot import (
21
20
  MaterializedSnapshot,
@@ -32,21 +31,40 @@ from pr_identity import (
32
31
  validate_pr_identifier,
33
32
  )
34
33
 
35
- from skills._cli import (
36
- argument_parser,
37
- git_read_arguments,
38
- git_read_environment,
39
- require_complete_git_history,
40
- require_unambiguous_git_merge_base,
41
- run_command,
42
- )
34
+ if TYPE_CHECKING or __package__ not in {None, ""}:
35
+ from skills._cli import (
36
+ argument_parser,
37
+ git_read_arguments,
38
+ git_read_environment,
39
+ require_complete_git_history,
40
+ require_unambiguous_git_merge_base,
41
+ run_command,
42
+ )
43
+ else:
44
+ _cli_path = Path(__file__).resolve().parents[2] / "_cli.py"
45
+ _cli_spec = importlib.util.spec_from_file_location(
46
+ "athena_installed_cli", _cli_path
47
+ )
48
+ if _cli_spec is None or _cli_spec.loader is None:
49
+ raise RuntimeError(
50
+ f"The installed Athena CLI helper is unavailable: '{_cli_path}'."
51
+ )
52
+ _cli = importlib.util.module_from_spec(_cli_spec)
53
+ _cli_spec.loader.exec_module(_cli)
54
+ argument_parser = _cli.argument_parser
55
+ git_read_arguments = _cli.git_read_arguments
56
+ git_read_environment = _cli.git_read_environment
57
+ require_complete_git_history = _cli.require_complete_git_history
58
+ require_unambiguous_git_merge_base = _cli.require_unambiguous_git_merge_base
59
+ run_command = _cli.run_command
43
60
 
44
61
  # Keep this query below GitHub's GraphQL complexity budget. Strict callers bind
45
- # changed paths to local immutable Git objects; legacy callers retain the REST
46
- # file-list fallback for backwards compatibility only.
62
+ # changed paths to local immutable Git objects. Earlier callers use the REST
63
+ # file-list fallback only for backward compatibility.
47
64
  FIELDS = (
48
65
  "number,title,body,state,isDraft,author,baseRefName,headRefName,"
49
- "baseRefOid,headRefOid,reviews,statusCheckRollup,closingIssuesReferences,url"
66
+ "baseRefOid,headRefOid,reviewDecision,reviews,statusCheckRollup,"
67
+ "closingIssuesReferences,url"
50
68
  )
51
69
  ISSUE_FIELDS = "id,number,url,title,body,state"
52
70
  READ_CHUNK_SIZE = 64 * 1024
@@ -68,7 +86,6 @@ MAX_CHANGED_PATH_MANIFEST_BYTES = 2 * 1024 * 1024
68
86
  MAX_CHANGED_PATHS = 10_000
69
87
  MAX_CHANGED_PATH_STDERR_BYTES = 16 * 1024
70
88
  CHANGED_PATH_REQUEST_TIMEOUT_SECONDS = 30.0
71
- MAX_CHECK_RUN_PAGE_BYTES = 256 * 1024
72
89
  MAX_CHECK_RUN_BYTES = 2 * 1024 * 1024
73
90
  MAX_CHECK_RUN_PAGES = 100
74
91
  MAX_CHECK_RUNS = 10_000
@@ -90,7 +107,7 @@ class CheckEvidenceCoverageGap(RuntimeError):
90
107
 
91
108
  @dataclass
92
109
  class LinkedRequirementBudget:
93
- """One cumulative provider budget shared by both strict evidence reads."""
110
+ """This budget controls both strict evidence reads from the provider."""
94
111
 
95
112
  pages: int = 0
96
113
  comments: int = 0
@@ -105,7 +122,7 @@ class LinkedRequirementBudget:
105
122
  """Reserve one bounded provider request before issuing it."""
106
123
  if self.requests >= MAX_LINKED_REQUIREMENT_REQUESTS:
107
124
  raise LinkedRequirementsCoverageGap(
108
- "linked issue requirements exceed the safe aggregate request limit"
125
+ "The linked issue requirements exceed the safe aggregate request limit."
109
126
  )
110
127
  self.requests += 1
111
128
 
@@ -113,7 +130,7 @@ class LinkedRequirementBudget:
113
130
  """Reserve one aggregate comment page and its provider request."""
114
131
  if self.pages >= MAX_LINKED_REQUIREMENT_PAGES:
115
132
  raise LinkedRequirementsCoverageGap(
116
- "linked issue requirements exceed the safe aggregate page limit"
133
+ "The linked issue requirements exceed the safe aggregate page limit."
117
134
  )
118
135
  self.reserve_request()
119
136
 
@@ -121,7 +138,7 @@ class LinkedRequirementBudget:
121
138
  """Account for one provider response without crossing the byte budget."""
122
139
  if count > self.remaining_bytes():
123
140
  raise LinkedRequirementsCoverageGap(
124
- "linked issue requirements exceed the safe aggregate byte limit"
141
+ "The linked issue requirements exceed the safe aggregate byte limit."
125
142
  )
126
143
  self.bytes_read += count
127
144
 
@@ -129,7 +146,7 @@ class LinkedRequirementBudget:
129
146
  """Account for one successful provider page and its item count."""
130
147
  if self.comments + count > MAX_LINKED_REQUIREMENT_COMMENTS:
131
148
  raise LinkedRequirementsCoverageGap(
132
- "linked issue requirements exceed the safe aggregate comment limit"
149
+ "The linked issue requirements exceed the safe aggregate comment limit."
133
150
  )
134
151
  self.pages += 1
135
152
  self.comments += count
@@ -137,7 +154,7 @@ class LinkedRequirementBudget:
137
154
 
138
155
  @dataclass
139
156
  class ProviderStream:
140
- """One bounded asynchronous stdout or stderr capture."""
157
+ """This record contains one bounded asynchronous output stream."""
141
158
 
142
159
  maximum_bytes: int
143
160
  output: bytearray = field(default_factory=bytearray)
@@ -148,7 +165,7 @@ class ProviderStream:
148
165
 
149
166
  @dataclass
150
167
  class ChangedPathStream:
151
- """Incrementally validate a bounded NUL-delimited Git path manifest."""
168
+ """Incrementally validate a bounded null-byte-delimited Git path manifest."""
152
169
 
153
170
  maximum_bytes: int
154
171
  maximum_paths: int
@@ -164,7 +181,7 @@ class ChangedPathStream:
164
181
 
165
182
  @dataclass(frozen=True)
166
183
  class ImmutableIdentity:
167
- """The review artifact and exact revisions that evidence is bound to."""
184
+ """This record binds evidence to a review artifact and its exact revisions."""
168
185
 
169
186
  repository: str
170
187
  number: int
@@ -187,7 +204,7 @@ class ImmutableIdentity:
187
204
 
188
205
  @dataclass(frozen=True)
189
206
  class ExpectedReviewTarget:
190
- """The immutable GitHub artifact target resolved before strict collection."""
207
+ """This record identifies the immutable GitHub target for strict collection."""
191
208
 
192
209
  host: str
193
210
  repository: str
@@ -201,7 +218,7 @@ class ExpectedReviewTarget:
201
218
 
202
219
  @dataclass(frozen=True)
203
220
  class ChangedPathManifest:
204
- """A canonical, immutable changed-path manifest derived from Git objects."""
221
+ """This record describes a canonical changed-path manifest from Git objects."""
205
222
 
206
223
  paths: tuple[str, ...]
207
224
  sha256: str
@@ -217,7 +234,7 @@ class ChangedPathManifest:
217
234
 
218
235
  @dataclass(frozen=True)
219
236
  class ReviewScope:
220
- """Canonical mutable review-context fields bound to an evidence collection."""
237
+ """This record binds mutable review-context fields to an evidence collection."""
221
238
 
222
239
  fields: dict[str, Any]
223
240
  sha256: str
@@ -229,7 +246,7 @@ class ReviewScope:
229
246
 
230
247
  @dataclass(frozen=True)
231
248
  class LinkedRequirements:
232
- """Canonical content binding for every linked issue used as requirements."""
249
+ """This record binds the canonical content of each linked issue used as requirements."""
233
250
 
234
251
  items: tuple[LinkedRequirement, ...]
235
252
  sha256: str
@@ -245,7 +262,7 @@ class LinkedRequirements:
245
262
 
246
263
  @dataclass(frozen=True)
247
264
  class LinkedRequirement:
248
- """One linked issue's stable identity and content-only requirements digest."""
265
+ """This record contains one linked issue identity and its requirements digest."""
249
266
 
250
267
  id: str
251
268
  repository: str
@@ -265,9 +282,9 @@ class LinkedRequirement:
265
282
 
266
283
 
267
284
  def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str | None:
268
- """Return a diagnostic when GitHub returns partial PR metadata."""
285
+ """Return a diagnostic when GitHub returns partial pull-request metadata."""
269
286
  if not isinstance(metadata, dict):
270
- return "PR metadata must be a JSON object"
287
+ return "The pull-request metadata must be a JSON object."
271
288
  required_types = {
272
289
  "number": int,
273
290
  "title": str,
@@ -287,11 +304,13 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
287
304
  if not isinstance(author, dict) or not isinstance(author.get("login"), str):
288
305
  invalid.append("author.login")
289
306
  if invalid:
290
- return "GitHub returned incomplete or invalid PR metadata fields: " + ", ".join(
291
- sorted(set(invalid))
307
+ return (
308
+ "GitHub returned pull-request metadata fields that are incomplete or not valid: "
309
+ + ", ".join(f"'{field}'" for field in sorted(set(invalid)))
310
+ + "."
292
311
  )
293
312
  if metadata["state"] != "OPEN":
294
- return f"pull request {metadata['number']} is not open"
313
+ return f"Pull request {metadata['number']} is not open."
295
314
  identity_fields = ("baseRefOid", "headRefOid")
296
315
  identity_values = [metadata.get(field) for field in identity_fields]
297
316
  has_identity = any(value is not None for value in identity_values)
@@ -303,8 +322,10 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
303
322
  ]
304
323
  if invalid_identity:
305
324
  return (
306
- "GitHub returned incomplete or invalid immutable PR identity fields: "
307
- + ", ".join(invalid_identity)
325
+ "GitHub returned immutable pull-request identity fields that are "
326
+ "incomplete or not valid: "
327
+ + ", ".join(f"'{field}'" for field in invalid_identity)
328
+ + "."
308
329
  )
309
330
  if require_immutable_identity:
310
331
  body = metadata.get("body")
@@ -320,8 +341,9 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
320
341
  scope_invalid.append("closingIssuesReferences")
321
342
  if scope_invalid:
322
343
  return (
323
- "GitHub returned incomplete or invalid review-scope fields: "
324
- + ", ".join(scope_invalid)
344
+ "GitHub returned review-scope fields that are incomplete or not valid: "
345
+ + ", ".join(f"'{field}'" for field in scope_invalid)
346
+ + "."
325
347
  )
326
348
  return None
327
349
 
@@ -335,11 +357,11 @@ def immutable_identity(
335
357
  if base_oid is None and head_oid is None and not require_immutable_identity:
336
358
  return None
337
359
  if not isinstance(base_oid, str) or not isinstance(head_oid, str):
338
- raise TypeError("GitHub returned incomplete immutable pull-request identity")
360
+ raise TypeError("GitHub returned incomplete immutable pull-request identity.")
339
361
  number = metadata.get("number")
340
362
  url = metadata.get("url")
341
363
  if not isinstance(number, int) or not isinstance(url, str):
342
- raise TypeError("GitHub returned incomplete pull-request identity")
364
+ raise TypeError("GitHub returned incomplete pull-request identity.")
343
365
  return ImmutableIdentity(
344
366
  repository=repository,
345
367
  number=number,
@@ -354,15 +376,19 @@ def expected_identity(
354
376
  ) -> tuple[str, str] | None:
355
377
  """Validate the optional immutable identity supplied by resolve_pr.py."""
356
378
  if (base_oid is None) != (head_oid is None):
357
- parser.error(
358
- "--expected-base-oid and --expected-head-oid must be supplied together"
359
- )
379
+ parser.error("Specify --expected-base-oid and --expected-head-oid together.")
360
380
  if base_oid is None or head_oid is None:
361
381
  return None
362
382
  if COMMIT_OID.fullmatch(base_oid) is None:
363
- parser.error("--expected-base-oid must be a lowercase 40-hex Git commit OID")
383
+ parser.error(
384
+ "--expected-base-oid must be a Git commit object identifier that contains "
385
+ "40 lowercase hexadecimal characters."
386
+ )
364
387
  if COMMIT_OID.fullmatch(head_oid) is None:
365
- parser.error("--expected-head-oid must be a lowercase 40-hex Git commit OID")
388
+ parser.error(
389
+ "--expected-head-oid must be a Git commit object identifier that contains "
390
+ "40 lowercase hexadecimal characters."
391
+ )
366
392
  return base_oid, head_oid
367
393
 
368
394
 
@@ -374,7 +400,7 @@ def expected_target(
374
400
  number: int | None,
375
401
  url: str | None,
376
402
  ) -> ExpectedReviewTarget | None:
377
- """Require the resolved GitHub target whenever immutable OIDs are supplied."""
403
+ """Require the resolved GitHub target when immutable object identifiers are supplied."""
378
404
  if identity is None:
379
405
  if (
380
406
  host is not None
@@ -383,21 +409,22 @@ def expected_target(
383
409
  or url is not None
384
410
  ):
385
411
  parser.error(
386
- "--expected-host, --expected-repository, --expected-pr-number, and "
387
- "--expected-pr-url require immutable expected OIDs"
412
+ "If you specify an expected target, also specify --expected-base-oid "
413
+ "and --expected-head-oid."
388
414
  )
389
415
  return None
390
416
  if host is None or repository is None or number is None or url is None:
391
417
  parser.error(
418
+ "With --expected-base-oid and --expected-head-oid, specify "
392
419
  "--expected-host, --expected-repository, --expected-pr-number, and "
393
- "--expected-pr-url are required with immutable expected OIDs"
420
+ "--expected-pr-url."
394
421
  )
395
422
  assert host is not None
396
423
  assert repository is not None
397
424
  assert number is not None
398
425
  assert url is not None
399
426
  if host != "github.com":
400
- parser.error("--expected-host must be github.com")
427
+ parser.error("'--expected-host' must be 'github.com'.")
401
428
  try:
402
429
  canonical_repository = require_github_repository(
403
430
  repository, "--expected-repository"
@@ -405,7 +432,7 @@ def expected_target(
405
432
  except RuntimeError as error:
406
433
  parser.error(str(error))
407
434
  if number < 1:
408
- parser.error("--expected-pr-number must be a positive pull-request number")
435
+ parser.error("--expected-pr-number must be a positive pull-request number.")
409
436
  try:
410
437
  canonical_url = require_canonical_pull_request_url(
411
438
  url, canonical_repository, number, "--expected-pr-url"
@@ -423,14 +450,15 @@ def expected_target(
423
450
  def ensure_expected_identity(
424
451
  identity: ImmutableIdentity | None, expected: tuple[str, str] | None
425
452
  ) -> None:
426
- """Fail closed when collected revisions differ from the resolved PR."""
453
+ """Fail closed when collected revisions differ from the resolved pull request."""
427
454
  if expected is None:
428
455
  return
429
456
  if identity is None:
430
- raise RuntimeError("GitHub returned no immutable pull-request identity")
457
+ raise RuntimeError("GitHub did not return an immutable pull-request identity.")
431
458
  if (identity.base_oid, identity.head_oid) != expected:
432
459
  raise RuntimeError(
433
- "immutable pull-request identity does not match the expected base/head OIDs"
460
+ "The immutable pull-request identity does not match the expected base "
461
+ "object identifier and head object identifier."
434
462
  )
435
463
 
436
464
 
@@ -441,20 +469,22 @@ def ensure_expected_target(
441
469
  if target is None:
442
470
  return
443
471
  if identity is None:
444
- raise RuntimeError("GitHub returned no immutable pull-request identity")
472
+ raise RuntimeError("GitHub did not return an immutable pull-request identity.")
445
473
  if (
446
474
  identity.repository.casefold() != target.repository.casefold()
447
475
  or identity.number != target.number
448
476
  or identity.url != target.url
449
477
  ):
450
- raise RuntimeError("pull-request identity does not match the expected target")
478
+ raise RuntimeError(
479
+ "The pull-request identity does not match the expected target."
480
+ )
451
481
 
452
482
 
453
483
  def review_scope(metadata: dict[str, Any]) -> ReviewScope:
454
- """Bind mutable issue/scope fields so they cannot drift during review."""
484
+ """Bind mutable issue fields and scope fields so they cannot change during review."""
455
485
  closing_issues = metadata.get("closingIssuesReferences")
456
486
  if not isinstance(closing_issues, list):
457
- raise TypeError("GitHub returned incomplete review-scope fields")
487
+ raise TypeError("GitHub returned incomplete review-scope fields.")
458
488
  try:
459
489
  canonical_issues = sorted(
460
490
  json.dumps(
@@ -485,7 +515,9 @@ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
485
515
  sort_keys=True,
486
516
  )
487
517
  except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
488
- raise RuntimeError("GitHub returned invalid review-scope fields") from error
518
+ raise RuntimeError(
519
+ "GitHub returned review-scope fields that are not valid."
520
+ ) from error
489
521
  return ReviewScope(
490
522
  fields=fields,
491
523
  sha256=sha256(canonical_scope.encode("utf-8")).hexdigest(),
@@ -495,7 +527,7 @@ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
495
527
  def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
496
528
  """Return one validated canonical linked-issue identity."""
497
529
  if not isinstance(issue, dict):
498
- raise TypeError("GitHub returned an invalid linked issue reference")
530
+ raise TypeError("GitHub returned a linked issue reference that is not valid.")
499
531
  issue_id = issue.get("id")
500
532
  repository_data = issue.get("repository")
501
533
  number = issue.get("number")
@@ -507,7 +539,7 @@ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
507
539
  or isinstance(number, bool)
508
540
  or not isinstance(number, int)
509
541
  ):
510
- raise RuntimeError("GitHub returned an incomplete linked issue reference")
542
+ raise RuntimeError("GitHub returned an incomplete linked issue reference.")
511
543
  owner_data = repository_data.get("owner")
512
544
  name = repository_data.get("name")
513
545
  owner = owner_data.get("login") if isinstance(owner_data, dict) else None
@@ -519,18 +551,18 @@ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
519
551
  or number < 1
520
552
  or not isinstance(url, str)
521
553
  ):
522
- raise RuntimeError("GitHub returned an incomplete linked issue reference")
554
+ raise RuntimeError("GitHub returned an incomplete linked issue reference.")
523
555
  try:
524
556
  repository = require_github_repository(
525
557
  f"{owner}/{name}", "GitHub linked issue repository"
526
558
  )
527
559
  except RuntimeError as error:
528
560
  raise RuntimeError(
529
- "GitHub returned an invalid linked issue repository"
561
+ "GitHub returned a linked issue repository that is not valid."
530
562
  ) from error
531
563
  canonical_url = f"https://github.com/{repository}/issues/{number}"
532
564
  if url != canonical_url:
533
- raise RuntimeError("GitHub returned an invalid linked issue URL")
565
+ raise RuntimeError("GitHub returned a linked issue URL that is not valid.")
534
566
  return issue_id, repository, number, canonical_url
535
567
 
536
568
 
@@ -545,7 +577,7 @@ def canonical_json(value: object, label: str) -> str:
545
577
  sort_keys=True,
546
578
  )
547
579
  except (TypeError, ValueError) as error:
548
- raise RuntimeError(f"GitHub returned invalid {label}") from error
580
+ raise RuntimeError(f"The data for {label} is not valid.") from error
549
581
 
550
582
 
551
583
  def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
@@ -571,7 +603,7 @@ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
571
603
  try:
572
604
  stream.close()
573
605
  except OSError:
574
- # Another cleanup path may already have closed this best-effort pipe.
606
+ # A different cleanup path can close this pipe first. Ignore the error.
575
607
  pass
576
608
  capture.completed.set()
577
609
 
@@ -579,15 +611,15 @@ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
579
611
  def validate_changed_path(entry: bytes) -> None:
580
612
  """Reject an empty or unsafe Git-relative path entry."""
581
613
  if not entry:
582
- raise RuntimeError("Git returned an empty changed path")
614
+ raise RuntimeError("Git returned an empty changed path.")
583
615
  if entry.startswith(b"/") or any(
584
616
  component in {b".", b".."} for component in entry.split(b"/")
585
617
  ):
586
- raise RuntimeError("Git returned an unsafe changed path")
618
+ raise RuntimeError("Git returned an unsafe changed path.")
587
619
 
588
620
 
589
621
  def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) -> None:
590
- """Incrementally collect one bounded, NUL-delimited Git path manifest."""
622
+ """Incrementally collect one bounded, null-byte-delimited Git path manifest."""
591
623
  try:
592
624
  while True:
593
625
  read_size = min(
@@ -595,7 +627,7 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
595
627
  )
596
628
  if read_size <= 0:
597
629
  capture.limit_error = (
598
- "changed-path manifest exceeds the safe byte limit"
630
+ "The changed-path manifest exceeds the safe byte limit."
599
631
  )
600
632
  capture.overflowed.set()
601
633
  return
@@ -603,12 +635,13 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
603
635
  if not chunk:
604
636
  if capture.trailing:
605
637
  raise RuntimeError(
606
- "Git returned a malformed NUL-delimited changed-path manifest"
638
+ "Git returned a null-byte-delimited changed-path manifest that "
639
+ "is not valid."
607
640
  )
608
641
  return
609
642
  if capture.bytes_read + len(chunk) > capture.maximum_bytes:
610
643
  capture.limit_error = (
611
- "changed-path manifest exceeds the safe byte limit"
644
+ "The changed-path manifest exceeds the safe byte limit."
612
645
  )
613
646
  capture.overflowed.set()
614
647
  return
@@ -618,10 +651,10 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
618
651
  for entry in entries:
619
652
  validate_changed_path(entry)
620
653
  if entry in capture.seen_paths:
621
- raise RuntimeError("Git returned duplicate changed paths")
654
+ raise RuntimeError("Git returned duplicate changed paths.")
622
655
  if len(capture.paths) >= capture.maximum_paths:
623
656
  capture.limit_error = (
624
- "changed-path manifest exceeds the safe path limit"
657
+ "The changed-path manifest exceeds the safe path limit."
625
658
  )
626
659
  capture.overflowed.set()
627
660
  return
@@ -633,7 +666,7 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
633
666
  try:
634
667
  stream.close()
635
668
  except (OSError, ValueError):
636
- # A sibling cleanup path can close the pipe before this reader exits.
669
+ # A different cleanup path can close this pipe first. Ignore the error.
637
670
  pass
638
671
  capture.completed.set()
639
672
 
@@ -650,7 +683,7 @@ def reap_provider(
650
683
  try:
651
684
  stream.close()
652
685
  except (OSError, ValueError):
653
- # Reaping is best effort after either reader may have closed the pipe.
686
+ # A reader can close this pipe first. Ignore the error.
654
687
  pass
655
688
  process.wait()
656
689
  for reader in readers:
@@ -704,14 +737,18 @@ def bounded_gh_output(
704
737
  limit_error: str,
705
738
  timeout_seconds: float | None = None,
706
739
  stderr_maximum_bytes: int | None = None,
707
- stderr_limit_error: str = "linked issue response exceeds the safe stderr limit",
708
- deadline_error: str = "linked issue provider exceeded the safe provider deadline",
709
- output_error: str = "cannot read linked issue provider output",
710
- unavailable_output_error: str = "GitHub did not provide linked issue provider output",
711
- operating_system_error: str = "cannot collect linked issue comments",
740
+ stderr_limit_error: str = (
741
+ "The linked issue response exceeds the safe standard-error limit."
742
+ ),
743
+ deadline_error: str = "The linked issue provider exceeded the safe deadline.",
744
+ output_error: str = "The tool cannot read linked issue provider output.",
745
+ unavailable_output_error: str = (
746
+ "The GitHub CLI process did not provide linked issue output."
747
+ ),
748
+ operating_system_error: str = "The tool cannot collect linked issue comments",
712
749
  coverage_gap: type[RuntimeError] = LinkedRequirementsCoverageGap,
713
750
  ) -> bytes:
714
- """Run one deadline-bound GitHub request with bounded stdout and stderr."""
751
+ """Run one deadline-bound GitHub request with bounded output streams."""
715
752
  command = ["gh", *arguments]
716
753
  effective_timeout = (
717
754
  LINKED_ISSUE_COMMENT_REQUEST_TIMEOUT_SECONDS
@@ -732,7 +769,8 @@ def bounded_gh_output(
732
769
  )
733
770
  except FileNotFoundError as error:
734
771
  raise RuntimeError(
735
- f"required command unavailable: {error.filename or command[0]}"
772
+ "The required command is not available: "
773
+ f"'{error.filename or command[0]}'."
736
774
  ) from error
737
775
  stdout = process.stdout
738
776
  stderr = process.stderr
@@ -777,10 +815,16 @@ def bounded_gh_output(
777
815
  message = (
778
816
  bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
779
817
  )
780
- raise RuntimeError(message or f"gh {' '.join(arguments)} failed")
818
+ raise RuntimeError(
819
+ message
820
+ or f"The GitHub CLI command failed. Command: gh {' '.join(arguments)}"
821
+ )
781
822
  return bytes(stdout_capture.output)
782
823
  except OSError as error:
783
- raise RuntimeError(f"{operating_system_error}: {error}") from error
824
+ raise RuntimeError(
825
+ f"{operating_system_error}. "
826
+ f"The operation returned this diagnostic.\n{error}"
827
+ ) from error
784
828
 
785
829
 
786
830
  def paginated_issue_comments(
@@ -795,11 +839,11 @@ def paginated_issue_comments(
795
839
  aggregate_remaining_bytes = collection_budget.remaining_bytes()
796
840
  if remaining_bytes <= 0:
797
841
  raise LinkedRequirementsCoverageGap(
798
- "linked issue comments exceed the safe byte limit"
842
+ "The linked issue comments exceed the safe byte limit."
799
843
  )
800
844
  if aggregate_remaining_bytes <= 0:
801
845
  raise LinkedRequirementsCoverageGap(
802
- "linked issue requirements exceed the safe aggregate byte limit"
846
+ "The linked issue requirements exceed the safe aggregate byte limit."
803
847
  )
804
848
  collection_budget.reserve_comment_page()
805
849
  response = bounded_gh_output(
@@ -819,7 +863,7 @@ def paginated_issue_comments(
819
863
  remaining_bytes,
820
864
  aggregate_remaining_bytes,
821
865
  ),
822
- limit_error="linked issue comments exceed the safe byte limit",
866
+ limit_error="The linked issue comments exceed the safe byte limit.",
823
867
  )
824
868
  bytes_read += len(response)
825
869
  collection_budget.record_bytes(len(response))
@@ -827,27 +871,33 @@ def paginated_issue_comments(
827
871
  page_comments = json.loads(response)
828
872
  except json.JSONDecodeError as error:
829
873
  raise RuntimeError(
830
- "GitHub returned invalid linked issue comment pages"
874
+ "GitHub returned linked issue comment pages that are not valid."
831
875
  ) from error
832
876
  if not isinstance(page_comments, list):
833
- raise TypeError("GitHub returned invalid linked issue comment pages")
877
+ raise TypeError(
878
+ "GitHub returned linked issue comment pages that are not valid."
879
+ )
834
880
  if not all(isinstance(comment, dict) for comment in page_comments):
835
- raise RuntimeError("GitHub returned an invalid linked issue comment")
881
+ raise RuntimeError(
882
+ "GitHub returned a linked issue comment that is not valid."
883
+ )
836
884
  if page > MAX_LINKED_ISSUE_COMMENT_PAGES:
837
885
  if page_comments:
838
886
  raise LinkedRequirementsCoverageGap(
839
- "linked issue comments exceed the safe page limit"
887
+ "The linked issue comments exceed the safe page limit."
840
888
  )
841
889
  return comments
842
890
  if len(comments) + len(page_comments) > MAX_LINKED_ISSUE_COMMENTS:
843
891
  raise LinkedRequirementsCoverageGap(
844
- "linked issue comments exceed the safe comment limit"
892
+ "The linked issue comments exceed the safe comment limit."
845
893
  )
846
894
  collection_budget.record_comment_page(len(page_comments))
847
895
  comments.extend(page_comments)
848
896
  if len(page_comments) < LINKED_ISSUE_COMMENT_PAGE_SIZE:
849
897
  return comments
850
- raise AssertionError("bounded linked issue comment pagination did not terminate")
898
+ raise AssertionError(
899
+ "The bounded linked issue comment pagination did not terminate."
900
+ )
851
901
 
852
902
 
853
903
  def structured_error(error: str, details: str) -> None:
@@ -862,7 +912,7 @@ def linked_issue_metadata(
862
912
  aggregate_remaining_bytes = budget.remaining_bytes()
863
913
  if aggregate_remaining_bytes <= 0:
864
914
  raise LinkedRequirementsCoverageGap(
865
- "linked issue requirements exceed the safe aggregate byte limit"
915
+ "The linked issue requirements exceed the safe aggregate byte limit."
866
916
  )
867
917
  budget.reserve_request()
868
918
  response = bounded_gh_output(
@@ -878,43 +928,69 @@ def linked_issue_metadata(
878
928
  maximum_bytes=min(
879
929
  MAX_LINKED_REQUIREMENT_METADATA_BYTES, aggregate_remaining_bytes
880
930
  ),
881
- limit_error="linked issue metadata exceeds the safe metadata byte limit",
931
+ limit_error="The linked issue metadata exceeds the safe metadata byte limit.",
882
932
  )
883
933
  budget.record_bytes(len(response))
884
934
  try:
885
935
  issue_data = json.loads(response)
886
936
  except json.JSONDecodeError as error:
887
- raise RuntimeError("GitHub returned an invalid linked issue") from error
937
+ raise RuntimeError(
938
+ "GitHub returned a linked issue that is not valid."
939
+ ) from error
888
940
  if not isinstance(issue_data, dict):
889
- raise TypeError("GitHub returned an invalid linked issue")
941
+ raise TypeError("GitHub returned a linked issue that is not valid.")
890
942
  return issue_data
891
943
 
892
944
 
893
945
  def linked_requirements(
894
- metadata: dict[str, Any], budget: LinkedRequirementBudget | None = None
946
+ metadata: dict[str, Any],
947
+ budget: LinkedRequirementBudget | None = None,
948
+ requirement_issues: Sequence[str] = (),
895
949
  ) -> LinkedRequirements:
896
950
  """Bind every linked issue's requirement content and complete comment history."""
897
951
  references = metadata.get("closingIssuesReferences")
898
952
  if not isinstance(references, list):
899
- raise TypeError("GitHub returned incomplete linked issue references")
953
+ raise TypeError("GitHub returned incomplete linked issue references.")
900
954
  identities = sorted(linked_issue_reference(issue) for issue in references)
901
955
  if len(identities) != len(set(identities)):
902
- raise RuntimeError("GitHub returned duplicate linked issue references")
956
+ raise RuntimeError("GitHub returned duplicate linked issue references.")
957
+ selected: dict[str, tuple[str | None, str, int, str]] = {
958
+ identity[3]: identity for identity in identities
959
+ }
960
+ if len(selected) != len(identities):
961
+ raise RuntimeError(
962
+ "GitHub returned different identities for the same linked issue URL."
963
+ )
964
+ for url in requirement_issues:
965
+ match = re.fullmatch(
966
+ r"https://github\.com/([^/]+)/([^/]+)/issues/([1-9][0-9]{0,19})", url
967
+ )
968
+ if match is None:
969
+ raise RuntimeError("Use a canonical GitHub issue URL for each requirement.")
970
+ repository = require_github_repository(
971
+ f"{match[1]}/{match[2]}", "requirement issue repository"
972
+ )
973
+ selected.setdefault(url, (None, repository, int(match[3]), url))
903
974
  collection_budget = budget if budget is not None else LinkedRequirementBudget()
904
975
  items: list[LinkedRequirement] = []
905
- for expected_id, repository, number, expected_url in identities:
976
+ for expected_id, repository, number, expected_url in sorted(
977
+ selected.values(), key=lambda identity: identity[3]
978
+ ):
906
979
  issue_data = linked_issue_metadata(repository, number, collection_budget)
907
980
  issue_id = issue_data.get("id")
908
981
  body = issue_data.get("body")
909
982
  if (
910
- issue_id != expected_id
983
+ not isinstance(issue_id, str)
984
+ or not issue_id
985
+ or (expected_id is not None and issue_id != expected_id)
986
+ or isinstance(issue_data.get("number"), bool)
911
987
  or issue_data.get("number") != number
912
988
  or issue_data.get("url") != expected_url
913
989
  or not isinstance(issue_data.get("title"), str)
914
990
  or (body is not None and not isinstance(body, str))
915
991
  or not isinstance(issue_data.get("state"), str)
916
992
  ):
917
- raise RuntimeError("GitHub returned incomplete linked issue requirements")
993
+ raise RuntimeError("GitHub returned incomplete linked issue requirements.")
918
994
  comments = sorted(
919
995
  canonical_json(comment, "linked issue comment")
920
996
  for comment in paginated_issue_comments(
@@ -929,7 +1005,7 @@ def linked_requirements(
929
1005
  }
930
1006
  items.append(
931
1007
  LinkedRequirement(
932
- id=expected_id,
1008
+ id=issue_id,
933
1009
  repository=repository,
934
1010
  number=number,
935
1011
  url=expected_url,
@@ -938,6 +1014,7 @@ def linked_requirements(
938
1014
  ).hexdigest(),
939
1015
  )
940
1016
  )
1017
+ items.sort(key=lambda item: (item.id, item.repository, item.number, item.url))
941
1018
  document = canonical_json(
942
1019
  [item.as_json() for item in items], "linked issue requirements"
943
1020
  )
@@ -962,14 +1039,17 @@ def bounded_git_path_manifest(
962
1039
  )
963
1040
  except FileNotFoundError as error:
964
1041
  raise RuntimeError(
965
- f"required command unavailable: {error.filename or command[0]}"
1042
+ "The required command is not available: "
1043
+ f"'{error.filename or command[0]}'."
966
1044
  ) from error
967
1045
  stdout = process.stdout
968
1046
  stderr = process.stderr
969
1047
  if stdout is None or stderr is None:
970
1048
  process.kill()
971
1049
  process.wait()
972
- raise RuntimeError("Git did not provide changed-path output")
1050
+ raise RuntimeError(
1051
+ "The Git process did not provide the required output streams."
1052
+ )
973
1053
  stdout_capture = ChangedPathStream(
974
1054
  MAX_CHANGED_PATH_MANIFEST_BYTES, MAX_CHANGED_PATHS
975
1055
  )
@@ -993,11 +1073,15 @@ def bounded_git_path_manifest(
993
1073
  process,
994
1074
  stdout_capture,
995
1075
  stderr_capture,
996
- "changed-path manifest exceeds the safe byte limit",
1076
+ "The changed-path manifest exceeds the safe byte limit.",
997
1077
  timeout_seconds=CHANGED_PATH_REQUEST_TIMEOUT_SECONDS,
998
- stderr_limit_error="changed-path response exceeds the safe stderr limit",
999
- deadline_error="changed-path provider exceeded the safe provider deadline",
1000
- output_error="cannot read immutable changed-path output",
1078
+ stderr_limit_error=(
1079
+ "The changed-path response exceeds the safe standard-error limit."
1080
+ ),
1081
+ deadline_error=(
1082
+ "The changed-path provider exceeded the safe deadline."
1083
+ ),
1084
+ output_error="The tool cannot read immutable changed-path output.",
1001
1085
  coverage_gap=ChangedPathCoverageGap,
1002
1086
  )
1003
1087
  except BaseException:
@@ -1009,16 +1093,19 @@ def bounded_git_path_manifest(
1009
1093
  message = (
1010
1094
  bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
1011
1095
  )
1012
- raise RuntimeError(message or f"git {' '.join(arguments)} failed")
1096
+ raise RuntimeError(
1097
+ message or f"The Git command failed. Command: git {' '.join(arguments)}"
1098
+ )
1013
1099
  return stdout_capture.paths
1014
1100
  except OSError as error:
1015
1101
  raise RuntimeError(
1016
- f"cannot collect immutable changed paths: {error}"
1102
+ "The tool cannot collect immutable changed paths. "
1103
+ f"The operation returned this diagnostic.\n{error}"
1017
1104
  ) from error
1018
1105
 
1019
1106
 
1020
1107
  def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
1021
- """Run a read-only Git query and return its byte-exact stdout."""
1108
+ """Run a read-only Git query and return its byte-exact standard output."""
1022
1109
  result: Any = run_command(
1023
1110
  ["git", *git_read_arguments(), *arguments],
1024
1111
  capture_output=True,
@@ -1032,17 +1119,19 @@ def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
1032
1119
  message = stderr.decode("utf-8", errors="replace").strip()
1033
1120
  else:
1034
1121
  message = str(stderr).strip()
1035
- raise RuntimeError(message or f"git {' '.join(arguments)} failed")
1122
+ raise RuntimeError(
1123
+ message or f"The Git command failed. Command: git {' '.join(arguments)}"
1124
+ )
1036
1125
  stdout = result.stdout
1037
1126
  if not isinstance(stdout, bytes):
1038
- raise TypeError("git returned non-byte output for immutable path evidence")
1127
+ raise TypeError("Git returned non-byte output for immutable path evidence.")
1039
1128
  return stdout
1040
1129
 
1041
1130
 
1042
1131
  def immutable_range_paths(
1043
1132
  base_oid: str, head_oid: str, *, cwd: Path | None = None
1044
1133
  ) -> list[bytes]:
1045
- """Return validated NUL-safe paths from one immutable Git diff range."""
1134
+ """Return validated null-byte-safe paths from one immutable Git diff range."""
1046
1135
  return bounded_git_path_manifest(
1047
1136
  (
1048
1137
  "-c",
@@ -1081,7 +1170,9 @@ def immutable_changed_paths(
1081
1170
  try:
1082
1171
  paths = tuple(entry.decode("utf-8") for entry in canonical_entries)
1083
1172
  except UnicodeDecodeError as error:
1084
- raise RuntimeError("Git returned a non-UTF-8 changed path") from error
1173
+ raise RuntimeError(
1174
+ "Git returned a changed path that is not valid 'UTF-8'."
1175
+ ) from error
1085
1176
  canonical_bytes = b"".join(entry + b"\0" for entry in canonical_entries)
1086
1177
  return ChangedPathManifest(paths=paths, sha256=sha256(canonical_bytes).hexdigest())
1087
1178
 
@@ -1101,13 +1192,15 @@ def strict_changed_paths(
1101
1192
  expected: tuple[str, str],
1102
1193
  target: ExpectedReviewTarget,
1103
1194
  ) -> tuple[ChangedPathManifest, MaterializedSnapshot | None]:
1104
- """Derive strict paths locally or from a verified host-owned snapshot."""
1195
+ """Derive strict paths locally or from a verified host-owned materialized snapshot."""
1105
1196
  base_oid, head_oid = expected
1106
1197
  if local_immutable_objects_available(base_oid, head_oid):
1107
1198
  return immutable_changed_paths(base_oid, head_oid), None
1108
1199
  base_ref = metadata.get("baseRefName")
1109
1200
  if not isinstance(base_ref, str):
1110
- raise TypeError("GitHub returned an invalid pull-request base ref")
1201
+ raise TypeError(
1202
+ "GitHub returned a pull-request base reference that is not valid."
1203
+ )
1111
1204
  snapshot = materialize_snapshot(
1112
1205
  repository=target.repository,
1113
1206
  number=target.number,
@@ -1129,7 +1222,10 @@ def gh(*arguments: str, accepted_codes: tuple[int, ...] = (0,)) -> str:
1129
1222
  ["gh", *arguments], capture_output=True, text=True, check=False
1130
1223
  )
1131
1224
  if result.returncode not in accepted_codes:
1132
- raise RuntimeError(result.stderr.strip() or f"gh {' '.join(arguments)} failed")
1225
+ raise RuntimeError(
1226
+ result.stderr.strip()
1227
+ or f"The GitHub CLI command failed. Command: gh {' '.join(arguments)}"
1228
+ )
1133
1229
  return result.stdout
1134
1230
 
1135
1231
 
@@ -1142,12 +1238,12 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
1142
1238
  for page_number in range(1, MAX_CHECK_RUN_PAGES + 2):
1143
1239
  if page_number > MAX_CHECK_RUN_PAGES:
1144
1240
  raise CheckEvidenceCoverageGap(
1145
- "GitHub check runs exceed the safe page limit"
1241
+ "GitHub check runs exceed the safe page limit."
1146
1242
  )
1147
1243
  remaining_bytes = MAX_CHECK_RUN_BYTES - bytes_read
1148
1244
  if remaining_bytes <= 0:
1149
1245
  raise CheckEvidenceCoverageGap(
1150
- "GitHub check runs exceed the safe aggregate byte limit"
1246
+ "GitHub check runs exceed the safe aggregate byte limit."
1151
1247
  )
1152
1248
  try:
1153
1249
  response = bounded_gh_output(
@@ -1164,32 +1260,40 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
1164
1260
  f"per_page=100&page={page_number}"
1165
1261
  ),
1166
1262
  ),
1167
- maximum_bytes=min(MAX_CHECK_RUN_PAGE_BYTES, remaining_bytes),
1168
- limit_error="GitHub check-run response exceeds the safe byte limit",
1263
+ maximum_bytes=remaining_bytes,
1264
+ limit_error="The GitHub check-run response exceeds the safe byte limit.",
1169
1265
  timeout_seconds=CHECK_RUN_REQUEST_TIMEOUT_SECONDS,
1170
1266
  stderr_maximum_bytes=MAX_CHECK_RUN_STDERR_BYTES,
1171
- stderr_limit_error="GitHub check-run response exceeds the safe stderr limit",
1172
- deadline_error="GitHub check-run provider exceeded the safe provider deadline",
1173
- output_error="cannot read GitHub check-run provider output",
1174
- unavailable_output_error="GitHub did not provide check-run provider output",
1175
- operating_system_error="cannot collect GitHub check runs",
1267
+ stderr_limit_error=(
1268
+ "The GitHub check-run response exceeds the safe standard-error limit."
1269
+ ),
1270
+ deadline_error=(
1271
+ "The GitHub check-run provider exceeded the safe deadline."
1272
+ ),
1273
+ output_error="The tool cannot read GitHub check-run provider output.",
1274
+ unavailable_output_error=(
1275
+ "The GitHub CLI process did not provide check-run output."
1276
+ ),
1277
+ operating_system_error="The tool cannot collect GitHub check runs",
1176
1278
  coverage_gap=CheckEvidenceCoverageGap,
1177
1279
  )
1178
1280
  except RuntimeError as error:
1179
1281
  if isinstance(error, CheckEvidenceCoverageGap):
1180
1282
  raise
1181
1283
  raise CheckEvidenceCoverageGap(
1182
- "GitHub did not return readable head-bound check evidence"
1284
+ "GitHub did not return readable check evidence for the head object identifier."
1183
1285
  ) from error
1184
1286
  bytes_read += len(response)
1185
1287
  try:
1186
1288
  page = json.loads(response)
1187
1289
  except json.JSONDecodeError as error:
1188
1290
  raise CheckEvidenceCoverageGap(
1189
- "GitHub did not return readable head-bound check evidence"
1291
+ "GitHub did not return readable check evidence for the head object identifier."
1190
1292
  ) from error
1191
1293
  if not isinstance(page, dict):
1192
- raise CheckEvidenceCoverageGap("GitHub returned a malformed check-run page")
1294
+ raise CheckEvidenceCoverageGap(
1295
+ "GitHub returned a check-run page that is not valid."
1296
+ )
1193
1297
  page_total = page.get("total_count")
1194
1298
  page_runs = page.get("check_runs")
1195
1299
  if (
@@ -1199,21 +1303,23 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
1199
1303
  or not isinstance(page_runs, list)
1200
1304
  ):
1201
1305
  raise CheckEvidenceCoverageGap(
1202
- "GitHub returned incomplete check-run evidence"
1306
+ "GitHub returned incomplete check-run evidence."
1203
1307
  )
1204
1308
  if total_count is None:
1205
1309
  total_count = page_total
1206
1310
  if total_count > MAX_CHECK_RUNS:
1207
1311
  raise CheckEvidenceCoverageGap(
1208
- "GitHub check runs exceed the safe run limit"
1312
+ "GitHub check runs exceed the safe run limit."
1209
1313
  )
1210
1314
  elif page_total != total_count:
1211
1315
  raise CheckEvidenceCoverageGap(
1212
- "GitHub returned inconsistent check-run totals"
1316
+ "GitHub returned inconsistent check-run totals."
1213
1317
  )
1214
1318
  for run in page_runs:
1215
1319
  if not isinstance(run, dict):
1216
- raise CheckEvidenceCoverageGap("GitHub returned a malformed check run")
1320
+ raise CheckEvidenceCoverageGap(
1321
+ "GitHub returned a check run that is not valid."
1322
+ )
1217
1323
  run_id = run.get("id")
1218
1324
  run_head_oid = run.get("head_sha")
1219
1325
  if (
@@ -1229,69 +1335,98 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
1229
1335
  or COMMIT_OID.fullmatch(run_head_oid) is None
1230
1336
  ):
1231
1337
  raise CheckEvidenceCoverageGap(
1232
- "GitHub returned incomplete check-run evidence"
1338
+ "GitHub returned incomplete check-run evidence."
1233
1339
  )
1234
1340
  if run_head_oid != head_oid:
1235
1341
  raise CheckEvidenceCoverageGap(
1236
- "GitHub returned a check run bound to a different head OID"
1342
+ "GitHub returned a check run that is bound to a different head "
1343
+ "object identifier."
1237
1344
  )
1238
1345
  run_ids.add(run_id)
1239
1346
  runs.append(run)
1240
1347
  if len(runs) == total_count:
1241
1348
  return runs
1242
1349
  if not page_runs:
1243
- raise CheckEvidenceCoverageGap("GitHub returned partial check-run evidence")
1244
- raise AssertionError("bounded check-run pagination did not terminate")
1350
+ raise CheckEvidenceCoverageGap(
1351
+ "GitHub returned partial check-run evidence."
1352
+ )
1353
+ raise AssertionError("The bounded check-run pagination did not terminate.")
1245
1354
 
1246
1355
 
1247
1356
  def pr_metadata(
1248
1357
  pull_request: str, target: ExpectedReviewTarget | None
1249
1358
  ) -> dict[str, Any]:
1250
- """Read one PR through the retained target when strict evidence is required."""
1359
+ """Read one pull request through the retained target when strict evidence is required."""
1251
1360
  command = ["pr", "view", pull_request]
1252
1361
  if target is not None:
1253
1362
  command.extend(("--repo", target.repository_argument()))
1254
1363
  command.extend(("--json", FIELDS))
1255
1364
  metadata = json.loads(gh(*command))
1256
1365
  if not isinstance(metadata, dict):
1257
- raise TypeError("GitHub returned an invalid pull-request object")
1366
+ raise TypeError("GitHub returned a pull-request object that is not valid.")
1258
1367
  return metadata
1259
1368
 
1260
1369
 
1370
+ def merge_readiness(metadata: dict[str, Any]) -> dict[str, str]:
1371
+ """Return forge approval state as evidence separate from the review verdict."""
1372
+ decision = metadata.get("reviewDecision")
1373
+ if not isinstance(decision, str) or not decision:
1374
+ decision = "UNAVAILABLE"
1375
+ approval_gate = {
1376
+ "APPROVED": "satisfied",
1377
+ "CHANGES_REQUESTED": "blocked",
1378
+ "REVIEW_REQUIRED": "blocked",
1379
+ }.get(decision, "unknown")
1380
+ return {
1381
+ "auto_merge_approval_gate": approval_gate,
1382
+ "authority": (
1383
+ "Repository-policy evidence excluded from the review verdict and scope digests."
1384
+ ),
1385
+ "review_decision": decision,
1386
+ }
1387
+
1388
+
1261
1389
  def main(argv: Sequence[str] | None = None) -> int:
1262
1390
  parser = argument_parser(description=__doc__)
1263
1391
  parser.add_argument(
1264
1392
  "--expected-base-oid",
1265
1393
  metavar="BASE_OID",
1266
- help="immutable base revision returned by resolve_pr.py",
1394
+ help="Use the immutable base revision from 'resolve_pr.py'.",
1267
1395
  )
1268
1396
  parser.add_argument(
1269
1397
  "--expected-head-oid",
1270
1398
  metavar="HEAD_OID",
1271
- help="immutable head revision returned by resolve_pr.py",
1399
+ help="Use the immutable head revision from 'resolve_pr.py'.",
1272
1400
  )
1273
1401
  parser.add_argument(
1274
1402
  "--expected-host",
1275
1403
  metavar="HOST",
1276
- help="canonical GitHub host returned by resolve_pr.py",
1404
+ help="Use the canonical GitHub host from 'resolve_pr.py'.",
1277
1405
  )
1278
1406
  parser.add_argument(
1279
1407
  "--expected-repository",
1280
1408
  metavar="OWNER/REPOSITORY",
1281
- help="canonical GitHub repository returned by resolve_pr.py",
1409
+ help="Use the canonical GitHub repository from 'resolve_pr.py'.",
1282
1410
  )
1283
1411
  parser.add_argument(
1284
1412
  "--expected-pr-number",
1285
1413
  metavar="NUMBER",
1286
1414
  type=int,
1287
- help="canonical pull-request number returned by resolve_pr.py",
1415
+ help="Use the canonical pull-request number from 'resolve_pr.py'.",
1288
1416
  )
1289
1417
  parser.add_argument(
1290
1418
  "--expected-pr-url",
1291
1419
  metavar="URL",
1292
- help="canonical pull-request URL returned by resolve_pr.py",
1420
+ help="Use the canonical pull-request URL from 'resolve_pr.py'.",
1293
1421
  )
1294
1422
  parser.add_argument("pull_request", metavar="PR_NUMBER_OR_URL")
1423
+ parser.add_argument(
1424
+ "--requirement-issue",
1425
+ action="append",
1426
+ default=[],
1427
+ metavar="ISSUE_URL",
1428
+ help="Bind another GitHub issue. Use this option for each non-closing requirement.",
1429
+ )
1295
1430
  arguments = parser.parse_args(argv)
1296
1431
  pull_request = arguments.pull_request
1297
1432
  expected = expected_identity(
@@ -1306,17 +1441,20 @@ def main(argv: Sequence[str] | None = None) -> int:
1306
1441
  arguments.expected_pr_url,
1307
1442
  )
1308
1443
  require_immutable_identity = expected is not None
1444
+ if arguments.requirement_issue and not require_immutable_identity:
1445
+ parser.error("All strict identity arguments are necessary for this option.")
1309
1446
  try:
1310
1447
  validate_pr_identifier(pull_request)
1311
1448
  requested = pull_request_number(pull_request)
1312
1449
  if target is not None:
1313
1450
  if requested != target.number:
1314
1451
  raise RuntimeError(
1315
- "requested pull request does not match the expected target number"
1452
+ "The requested pull request does not match the expected target "
1453
+ "number."
1316
1454
  )
1317
1455
  if pull_request.startswith("https://") and pull_request != target.url:
1318
1456
  raise RuntimeError(
1319
- "requested pull request does not match the expected target URL"
1457
+ "The requested pull request does not match the expected target URL."
1320
1458
  )
1321
1459
  metadata = pr_metadata(pull_request, target)
1322
1460
  metadata_problem = metadata_error(
@@ -1337,15 +1475,18 @@ def main(argv: Sequence[str] | None = None) -> int:
1337
1475
  or not isinstance(number, int)
1338
1476
  or not isinstance(url, str)
1339
1477
  ):
1340
- raise TypeError("GitHub returned incomplete repository or PR identity")
1478
+ raise TypeError(
1479
+ "GitHub returned an incomplete repository or pull-request identity."
1480
+ )
1341
1481
  pull_repository = repository_from_pr_url(url, number)
1342
1482
  if pull_repository.casefold() != repository.casefold():
1343
1483
  raise RuntimeError(
1344
- f"pull request {url} does not belong to current repository {repository}"
1484
+ f"Pull request '{url}' is not in the current repository '{repository}'."
1345
1485
  )
1346
1486
  if number != requested:
1347
1487
  raise RuntimeError(
1348
- "GitHub returned a pull request different from the requested identifier"
1488
+ "GitHub returned a pull request that is different from the requested "
1489
+ "identifier."
1349
1490
  )
1350
1491
  identity = immutable_identity(
1351
1492
  metadata,
@@ -1359,7 +1500,9 @@ def main(argv: Sequence[str] | None = None) -> int:
1359
1500
  LinkedRequirementBudget() if expected is not None else None
1360
1501
  )
1361
1502
  reviewed_linked_requirements = (
1362
- linked_requirements(metadata, linked_requirement_budget)
1503
+ linked_requirements(
1504
+ metadata, linked_requirement_budget, arguments.requirement_issue
1505
+ )
1363
1506
  if expected is not None
1364
1507
  else None
1365
1508
  )
@@ -1410,7 +1553,7 @@ def main(argv: Sequence[str] | None = None) -> int:
1410
1553
  )
1411
1554
  )
1412
1555
  if not isinstance(checks, list):
1413
- raise RuntimeError("GitHub returned invalid check evidence")
1556
+ raise RuntimeError("GitHub returned check evidence that is not valid.")
1414
1557
  final_metadata = pr_metadata(pull_request, target)
1415
1558
  final_problem = metadata_error(
1416
1559
  final_metadata, require_immutable_identity=require_immutable_identity
@@ -1425,21 +1568,23 @@ def main(argv: Sequence[str] | None = None) -> int:
1425
1568
  )
1426
1569
  if final_identity != identity:
1427
1570
  raise RuntimeError(
1428
- "immutable pull-request identity changed while collecting evidence"
1571
+ "The immutable pull-request identity changed during evidence collection."
1429
1572
  )
1430
1573
  ensure_expected_identity(final_identity, expected)
1431
1574
  ensure_expected_target(final_identity, target)
1432
1575
  final_scope = review_scope(final_metadata) if expected is not None else None
1433
1576
  if final_scope != reviewed_scope:
1434
- raise RuntimeError("review scope changed while collecting evidence")
1577
+ raise RuntimeError("The review scope changed during evidence collection.")
1435
1578
  final_linked_requirements = (
1436
- linked_requirements(final_metadata, linked_requirement_budget)
1579
+ linked_requirements(
1580
+ final_metadata, linked_requirement_budget, arguments.requirement_issue
1581
+ )
1437
1582
  if expected is not None
1438
1583
  else None
1439
1584
  )
1440
1585
  if final_linked_requirements != reviewed_linked_requirements:
1441
1586
  raise RuntimeError(
1442
- "linked issue requirements changed while collecting evidence"
1587
+ "The linked issue requirements changed during evidence collection."
1443
1588
  )
1444
1589
  except ChangedPathCoverageGap as error:
1445
1590
  structured_error("changed path coverage gap", str(error))
@@ -1454,7 +1599,12 @@ def main(argv: Sequence[str] | None = None) -> int:
1454
1599
  "changed_files": changed_files,
1455
1600
  "changed_paths": changed_files,
1456
1601
  "checks": checks,
1457
- "pull_request": final_metadata,
1602
+ "pull_request": {
1603
+ key: value
1604
+ for key, value in final_metadata.items()
1605
+ if key != "reviewDecision"
1606
+ },
1607
+ "merge_readiness": merge_readiness(final_metadata),
1458
1608
  }
1459
1609
  if identity is not None:
1460
1610
  evidence["reviewed_identity"] = identity.as_json()