@deftai/directive-content 0.55.2 → 0.56.1

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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,432 @@
1
+ """Milestone-rule helpers for ``scripts/triage_scope.py`` (D14b / #1181).
2
+
3
+ D14 (#1133) shipped the milestone rule with the v1 exact-match shape
4
+ ``{rule: "milestone", name: "<exact-name>"}``. D14b (#1181) extends the
5
+ grammar with two additional, mutually-exclusive variants:
6
+
7
+ * ``{rule: "milestone", any-of: ["<n1>", "<n2>", ...]}`` -- issue matches
8
+ if its milestone title is in the list.
9
+ * ``{rule: "milestone", is-open: true}`` -- issue matches if its
10
+ milestone is currently open upstream.
11
+
12
+ Validation: exactly one of ``name`` / ``any-of`` / ``is-open`` MUST be
13
+ present per rule. ``is-open`` MUST be the literal ``true`` (``false`` is
14
+ meaningless; consumers wanting specific milestones use ``name`` or
15
+ ``any-of``).
16
+
17
+ Evaluation: the ``is-open`` variant queries
18
+ ``gh api repos/<o>/<r>/milestones?state=open`` exactly ONCE per
19
+ ``evaluate_rules`` call (memoized snapshot, never per-issue).
20
+
21
+ Kept out of ``scripts/triage_scope.py`` to stay under the 1000-line MUST
22
+ cap from ``coding/coding.md``. Re-exported for back-compat where useful.
23
+
24
+ Refs #1181, #1119, #1131 (D12), #1133 (D14).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import subprocess
32
+ import sys
33
+ from collections.abc import Callable, Iterable
34
+ from pathlib import Path
35
+ from typing import Any
36
+ from urllib.parse import urlparse
37
+
38
+ #: Strict allow-list of GitHub hostnames accepted by
39
+ #: :func:`infer_repo_from_issues`. Substring / `in` matching is a CodeQL
40
+ #: ``py/incomplete-url-substring-sanitization`` finding: an attacker-controlled
41
+ #: ``html_url`` value of the form ``https://evil-github.com.attacker.com/...``
42
+ #: would satisfy a naive ``"github.com" in url`` check. Enforce strict host
43
+ #: equality via :func:`urllib.parse.urlparse` instead.
44
+ _GITHUB_HOSTNAMES: frozenset[str] = frozenset({"github.com", "api.github.com"})
45
+
46
+ #: The set of recognised keys on a ``milestone`` rule body. ``rule`` is
47
+ #: the discriminator itself; the rest are the three variant keys.
48
+ _MILESTONE_VARIANT_KEYS: tuple[str, ...] = ("name", "any-of", "is-open")
49
+ _MILESTONE_ALL_KEYS: frozenset[str] = frozenset(("rule", *_MILESTONE_VARIANT_KEYS))
50
+
51
+
52
+ def validate_milestone_rule(
53
+ rule: dict[str, Any],
54
+ prefix: str,
55
+ errors: list[str],
56
+ warnings: list[str],
57
+ ) -> None:
58
+ """Validate a single ``milestone`` rule body in place.
59
+
60
+ Mutates ``errors`` / ``warnings`` to mirror the existing
61
+ ``_validate_rule_body`` contract in ``triage_scope.py`` so the parent
62
+ module can delegate without round-tripping return values.
63
+
64
+ Acceptance:
65
+
66
+ * Exactly one of ``name`` / ``any-of`` / ``is-open`` MUST be present.
67
+ * ``name`` (when set) MUST be a non-empty string.
68
+ * ``any-of`` (when set) MUST be a non-empty list of non-empty strings.
69
+ * ``is-open`` (when set) MUST be the literal boolean ``True``. The
70
+ literal ``False`` is rejected with a hint pointing at the other
71
+ two variants (``False`` is the do-nothing case the operator
72
+ almost certainly does NOT want, so a silent accept would be a
73
+ footgun).
74
+
75
+ Unknown sibling keys produce a warning (not an error) so a
76
+ forward-compat consumer who hand-edits a future shape gets a clear
77
+ hint rather than silent drift.
78
+ """
79
+ has_name = "name" in rule
80
+ has_any = "any-of" in rule
81
+ has_open = "is-open" in rule
82
+ set_count = sum([has_name, has_any, has_open])
83
+
84
+ if set_count == 0:
85
+ errors.append(
86
+ f"{prefix}.milestone requires one of 'name' / 'any-of' / "
87
+ "'is-open: true' (D14b / #1181); see "
88
+ "scripts/triage_scope.py for the variant matrix"
89
+ )
90
+ return
91
+
92
+ if set_count > 1:
93
+ present = [k for k in _MILESTONE_VARIANT_KEYS if k in rule]
94
+ errors.append(
95
+ f"{prefix}.milestone: {present} are mutually exclusive; "
96
+ "choose exactly one of name / any-of / is-open (#1181)"
97
+ )
98
+ return
99
+
100
+ if has_name:
101
+ name = rule.get("name")
102
+ if not isinstance(name, str) or not name.strip():
103
+ errors.append(
104
+ f"{prefix}.milestone.name must be a non-empty string"
105
+ )
106
+ return
107
+ elif has_any:
108
+ any_of = rule.get("any-of")
109
+ if not isinstance(any_of, list) or not any_of:
110
+ errors.append(
111
+ f"{prefix}.milestone.any-of must be a non-empty list of strings (#1181)"
112
+ )
113
+ return
114
+ for j, item in enumerate(any_of):
115
+ if not isinstance(item, str) or not item:
116
+ errors.append(
117
+ f"{prefix}.milestone.any-of[{j}] must be a non-empty string"
118
+ )
119
+ else: # has_open
120
+ is_open = rule.get("is-open")
121
+ if not isinstance(is_open, bool):
122
+ errors.append(
123
+ f"{prefix}.milestone.is-open must be a boolean literal `true`; "
124
+ f"got {type(is_open).__name__} (#1181)"
125
+ )
126
+ return
127
+ if is_open is False:
128
+ errors.append(
129
+ f"{prefix}.milestone.is-open: false is meaningless -- "
130
+ "to subscribe to specific milestones use `name` or "
131
+ "`any-of` (#1181)"
132
+ )
133
+ return
134
+
135
+ extra = sorted(k for k in rule if k not in _MILESTONE_ALL_KEYS)
136
+ if extra:
137
+ warnings.append(
138
+ f"{prefix}.milestone: ignoring unrecognised keys {extra}"
139
+ )
140
+
141
+
142
+ def collect_milestone_subscribed_names(
143
+ rules: Iterable[dict[str, Any]],
144
+ ) -> set[str]:
145
+ """Return the set of milestone names covered by ``name`` / ``any-of``.
146
+
147
+ Used by the drift detector to suppress entries the operator already
148
+ knows about. The ``is-open: true`` variant is NOT consulted here --
149
+ that variant resolves against the live upstream snapshot, which the
150
+ caller adds separately when any rule requests ``is-open: true``.
151
+ """
152
+ out: set[str] = set()
153
+ for rule in rules:
154
+ if not isinstance(rule, dict) or rule.get("rule") != "milestone":
155
+ continue
156
+ name = rule.get("name")
157
+ if isinstance(name, str) and name:
158
+ out.add(name)
159
+ any_of = rule.get("any-of")
160
+ if isinstance(any_of, list):
161
+ for item in any_of:
162
+ if isinstance(item, str) and item:
163
+ out.add(item)
164
+ return out
165
+
166
+
167
+ def rules_request_is_open(rules: Iterable[dict[str, Any]]) -> bool:
168
+ """True iff any milestone rule asks for ``is-open: true``."""
169
+ return any(
170
+ isinstance(r, dict)
171
+ and r.get("rule") == "milestone"
172
+ and r.get("is-open") is True
173
+ for r in rules
174
+ )
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Open-milestones snapshot fetcher
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ #: Env-var override for the default fetcher's subprocess timeout. Bounded
183
+ #: so a hung ``gh`` invocation can't wedge a long evaluator call.
184
+ ENV_FETCH_TIMEOUT_S = "DEFT_MILESTONE_FETCH_TIMEOUT_S"
185
+ DEFAULT_FETCH_TIMEOUT_S: int = 30
186
+
187
+
188
+ def infer_repo_from_issues(issues: Iterable[dict[str, Any]]) -> str | None:
189
+ """Best-effort ``owner/name`` inference from the issue list.
190
+
191
+ Reads ``repository_url`` (canonical REST field, shape
192
+ ``https://api.github.com/repos/<owner>/<name>``) and falls back to
193
+ ``html_url``. Returns the first plausible match so a heterogeneous
194
+ issue list (cross-repo cohort) still resolves to a deterministic
195
+ repo for the upstream milestones call.
196
+
197
+ Strictly validates the URL's hostname via :func:`urllib.parse.urlparse`
198
+ against :data:`_GITHUB_HOSTNAMES` before extracting any path segment.
199
+ Substring / ``in`` matching on the URL string would be a CodeQL
200
+ ``py/incomplete-url-substring-sanitization`` finding -- an attacker
201
+ controlling an issue payload could craft ``https://evil-github.com.attacker.com/owner/name/...``
202
+ that satisfies a naive ``"github.com" in url`` check.
203
+ """
204
+ for issue in issues:
205
+ if not isinstance(issue, dict):
206
+ continue
207
+ for key in ("repository_url", "html_url"):
208
+ value = issue.get(key)
209
+ if not isinstance(value, str) or not value:
210
+ continue
211
+ try:
212
+ parsed = urlparse(value)
213
+ except (ValueError, TypeError):
214
+ continue
215
+ host = (parsed.hostname or "").lower()
216
+ if host not in _GITHUB_HOSTNAMES:
217
+ continue
218
+ segments = [s for s in parsed.path.split("/") if s]
219
+ # api.github.com canonical repository_url:
220
+ # path = "/repos/<owner>/<name>" -> segments[0]=="repos"
221
+ # github.com html_url:
222
+ # path = "/<owner>/<name>" or "/<owner>/<name>/issues/<n>"
223
+ if segments and segments[0] == "repos" and len(segments) >= 3:
224
+ owner, name = segments[1], segments[2]
225
+ elif len(segments) >= 2:
226
+ owner, name = segments[0], segments[1]
227
+ else:
228
+ continue
229
+ if owner and name:
230
+ return f"{owner}/{name}"
231
+ return None
232
+
233
+
234
+ def default_open_milestones_fetcher(repo: str | None) -> set[str]:
235
+ """Invoke ``gh api`` to list currently-open milestones for ``repo``.
236
+
237
+ Returns the set of milestone titles. On any failure (missing repo,
238
+ non-zero exit, unparseable JSON, hung subprocess) returns an empty
239
+ set rather than raising -- callers consuming the result via
240
+ :func:`evaluate_rules` already tolerate empty snapshots (no
241
+ matches for that rule).
242
+
243
+ Production callers SHOULD pass an explicit ``open_milestones_fetcher``
244
+ closure that wraps a higher-level cache (``ghx`` / per-process
245
+ memoization). This default is the bottom of the ladder so an
246
+ out-of-the-box call still works.
247
+ """
248
+ if not isinstance(repo, str) or "/" not in repo:
249
+ return set()
250
+ timeout = DEFAULT_FETCH_TIMEOUT_S
251
+ raw = os.environ.get(ENV_FETCH_TIMEOUT_S, "").strip()
252
+ if raw:
253
+ try:
254
+ timeout = max(1, int(raw))
255
+ except ValueError:
256
+ timeout = DEFAULT_FETCH_TIMEOUT_S
257
+
258
+ binary = _resolve_gh_binary()
259
+ if binary is None:
260
+ return set()
261
+ cmd = [
262
+ binary,
263
+ "api",
264
+ f"repos/{repo}/milestones?state=open&per_page=100",
265
+ "--paginate",
266
+ ]
267
+ try:
268
+ result = subprocess.run( # noqa: S603 -- argv list, no shell
269
+ cmd,
270
+ capture_output=True,
271
+ text=True,
272
+ encoding="utf-8",
273
+ timeout=timeout,
274
+ check=False,
275
+ )
276
+ except (OSError, subprocess.TimeoutExpired):
277
+ return set()
278
+ if result.returncode != 0:
279
+ return set()
280
+ return _parse_milestone_titles(result.stdout)
281
+
282
+
283
+ def _resolve_gh_binary() -> str | None:
284
+ """Return the gh binary path via ``scripts.scm.resolve_binary``.
285
+
286
+ Falls back to the literal ``"gh"`` (PATH lookup) if scm is not
287
+ importable, e.g. during a fresh-checkout test that pulls only this
288
+ module in isolation.
289
+ """
290
+ try:
291
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
292
+ import scm # type: ignore[import-not-found]
293
+
294
+ return scm.resolve_binary()
295
+ except (ImportError, AttributeError):
296
+ return "gh"
297
+
298
+
299
+ def _parse_milestone_titles(stdout: str) -> set[str]:
300
+ """Parse ``gh api ... milestones`` JSON output.
301
+
302
+ ``gh api --paginate`` concatenates JSON arrays per page; we accept
303
+ either a single top-level array OR a series of concatenated arrays
304
+ (paginate fallback). Bad / truncated output yields an empty set.
305
+ """
306
+ text = (stdout or "").strip()
307
+ if not text:
308
+ return set()
309
+ titles: set[str] = set()
310
+ try:
311
+ data = json.loads(text)
312
+ return _extract_titles(data)
313
+ except json.JSONDecodeError:
314
+ # Paginate may concatenate arrays directly; split + retry.
315
+ pass
316
+ decoder = json.JSONDecoder()
317
+ idx = 0
318
+ while idx < len(text):
319
+ while idx < len(text) and text[idx].isspace():
320
+ idx += 1
321
+ if idx >= len(text):
322
+ break
323
+ try:
324
+ data, end = decoder.raw_decode(text, idx)
325
+ except json.JSONDecodeError:
326
+ break
327
+ titles |= _extract_titles(data)
328
+ idx = end
329
+ return titles
330
+
331
+
332
+ def _extract_titles(data: Any) -> set[str]:
333
+ out: set[str] = set()
334
+ if isinstance(data, list):
335
+ for entry in data:
336
+ if not isinstance(entry, dict):
337
+ continue
338
+ title = entry.get("title")
339
+ if isinstance(title, str) and title:
340
+ out.add(title)
341
+ return out
342
+
343
+ def make_open_milestones_resolver(
344
+ open_milestones_fetcher: Callable[[], Any] | None,
345
+ issues: Iterable[dict[str, Any]],
346
+ repo: str | None,
347
+ ) -> Callable[[], set[str]]:
348
+ """Return a once-per-call memoized open-milestones resolver.
349
+
350
+ ``triage_scope.evaluate_rules`` uses this to ensure the D14b
351
+ ``milestone {is-open: true}`` variant fetches the upstream
352
+ open-milestones snapshot AT MOST ONCE per evaluator call, even when
353
+ multiple ``is-open`` rules are present.
354
+ """
355
+ materialised = list(issues)
356
+ cache: dict[str, set[str] | None] = {"value": None}
357
+
358
+ def resolve() -> set[str]:
359
+ cached = cache["value"]
360
+ if cached is not None:
361
+ return cached
362
+ if open_milestones_fetcher is not None:
363
+ try:
364
+ raw = open_milestones_fetcher()
365
+ except Exception: # noqa: BLE001 -- defensive; empty snapshot = no matches
366
+ raw = set()
367
+ snapshot = (
368
+ set(raw)
369
+ if isinstance(raw, (set, frozenset, list, tuple))
370
+ else set()
371
+ )
372
+ else:
373
+ resolved_repo = repo or infer_repo_from_issues(materialised)
374
+ snapshot = default_open_milestones_fetcher(resolved_repo)
375
+ cache["value"] = snapshot
376
+ return snapshot
377
+
378
+ return resolve
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Evaluator delegate
383
+ # ---------------------------------------------------------------------------
384
+
385
+
386
+ def evaluate_milestone_rule_into(
387
+ rule: dict[str, Any],
388
+ issues: list[dict[str, Any]],
389
+ matched: dict[int, dict[str, Any]],
390
+ *,
391
+ get_open_milestones: Callable[[], set[str]],
392
+ is_open_issue: Callable[[dict[str, Any]], bool],
393
+ issue_number: Callable[[dict[str, Any]], int],
394
+ milestone_name: Callable[[dict[str, Any]], str],
395
+ ) -> None:
396
+ """Apply a single ``milestone`` rule to ``issues`` and merge into ``matched``.
397
+
398
+ Delegated from ``triage_scope.evaluate_rules`` so the parent module
399
+ stays under the 1000-line MUST cap. The four predicates
400
+ (``is_open_issue`` / ``issue_number`` / ``milestone_name``) are
401
+ passed in so this helper doesn't need to import them back from
402
+ ``triage_scope`` (avoids a circular import).
403
+ """
404
+ if "name" in rule:
405
+ wanted = rule.get("name")
406
+ if not isinstance(wanted, str) or not wanted:
407
+ return
408
+ for issue in issues:
409
+ if is_open_issue(issue) and milestone_name(issue) == wanted:
410
+ matched.setdefault(issue_number(issue), issue)
411
+ return
412
+
413
+ if "any-of" in rule:
414
+ raw = rule.get("any-of")
415
+ if not isinstance(raw, list) or not raw:
416
+ return
417
+ wanted_set = {w for w in raw if isinstance(w, str) and w}
418
+ if not wanted_set:
419
+ return
420
+ for issue in issues:
421
+ if is_open_issue(issue) and milestone_name(issue) in wanted_set:
422
+ matched.setdefault(issue_number(issue), issue)
423
+ return
424
+
425
+ if rule.get("is-open") is True:
426
+ open_set = get_open_milestones()
427
+ if not open_set:
428
+ return
429
+ for issue in issues:
430
+ if is_open_issue(issue) and milestone_name(issue) in open_set:
431
+ matched.setdefault(issue_number(issue), issue)
432
+ return