@momentiq/dark-factory-cli 0.1.0-alpha.4

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 (132) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +150 -0
  3. package/dist/adapters/_shared.d.ts +45 -0
  4. package/dist/adapters/_shared.d.ts.map +1 -0
  5. package/dist/adapters/_shared.js +200 -0
  6. package/dist/adapters/_shared.js.map +1 -0
  7. package/dist/adapters/codex-sdk.d.ts +279 -0
  8. package/dist/adapters/codex-sdk.d.ts.map +1 -0
  9. package/dist/adapters/codex-sdk.js +930 -0
  10. package/dist/adapters/codex-sdk.js.map +1 -0
  11. package/dist/adapters/critic-result-schema.d.ts +215 -0
  12. package/dist/adapters/critic-result-schema.d.ts.map +1 -0
  13. package/dist/adapters/critic-result-schema.js +153 -0
  14. package/dist/adapters/critic-result-schema.js.map +1 -0
  15. package/dist/adapters/critic.d.ts +62 -0
  16. package/dist/adapters/critic.d.ts.map +1 -0
  17. package/dist/adapters/critic.js +79 -0
  18. package/dist/adapters/critic.js.map +1 -0
  19. package/dist/adapters/cursor-sdk.d.ts +153 -0
  20. package/dist/adapters/cursor-sdk.d.ts.map +1 -0
  21. package/dist/adapters/cursor-sdk.js +818 -0
  22. package/dist/adapters/cursor-sdk.js.map +1 -0
  23. package/dist/adapters/gemini-sdk.d.ts +113 -0
  24. package/dist/adapters/gemini-sdk.d.ts.map +1 -0
  25. package/dist/adapters/gemini-sdk.js +532 -0
  26. package/dist/adapters/gemini-sdk.js.map +1 -0
  27. package/dist/adapters/grok-direct-sdk.d.ts +148 -0
  28. package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
  29. package/dist/adapters/grok-direct-sdk.js +694 -0
  30. package/dist/adapters/grok-direct-sdk.js.map +1 -0
  31. package/dist/branch-protection/audit_branch_protection.py +759 -0
  32. package/dist/branch-protection/index.d.ts +25 -0
  33. package/dist/branch-protection/index.d.ts.map +1 -0
  34. package/dist/branch-protection/index.js +70 -0
  35. package/dist/branch-protection/index.js.map +1 -0
  36. package/dist/branch-protection/spec-default.yaml +314 -0
  37. package/dist/cli.d.ts +3 -0
  38. package/dist/cli.d.ts.map +1 -0
  39. package/dist/cli.js +581 -0
  40. package/dist/cli.js.map +1 -0
  41. package/dist/cycle-doc-validator/index.d.ts +43 -0
  42. package/dist/cycle-doc-validator/index.d.ts.map +1 -0
  43. package/dist/cycle-doc-validator/index.js +69 -0
  44. package/dist/cycle-doc-validator/index.js.map +1 -0
  45. package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
  46. package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
  47. package/dist/cycle-tracker-sync/index.d.ts +20 -0
  48. package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
  49. package/dist/cycle-tracker-sync/index.js +71 -0
  50. package/dist/cycle-tracker-sync/index.js.map +1 -0
  51. package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
  52. package/dist/evidence/audit-trail.d.ts +59 -0
  53. package/dist/evidence/audit-trail.d.ts.map +1 -0
  54. package/dist/evidence/audit-trail.js +283 -0
  55. package/dist/evidence/audit-trail.js.map +1 -0
  56. package/dist/evidence/index.d.ts +4 -0
  57. package/dist/evidence/index.d.ts.map +1 -0
  58. package/dist/evidence/index.js +29 -0
  59. package/dist/evidence/index.js.map +1 -0
  60. package/dist/evidence/per-sha.d.ts +13 -0
  61. package/dist/evidence/per-sha.d.ts.map +1 -0
  62. package/dist/evidence/per-sha.js +97 -0
  63. package/dist/evidence/per-sha.js.map +1 -0
  64. package/dist/evidence/quality-gates.d.ts +19 -0
  65. package/dist/evidence/quality-gates.d.ts.map +1 -0
  66. package/dist/evidence/quality-gates.js +212 -0
  67. package/dist/evidence/quality-gates.js.map +1 -0
  68. package/dist/git.d.ts +40 -0
  69. package/dist/git.d.ts.map +1 -0
  70. package/dist/git.js +414 -0
  71. package/dist/git.js.map +1 -0
  72. package/dist/glob.d.ts +4 -0
  73. package/dist/glob.d.ts.map +1 -0
  74. package/dist/glob.js +99 -0
  75. package/dist/glob.js.map +1 -0
  76. package/dist/index.d.ts +17 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +33 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/paths.d.ts +12 -0
  81. package/dist/paths.d.ts.map +1 -0
  82. package/dist/paths.js +42 -0
  83. package/dist/paths.js.map +1 -0
  84. package/dist/policy/baseline.d.ts +15 -0
  85. package/dist/policy/baseline.d.ts.map +1 -0
  86. package/dist/policy/baseline.js +115 -0
  87. package/dist/policy/baseline.js.map +1 -0
  88. package/dist/policy/config.d.ts +64 -0
  89. package/dist/policy/config.d.ts.map +1 -0
  90. package/dist/policy/config.js +363 -0
  91. package/dist/policy/config.js.map +1 -0
  92. package/dist/policy/gate.d.ts +80 -0
  93. package/dist/policy/gate.d.ts.map +1 -0
  94. package/dist/policy/gate.js +1019 -0
  95. package/dist/policy/gate.js.map +1 -0
  96. package/dist/policy/index.d.ts +7 -0
  97. package/dist/policy/index.d.ts.map +1 -0
  98. package/dist/policy/index.js +14 -0
  99. package/dist/policy/index.js.map +1 -0
  100. package/dist/policy/merge-queue.d.ts +183 -0
  101. package/dist/policy/merge-queue.d.ts.map +1 -0
  102. package/dist/policy/merge-queue.js +310 -0
  103. package/dist/policy/merge-queue.js.map +1 -0
  104. package/dist/policy/profile.d.ts +98 -0
  105. package/dist/policy/profile.d.ts.map +1 -0
  106. package/dist/policy/profile.js +156 -0
  107. package/dist/policy/profile.js.map +1 -0
  108. package/dist/policy/tdd-classifier.d.ts +18 -0
  109. package/dist/policy/tdd-classifier.d.ts.map +1 -0
  110. package/dist/policy/tdd-classifier.js +79 -0
  111. package/dist/policy/tdd-classifier.js.map +1 -0
  112. package/dist/prompt.d.ts +13 -0
  113. package/dist/prompt.d.ts.map +1 -0
  114. package/dist/prompt.js +175 -0
  115. package/dist/prompt.js.map +1 -0
  116. package/dist/report.d.ts +88 -0
  117. package/dist/report.d.ts.map +1 -0
  118. package/dist/report.js +376 -0
  119. package/dist/report.js.map +1 -0
  120. package/dist/runner.d.ts +30 -0
  121. package/dist/runner.d.ts.map +1 -0
  122. package/dist/runner.js +456 -0
  123. package/dist/runner.js.map +1 -0
  124. package/dist/security.d.ts +2 -0
  125. package/dist/security.d.ts.map +1 -0
  126. package/dist/security.js +19 -0
  127. package/dist/security.js.map +1 -0
  128. package/dist/trusted-surface/rebind.d.ts +10 -0
  129. package/dist/trusted-surface/rebind.d.ts.map +1 -0
  130. package/dist/trusted-surface/rebind.js +154 -0
  131. package/dist/trusted-surface/rebind.js.map +1 -0
  132. package/package.json +78 -0
