@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,759 @@
1
+ #!/usr/bin/env python3
2
+ """Cycle 318.4 Component 3 — branch-protection drift audit.
3
+
4
+ Reads ``tools/branch-protection/spec.yaml`` (the declarative source of
5
+ truth shipped by Cycle 318.3) and compares it against the live GitHub
6
+ Repository Ruleset. Exits non-zero on any divergence and prints a
7
+ structured field-by-field report (``[branch-protection-audit] FAIL:
8
+ ...`` followed by one ``field: ... intended: ... live: ... detail:
9
+ ...`` block per divergence — NOT a unified diff). Catches passive
10
+ drift (someone toggles a setting in the GitHub UI without a PR) AND
11
+ active drift (a PR weakens the spec without removing a corresponding
12
+ rule from the ruleset).
13
+
14
+ What gets compared:
15
+
16
+ * Set of REQUIRED status-check contexts (status_checks where
17
+ ``status: required`` in spec, vs ``required_status_checks`` array
18
+ in the live rule). ``status: planned`` contexts are aspirational —
19
+ they are NOT expected on the live ruleset yet, but the audit
20
+ flags any LIVE context that is neither ``required`` nor ``planned``
21
+ in the spec (drift: "live has something we didn't plan for").
22
+
23
+ * Strict status-check policy: ``strict_required_status_checks_policy``
24
+ in spec.required_status_checks vs the live rule parameters.
25
+
26
+ * Pull-request rule settings: ``required_approving_review_count``,
27
+ ``required_review_thread_resolution``, ``allowed_merge_methods``.
28
+
29
+ * Merge-queue settings: ``merge_method``, ``grouping_strategy``,
30
+ ``max_entries_to_build``, etc.
31
+
32
+ * Forbidden bypass surfaces:
33
+ - ``repo_variable`` entries: queried via paginated ``gh api``;
34
+ any match is a hard fail.
35
+ - ``disabled_workflow`` pattern: ``.github/workflows/*.yml.disabled``
36
+ files in the working tree. Each file is a divergence unless
37
+ the spec explicitly lists it under ``known_violations`` for an
38
+ active cycle's resolution window.
39
+ - ``pr_title_skip``: grep ``.github/workflows/*.yml`` for any
40
+ check that gates on the configured title pattern (currently
41
+ ``[skip ci]``).
42
+
43
+ Inputs:
44
+
45
+ positional ``spec_path`` — path to ``tools/branch-protection/spec.yaml``.
46
+
47
+ Environment:
48
+
49
+ GH_TOKEN — token with repo-read on rulesets. Without this the live
50
+ fetch fails (gh CLI surfaces 401); the audit will print a clear
51
+ diagnostic and exit non-zero.
52
+
53
+ REPO_ACTIONS_VARIABLES_JSON — optional JSON object from the GitHub
54
+ Actions ``vars`` context (``${{ toJSON(vars) }}``). Used only as a
55
+ fallback when ``gh api repos/<repo>/actions/variables`` is not readable
56
+ by the workflow token. This keeps forbidden repo-variable checks
57
+ fail-closed locally while letting CI evaluate known kill-switch names
58
+ without requiring a privileged PAT on PR-controlled workflow code.
59
+
60
+ Outputs:
61
+
62
+ exit 0 — spec == live, no divergence;
63
+ exit 1 — divergence (structured field-by-field report on stdout);
64
+ exit 2 — usage / configuration error (e.g., spec.yaml missing).
65
+ """
66
+ from __future__ import annotations
67
+
68
+ import argparse
69
+ import json
70
+ import os
71
+ import re
72
+ import subprocess
73
+ import sys
74
+ from dataclasses import dataclass, field
75
+ from pathlib import Path
76
+ from typing import Any
77
+
78
+ import yaml
79
+
80
+ def _resolve_repo_root() -> Path:
81
+ """Resolve the consumer repo root.
82
+
83
+ Phase C extraction (cycle 331.1): same shape as
84
+ ``cycle_doc_validator/validate_cycle_doc.py``. Resolution order:
85
+ ``DF_REPO_ROOT`` env var → ``git rev-parse --show-toplevel`` → legacy
86
+ ``__file__`` parents[2] fallback for in-tree pytest runs.
87
+ """
88
+ override = os.environ.get("DF_REPO_ROOT")
89
+ if override:
90
+ return Path(override).resolve()
91
+ try:
92
+ result = subprocess.run(
93
+ ["git", "rev-parse", "--show-toplevel"],
94
+ check=True,
95
+ capture_output=True,
96
+ text=True,
97
+ )
98
+ return Path(result.stdout.strip()).resolve()
99
+ except (subprocess.CalledProcessError, FileNotFoundError):
100
+ return Path(__file__).resolve().parents[2]
101
+
102
+
103
+ REPO_ROOT = _resolve_repo_root()
104
+ WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
105
+
106
+
107
+ @dataclass
108
+ class Divergence:
109
+ field: str
110
+ intended: Any
111
+ live: Any
112
+ detail: str = ""
113
+
114
+ def render(self) -> str:
115
+ lines = [f" field: {self.field}"]
116
+ lines.append(f" intended: {self.intended!r}")
117
+ lines.append(f" live: {self.live!r}")
118
+ if self.detail:
119
+ lines.append(f" detail: {self.detail}")
120
+ return "\n".join(lines)
121
+
122
+
123
+ @dataclass
124
+ class AuditReport:
125
+ divergences: list[Divergence] = field(default_factory=list)
126
+
127
+ @property
128
+ def ok(self) -> bool:
129
+ return not self.divergences
130
+
131
+ def add(self, *args: Any, **kwargs: Any) -> None:
132
+ self.divergences.append(Divergence(*args, **kwargs))
133
+
134
+ def render(self) -> str:
135
+ if self.ok:
136
+ return "[branch-protection-audit] OK: spec.yaml matches live ruleset.\n"
137
+ body = "\n".join(d.render() for d in self.divergences)
138
+ return (
139
+ "[branch-protection-audit] FAIL: spec.yaml vs live ruleset divergence:\n"
140
+ f"{body}\n"
141
+ )
142
+
143
+
144
+ def load_spec(path: Path) -> dict[str, Any]:
145
+ if not path.exists():
146
+ raise FileNotFoundError(f"spec.yaml not found at {path}")
147
+ with path.open(encoding="utf-8") as fh:
148
+ data = yaml.safe_load(fh)
149
+ if not isinstance(data, dict):
150
+ raise ValueError(f"{path} did not parse to a mapping")
151
+ return data
152
+
153
+
154
+ def fetch_live_ruleset(repo: str, ruleset_id: int) -> dict[str, Any]:
155
+ """Fetch the live ruleset via gh api. Raises on transport failure."""
156
+ args = ["gh", "api", f"repos/{repo}/rulesets/{ruleset_id}"]
157
+ try:
158
+ result = subprocess.run(
159
+ args,
160
+ check=True,
161
+ capture_output=True,
162
+ text=True,
163
+ timeout=30,
164
+ )
165
+ except FileNotFoundError as exc:
166
+ raise RuntimeError(
167
+ "gh CLI not on PATH — install GitHub CLI or run inside an environment where it is available."
168
+ ) from exc
169
+ except subprocess.CalledProcessError as exc:
170
+ raise RuntimeError(
171
+ f"gh api repos/{repo}/rulesets/{ruleset_id} failed (exit {exc.returncode}):\n"
172
+ f" stderr: {exc.stderr.strip()}"
173
+ ) from exc
174
+ return json.loads(result.stdout)
175
+
176
+
177
+ class RepoVariableFetchError(RuntimeError):
178
+ """Raised when the repo variables API is unreachable.
179
+
180
+ We surface this rather than returning ``[]`` so the audit can fail
181
+ closed when a forbidden ``repo_variable`` rule cannot be evaluated.
182
+ Silently returning an empty list would hide a regression where
183
+ ``SKIP_PR_CI`` got re-introduced and the audit step happened to lose
184
+ its ``gh`` PATH or permission scope at the same time.
185
+ """
186
+
187
+
188
+ def fetch_repo_variables(repo: str) -> list[str]:
189
+ """List repo variable names via gh api.
190
+
191
+ Raises :class:`RepoVariableFetchError` on any transport failure so
192
+ the caller can decide whether to fail-closed (the default for
193
+ ``check_forbidden_bypasses``) or skip the check explicitly.
194
+ """
195
+ try:
196
+ result = subprocess.run(
197
+ [
198
+ "gh",
199
+ "api",
200
+ "--paginate",
201
+ f"repos/{repo}/actions/variables",
202
+ "--jq",
203
+ ".variables[].name",
204
+ ],
205
+ check=True,
206
+ capture_output=True,
207
+ text=True,
208
+ timeout=30,
209
+ )
210
+ except FileNotFoundError as exc:
211
+ raise RepoVariableFetchError(
212
+ "gh CLI not on PATH; cannot enumerate repo variables for forbidden-bypass check."
213
+ ) from exc
214
+ except subprocess.CalledProcessError as exc:
215
+ raise RepoVariableFetchError(
216
+ f"gh api repos/{repo}/actions/variables failed (exit {exc.returncode}): "
217
+ f"{exc.stderr.strip()}"
218
+ ) from exc
219
+ except subprocess.TimeoutExpired as exc:
220
+ raise RepoVariableFetchError(
221
+ f"gh api repos/{repo}/actions/variables timed out after {exc.timeout}s"
222
+ ) from exc
223
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
224
+
225
+
226
+ def repo_variables_from_actions_context() -> list[str] | None:
227
+ """Return variable names from the GitHub Actions ``vars`` JSON fallback.
228
+
229
+ ``None`` means the fallback was not supplied. An empty list means it
230
+ was supplied and no variables are visible to the workflow.
231
+ """
232
+ raw = os.environ.get("REPO_ACTIONS_VARIABLES_JSON")
233
+ if raw is None or not raw.strip():
234
+ return None
235
+ try:
236
+ data = json.loads(raw)
237
+ except json.JSONDecodeError as exc:
238
+ raise RepoVariableFetchError(
239
+ "REPO_ACTIONS_VARIABLES_JSON is not valid JSON"
240
+ ) from exc
241
+ if not isinstance(data, dict):
242
+ raise RepoVariableFetchError(
243
+ "REPO_ACTIONS_VARIABLES_JSON must be a JSON object"
244
+ )
245
+ return [str(name) for name in data if name]
246
+
247
+
248
+ def extract_live_status_check_contexts(live: dict[str, Any]) -> tuple[list[str], bool | None]:
249
+ """Pull (contexts, strict_required) from the live ruleset."""
250
+ for rule in live.get("rules", []):
251
+ if rule.get("type") == "required_status_checks":
252
+ params = rule.get("parameters", {})
253
+ contexts = [
254
+ c.get("context") for c in params.get("required_status_checks", [])
255
+ ]
256
+ return [c for c in contexts if c], params.get(
257
+ "strict_required_status_checks_policy"
258
+ )
259
+ return [], None
260
+
261
+
262
+ def extract_live_pr_rule(live: dict[str, Any]) -> dict[str, Any]:
263
+ for rule in live.get("rules", []):
264
+ if rule.get("type") == "pull_request":
265
+ params = dict(rule.get("parameters") or {})
266
+ params["__present__"] = True
267
+ return params
268
+ return {"__present__": False}
269
+
270
+
271
+ def extract_live_merge_queue(live: dict[str, Any]) -> dict[str, Any]:
272
+ for rule in live.get("rules", []):
273
+ if rule.get("type") == "merge_queue":
274
+ params = dict(rule.get("parameters") or {})
275
+ params["__present__"] = True
276
+ return params
277
+ return {"__present__": False}
278
+
279
+
280
+ def extract_live_linear(live: dict[str, Any]) -> bool:
281
+ return any(r.get("type") == "required_linear_history" for r in live.get("rules", []))
282
+
283
+
284
+ def extract_live_deletion_blocked(live: dict[str, Any]) -> bool:
285
+ return any(r.get("type") == "deletion" for r in live.get("rules", []))
286
+
287
+
288
+ def extract_live_non_fast_forward(live: dict[str, Any]) -> bool:
289
+ return any(r.get("type") == "non_fast_forward" for r in live.get("rules", []))
290
+
291
+
292
+ def compare_status_check_contexts(
293
+ spec: dict[str, Any],
294
+ live: dict[str, Any],
295
+ report: AuditReport,
296
+ ) -> None:
297
+ branch_spec = spec["branches"]["main"]
298
+ rsc_spec = branch_spec.get("required_status_checks", {})
299
+ raw_contexts = rsc_spec.get("contexts", [])
300
+
301
+ required: set[str] = set()
302
+ planned: set[str] = set()
303
+ for entry in raw_contexts:
304
+ if not isinstance(entry, dict):
305
+ continue
306
+ context = entry.get("context")
307
+ if not context:
308
+ continue
309
+ status = entry.get("status", "required")
310
+ if status == "required":
311
+ required.add(context)
312
+ elif status == "planned":
313
+ planned.add(context)
314
+ # Other statuses (e.g., 'audit') are advisory; ignored here.
315
+
316
+ live_contexts, live_strict = extract_live_status_check_contexts(live)
317
+ live_set = set(live_contexts)
318
+
319
+ missing_required = required - live_set
320
+ if missing_required:
321
+ report.add(
322
+ field="required_status_checks.contexts.required",
323
+ intended=sorted(required),
324
+ live=sorted(live_set),
325
+ detail=f"missing in live: {sorted(missing_required)}",
326
+ )
327
+
328
+ unplanned_live = live_set - required - planned
329
+ if unplanned_live:
330
+ report.add(
331
+ field="required_status_checks.contexts.unplanned_in_live",
332
+ intended=sorted(required | planned),
333
+ live=sorted(live_set),
334
+ detail=(
335
+ f"live ruleset requires checks NOT declared in spec.yaml: "
336
+ f"{sorted(unplanned_live)}. Add them to "
337
+ "branches.main.required_status_checks.contexts or remove "
338
+ "from the live ruleset."
339
+ ),
340
+ )
341
+
342
+ intended_strict = bool(rsc_spec.get("strict_required_status_checks_policy"))
343
+ if live_strict is not None and intended_strict != bool(live_strict):
344
+ report.add(
345
+ field="required_status_checks.strict_required_status_checks_policy",
346
+ intended=intended_strict,
347
+ live=bool(live_strict),
348
+ )
349
+
350
+
351
+ def compare_pull_request_rule(
352
+ spec: dict[str, Any], live: dict[str, Any], report: AuditReport
353
+ ) -> None:
354
+ branch_spec = spec["branches"]["main"]
355
+ pr_spec = branch_spec.get("required_pull_request_reviews", {})
356
+ pr_live = extract_live_pr_rule(live)
357
+ if not pr_live.get("__present__"):
358
+ if pr_spec:
359
+ report.add(
360
+ field="pull_request_rule",
361
+ intended=pr_spec,
362
+ live=None,
363
+ detail="spec declares pull-request rule but live ruleset has none.",
364
+ )
365
+ return
366
+
367
+ for key in (
368
+ "required_approving_review_count",
369
+ "required_review_thread_resolution",
370
+ "dismiss_stale_reviews_on_push",
371
+ "require_code_owner_reviews",
372
+ "require_last_push_approval",
373
+ ):
374
+ if key in pr_spec:
375
+ # spec uses ``require_code_owner_reviews``; live uses
376
+ # ``require_code_owner_review`` (no trailing s). Map both.
377
+ live_key = key.rstrip("s") if key == "require_code_owner_reviews" else key
378
+ if pr_live.get(live_key) != pr_spec[key]:
379
+ report.add(
380
+ field=f"pull_request.{key}",
381
+ intended=pr_spec[key],
382
+ live=pr_live.get(live_key),
383
+ )
384
+
385
+ if "allowed_merge_methods" in pr_spec:
386
+ intended = sorted(pr_spec["allowed_merge_methods"])
387
+ live_methods = sorted(pr_live.get("allowed_merge_methods", []))
388
+ if intended != live_methods:
389
+ report.add(
390
+ field="pull_request.allowed_merge_methods",
391
+ intended=intended,
392
+ live=live_methods,
393
+ )
394
+
395
+
396
+ def compare_merge_queue(
397
+ spec: dict[str, Any], live: dict[str, Any], report: AuditReport
398
+ ) -> None:
399
+ branch_spec = spec["branches"]["main"]
400
+ mq_spec = branch_spec.get("merge_queue", {})
401
+ mq_live = extract_live_merge_queue(live)
402
+ if not mq_spec.get("enabled", True) and not mq_live.get("__present__"):
403
+ return # both disabled — fine.
404
+ if not mq_live.get("__present__"):
405
+ report.add(
406
+ field="merge_queue",
407
+ intended=mq_spec,
408
+ live=None,
409
+ detail="spec declares merge_queue but live ruleset has none.",
410
+ )
411
+ return
412
+ for key in (
413
+ "merge_method",
414
+ "grouping_strategy",
415
+ "check_response_timeout_minutes",
416
+ "max_entries_to_build",
417
+ "max_entries_to_merge",
418
+ "min_entries_to_merge",
419
+ "min_entries_to_merge_wait_minutes",
420
+ ):
421
+ if key in mq_spec and mq_live.get(key) != mq_spec[key]:
422
+ report.add(
423
+ field=f"merge_queue.{key}",
424
+ intended=mq_spec[key],
425
+ live=mq_live.get(key),
426
+ )
427
+
428
+
429
+ def compare_linear_history(
430
+ spec: dict[str, Any], live: dict[str, Any], report: AuditReport
431
+ ) -> None:
432
+ intended = bool(spec["branches"]["main"].get("required_linear_history", False))
433
+ actual = extract_live_linear(live)
434
+ if intended != actual:
435
+ report.add(
436
+ field="required_linear_history",
437
+ intended=intended,
438
+ live=actual,
439
+ )
440
+
441
+
442
+ def compare_block_flags(
443
+ spec: dict[str, Any], live: dict[str, Any], report: AuditReport
444
+ ) -> None:
445
+ branch_spec = spec["branches"]["main"]
446
+ if "deletion_blocked" in branch_spec:
447
+ intended = bool(branch_spec["deletion_blocked"])
448
+ actual = extract_live_deletion_blocked(live)
449
+ if intended != actual:
450
+ report.add(
451
+ field="deletion_blocked", intended=intended, live=actual,
452
+ )
453
+ if "non_fast_forward_blocked" in branch_spec:
454
+ intended = bool(branch_spec["non_fast_forward_blocked"])
455
+ actual = extract_live_non_fast_forward(live)
456
+ if intended != actual:
457
+ report.add(
458
+ field="non_fast_forward_blocked", intended=intended, live=actual,
459
+ )
460
+
461
+
462
+ def compare_enforcement(
463
+ spec: dict[str, Any], live: dict[str, Any], report: AuditReport
464
+ ) -> None:
465
+ intended = spec.get("ruleset", {}).get("enforcement", "active")
466
+ actual = live.get("enforcement")
467
+ if actual != intended:
468
+ report.add(field="ruleset.enforcement", intended=intended, live=actual)
469
+
470
+
471
+ def check_forbidden_bypasses(
472
+ spec: dict[str, Any],
473
+ repo_variables: list[str],
474
+ workflows_dir: Path,
475
+ report: AuditReport,
476
+ ) -> None:
477
+ """Verify each forbidden bypass entry is actually closed."""
478
+ for entry in spec.get("forbidden_bypasses", []):
479
+ if not isinstance(entry, dict):
480
+ continue
481
+ kind = entry.get("kind")
482
+ if kind == "repo_variable":
483
+ name = entry.get("name")
484
+ if name and name in repo_variables:
485
+ report.add(
486
+ field=f"forbidden_bypasses[repo_variable={name}]",
487
+ intended="absent",
488
+ live="present",
489
+ detail=(
490
+ f"Repo variable `{name}` is set in GitHub Actions. "
491
+ f"This was a documented bypass surface; remove it via "
492
+ f"`gh variable delete {name}`."
493
+ ),
494
+ )
495
+ elif kind == "disabled_workflow":
496
+ pattern = entry.get("pattern", "*.yml.disabled")
497
+ known = set(entry.get("known_violations", []) or [])
498
+ # Match the bare filename glob (e.g., ``*.yml.disabled``)
499
+ # against entries in the workflows directory. Paths are
500
+ # rendered repo-relative when the workflows dir is under
501
+ # REPO_ROOT; otherwise (unit tests with tmp_path) we use the
502
+ # workflows-dir-relative path.
503
+ #
504
+ # GitHub Actions also accepts `.yaml`, so if the spec
505
+ # pattern uses `*.yml.disabled` we also scan
506
+ # `*.yaml.disabled` to catch the equivalent rename-disable
507
+ # of a `.yaml` workflow.
508
+ bare = pattern.split("/")[-1]
509
+ extra_globs: list[str] = []
510
+ if bare == "*.yml.disabled":
511
+ extra_globs.append("*.yaml.disabled")
512
+ matches = [
513
+ p for p in workflows_dir.glob(bare) if p.is_file()
514
+ ]
515
+ for g in extra_globs:
516
+ matches.extend(p for p in workflows_dir.glob(g) if p.is_file())
517
+ found: list[str] = []
518
+ for p in matches:
519
+ try:
520
+ found.append(str(p.relative_to(REPO_ROOT)))
521
+ except ValueError:
522
+ found.append(str(p.relative_to(workflows_dir)))
523
+ found = sorted(found)
524
+ extras = sorted(set(found) - known)
525
+ still_present = sorted(set(found) & known)
526
+ if extras:
527
+ report.add(
528
+ field=f"forbidden_bypasses[disabled_workflow={pattern}]",
529
+ intended="no matches outside known_violations",
530
+ live=extras,
531
+ detail=(
532
+ "Disabled-workflow files exist that are not enumerated "
533
+ f"in spec.yaml's `known_violations`: {extras}. Either "
534
+ "delete them, re-enable, or add to allowed_bypasses."
535
+ ),
536
+ )
537
+ if still_present:
538
+ report.add(
539
+ field=f"forbidden_bypasses[disabled_workflow={pattern}]",
540
+ intended="known_violations resolved before merge",
541
+ live=still_present,
542
+ detail=(
543
+ "Files listed in `known_violations` are still present. "
544
+ "Cycle 318.4 must resolve them (delete / re-enable / "
545
+ "move to allowed_bypasses)."
546
+ ),
547
+ )
548
+ elif kind == "pr_title_skip":
549
+ pattern = entry.get("pattern", "[skip ci]")
550
+ hits = grep_pr_title_skip(workflows_dir, pattern)
551
+ if hits:
552
+ report.add(
553
+ field=f"forbidden_bypasses[pr_title_skip={pattern}]",
554
+ intended="no workflow gates on this title token",
555
+ live=hits,
556
+ detail=(
557
+ f"Workflow files reference the forbidden title-skip "
558
+ f"token {pattern!r}: {hits}. Remove the if: clause."
559
+ ),
560
+ )
561
+
562
+
563
+ def grep_pr_title_skip(workflows_dir: Path, pattern: str) -> list[str]:
564
+ """Return relative paths of workflow files that gate on a forbidden PR title pattern.
565
+
566
+ Strict match only: a workflow expression that calls
567
+ ``contains(github.event.pull_request.title, '<pattern>')`` (single
568
+ or double quotes, case-insensitive). Comments are stripped before
569
+ matching so a documentation reference to ``[skip ci]`` does not
570
+ trip the audit. A previous broad-fallback heuristic was removed —
571
+ it false-positived on incidental prose that happened to mention
572
+ both ``contains`` and ``pull_request.title`` without forming an
573
+ ambient-bypass gate.
574
+ """
575
+ hits: list[str] = []
576
+ escaped = re.escape(pattern)
577
+ expr = re.compile(
578
+ rf"contains\([^)]*pull_request\.title[^)]*[\"']{escaped}[\"']",
579
+ re.IGNORECASE,
580
+ )
581
+ if not workflows_dir.exists():
582
+ return hits
583
+ # GitHub Actions accepts both `.yml` and `.yaml` per
584
+ # https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
585
+ # — a forbidden title-skip gate hiding inside `foo.yaml` would
586
+ # otherwise slip past the audit. Glob both extensions; sort the
587
+ # combined list so the report is deterministic.
588
+ workflow_paths = sorted(
589
+ list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
590
+ )
591
+ for path in workflow_paths:
592
+ try:
593
+ text = path.read_text(encoding="utf-8")
594
+ except OSError:
595
+ continue
596
+ stripped_lines = [
597
+ (line.split("#", 1)[0] if "#" in line else line) for line in text.split("\n")
598
+ ]
599
+ joined = "\n".join(stripped_lines)
600
+ if expr.search(joined):
601
+ hits.append(str(path.relative_to(REPO_ROOT)))
602
+ return hits
603
+
604
+
605
+ def run_audit(spec_path: Path, repo: str, ruleset_id: int | None = None) -> AuditReport:
606
+ spec = load_spec(spec_path)
607
+ rsid = ruleset_id or spec.get("ruleset", {}).get("id")
608
+ if not rsid:
609
+ raise ValueError(
610
+ f"{spec_path} does not declare ruleset.id; cannot fetch live state."
611
+ )
612
+ live = fetch_live_ruleset(repo, int(rsid))
613
+ # Only call the variables API if the spec has at least one
614
+ # ``repo_variable`` forbidden-bypass entry. When no entry needs it,
615
+ # skipping the API call avoids exercising a permission scope the
616
+ # token may legitimately not have. When an entry exists, fail-closed
617
+ # on transport failure so a regression in the audit's variable
618
+ # visibility is surfaced rather than masked.
619
+ needs_repo_vars = any(
620
+ isinstance(e, dict) and e.get("kind") == "repo_variable"
621
+ for e in spec.get("forbidden_bypasses", []) or []
622
+ )
623
+ report = AuditReport()
624
+ if needs_repo_vars:
625
+ try:
626
+ repo_vars = fetch_repo_variables(repo)
627
+ except RepoVariableFetchError as exc:
628
+ try:
629
+ repo_vars = repo_variables_from_actions_context()
630
+ except RepoVariableFetchError as fallback_exc:
631
+ report.add(
632
+ field="forbidden_bypasses[repo_variable].fetch_failure",
633
+ intended="readable repo-variables API or vars-context fallback",
634
+ live=f"{exc}; fallback failed: {fallback_exc}",
635
+ detail=(
636
+ "Cannot enumerate repo Actions variables to check forbidden-bypass "
637
+ "rules. Either grant the audit token repository Variables:read "
638
+ "access, pass REPO_ACTIONS_VARIABLES_JSON from `${{ toJSON(vars) }}`, "
639
+ "or remove the repo_variable entries from spec.yaml's "
640
+ "forbidden_bypasses. Fail-closed so a missing variable source cannot "
641
+ "silently hide a re-introduced kill switch like SKIP_PR_CI."
642
+ ),
643
+ )
644
+ repo_vars = []
645
+ if repo_vars is None:
646
+ report.add(
647
+ field="forbidden_bypasses[repo_variable].fetch_failure",
648
+ intended="readable repo-variables API or vars-context fallback",
649
+ live=str(exc),
650
+ detail=(
651
+ "Cannot enumerate repo Actions variables to check forbidden-bypass "
652
+ "rules. Either grant the audit token repository Variables:read "
653
+ "access, pass REPO_ACTIONS_VARIABLES_JSON from `${{ toJSON(vars) }}`, "
654
+ "or remove the repo_variable entries from spec.yaml's "
655
+ "forbidden_bypasses. Fail-closed so a missing variable source cannot "
656
+ "silently hide a re-introduced kill switch like SKIP_PR_CI."
657
+ ),
658
+ )
659
+ # Fall through to the standard audit path with an empty
660
+ # repo_vars list — the repo_variable rules will not surface
661
+ # additional divergences (the fetch_failure entry above is
662
+ # the canonical signal), but disabled_workflow and
663
+ # pr_title_skip checks STILL run. Without this, a combined
664
+ # failure (variables API down AND a disabled-workflow file
665
+ # reappears) would only surface the API failure and hide
666
+ # the second regression.
667
+ repo_vars = []
668
+ else:
669
+ repo_vars = []
670
+ compare_enforcement(spec, live, report)
671
+ compare_status_check_contexts(spec, live, report)
672
+ compare_pull_request_rule(spec, live, report)
673
+ compare_merge_queue(spec, live, report)
674
+ compare_linear_history(spec, live, report)
675
+ compare_block_flags(spec, live, report)
676
+ check_forbidden_bypasses(spec, repo_vars, WORKFLOWS_DIR, report)
677
+ return report
678
+
679
+
680
+ def audit_in_memory(
681
+ spec: dict[str, Any],
682
+ live: dict[str, Any],
683
+ repo_vars: list[str] | None = None,
684
+ workflows_dir: Path | None = None,
685
+ ) -> AuditReport:
686
+ """Pure-function entry point used by unit tests.
687
+
688
+ Skips the live-fetch step; the caller injects spec / live / repo_vars
689
+ directly. Makes the audit logic testable without hitting GitHub.
690
+ """
691
+ report = AuditReport()
692
+ compare_enforcement(spec, live, report)
693
+ compare_status_check_contexts(spec, live, report)
694
+ compare_pull_request_rule(spec, live, report)
695
+ compare_merge_queue(spec, live, report)
696
+ compare_linear_history(spec, live, report)
697
+ compare_block_flags(spec, live, report)
698
+ check_forbidden_bypasses(
699
+ spec,
700
+ repo_vars or [],
701
+ workflows_dir or WORKFLOWS_DIR,
702
+ report,
703
+ )
704
+ return report
705
+
706
+
707
+ def main(argv: list[str] | None = None) -> int:
708
+ parser = argparse.ArgumentParser(description=__doc__)
709
+ parser.add_argument(
710
+ "spec_path",
711
+ nargs="?",
712
+ default=str(REPO_ROOT / "tools" / "branch-protection" / "spec.yaml"),
713
+ help="Path to spec.yaml (defaults to tools/branch-protection/spec.yaml).",
714
+ )
715
+ parser.add_argument(
716
+ "--repo",
717
+ default=os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or "momentiq-ai/sage3c",
718
+ )
719
+ parser.add_argument(
720
+ "--use-bundled-default-spec",
721
+ action="store_true",
722
+ help=(
723
+ "Fall back to the bundled spec-default.yaml when the consumer "
724
+ "repo has no tools/branch-protection/spec.yaml. Useful for "
725
+ "first-run audits where the consumer hasn't yet authored their "
726
+ "own desired-state spec."
727
+ ),
728
+ )
729
+ args = parser.parse_args(argv)
730
+
731
+ spec_path = Path(args.spec_path)
732
+ if args.use_bundled_default_spec and not spec_path.exists():
733
+ bundled = Path(__file__).resolve().parent / "spec-default.yaml"
734
+ if bundled.exists():
735
+ spec_path = bundled
736
+ print(
737
+ f"[branch-protection-audit] using bundled default spec: {bundled}",
738
+ file=sys.stderr,
739
+ )
740
+ args.spec_path = str(spec_path)
741
+
742
+ try:
743
+ report = run_audit(Path(args.spec_path), args.repo)
744
+ except FileNotFoundError as exc:
745
+ print(f"[branch-protection-audit] FAIL: {exc}", file=sys.stderr)
746
+ return 2
747
+ except ValueError as exc:
748
+ print(f"[branch-protection-audit] FAIL: {exc}", file=sys.stderr)
749
+ return 2
750
+ except RuntimeError as exc:
751
+ print(f"[branch-protection-audit] FAIL: {exc}", file=sys.stderr)
752
+ return 1
753
+
754
+ print(report.render())
755
+ return 0 if report.ok else 1
756
+
757
+
758
+ if __name__ == "__main__":
759
+ sys.exit(main())