@@ -0,0 +1,1260 @@
1
+ #!/usr/bin/env python3
2
+ """Cycle 318.4 Component 2 — cycle-doc CI gate.
3
+
4
+ Enforces AI-Native Manifesto §10 (Spec-Driven Traceability) per the
5
+ trailer policy documented in ``CONTRIBUTING.md``: every PR has some
6
+ anchor in the project's tracking surface, with two paths:
7
+
8
+ * **Cycle-tracked product code** — ``Cycle: <N>`` references a cycle
9
+ doc in ``docs/roadmap/cycles/`` (the cycle must exist and not be
10
+ in a terminal status); ``Issue:`` OR ``ProjectItem:`` is also
11
+ required as a secondary anchor.
12
+ * **Non-cycle PRs** (drift fixes, dependabot bumps, doc tweaks,
13
+ hotfixes) — no ``Cycle:`` required; ``Issue:`` OR ``ProjectItem:``
14
+ alone is sufficient.
15
+ * **Bot/automation PRs** carrying an allowlisted label
16
+ (``dependencies`` / ``automated`` / ``autorelease: pending`` /
17
+ ``autorelease: tagged``) bypass trailer enforcement entirely.
18
+
19
+ Without this gate, the existing PR-body substring check in
20
+ ``.github/workflows/quality-gate.yml`` only catches that the strings
21
+ "cycle doc", "definition of done", etc. appear — it does not parse
22
+ trailers, does not verify the referenced cycle exists, and does not
23
+ distinguish plan PRs from code PRs.
24
+
25
+ Inputs (all environment variables; the workflow sets them):
26
+
27
+ PR_NUMBER — PR number being validated.
28
+ PR_TITLE — full PR title.
29
+ PR_BODY — full PR body (the description shown on GitHub).
30
+ PR_LABELS — JSON array of label objects, ``[{"name":"plan-pr"},...]``.
31
+ REPO — ``owner/name``.
32
+ GH_TOKEN — token with ``repo:read`` for the changed-files lookup.
33
+
34
+ Outputs:
35
+
36
+ exit 0 on success; exit 1 with an actionable stderr message on failure.
37
+
38
+ The check matrix (per Component 2 of cycle318.4-ci-fallback-and-auto-merge.md,
39
+ aligned with CONTRIBUTING.md "Trailers" section):
40
+
41
+ 1. **Type detection.** Plan PR if title starts with ``docs(roadmap):``
42
+ or ``docs(<cycle>):``, OR PR has label ``plan-pr``, AND every
43
+ changed path is under ``docs/`` (or is the repo-root ``AGENTS.md``
44
+ / ``CLAUDE.md``). The "docs only" check is broader than just
45
+ cycle-doc paths so a plan PR can include supporting ADR / engineering
46
+ docs in the same change without being misclassified as a code PR.
47
+ Otherwise code PR.
48
+
49
+ 2. **Bot/automation exemption.** PRs with any of the labels
50
+ ``dependencies`` / ``automated`` / ``autorelease: pending`` /
51
+ ``autorelease: tagged`` short-circuit to PASS without trailer
52
+ checks. Matches CONTRIBUTING.md "Bot / automation PRs are exempted
53
+ by label allowlist".
54
+
55
+ 3. **Trailer parsing.** Read PR body AND every commit message in the
56
+ PR; extract ``Cycle:``, ``Issue:``, ``ProjectItem:`` trailer values
57
+ per ``git-interpret-trailers`` semantics. Commit messages are
58
+ fetched via ``gh api repos/<repo>/pulls/<n>/commits`` and
59
+ concatenated to the body before parsing. GitHub auto-close keywords
60
+ (``Closes #N``, ``Fixes #N``, ``Resolves #N``) count as an
61
+ ``Issue:`` trailer for the purpose of this gate.
62
+
63
+ 4. **Code PR with Cycle: (product code).** When ``Cycle: <N>`` is
64
+ present, the cycle must resolve to
65
+ ``docs/roadmap/cycles/cycle<N>*.md`` and status must not be
66
+ terminal (completed/complete/superseded/abandoned/absorbed). An
67
+ ``Issue:`` OR ``ProjectItem:`` MUST also be present (one is
68
+ sufficient).
69
+
70
+ 5. **Code PR without Cycle: (drift / hotfix / non-cycle).** When
71
+ no ``Cycle:`` is present, ``Issue:`` OR ``ProjectItem:`` MUST be
72
+ present. This is the non-cycle-PR path documented in
73
+ CONTRIBUTING.md (drift fixes, dependabot bumps, doc tweaks,
74
+ hotfixes).
75
+
76
+ 6. **Plan PR rules.** ``Cycle: <N>`` MUST be present AND the diff
77
+ must include ``docs/roadmap/cycles/cycle<N>*.md`` (i.e., the PR
78
+ is creating or updating that cycle's plan).
79
+
80
+ 7. **Status transitions.** If the same diff sets ``status: completed``
81
+ on a cycle's frontmatter, fail with a clear message — completion
82
+ belongs in a separate doc-update PR after the implementing code
83
+ PR has merged.
84
+
85
+ Failure messages name the file and the missing/conflicting field so
86
+ operators can fix without reading this script.
87
+ """
88
+ from __future__ import annotations
89
+
90
+ import argparse
91
+ import base64
92
+ import json
93
+ import os
94
+ import re
95
+ import subprocess
96
+ import sys
97
+ from dataclasses import dataclass, field
98
+ from pathlib import Path
99
+ from typing import Iterable
100
+
101
+ def _resolve_repo_root() -> Path:
102
+ """Resolve the consumer repo root.
103
+
104
+ Phase C extraction (cycle 331.1): the same script ships inside
105
+ ``@momentiq/dark-factory-cli`` and is invoked from arbitrary cwds
106
+ in consumer repos. Resolution order:
107
+
108
+ 1. ``DF_REPO_ROOT`` env var — explicit override (the TS CLI wrapper
109
+ sets this when spawning).
110
+ 2. ``git rev-parse --show-toplevel`` from the current cwd — the
111
+ standard "find the consumer repo" path for any in-repo invocation.
112
+ 3. ``Path(__file__).resolve().parents[2]`` — legacy fallback that
113
+ matched sage3c's ``scripts/ci/`` layout. Kept so the original
114
+ pytest contract still works when the script is invoked in-tree.
115
+ """
116
+ override = os.environ.get("DF_REPO_ROOT")
117
+ if override:
118
+ return Path(override).resolve()
119
+ try:
120
+ result = subprocess.run(
121
+ ["git", "rev-parse", "--show-toplevel"],
122
+ check=True,
123
+ capture_output=True,
124
+ text=True,
125
+ )
126
+ return Path(result.stdout.strip()).resolve()
127
+ except (subprocess.CalledProcessError, FileNotFoundError):
128
+ return Path(__file__).resolve().parents[2]
129
+
130
+
131
+ REPO_ROOT = _resolve_repo_root()
132
+ CYCLES_DIR = REPO_ROOT / "docs" / "roadmap" / "cycles"
133
+ CYCLE_DOC_GLOB = "docs/roadmap/cycles/cycle*.md"
134
+
135
+ # Trailers we recognize. ``git-interpret-trailers`` defines a trailer as
136
+ # ``Token: Value`` on its own line in the trailer block; we accept the
137
+ # same shape anywhere in the body text, since GitHub PR bodies don't have
138
+ # a strict trailer block.
139
+ TRAILER_RE = re.compile(r"^\s*(?P<key>[A-Za-z][A-Za-z0-9-]*):\s*(?P<value>.+?)\s*$", re.MULTILINE)
140
+
141
+ # GitHub auto-close keywords — treat as equivalent to ``Issue:`` trailers.
142
+ # Matches "Closes #123", "fixes momentiq-ai/sage3c#42", "Resolves #5", etc.
143
+ GH_AUTOCLOSE_RE = re.compile(
144
+ r"\b(?P<verb>close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+"
145
+ r"(?:(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+))?#(?P<num>\d+)\b",
146
+ re.IGNORECASE,
147
+ )
148
+
149
+ # Plan-PR title patterns: ``docs(roadmap): ...`` or ``docs(<cycle>): ...``
150
+ # where <cycle> looks like a cycle number (118, 318.4, etc.).
151
+ PLAN_TITLE_RE = re.compile(
152
+ r"^docs\((?:roadmap|\d+(?:\.\d+)*)\):",
153
+ )
154
+
155
+ # Bot/automation label allowlist per CONTRIBUTING.md "Trailers" section.
156
+ # PRs carrying any of these labels skip the trailer requirement.
157
+ BOT_EXEMPT_LABELS = frozenset(
158
+ {
159
+ "dependencies",
160
+ "automated",
161
+ "autorelease: pending",
162
+ "autorelease: tagged",
163
+ }
164
+ )
165
+
166
+
167
+ @dataclass
168
+ class Trailers:
169
+ """Parsed trailer set with normalized presence flags."""
170
+
171
+ cycle: str | None = None
172
+ issue: str | None = None
173
+ project_item: str | None = None
174
+ raw: dict[str, list[str]] = field(default_factory=dict)
175
+
176
+
177
+ @dataclass
178
+ class CycleDoc:
179
+ path: Path
180
+ status: str | None
181
+ superseded_by: str | None
182
+
183
+
184
+ def parse_trailers(text: str) -> Trailers:
185
+ """Extract recognized trailers from a body of text.
186
+
187
+ Multiple values per key are preserved in ``raw`` so a downstream
188
+ consumer can debug accidentally-doubled trailers. The convenience
189
+ fields ``cycle`` / ``issue`` / ``project_item`` carry the LAST seen
190
+ value of each (the trailer-block tail is the canonical position).
191
+ """
192
+ out = Trailers()
193
+ for match in TRAILER_RE.finditer(text):
194
+ key = match.group("key").lower()
195
+ value = match.group("value").strip()
196
+ out.raw.setdefault(key, []).append(value)
197
+ if key == "cycle":
198
+ out.cycle = value
199
+ elif key == "issue":
200
+ out.issue = value
201
+ elif key == "projectitem":
202
+ out.project_item = value
203
+
204
+ if out.issue is None:
205
+ # GitHub auto-close keywords substitute for an explicit Issue: trailer.
206
+ # We only need ONE match — the gate doesn't care which issue is linked.
207
+ autoclose = GH_AUTOCLOSE_RE.search(text)
208
+ if autoclose:
209
+ out.issue = f"#{autoclose.group('num')}"
210
+ out.raw.setdefault("_autoclose", []).append(autoclose.group(0))
211
+ return out
212
+
213
+
214
+ def normalize_cycle_id(raw: str) -> str | None:
215
+ """Pull the cycle id out of a trailer value.
216
+
217
+ Accepts ``318.4``, ``318``, ``Cycle 318.4``, ``318.4-foo``. Rejects
218
+ obviously malformed input. The id is a dotted-decimal sequence of
219
+ 1-4 segments (``318``, ``318.4``, ``313.1.e`` would NOT match — we
220
+ accept only digits + dots for the gate's normalized form, which is
221
+ what ``sync_cycle_trackers.py`` already standardizes on).
222
+ """
223
+ if not raw:
224
+ return None
225
+ # Strip any leading word like "Cycle " or surrounding markdown.
226
+ candidate = raw.strip()
227
+ # Take just the leading cycle-id token; drop any trailing text after
228
+ # the first whitespace or punctuation that isn't ``.`` or a digit.
229
+ m = re.match(r"(\d+(?:\.\d+)*)", candidate)
230
+ if not m:
231
+ return None
232
+ return m.group(1)
233
+
234
+
235
+ def find_cycle_doc(cycle_id: str) -> CycleDoc | None:
236
+ """Locate ``docs/roadmap/cycles/cycle<id>*.md`` and read its status."""
237
+ matches = sorted(CYCLES_DIR.glob(f"cycle{cycle_id}-*.md")) + sorted(
238
+ CYCLES_DIR.glob(f"cycle{cycle_id}.md")
239
+ )
240
+ if not matches:
241
+ return None
242
+ # If multiple matches exist (legacy ``cycle318.md`` + new ``cycle318-foo.md``),
243
+ # prefer the one with the longer name (more descriptive slug).
244
+ path = max(matches, key=lambda p: len(p.name))
245
+ status, superseded_by = read_cycle_frontmatter(path)
246
+ return CycleDoc(path=path, status=status, superseded_by=superseded_by)
247
+
248
+
249
+ def read_cycle_frontmatter(path: Path) -> tuple[str | None, str | None]:
250
+ """Read the ``status:`` and ``superseded_by:`` fields from frontmatter.
251
+
252
+ Frontmatter parsing is deliberately simple — we don't pull PyYAML
253
+ for this since the validator runs in CI on every PR and we want a
254
+ fast, dependency-light boot. Frontmatter is the first ``---`` block
255
+ at the top of the file; we look for ``status:`` and
256
+ ``superseded_by:`` lines until the closing ``---``.
257
+ """
258
+ status: str | None = None
259
+ superseded_by: str | None = None
260
+ try:
261
+ with path.open(encoding="utf-8") as fh:
262
+ inside = False
263
+ for line in fh:
264
+ if line.strip() == "---":
265
+ if not inside:
266
+ inside = True
267
+ continue
268
+ break # closing fence
269
+ if not inside:
270
+ continue
271
+ stripped = line.strip()
272
+ if stripped.startswith("status:"):
273
+ status = stripped[len("status:") :].strip().strip("\"'")
274
+ elif stripped.startswith("superseded_by:"):
275
+ superseded_by = stripped[len("superseded_by:") :].strip().strip("\"'")
276
+ except OSError:
277
+ return None, None
278
+ return status, superseded_by
279
+
280
+
281
+ def read_cycle_frontmatter_from_text(text: str) -> tuple[str | None, str | None]:
282
+ """Read the ``status:`` and ``superseded_by:`` fields from frontmatter text."""
283
+ status: str | None = None
284
+ superseded_by: str | None = None
285
+ inside = False
286
+ for line in text.splitlines():
287
+ if line.strip() == "---":
288
+ if not inside:
289
+ inside = True
290
+ continue
291
+ break
292
+ if not inside:
293
+ continue
294
+ stripped = line.strip()
295
+ if stripped.startswith("status:"):
296
+ status = stripped[len("status:") :].strip().strip("\"'")
297
+ elif stripped.startswith("superseded_by:"):
298
+ superseded_by = stripped[len("superseded_by:") :].strip().strip("\"'")
299
+ return status, superseded_by
300
+
301
+
302
+ class BaseCycleDocFetchError(RuntimeError):
303
+ """Raised when the base-ref cycle document cannot be read."""
304
+
305
+
306
+ def _is_gh_not_found_error(exc: subprocess.CalledProcessError) -> bool:
307
+ """Return True only for confirmed missing-content responses from gh."""
308
+
309
+ output = f"{exc.stderr or ''}\n{exc.stdout or ''}".lower()
310
+ return "404" in output or "not found" in output
311
+
312
+
313
+ def base_cycle_doc(
314
+ repo: str,
315
+ cycle_id: str,
316
+ base_ref: str,
317
+ gh_token: str | None,
318
+ ) -> CycleDoc | None:
319
+ """Read the cited cycle doc from the protected base ref.
320
+
321
+ CI checks out the PR head, so a PR can edit the cited doc before
322
+ this validator reads it from disk. Use the GitHub contents API
323
+ against the immutable base SHA to catch cycles that were already
324
+ terminal before the PR.
325
+ """
326
+ env = os.environ.copy()
327
+ if gh_token:
328
+ env["GH_TOKEN"] = gh_token
329
+
330
+ try:
331
+ listing_result = subprocess.run(
332
+ [
333
+ "gh",
334
+ "api",
335
+ f"repos/{repo}/contents/docs/roadmap/cycles?ref={base_ref}",
336
+ ],
337
+ check=True,
338
+ capture_output=True,
339
+ text=True,
340
+ env=env,
341
+ timeout=60,
342
+ )
343
+ except FileNotFoundError as exc:
344
+ raise BaseCycleDocFetchError(
345
+ "gh CLI not on PATH; cannot read cycle docs from base ref."
346
+ ) from exc
347
+ except subprocess.CalledProcessError as exc:
348
+ raise BaseCycleDocFetchError(
349
+ f"gh api repos/{repo}/contents/docs/roadmap/cycles failed "
350
+ f"(exit {exc.returncode}): {exc.stderr.strip()}"
351
+ ) from exc
352
+ except subprocess.TimeoutExpired as exc:
353
+ raise BaseCycleDocFetchError(
354
+ f"gh api repos/{repo}/contents/docs/roadmap/cycles timed out after {exc.timeout}s"
355
+ ) from exc
356
+
357
+ try:
358
+ entries = json.loads(listing_result.stdout)
359
+ except json.JSONDecodeError as exc:
360
+ raise BaseCycleDocFetchError(
361
+ "GitHub contents API returned malformed JSON for cycle docs listing."
362
+ ) from exc
363
+
364
+ pattern = re.compile(rf"^cycle{re.escape(cycle_id)}(?:-.+)?\.md$")
365
+ candidates = [
366
+ entry
367
+ for entry in entries
368
+ if isinstance(entry, dict)
369
+ and entry.get("type") == "file"
370
+ and isinstance(entry.get("name"), str)
371
+ and pattern.match(entry["name"])
372
+ ]
373
+ if not candidates:
374
+ return None
375
+
376
+ chosen = max(candidates, key=lambda entry: len(entry["name"]))
377
+ path = chosen.get("path")
378
+ if not isinstance(path, str) or not path:
379
+ raise BaseCycleDocFetchError(
380
+ f"GitHub contents API returned a cycle doc entry without a path for cycle {cycle_id}."
381
+ )
382
+
383
+ try:
384
+ content_result = subprocess.run(
385
+ [
386
+ "gh",
387
+ "api",
388
+ f"repos/{repo}/contents/{path}?ref={base_ref}",
389
+ "--jq",
390
+ ".content",
391
+ ],
392
+ check=True,
393
+ capture_output=True,
394
+ text=True,
395
+ env=env,
396
+ timeout=60,
397
+ )
398
+ except subprocess.CalledProcessError as exc:
399
+ raise BaseCycleDocFetchError(
400
+ f"gh api repos/{repo}/contents/{path} failed "
401
+ f"(exit {exc.returncode}): {exc.stderr.strip()}"
402
+ ) from exc
403
+ except subprocess.TimeoutExpired as exc:
404
+ raise BaseCycleDocFetchError(
405
+ f"gh api repos/{repo}/contents/{path} timed out after {exc.timeout}s"
406
+ ) from exc
407
+
408
+ try:
409
+ decoded = base64.b64decode(content_result.stdout).decode("utf-8")
410
+ except (UnicodeDecodeError, ValueError) as exc:
411
+ raise BaseCycleDocFetchError(
412
+ f"Could not decode base-ref cycle doc `{path}` from GitHub contents API."
413
+ ) from exc
414
+
415
+ status, superseded_by = read_cycle_frontmatter_from_text(decoded)
416
+ return CycleDoc(path=REPO_ROOT / path, status=status, superseded_by=superseded_by)
417
+
418
+
419
+ class ChangedFilesFetchError(RuntimeError):
420
+ """Raised when the PR-files API is unreachable.
421
+
422
+ Surfacing the failure (rather than returning an empty list) lets
423
+ the caller fail-closed: empty changed-files would push every PR
424
+ into the code-PR rule set (Issue/ProjectItem required), so a
425
+ transient gh outage could falsely block a legitimate plan PR.
426
+ """
427
+
428
+
429
+ def pr_changed_files(repo: str, pr_number: int, gh_token: str | None) -> list[str]:
430
+ """Fetch the PR's changed file list via the GitHub API.
431
+
432
+ Raises :class:`ChangedFilesFetchError` on any transport failure so
433
+ the caller can distinguish "no files changed" (legitimate) from
434
+ "could not enumerate files" (the gate must fail-closed rather than
435
+ silently mis-classify the PR as code-PR).
436
+ """
437
+ env = os.environ.copy()
438
+ if gh_token:
439
+ env["GH_TOKEN"] = gh_token
440
+ args = [
441
+ "gh",
442
+ "api",
443
+ "--paginate",
444
+ f"repos/{repo}/pulls/{pr_number}/files",
445
+ "--jq",
446
+ ".[].filename",
447
+ ]
448
+ try:
449
+ result = subprocess.run(
450
+ args,
451
+ check=True,
452
+ capture_output=True,
453
+ text=True,
454
+ env=env,
455
+ timeout=60,
456
+ )
457
+ except FileNotFoundError as exc:
458
+ raise ChangedFilesFetchError(
459
+ "gh CLI not on PATH; cannot enumerate PR changed files."
460
+ ) from exc
461
+ except subprocess.CalledProcessError as exc:
462
+ raise ChangedFilesFetchError(
463
+ f"gh api repos/{repo}/pulls/{pr_number}/files failed "
464
+ f"(exit {exc.returncode}): {exc.stderr.strip()}"
465
+ ) from exc
466
+ except subprocess.TimeoutExpired as exc:
467
+ raise ChangedFilesFetchError(
468
+ f"gh api repos/{repo}/pulls/{pr_number}/files timed out after {exc.timeout}s"
469
+ ) from exc
470
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
471
+
472
+
473
+ class DiffFetchError(RuntimeError):
474
+ """Raised when the PR-diff API is unreachable.
475
+
476
+ The status-completion guard reads the diff to detect PRs that flip
477
+ a cited cycle's frontmatter to ``status: completed``. Without the
478
+ diff, the guard fails open — exactly the failure mode the gate
479
+ exists to prevent. Surfacing the error lets ``main()`` fail-closed.
480
+ """
481
+
482
+
483
+ def pr_diff_patches(repo: str, pr_number: int, gh_token: str | None) -> str:
484
+ """Fetch the full unified diff of the PR.
485
+
486
+ Raises :class:`DiffFetchError` on any transport failure so the
487
+ caller can fail-closed. A silently-empty return value would mean
488
+ the status-completion guard never fires — that's exactly the bypass
489
+ surface this cycle is meant to harden, so any inability to read the
490
+ diff must surface.
491
+ """
492
+ env = os.environ.copy()
493
+ if gh_token:
494
+ env["GH_TOKEN"] = gh_token
495
+ args = [
496
+ "gh",
497
+ "pr",
498
+ "diff",
499
+ str(pr_number),
500
+ "--repo",
501
+ repo,
502
+ ]
503
+ try:
504
+ result = subprocess.run(
505
+ args,
506
+ check=True,
507
+ capture_output=True,
508
+ text=True,
509
+ env=env,
510
+ timeout=60,
511
+ )
512
+ except FileNotFoundError as exc:
513
+ raise DiffFetchError(
514
+ "gh CLI not on PATH; cannot fetch PR diff for status-transition check."
515
+ ) from exc
516
+ except subprocess.CalledProcessError as exc:
517
+ raise DiffFetchError(
518
+ f"gh pr diff {pr_number} --repo {repo} failed (exit {exc.returncode}): "
519
+ f"{exc.stderr.strip()}"
520
+ ) from exc
521
+ except subprocess.TimeoutExpired as exc:
522
+ raise DiffFetchError(
523
+ f"gh pr diff {pr_number} --repo {repo} timed out after {exc.timeout}s"
524
+ ) from exc
525
+ return result.stdout
526
+
527
+
528
+ class CommitMessagesFetchError(RuntimeError):
529
+ """Raised when the PR-commits API is unreachable.
530
+
531
+ Trailers on the last commit (the documented Sage workflow) live
532
+ here, not in the PR body. Silently degrading to "body-only" hides
533
+ half of the trailer surface and can produce false-red gate failures
534
+ for PRs that follow the documented commit-trailer convention.
535
+ Fail-closed and let the operator retry once gh is reachable.
536
+ """
537
+
538
+
539
+ def pr_commit_messages(repo: str, pr_number: int, gh_token: str | None) -> list[str]:
540
+ """Fetch the commit messages for every commit in the PR.
541
+
542
+ Returns a list of commit messages in chronological order
543
+ (oldest → newest). Each message is preserved intact (including any
544
+ blank lines inside the body); the list-of-strings shape avoids the
545
+ delimiter-ambiguity that a single ``"\n\n"``-joined string would
546
+ have when commit bodies contain blank paragraphs of their own.
547
+
548
+ Trailers placed on the last commit (the documented Sage workflow
549
+ per CLAUDE.md "Commit Messages" section) are visible to the gate
550
+ alongside trailers in the PR body via :func:`build_trailer_input`.
551
+
552
+ Raises :class:`CommitMessagesFetchError` on transport failure so
553
+ the caller can fail-closed — mirroring the discipline applied to
554
+ :func:`pr_changed_files` and :func:`pr_diff_patches`. Silent
555
+ "body-only" degradation would mean a transient gh outage produces
556
+ false-red gate failures for PRs that follow the documented
557
+ last-commit-trailer convention.
558
+ """
559
+ env = os.environ.copy()
560
+ if gh_token:
561
+ env["GH_TOKEN"] = gh_token
562
+ # Fetch structured JSON rather than --jq-flattening to a list of
563
+ # strings; the latter collapses commit messages whose body spans
564
+ # multiple lines and breaks trailer-block detection per git's own
565
+ # parsing semantics. We parse the JSON ourselves.
566
+ args = [
567
+ "gh",
568
+ "api",
569
+ "--paginate",
570
+ f"repos/{repo}/pulls/{pr_number}/commits",
571
+ ]
572
+ try:
573
+ result = subprocess.run(
574
+ args,
575
+ check=True,
576
+ capture_output=True,
577
+ text=True,
578
+ env=env,
579
+ timeout=60,
580
+ )
581
+ except FileNotFoundError as exc:
582
+ raise CommitMessagesFetchError(
583
+ "gh CLI not on PATH; cannot fetch PR commit messages."
584
+ ) from exc
585
+ except subprocess.CalledProcessError as exc:
586
+ raise CommitMessagesFetchError(
587
+ f"gh api repos/{repo}/pulls/{pr_number}/commits failed "
588
+ f"(exit {exc.returncode}): {exc.stderr.strip()}"
589
+ ) from exc
590
+ except subprocess.TimeoutExpired as exc:
591
+ raise CommitMessagesFetchError(
592
+ f"gh api repos/{repo}/pulls/{pr_number}/commits timed out after {exc.timeout}s"
593
+ ) from exc
594
+ return _parse_paginated_commits_to_list(result.stdout)
595
+
596
+
597
+ def _parse_paginated_commits_to_list(raw: str) -> list[str]:
598
+ """Extract commit messages from a `gh api --paginate` JSON stream as a list.
599
+
600
+ `gh api --paginate` concatenates each page's JSON array back-to-back
601
+ into a single stream — e.g., ``[{...},{...}][{...}]`` — without a
602
+ delimiter between arrays. The same pattern is parsed by
603
+ ``scripts/ci/check_agentic_engineer_authorship.py:fetch_pr_commits``
604
+ via ``json.JSONDecoder().raw_decode``; this helper applies the same
605
+ streaming parse and returns commit messages as a list of strings,
606
+ preserving every commit body intact (including blank-line
607
+ paragraphs that would otherwise alias the inter-commit delimiter).
608
+ """
609
+ raw = raw.strip()
610
+ if not raw:
611
+ return []
612
+ commits: list[str] = []
613
+ decoder = json.JSONDecoder()
614
+ idx = 0
615
+ while idx < len(raw):
616
+ try:
617
+ value, end = decoder.raw_decode(raw, idx)
618
+ except json.JSONDecodeError:
619
+ # Skip past one character and retry; defensive guard against
620
+ # malformed prefixes that aren't JSON. The reference parser
621
+ # in `check_agentic_engineer_authorship.py` does not retry,
622
+ # but it operates on tighter-controlled input.
623
+ idx += 1
624
+ continue
625
+ if isinstance(value, list):
626
+ for entry in value:
627
+ msg = (entry or {}).get("commit", {}).get("message")
628
+ if isinstance(msg, str) and msg.strip():
629
+ commits.append(msg.rstrip())
630
+ elif isinstance(value, dict):
631
+ # Single-commit case (rare). Defensive.
632
+ msg = value.get("commit", {}).get("message")
633
+ if isinstance(msg, str) and msg.strip():
634
+ commits.append(msg.rstrip())
635
+ idx = end
636
+ while idx < len(raw) and raw[idx].isspace():
637
+ idx += 1
638
+ return commits
639
+
640
+
641
+ def _parse_paginated_commit_messages(raw: str) -> str:
642
+ """Backward-compatible wrapper that returns the list as a joined string.
643
+
644
+ Retained for the unit tests that were written before
645
+ :func:`_parse_paginated_commits_to_list` existed. Production code
646
+ uses the list-returning variant via :func:`pr_commit_messages`.
647
+ Joining with ``"\n\n"`` is lossy when commit bodies contain blank
648
+ paragraphs — DO NOT use this form for any precedence-sensitive
649
+ parsing path.
650
+ """
651
+ return "\n\n".join(_parse_paginated_commits_to_list(raw))
652
+
653
+
654
+ def is_plan_pr(
655
+ title: str,
656
+ labels: Iterable[str],
657
+ changed_files: Iterable[str],
658
+ ) -> bool:
659
+ """Plan PR detection per Component 2 rule 1.
660
+
661
+ A PR is a plan PR if BOTH conditions hold:
662
+ - title matches ``docs(roadmap):`` OR ``docs(<cycle>):``,
663
+ OR the PR has the label ``plan-pr``;
664
+ - AND every changed file is under ``docs/`` (no code paths). We
665
+ use the broader ``docs/`` rather than just cycle-doc paths so
666
+ that a plan PR can add supporting docs (ADR, engineering doc)
667
+ in the same change.
668
+ """
669
+ label_match = any(label.strip().lower() == "plan-pr" for label in labels)
670
+ title_match = bool(PLAN_TITLE_RE.match(title or ""))
671
+ files = list(changed_files)
672
+ if not files:
673
+ return False
674
+ docs_only = all(p.startswith("docs/") or p == "AGENTS.md" or p == "CLAUDE.md" for p in files)
675
+ return (label_match or title_match) and docs_only
676
+
677
+
678
+ def status_completion_in_diff(diff: str) -> list[str]:
679
+ """Find cycle docs whose frontmatter is being set to ``status: completed`` in this PR.
680
+
681
+ Reads the unified diff and looks for added lines matching
682
+ ``+status: completed`` inside a file under ``docs/roadmap/cycles/``.
683
+ Returns the list of cycle doc file paths affected. The caller uses
684
+ this to enforce: a PR that completes a cycle must not also be the
685
+ PR that implements it (status transitions go in their own PR).
686
+ """
687
+ affected: list[str] = []
688
+ current_file: str | None = None
689
+ in_frontmatter = False
690
+ seen_completed_in_frontmatter = False
691
+ for line in diff.splitlines():
692
+ if line.startswith("+++ b/"):
693
+ # New file header. Flush prior state.
694
+ if (
695
+ seen_completed_in_frontmatter
696
+ and current_file
697
+ and current_file not in affected
698
+ ):
699
+ affected.append(current_file)
700
+ current_file = line[len("+++ b/") :].strip()
701
+ in_frontmatter = False
702
+ seen_completed_in_frontmatter = False
703
+ continue
704
+ if not current_file or not current_file.startswith("docs/roadmap/cycles/"):
705
+ continue
706
+ # Naive frontmatter tracking inside the diff hunk: ``---`` delimiters
707
+ # toggle the flag. False positives are acceptable here — the goal
708
+ # is to flag obvious "added a status: completed line in a cycle
709
+ # doc" cases, not to perfectly parse YAML.
710
+ if line.startswith("+") and line[1:].strip() == "---":
711
+ in_frontmatter = not in_frontmatter
712
+ continue
713
+ if line.startswith(" ") and line.strip() == "---":
714
+ in_frontmatter = not in_frontmatter
715
+ continue
716
+ if (
717
+ line.startswith("+")
718
+ and in_frontmatter
719
+ and re.match(r"\+\s*status:\s*[\"']?completed[\"']?\s*$", line)
720
+ ):
721
+ seen_completed_in_frontmatter = True
722
+ # Flush trailing file.
723
+ if seen_completed_in_frontmatter and current_file and current_file not in affected:
724
+ affected.append(current_file)
725
+ return affected
726
+
727
+
728
+ _TERMINAL_STATUSES = frozenset({"completed", "complete", "superseded", "abandoned", "absorbed"})
729
+
730
+
731
+ def is_terminal_status(status: str | None) -> bool:
732
+ return (status or "").lower().strip() in _TERMINAL_STATUSES
733
+
734
+
735
+ def cycle_docs_transitioned_to_terminal(
736
+ repo: str,
737
+ base_ref: str,
738
+ gh_token: str | None,
739
+ changed_files: Iterable[str],
740
+ ) -> list[str]:
741
+ """Find cycle docs whose status transitions to terminal in this PR.
742
+
743
+ Compares each modified cycle-doc file's status at the base ref vs the
744
+ PR checkout. Returns the list of repo-relative paths that flipped
745
+ from a non-terminal status (in-progress / draft / planned / …) to a
746
+ terminal one (completed / complete / superseded / abandoned /
747
+ absorbed).
748
+
749
+ Replaces the prior diff-context-based detection
750
+ (``status_completion_in_diff``), which missed transitions when
751
+ ``gh pr diff`` omitted the opening ``---`` frontmatter fence from
752
+ the hunk context — a real failure mode flagged by Codex P2 review
753
+ on PR #1380. Reading the files directly via the GitHub contents API
754
+ (and locally for the PR-head version) is robust to diff truncation.
755
+
756
+ Raises :class:`BaseCycleDocFetchError` on transport failure; caller
757
+ decides whether to fail-closed.
758
+ """
759
+ cycle_paths = [
760
+ p
761
+ for p in changed_files
762
+ if p.startswith("docs/roadmap/cycles/cycle") and p.endswith(".md")
763
+ ]
764
+ if not cycle_paths:
765
+ return []
766
+
767
+ env = os.environ.copy()
768
+ if gh_token:
769
+ env["GH_TOKEN"] = gh_token
770
+
771
+ transitioned: list[str] = []
772
+ for path in cycle_paths:
773
+ # Head status: read from the PR checkout (already on disk).
774
+ head_path = REPO_ROOT / path
775
+ if not head_path.exists():
776
+ # File was deleted in the PR — not a transition-to-terminal.
777
+ continue
778
+ head_status, _ = read_cycle_frontmatter(head_path)
779
+ if not is_terminal_status(head_status):
780
+ continue # head is non-terminal; no transition to terminal.
781
+
782
+ # Base status: fetch via contents API at the immutable base SHA.
783
+ try:
784
+ result = subprocess.run(
785
+ [
786
+ "gh",
787
+ "api",
788
+ f"repos/{repo}/contents/{path}?ref={base_ref}",
789
+ "--jq",
790
+ ".content",
791
+ ],
792
+ check=True,
793
+ capture_output=True,
794
+ text=True,
795
+ env=env,
796
+ timeout=60,
797
+ )
798
+ except FileNotFoundError as exc:
799
+ raise BaseCycleDocFetchError(
800
+ "gh CLI not on PATH; cannot read base-ref cycle doc."
801
+ ) from exc
802
+ except subprocess.CalledProcessError as exc:
803
+ # 404 means the file is new in the PR — treat as "no base
804
+ # status to compare against" rather than failure.
805
+ if _is_gh_not_found_error(exc):
806
+ # New file in PR; if head is terminal, that's a fresh
807
+ # cycle being created already terminal — almost certainly
808
+ # an error but not a transition the gate is meant to
809
+ # catch (the cycle-doc-existence checks above handle
810
+ # the "you can't cite a terminal cycle" path).
811
+ continue
812
+ raise BaseCycleDocFetchError(
813
+ f"gh api contents/{path}?ref={base_ref} failed "
814
+ f"(exit {exc.returncode}): {exc.stderr.strip()}"
815
+ ) from exc
816
+ except subprocess.TimeoutExpired as exc:
817
+ raise BaseCycleDocFetchError(
818
+ f"gh api contents/{path}?ref={base_ref} timed out after {exc.timeout}s"
819
+ ) from exc
820
+
821
+ try:
822
+ decoded = base64.b64decode(result.stdout.strip()).decode("utf-8")
823
+ except (ValueError, UnicodeDecodeError) as exc:
824
+ # Codex P2 #3 follow-up: fail-closed on decode failure
825
+ # rather than silently skipping. A corrupt/unexpected
826
+ # contents-API response on a cited cycle doc must surface
827
+ # — silently dropping the file from transition detection
828
+ # would re-open the bypass surface this function exists
829
+ # to close. Mirrors the DiffFetchError / CommitMessages
830
+ # FetchError fail-closed pattern.
831
+ raise BaseCycleDocFetchError(
832
+ f"failed to decode contents-API payload for {path} at "
833
+ f"{base_ref}: {exc}"
834
+ ) from exc
835
+
836
+ base_status: str | None = None
837
+ inside = False
838
+ for line in decoded.split("\n"):
839
+ stripped = line.strip()
840
+ if stripped == "---":
841
+ if not inside:
842
+ inside = True
843
+ continue
844
+ break
845
+ if not inside:
846
+ continue
847
+ if stripped.startswith("status:"):
848
+ base_status = stripped[len("status:") :].strip().strip("\"'")
849
+ break
850
+
851
+ if base_status is not None and not is_terminal_status(base_status):
852
+ transitioned.append(path)
853
+
854
+ return transitioned
855
+
856
+
857
+ def build_trailer_input(pr_body: str, commits: list[str] | str) -> str:
858
+ """Concatenate PR body and commits into the order trailers should be parsed in.
859
+
860
+ Precedence (lowest → highest, with the last-write-wins behavior of
861
+ ``parse_trailers`` meaning later text overrides earlier):
862
+
863
+ 1. Older commits first (the PR commit list is chronological;
864
+ :func:`pr_commit_messages` returns commits oldest → newest).
865
+ 2. Tip commit (last commit) — the documented Sage convention
866
+ puts trailers here.
867
+ 3. PR body LAST — authoritative; what the human / reviewer sees
868
+ on GitHub. A correct ``Cycle:`` in the body overrides any
869
+ stale value on an early commit (the bug this function fixes).
870
+
871
+ Accepts ``commits`` as either a list of commit-message strings
872
+ (preferred — preserves blank-line paragraphs inside individual
873
+ commit bodies) or a single ``"\n\n"``-joined string (legacy; ONLY
874
+ safe when no commit body contains a blank-line paragraph).
875
+ """
876
+ # Normalize the input into a list of non-empty commit messages.
877
+ if isinstance(commits, str):
878
+ if not commits.strip():
879
+ return pr_body
880
+ # Legacy delimited-string path. Lossy on multi-paragraph
881
+ # commit bodies — production callers should pass a list.
882
+ commit_list = [c for c in commits.split("\n\n") if c.strip()]
883
+ else:
884
+ commit_list = [c for c in commits if c.strip()]
885
+
886
+ if not commit_list:
887
+ return pr_body
888
+
889
+ # Older commits come first in the returned order; the tip is the
890
+ # last entry. Concatenate older→tip→body so the body wins on
891
+ # last-write-wins parsing.
892
+ parts: list[str] = []
893
+ if len(commit_list) > 1:
894
+ parts.append("\n\n".join(commit_list[:-1]))
895
+ parts.append(commit_list[-1]) # tip commit
896
+ parts.append(pr_body)
897
+ return "\n\n".join(p for p in parts if p)
898
+
899
+
900
+ def is_bot_exempt(labels: Iterable[str]) -> bool:
901
+ """Bot/automation label allowlist per CONTRIBUTING.md.
902
+
903
+ PRs with any of these labels skip trailer enforcement entirely.
904
+ """
905
+ norm = {(lbl or "").strip().lower() for lbl in labels}
906
+ return any(lbl in norm for lbl in BOT_EXEMPT_LABELS)
907
+
908
+
909
+ def terminal_status_error(cycle_id: str, doc: CycleDoc, pr_type: str, source: str) -> str | None:
910
+ """Return a terminal-status error for ``doc`` if its status blocks citation."""
911
+ status_norm = (doc.status or "").lower().strip()
912
+ path = doc.path.relative_to(REPO_ROOT)
913
+ source_suffix = f" in {source}" if source else ""
914
+ if status_norm in {"completed", "complete"}:
915
+ return (
916
+ f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` is "
917
+ f"`status: {doc.status}` in `{path}`{source_suffix}; "
918
+ "cannot reference a completed cycle from a new PR. Open a "
919
+ "follow-up cycle for additional work."
920
+ )
921
+ if status_norm == "superseded":
922
+ suffix = (
923
+ f" (superseded by `{doc.superseded_by}`)"
924
+ if doc.superseded_by
925
+ else ""
926
+ )
927
+ return (
928
+ f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` has "
929
+ f"`status: superseded`{suffix} in `{path}`{source_suffix}; "
930
+ "cannot reference a superseded cycle. Use the successor cycle's id."
931
+ )
932
+ if status_norm in {"abandoned", "absorbed"}:
933
+ return (
934
+ f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` is "
935
+ f"`status: {doc.status}` in `{path}`{source_suffix}; cannot reference "
936
+ "a terminated cycle from a new PR."
937
+ )
938
+ return None
939
+
940
+
941
+ def validate(
942
+ title: str,
943
+ body: str,
944
+ labels: Iterable[str],
945
+ changed_files: Iterable[str],
946
+ diff: str,
947
+ base_doc: CycleDoc | None = None,
948
+ terminal_transitions: Iterable[str] | None = None,
949
+ ) -> list[str]:
950
+ """Run all checks. Returns a list of error messages; empty means pass."""
951
+ errors: list[str] = []
952
+
953
+ labels_list = list(labels)
954
+ if is_bot_exempt(labels_list):
955
+ # Bot/automation PRs are exempted by label per CONTRIBUTING.md
956
+ # "Trailers" section. The exemption is rare and explicit (one
957
+ # of four named labels); the PR review reactor and dependabot
958
+ # carry these. Code-PR rules don't apply.
959
+ return errors
960
+
961
+ trailers = parse_trailers(body)
962
+ plan = is_plan_pr(title, labels_list, changed_files)
963
+ pr_type = "plan-pr" if plan else "code-pr"
964
+
965
+ cycle_id = normalize_cycle_id(trailers.cycle or "")
966
+
967
+ if plan:
968
+ # Plan PR: Cycle: is required. We also need the cycle doc in
969
+ # the diff (checked below after find_cycle_doc).
970
+ if not cycle_id:
971
+ errors.append(
972
+ "[cycle-doc] FAIL (plan-pr): missing required `Cycle:` trailer.\n"
973
+ " Plan PRs must cite the cycle they describe.\n"
974
+ " Add to PR description (or last commit message):\n"
975
+ " Cycle: <N>\n"
976
+ )
977
+ return errors
978
+ else:
979
+ # Code PR: either Cycle: is present (cycle-tracked product code,
980
+ # the dominant case) or Issue:/ProjectItem: is present (non-cycle
981
+ # PR — drift, hotfix, dependabot bump). Bot-exempt PRs already
982
+ # returned above.
983
+ if not cycle_id and not trailers.issue and not trailers.project_item:
984
+ errors.append(
985
+ "[cycle-doc] FAIL (code-pr): missing trailer.\n"
986
+ " Either:\n"
987
+ " Cycle: <N> (for product code tied to a cycle, "
988
+ "PLUS Issue:/ProjectItem: as a secondary anchor), or\n"
989
+ " Issue: #<N> (for drift / dependabot / hotfix "
990
+ "PRs without a cycle, per CONTRIBUTING.md), or\n"
991
+ " ProjectItem: <id>\n"
992
+ " GitHub auto-close keywords (`Closes #N`, `Fixes #N`, "
993
+ "`Resolves #N`) count as `Issue:`."
994
+ )
995
+ return errors
996
+
997
+ doc: CycleDoc | None = None
998
+ if cycle_id:
999
+ doc = find_cycle_doc(cycle_id)
1000
+ if doc is None:
1001
+ errors.append(
1002
+ f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` not found in "
1003
+ f"`docs/roadmap/cycles/`. Either fix the trailer or open a plan "
1004
+ "PR that creates the cycle doc first."
1005
+ )
1006
+ return errors
1007
+
1008
+ # Terminal statuses that mean "do not cite this cycle from a new PR."
1009
+ # Spelling drift across older docs is reality: ``complete`` (no
1010
+ # ``-ed``) and ``Complete`` (capitalized) appear alongside the
1011
+ # canonical ``completed``. ``absorbed`` / ``abandoned`` are also
1012
+ # terminal. Treat all of these as block-cite.
1013
+ if error := terminal_status_error(cycle_id, doc, pr_type, "the PR checkout"):
1014
+ errors.append(error)
1015
+
1016
+ # The PR can edit the cited cycle doc itself. In CI we therefore
1017
+ # also validate the protected base-ref copy; otherwise a PR could
1018
+ # change `status: completed` back to `in-progress` and cite the
1019
+ # stale cycle in the same diff.
1020
+ if base_doc is not None:
1021
+ if error := terminal_status_error(cycle_id, base_doc, pr_type, "the base ref"):
1022
+ errors.append(error)
1023
+
1024
+ if plan:
1025
+ # Plan PR: the diff MUST include the cycle's own doc file. A plan
1026
+ # PR that doesn't touch its cycle's doc is mis-labelled.
1027
+ assert doc is not None # plan PR without cycle_id already returned
1028
+ cycle_paths = [
1029
+ f for f in changed_files if f.startswith("docs/roadmap/cycles/")
1030
+ ]
1031
+ doc_relative = doc.path.relative_to(REPO_ROOT).as_posix()
1032
+ if doc_relative not in cycle_paths:
1033
+ errors.append(
1034
+ f"[cycle-doc] FAIL (plan-pr): plan PR cites `Cycle: {cycle_id}` "
1035
+ f"but does not modify `{doc_relative}`. Plan PRs must include "
1036
+ "the cycle doc they describe."
1037
+ )
1038
+ elif cycle_id and not trailers.issue and not trailers.project_item:
1039
+ # Code PR WITH Cycle but no Issue/ProjectItem: still required as
1040
+ # the secondary anchor (per CONTRIBUTING.md "For cycle PRs, both
1041
+ # Cycle: and Issue: (or ProjectItem:) are required").
1042
+ errors.append(
1043
+ "[cycle-doc] FAIL (code-pr): code PR cites a Cycle but no "
1044
+ "`Issue:` / `ProjectItem:` trailer. For cycle PRs both are "
1045
+ "required (CONTRIBUTING.md). GitHub auto-close keywords "
1046
+ "(`Closes #N`, `Fixes #N`, `Resolves #N`) count as `Issue:`."
1047
+ )
1048
+
1049
+ if cycle_id and doc is not None:
1050
+ # Prefer the base-vs-head transition check (Codex P2 #3: avoids
1051
+ # diff-context misses when the frontmatter fence is outside the
1052
+ # hunk window). Fall back to the diff-based heuristic when no
1053
+ # transition set was supplied (in-memory tests, or when the
1054
+ # base-ref fetch was skipped).
1055
+ if terminal_transitions is not None:
1056
+ completed = list(terminal_transitions)
1057
+ else:
1058
+ completed = status_completion_in_diff(diff)
1059
+ if completed:
1060
+ # Same PR creates AND completes a cycle: that's a status-transition
1061
+ # PR that should be split from the implementation. Note: completing
1062
+ # a *different* cycle is fine (and common — a meta-PR can close
1063
+ # multiple cycles), so we only fail when the completed file
1064
+ # matches the PR's own cited cycle.
1065
+ doc_relative = doc.path.relative_to(REPO_ROOT).as_posix()
1066
+ if doc_relative in completed:
1067
+ errors.append(
1068
+ f"[cycle-doc] FAIL ({pr_type}): same PR sets "
1069
+ f"`status: completed` on `{doc_relative}` AND cites "
1070
+ f"`Cycle: {cycle_id}` as the in-flight cycle. Open a "
1071
+ "separate doc PR to flip status to completed after the "
1072
+ "implementing PR merges."
1073
+ )
1074
+
1075
+ return errors
1076
+
1077
+
1078
+ def main(argv: list[str] | None = None) -> int:
1079
+ parser = argparse.ArgumentParser(description=__doc__)
1080
+ parser.add_argument(
1081
+ "--pr-number",
1082
+ type=int,
1083
+ default=int(os.environ.get("PR_NUMBER", "0") or 0),
1084
+ help="PR number (defaults to $PR_NUMBER).",
1085
+ )
1086
+ parser.add_argument(
1087
+ "--repo",
1088
+ default=os.environ.get("REPO", "momentiq-ai/sage3c"),
1089
+ help="Repo in owner/name form (defaults to $REPO).",
1090
+ )
1091
+ parser.add_argument(
1092
+ "--title",
1093
+ default=os.environ.get("PR_TITLE", ""),
1094
+ help="PR title (defaults to $PR_TITLE).",
1095
+ )
1096
+ parser.add_argument(
1097
+ "--body",
1098
+ default=os.environ.get("PR_BODY", ""),
1099
+ help="PR body (defaults to $PR_BODY).",
1100
+ )
1101
+ parser.add_argument(
1102
+ "--labels-json",
1103
+ default=os.environ.get("PR_LABELS", "[]"),
1104
+ help="JSON array of label objects (defaults to $PR_LABELS).",
1105
+ )
1106
+ parser.add_argument(
1107
+ "--gh-token",
1108
+ default=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"),
1109
+ )
1110
+ parser.add_argument(
1111
+ "--base-ref",
1112
+ default=os.environ.get("PR_BASE_SHA") or os.environ.get("PR_BASE_REF") or "",
1113
+ help="Base commit/ref for base-ref cycle-doc status checks.",
1114
+ )
1115
+ args = parser.parse_args(argv)
1116
+
1117
+ try:
1118
+ labels_raw = json.loads(args.labels_json or "[]")
1119
+ except json.JSONDecodeError:
1120
+ labels_raw = []
1121
+ labels = [
1122
+ (lbl.get("name") if isinstance(lbl, dict) else str(lbl))
1123
+ for lbl in labels_raw
1124
+ if lbl
1125
+ ]
1126
+ labels = [label for label in labels if label]
1127
+
1128
+ if args.pr_number <= 0:
1129
+ print(
1130
+ "[cycle-doc] FAIL: PR_NUMBER missing or invalid. Set $PR_NUMBER or pass --pr-number.",
1131
+ file=sys.stderr,
1132
+ )
1133
+ return 1
1134
+
1135
+ try:
1136
+ changed_files = pr_changed_files(args.repo, args.pr_number, args.gh_token)
1137
+ except ChangedFilesFetchError as exc:
1138
+ print(
1139
+ f"[cycle-doc] FAIL: cannot enumerate PR changed files: {exc}\n"
1140
+ " Without the file list the gate cannot distinguish plan PR from "
1141
+ "code PR and would silently mis-classify. Failing closed.",
1142
+ file=sys.stderr,
1143
+ )
1144
+ return 1
1145
+
1146
+ try:
1147
+ diff = pr_diff_patches(args.repo, args.pr_number, args.gh_token)
1148
+ except DiffFetchError as exc:
1149
+ print(
1150
+ f"[cycle-doc] FAIL: cannot fetch PR diff: {exc}\n"
1151
+ " Without the diff the status-completion guard cannot fire and "
1152
+ "would silently allow a PR that both implements a cycle AND flips "
1153
+ "its status to completed. Failing closed.",
1154
+ file=sys.stderr,
1155
+ )
1156
+ return 1
1157
+
1158
+ try:
1159
+ commit_messages = pr_commit_messages(args.repo, args.pr_number, args.gh_token)
1160
+ except CommitMessagesFetchError as exc:
1161
+ print(
1162
+ f"[cycle-doc] FAIL: cannot fetch PR commit messages: {exc}\n"
1163
+ " Trailers placed on the last commit (the documented Sage "
1164
+ "workflow) would be invisible to the gate. Failing closed to "
1165
+ "avoid false-red rejection of PRs that follow the convention.",
1166
+ file=sys.stderr,
1167
+ )
1168
+ return 1
1169
+
1170
+ # Build the parse body with explicit trailer precedence.
1171
+ # build_trailer_input concatenates older-commits → tip-commit →
1172
+ # PR-body, so parse_trailers' last-write-wins ordering means:
1173
+ # 1. PR body wins on duplicate keys (textually last segment).
1174
+ # 2. Tip commit (last commit) overrides any older commits.
1175
+ # 3. Older commits are additive fallback (e.g., a `Closes #N`
1176
+ # left on an early commit by a tooling helper).
1177
+ body_for_parse = build_trailer_input(args.body, commit_messages)
1178
+
1179
+ base_doc_for_cited_cycle: CycleDoc | None = None
1180
+ cycle_id = normalize_cycle_id(parse_trailers(body_for_parse).cycle or "")
1181
+ if cycle_id:
1182
+ if not args.base_ref:
1183
+ print(
1184
+ "[cycle-doc] FAIL: PR_BASE_SHA / --base-ref missing.\n"
1185
+ " The gate must compare cited cycle-doc status against the "
1186
+ "protected base ref; reading only the PR checkout lets the PR "
1187
+ "rewrite terminal cycle status. Failing closed.",
1188
+ file=sys.stderr,
1189
+ )
1190
+ return 1
1191
+ try:
1192
+ base_doc_for_cited_cycle = base_cycle_doc(
1193
+ args.repo,
1194
+ cycle_id,
1195
+ args.base_ref,
1196
+ args.gh_token,
1197
+ )
1198
+ except BaseCycleDocFetchError as exc:
1199
+ print(
1200
+ f"[cycle-doc] FAIL: cannot read cited cycle doc from base ref: {exc}\n"
1201
+ " Without the base-ref copy, a PR could change a completed or "
1202
+ "superseded cycle back to in-progress and bypass the terminal-status "
1203
+ "guard. Failing closed.",
1204
+ file=sys.stderr,
1205
+ )
1206
+ return 1
1207
+
1208
+ # Codex P2 #3: detect terminal-status transitions via base-vs-head
1209
+ # status comparison rather than diff-context parsing. The diff path
1210
+ # missed transitions where the opening `---` fence fell outside the
1211
+ # default hunk context. Reading the files directly is robust to
1212
+ # truncation.
1213
+ transitions: list[str] = []
1214
+ if cycle_id and args.base_ref:
1215
+ try:
1216
+ transitions = cycle_docs_transitioned_to_terminal(
1217
+ args.repo,
1218
+ args.base_ref,
1219
+ args.gh_token,
1220
+ changed_files,
1221
+ )
1222
+ except BaseCycleDocFetchError as exc:
1223
+ print(
1224
+ f"[cycle-doc] FAIL: cannot read base-ref cycle docs for "
1225
+ f"terminal-transition check: {exc}\n"
1226
+ " Without base-ref reads the same-PR completion guard "
1227
+ "can be bypassed by a hunk that omits the frontmatter "
1228
+ "fence. Failing closed.",
1229
+ file=sys.stderr,
1230
+ )
1231
+ return 1
1232
+
1233
+ errors = validate(
1234
+ title=args.title,
1235
+ body=body_for_parse,
1236
+ labels=labels,
1237
+ changed_files=changed_files,
1238
+ diff=diff,
1239
+ base_doc=base_doc_for_cited_cycle,
1240
+ terminal_transitions=transitions,
1241
+ )
1242
+
1243
+ if errors:
1244
+ for err in errors:
1245
+ print(err, file=sys.stderr)
1246
+ print(
1247
+ "\n[cycle-doc] See docs/roadmap/cycles/cycle318.4-ci-fallback-and-auto-merge.md "
1248
+ "(Component 2) for the gate specification.",
1249
+ file=sys.stderr,
1250
+ )
1251
+ return 1
1252
+
1253
+ print(
1254
+ f"[cycle-doc] OK: PR #{args.pr_number} satisfies cycle-doc trailer requirements."
1255
+ )
1256
+ return 0
1257
+
1258
+
1259
+ if __name__ == "__main__":
1260
+ sys.exit(main())