@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,999 @@
1
+ #!/usr/bin/env python3
2
+ """triage_scope.py -- typed cache-scope contract for the deft framework (#1131).
3
+
4
+ D12 introduces ``plan.policy.triageScope[]`` on
5
+ ``vbrief/PROJECT-DEFINITION.vbrief.json`` -- a list of typed subscription
6
+ rules that determines which upstream issues a consumer cares about. The
7
+ framework default (per umbrella #1119 section 12 framework-vs-consumer
8
+ boundary) is ``[{"rule": "all-open"}]`` -- subscribe to every currently
9
+ open upstream issue. Consumers tighten the scope by adding rules that
10
+ restrict by label / age / explicit-watch / vbrief reference / umbrella
11
+ slicing; consumer-specific rule values live OUTSIDE the framework.
12
+
13
+ Programmatic API:
14
+
15
+ * :func:`resolve_scope_rules` -- read PROJECT-DEFINITION and return the
16
+ effective rule list (default: ``[{"rule":"all-open"}]``).
17
+ * :func:`validate_scope_rules` -- structural validation. The
18
+ ``milestone`` rule type ACCEPTS three mutually-exclusive variants:
19
+ ``{name: "<exact-name>"}`` (D14 / #1133 v1), ``{any-of: [<n1>, <n2>]}``
20
+ and ``{is-open: true}`` (D14b / #1181).
21
+ * :func:`subscription_hash` -- stable canonical-JSON SHA-256 digest
22
+ (truncated to 16 chars) used as the coverage-cache invalidation key.
23
+ * :func:`evaluate_rules` -- apply the rule set to an issue list; the
24
+ union of matches is returned.
25
+ * :func:`read_coverage_denominator` / :func:`write_coverage_denominator`
26
+ -- coverage cache lifecycle helpers (Decision 3). Reads NEVER trigger
27
+ a recompute; stale records surface as ``stale=True`` so callers can
28
+ render ``coverage 247/?`` (literal ``?``).
29
+ * :func:`validate_scope_ignores` /
30
+ :func:`resolve_scope_ignores` -- D14 (#1133) typed
31
+ ``plan.policy.triageScopeIgnores[]`` foundation: list of
32
+ ``{label: <L>}`` / ``{milestone: <M>}`` records the drift detector
33
+ consults to suppress entries the operator explicitly chose not to
34
+ subscribe to. Long-tail tuning verbs (mass-edit, sunset-on,
35
+ match-many) are D14c / #1182 scope.
36
+
37
+ See ``scripts/_triage_scope_cli.py`` for the argparse shim. See the
38
+ Current Shape comment 4471901494 on issue #1131 for the canonical
39
+ decision record.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import contextlib
45
+ import hashlib
46
+ import json
47
+ import os
48
+ import re
49
+ import sys
50
+ from collections.abc import Iterable
51
+ from dataclasses import dataclass
52
+ from datetime import UTC, datetime, timedelta
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ # Make sibling scripts importable when invoked as ``python scripts/triage_scope.py``.
57
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
58
+
59
+ # UTF-8 self-reconfigure -- the recap printed by ``--list`` includes the
60
+ # ✓ / · / ⚠ glyphs that cp1252 cannot encode.
61
+ for _stream in (sys.stdout, sys.stderr):
62
+ if hasattr(_stream, "reconfigure"):
63
+ with contextlib.suppress(AttributeError, ValueError):
64
+ _stream.reconfigure(encoding="utf-8", errors="replace")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Public constants
69
+ # ---------------------------------------------------------------------------
70
+
71
+ #: Filesystem-relative location of the PROJECT-DEFINITION vBRIEF.
72
+ PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
73
+
74
+ #: Canonical cache directory name. Matches ``scripts/triage_bootstrap.py``.
75
+ CACHE_DIR_NAME = ".deft-cache"
76
+
77
+ #: Coverage-denominator filename written under
78
+ #: ``.deft-cache/<source>/<owner>/<repo>/``.
79
+ COVERAGE_FILENAME = "coverage.json"
80
+
81
+ #: TTL env var name. Hours; default 24.
82
+ ENV_COVERAGE_TTL_HOURS = "DEFT_COVERAGE_MAX_AGE_HOURS"
83
+
84
+ #: Default coverage TTL when the env var is unset / unparseable.
85
+ DEFAULT_COVERAGE_TTL_HOURS: int = 24
86
+
87
+ #: Framework default per umbrella section 12. When ``plan.policy.triageScope``
88
+ #: is unset / missing, this list applies. Consumers MUST NOT special-case
89
+ #: their labels or milestones here -- consumer code lives outside the
90
+ #: framework (see deft consumer-example child of #1119).
91
+ DEFAULT_TRIAGE_SCOPE: list[dict[str, Any]] = [{"rule": "all-open"}]
92
+
93
+ #: Truncated hex length for :func:`subscription_hash`. 16 hex chars = 64 bits
94
+ #: of entropy, plenty for a cache-key in a small-cardinality space (one per
95
+ #: consumer cache).
96
+ SUBSCRIPTION_HASH_LEN: int = 16
97
+
98
+ #: Recognised rule discriminator values. ``milestone`` shipped in D14
99
+ #: (#1133) with the v1 ``{name: "<exact-name>"}`` shape; D14b (#1181)
100
+ #: adds the ``any-of`` + ``is-open: true`` variants on the same
101
+ #: discriminator.
102
+ VALID_RULE_TYPES: frozenset[str] = frozenset(
103
+ {
104
+ "all-open",
105
+ "labels",
106
+ "milestone",
107
+ "opened-since",
108
+ "updated-since",
109
+ "referenced-by-vbrief",
110
+ "sliced-from",
111
+ "explicit-watch",
112
+ }
113
+ )
114
+
115
+ #: Rule types reserved for downstream stories. Validation rejects them
116
+ #: with a pointer to the owning issue so consumers get a clear error
117
+ #: rather than silent ignore. D14 (#1133) shipped the v1 exact-match
118
+ #: ``milestone`` shape; D14b (#1181) added the ``any-of`` +
119
+ #: ``is-open: true`` variants -- future variants will surface as
120
+ #: per-field validation errors rather than discriminator-level
121
+ #: rejections.
122
+ DEFERRED_RULE_TYPES: dict[str, str] = {}
123
+
124
+ #: Recognised ignore-entry discriminator values (D14 / #1133).
125
+ #: Re-exported from :mod:`_triage_scope_ignores` so existing call
126
+ #: sites that ``triage_scope.VALID_IGNORE_KEYS`` keep working.
127
+ from _triage_scope_ignores import VALID_IGNORE_KEYS # noqa: E402,F401,I001
128
+
129
+ #: Valid scope values for ``referenced-by-vbrief``.
130
+ _REFERENCED_BY_VBRIEF_SCOPES: frozenset[str] = frozenset({"any", "active"})
131
+
132
+ #: Valid scope values for ``sliced-from``.
133
+ _SLICED_FROM_SCOPES: frozenset[str] = frozenset({"any-umbrella-in-cache"})
134
+
135
+ #: Duration regex -- accepts ``7d`` / ``24h`` / ``30m`` / ``45s`` and the
136
+ #: ISO-8601 ``PnDTnHnMnS`` forms (e.g. ``P7D``, ``PT24H``). Case-insensitive.
137
+ _DURATION_RE_SIMPLE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$", re.IGNORECASE)
138
+ _DURATION_RE_ISO = re.compile(
139
+ r"^P"
140
+ r"(?:(?P<days>\d+)D)?"
141
+ r"(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?"
142
+ r"$",
143
+ re.IGNORECASE,
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Dataclasses
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class CoverageRecord:
154
+ """Coverage denominator cache record.
155
+
156
+ Mirrors the JSON written to ``.deft-cache/<source>/<owner>/<repo>/coverage.json``.
157
+
158
+ ``stale`` is True when the cache is older than the TTL OR when the
159
+ stored ``subscription_hash`` no longer matches the current rule set.
160
+ Stale records MUST NOT be treated as authoritative for ``triage:summary``
161
+ output -- callers render ``coverage 247/?`` instead (Decision 3).
162
+ """
163
+
164
+ count: int
165
+ fetched_at: str # ISO-8601 UTC with trailing 'Z'
166
+ subscription_hash: str
167
+ stale: bool = False
168
+ age_hours: float | None = None
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Time helpers (shared with cache.py-style stamps)
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def _utc_now() -> datetime:
177
+ return datetime.now(UTC)
178
+
179
+
180
+ def _utc_iso(dt: datetime | None = None) -> str:
181
+ return (dt or _utc_now()).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
182
+
183
+
184
+ def _parse_iso(stamp: str) -> datetime:
185
+ text = stamp.strip()
186
+ if text.endswith("Z"):
187
+ text = text[:-1] + "+00:00"
188
+ return datetime.fromisoformat(text)
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Duration parser
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def parse_duration(raw: str) -> timedelta:
197
+ """Parse a duration string into a :class:`timedelta`.
198
+
199
+ Accepts:
200
+
201
+ * Compact form: ``7d`` / ``24h`` / ``30m`` / ``45s`` / ``2w`` -- case
202
+ insensitive.
203
+ * ISO-8601 form: ``P7D`` / ``PT24H`` / ``PT30M`` / ``P1DT12H``.
204
+
205
+ Raises :class:`ValueError` on malformed input. The returned delta is
206
+ always positive; zero-length durations (``0d``, ``P0D``) are accepted
207
+ and return ``timedelta(0)``.
208
+ """
209
+ if not isinstance(raw, str):
210
+ raise ValueError(f"duration must be a string, got {type(raw).__name__}")
211
+ text = raw.strip()
212
+ if not text:
213
+ raise ValueError("duration must be a non-empty string")
214
+
215
+ m = _DURATION_RE_SIMPLE.match(text)
216
+ if m:
217
+ n = int(m.group(1))
218
+ unit = m.group(2).lower()
219
+ return _scale_duration(n, unit)
220
+
221
+ m = _DURATION_RE_ISO.match(text)
222
+ if m and any(m.group(g) for g in ("days", "hours", "minutes", "seconds")):
223
+ days = int(m.group("days") or 0)
224
+ hours = int(m.group("hours") or 0)
225
+ minutes = int(m.group("minutes") or 0)
226
+ seconds = int(m.group("seconds") or 0)
227
+ return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
228
+
229
+ raise ValueError(
230
+ f"invalid duration {raw!r}: expected '<N>(s|m|h|d|w)' "
231
+ "(e.g. '7d', '24h') or ISO-8601 'PnDTnHnMnS' (e.g. 'P7D', 'PT24H')"
232
+ )
233
+
234
+
235
+ def _scale_duration(n: int, unit: str) -> timedelta:
236
+ if unit == "s":
237
+ return timedelta(seconds=n)
238
+ if unit == "m":
239
+ return timedelta(minutes=n)
240
+ if unit == "h":
241
+ return timedelta(hours=n)
242
+ if unit == "d":
243
+ return timedelta(days=n)
244
+ if unit == "w":
245
+ return timedelta(weeks=n)
246
+ raise ValueError(f"unknown duration unit {unit!r}")
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Schema validation
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def validate_scope_rules(rules: Any) -> tuple[list[str], list[str]]:
255
+ """Validate a ``plan.policy.triageScope`` payload.
256
+
257
+ Returns ``(errors, warnings)``. ``errors`` is empty on success.
258
+
259
+ Validation rules (per #1131 Current Shape Decision 2):
260
+
261
+ * The top-level value MUST be a list (omission is fine and is
262
+ handled by :func:`resolve_scope_rules` with the framework default).
263
+ * Each rule MUST be an object with a ``rule`` string discriminator.
264
+ * The discriminator MUST be a member of :data:`VALID_RULE_TYPES`.
265
+ * The ``milestone`` discriminator was deferred from D12 / #1131 to
266
+ D14 / #1133, which shipped the v1 exact-match shape
267
+ (``{name: "<exact-name>"}``). D14b (#1181) added the
268
+ mutually-exclusive ``any-of`` and ``is-open: true`` variants;
269
+ see :mod:`_triage_scope_milestone` for the variant matrix.
270
+ * Per-type field shape is checked (label list, duration string, etc.).
271
+ """
272
+ errors: list[str] = []
273
+ warnings: list[str] = []
274
+
275
+ if rules is None:
276
+ # Default behaviour (#1131 Decision 5): unset / missing scope is
277
+ # equivalent to [{"rule": "all-open"}]. Not an error.
278
+ return errors, warnings
279
+
280
+ if not isinstance(rules, list):
281
+ errors.append(
282
+ "plan.policy.triageScope must be a list of rule objects; "
283
+ f"got {type(rules).__name__}"
284
+ )
285
+ return errors, warnings
286
+
287
+ for i, rule in enumerate(rules):
288
+ prefix = f"plan.policy.triageScope[{i}]"
289
+ if not isinstance(rule, dict):
290
+ errors.append(f"{prefix} must be an object, got {type(rule).__name__}")
291
+ continue
292
+ kind = rule.get("rule")
293
+ if not isinstance(kind, str) or not kind:
294
+ errors.append(f"{prefix}.rule must be a non-empty string")
295
+ continue
296
+ if kind in DEFERRED_RULE_TYPES:
297
+ errors.append(f"{prefix}: {DEFERRED_RULE_TYPES[kind]}")
298
+ continue
299
+ if kind not in VALID_RULE_TYPES:
300
+ errors.append(
301
+ f"{prefix}.rule {kind!r} is not a valid rule type; "
302
+ f"expected one of {sorted(VALID_RULE_TYPES)}"
303
+ )
304
+ continue
305
+ _validate_rule_body(rule, prefix, errors, warnings)
306
+
307
+ return errors, warnings
308
+
309
+
310
+ def _validate_rule_body(
311
+ rule: dict[str, Any], prefix: str, errors: list[str], warnings: list[str]
312
+ ) -> None:
313
+ kind = rule["rule"]
314
+ if kind == "all-open":
315
+ # No parameters; warn if extra keys are present so consumers don't
316
+ # silently lose configuration on a typo.
317
+ extra = sorted(k for k in rule if k != "rule")
318
+ if extra:
319
+ warnings.append(
320
+ f"{prefix}: all-open takes no parameters; ignoring extra keys "
321
+ f"{extra}"
322
+ )
323
+ return
324
+
325
+ if kind == "labels":
326
+ any_of = rule.get("any-of")
327
+ all_of = rule.get("all-of")
328
+ if any_of is None and all_of is None:
329
+ errors.append(f"{prefix}.labels requires 'any-of' or 'all-of'")
330
+ return
331
+ if any_of is not None and all_of is not None:
332
+ errors.append(
333
+ f"{prefix}.labels: 'any-of' and 'all-of' are mutually exclusive"
334
+ )
335
+ return
336
+ target = any_of if any_of is not None else all_of
337
+ which = "any-of" if any_of is not None else "all-of"
338
+ if not isinstance(target, list) or not target:
339
+ errors.append(f"{prefix}.labels.{which} must be a non-empty list of strings")
340
+ return
341
+ for j, label in enumerate(target):
342
+ if not isinstance(label, str) or not label:
343
+ errors.append(f"{prefix}.labels.{which}[{j}] must be a non-empty string")
344
+ return
345
+
346
+ if kind == "milestone":
347
+ # D14b (#1181) variant matrix lives in _triage_scope_milestone.
348
+ from _triage_scope_milestone import validate_milestone_rule
349
+ validate_milestone_rule(rule, prefix, errors, warnings)
350
+ return
351
+
352
+ if kind in {"opened-since", "updated-since"}:
353
+ duration = rule.get("duration")
354
+ if not isinstance(duration, str) or not duration:
355
+ errors.append(f"{prefix}.{kind} requires a non-empty 'duration' string")
356
+ return
357
+ try:
358
+ parse_duration(duration)
359
+ except ValueError as exc:
360
+ errors.append(f"{prefix}.{kind}.duration: {exc}")
361
+ return
362
+
363
+ if kind == "referenced-by-vbrief":
364
+ scope = rule.get("scope")
365
+ if scope not in _REFERENCED_BY_VBRIEF_SCOPES:
366
+ errors.append(
367
+ f"{prefix}.referenced-by-vbrief.scope must be one of "
368
+ f"{sorted(_REFERENCED_BY_VBRIEF_SCOPES)}; got {scope!r}"
369
+ )
370
+ return
371
+
372
+ if kind == "sliced-from":
373
+ scope = rule.get("scope")
374
+ if scope not in _SLICED_FROM_SCOPES:
375
+ errors.append(
376
+ f"{prefix}.sliced-from.scope must be one of "
377
+ f"{sorted(_SLICED_FROM_SCOPES)}; got {scope!r}"
378
+ )
379
+ return
380
+
381
+ if kind == "explicit-watch":
382
+ issues = rule.get("issues")
383
+ if not isinstance(issues, list) or not issues:
384
+ errors.append(
385
+ f"{prefix}.explicit-watch.issues must be a non-empty list of "
386
+ "{n: <int>, note: <str>} objects"
387
+ )
388
+ return
389
+ for j, entry in enumerate(issues):
390
+ if not isinstance(entry, dict):
391
+ errors.append(
392
+ f"{prefix}.explicit-watch.issues[{j}] must be an object, "
393
+ f"got {type(entry).__name__}"
394
+ )
395
+ continue
396
+ n = entry.get("n")
397
+ note = entry.get("note")
398
+ if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
399
+ errors.append(
400
+ f"{prefix}.explicit-watch.issues[{j}].n must be a positive integer"
401
+ )
402
+ if not isinstance(note, str) or not note.strip():
403
+ errors.append(
404
+ f"{prefix}.explicit-watch.issues[{j}].note must be a non-empty string "
405
+ "(Decision 4: per-issue note required for future-operator legibility)"
406
+ )
407
+ return
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Rule normalisation + subscription hash
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ def normalize_scope_rules(rules: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
416
+ """Return a stable canonical-ordered copy of ``rules``.
417
+
418
+ Used as the input to :func:`subscription_hash` so two rule sets that
419
+ differ only in key ordering or list ordering hash to the same digest.
420
+
421
+ Normalisation is intentionally shallow:
422
+
423
+ * Each rule object's keys are sorted alphabetically.
424
+ * For ``labels.any-of`` / ``labels.all-of`` the value list is sorted
425
+ (label order is semantically irrelevant).
426
+ * For ``explicit-watch.issues`` the list is sorted by ``n`` (per-issue
427
+ order is also irrelevant).
428
+ * The top-level list of rules is sorted by a stable serialisation of
429
+ each normalised rule.
430
+ """
431
+ normalised: list[dict[str, Any]] = []
432
+ for rule in rules:
433
+ if not isinstance(rule, dict):
434
+ continue
435
+ n_rule: dict[str, Any] = {}
436
+ for key in sorted(rule):
437
+ value = rule[key]
438
+ if (
439
+ rule.get("rule") == "labels"
440
+ and key in {"any-of", "all-of"}
441
+ and isinstance(value, list)
442
+ ):
443
+ value = sorted(value)
444
+ elif (
445
+ rule.get("rule") == "explicit-watch"
446
+ and key == "issues"
447
+ and isinstance(value, list)
448
+ ):
449
+ value = sorted(
450
+ (
451
+ {k: v[k] for k in sorted(v)}
452
+ for v in value
453
+ if isinstance(v, dict)
454
+ ),
455
+ key=lambda v: v.get("n", 0),
456
+ )
457
+ n_rule[key] = value
458
+ normalised.append(n_rule)
459
+ return sorted(normalised, key=lambda r: json.dumps(r, sort_keys=True))
460
+
461
+
462
+ def subscription_hash(rules: Iterable[dict[str, Any]]) -> str:
463
+ """Return a stable canonical-JSON SHA-256 digest of ``rules``.
464
+
465
+ Truncated to :data:`SUBSCRIPTION_HASH_LEN` hex chars. The hash is
466
+ used as the coverage-denominator cache key so subscription changes
467
+ invalidate the cached count automatically.
468
+ """
469
+ canonical = json.dumps(
470
+ normalize_scope_rules(rules), sort_keys=True, separators=(",", ":")
471
+ )
472
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
473
+ return digest[:SUBSCRIPTION_HASH_LEN]
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # Resolve scope from PROJECT-DEFINITION
478
+ # ---------------------------------------------------------------------------
479
+
480
+
481
+ def project_definition_path(project_root: Path | None = None) -> Path:
482
+ root = project_root or Path.cwd()
483
+ return root / PROJECT_DEFINITION_REL_PATH
484
+
485
+
486
+ def _load_project_definition(project_root: Path | None = None) -> dict[str, Any] | None:
487
+ path = project_definition_path(project_root)
488
+ if not path.is_file():
489
+ return None
490
+ try:
491
+ data = json.loads(path.read_text(encoding="utf-8"))
492
+ except (json.JSONDecodeError, OSError):
493
+ return None
494
+ return data if isinstance(data, dict) else None
495
+
496
+
497
+ def resolve_scope_rules(
498
+ project_root: Path | None = None,
499
+ *,
500
+ project_definition: dict[str, Any] | None = None,
501
+ ) -> list[dict[str, Any]]:
502
+ """Resolve the effective ``plan.policy.triageScope`` rule list.
503
+
504
+ Resolution order (#1131 Decision 5):
505
+
506
+ 1. If a non-empty list is set on ``plan.policy.triageScope``, return
507
+ its normalised copy.
508
+ 2. Otherwise (unset / missing / non-list), return the framework
509
+ default ``[{"rule": "all-open"}]``.
510
+
511
+ Note: an EMPTY list (``[]``) is treated as unset too, so consumers
512
+ who clear the field accidentally still get the safe default rather
513
+ than a silently-empty subscription. Schema validation surfaces empty
514
+ lists as a warning so the operator can opt back into ``all-open``
515
+ explicitly if desired.
516
+ """
517
+ data = project_definition if project_definition is not None else _load_project_definition(
518
+ project_root
519
+ )
520
+ if not isinstance(data, dict):
521
+ return [dict(r) for r in DEFAULT_TRIAGE_SCOPE]
522
+ plan = data.get("plan")
523
+ if not isinstance(plan, dict):
524
+ return [dict(r) for r in DEFAULT_TRIAGE_SCOPE]
525
+ policy = plan.get("policy")
526
+ if not isinstance(policy, dict):
527
+ return [dict(r) for r in DEFAULT_TRIAGE_SCOPE]
528
+ scope = policy.get("triageScope")
529
+ if not isinstance(scope, list) or not scope:
530
+ return [dict(r) for r in DEFAULT_TRIAGE_SCOPE]
531
+ return [dict(r) for r in scope if isinstance(r, dict)] or [
532
+ dict(r) for r in DEFAULT_TRIAGE_SCOPE
533
+ ]
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Rule evaluators
538
+ # ---------------------------------------------------------------------------
539
+
540
+
541
+ def evaluate_rules(
542
+ rules: Iterable[dict[str, Any]],
543
+ issues: Iterable[dict[str, Any]],
544
+ *,
545
+ now: datetime | None = None,
546
+ vbrief_referenced: set[int] | None = None,
547
+ vbrief_active_referenced: set[int] | None = None,
548
+ umbrella_slices: set[int] | None = None,
549
+ open_milestones_fetcher: Any = None,
550
+ repo: str | None = None,
551
+ ) -> list[dict[str, Any]]:
552
+ """Apply ``rules`` to ``issues`` and return the union of matches.
553
+
554
+ Each issue is a dict with at minimum the following fields (a subset
555
+ of the GitHub REST ``issues`` payload):
556
+
557
+ * ``number``: int
558
+ * ``state``: "open" | "closed"
559
+ * ``labels``: list of ``{"name": str}`` or list of label strings
560
+ * ``created_at``: ISO-8601 timestamp (optional)
561
+ * ``updated_at``: ISO-8601 timestamp (optional)
562
+
563
+ Auxiliary inputs:
564
+
565
+ * ``vbrief_referenced``: set of issue numbers referenced by ANY scope
566
+ vBRIEF (proposed/pending/active/completed/cancelled) -- consumed
567
+ by the ``referenced-by-vbrief`` rule with ``scope="any"``.
568
+ * ``vbrief_active_referenced``: same but limited to ``active/``
569
+ vBRIEFs.
570
+ * ``umbrella_slices``: set of issue numbers sliced from any cached
571
+ umbrella -- consumed by the ``sliced-from`` rule.
572
+ * ``open_milestones_fetcher`` / ``repo``: D14b (#1181) inputs for
573
+ the ``milestone {is-open: true}`` variant. The fetcher is a
574
+ ``Callable[[], set[str]]`` invoked AT MOST ONCE per call
575
+ (memoized); see :mod:`_triage_scope_milestone` for the default
576
+ ``gh api ... /milestones?state=open`` fallback + repo inference.
577
+
578
+ A nil rule set returns the framework default behaviour (all open
579
+ issues). Multiple rules union their matched sets.
580
+ """
581
+ rule_list = list(rules) or [dict(r) for r in DEFAULT_TRIAGE_SCOPE]
582
+ issue_list = list(issues)
583
+ now_dt = now or _utc_now()
584
+ matched: dict[int, dict[str, Any]] = {}
585
+
586
+ # D14b (#1181) is-open snapshot resolver -- memoized once per call.
587
+ from _triage_scope_milestone import make_open_milestones_resolver
588
+ _resolve_open_milestones = make_open_milestones_resolver(
589
+ open_milestones_fetcher, issue_list, repo
590
+ )
591
+
592
+ for rule in rule_list:
593
+ if not isinstance(rule, dict):
594
+ continue
595
+ kind = rule.get("rule")
596
+ if kind == "all-open":
597
+ for issue in issue_list:
598
+ if _is_open(issue):
599
+ matched.setdefault(_issue_number(issue), issue)
600
+ elif kind == "labels":
601
+ wanted_any = rule.get("any-of")
602
+ wanted_all = rule.get("all-of")
603
+ for issue in issue_list:
604
+ if not _is_open(issue):
605
+ continue
606
+ names = _label_names(issue)
607
+ hit_any = (
608
+ wanted_any is not None
609
+ and any(label in names for label in wanted_any)
610
+ )
611
+ hit_all = (
612
+ wanted_all is not None
613
+ and all(label in names for label in wanted_all)
614
+ )
615
+ if hit_any or hit_all:
616
+ matched.setdefault(_issue_number(issue), issue)
617
+ elif kind == "opened-since":
618
+ cutoff = now_dt - parse_duration(rule["duration"])
619
+ for issue in issue_list:
620
+ if _is_open(issue) and _ts_after(issue.get("created_at"), cutoff):
621
+ matched.setdefault(_issue_number(issue), issue)
622
+ elif kind == "updated-since":
623
+ cutoff = now_dt - parse_duration(rule["duration"])
624
+ for issue in issue_list:
625
+ if _is_open(issue) and _ts_after(issue.get("updated_at"), cutoff):
626
+ matched.setdefault(_issue_number(issue), issue)
627
+ elif kind == "referenced-by-vbrief":
628
+ scope = rule.get("scope", "any")
629
+ ref_set = (
630
+ vbrief_active_referenced
631
+ if scope == "active"
632
+ else vbrief_referenced
633
+ ) or set()
634
+ for issue in issue_list:
635
+ n = _issue_number(issue)
636
+ if _is_open(issue) and n in ref_set:
637
+ matched.setdefault(n, issue)
638
+ elif kind == "sliced-from":
639
+ slices = umbrella_slices or set()
640
+ for issue in issue_list:
641
+ n = _issue_number(issue)
642
+ if _is_open(issue) and n in slices:
643
+ matched.setdefault(n, issue)
644
+ elif kind == "explicit-watch":
645
+ pinned = {
646
+ e.get("n")
647
+ for e in rule.get("issues", [])
648
+ if isinstance(e, dict) and isinstance(e.get("n"), int)
649
+ }
650
+ for issue in issue_list:
651
+ n = _issue_number(issue)
652
+ if n in pinned:
653
+ matched.setdefault(n, issue)
654
+ elif kind == "milestone":
655
+ # D14 (#1133) + D14b (#1181) variants delegated to sidecar.
656
+ from _triage_scope_milestone import evaluate_milestone_rule_into
657
+ evaluate_milestone_rule_into(
658
+ rule,
659
+ issue_list,
660
+ matched,
661
+ get_open_milestones=_resolve_open_milestones,
662
+ is_open_issue=_is_open,
663
+ issue_number=_issue_number,
664
+ milestone_name=_milestone_name,
665
+ )
666
+
667
+ return [matched[k] for k in sorted(matched)]
668
+
669
+
670
+ def _is_open(issue: dict[str, Any]) -> bool:
671
+ return issue.get("state", "open") == "open"
672
+
673
+
674
+ def _issue_number(issue: dict[str, Any]) -> int:
675
+ n = issue.get("number")
676
+ return int(n) if isinstance(n, int) else 0
677
+
678
+
679
+ def _label_names(issue: dict[str, Any]) -> set[str]:
680
+ raw = issue.get("labels", [])
681
+ names: set[str] = set()
682
+ if not isinstance(raw, list):
683
+ return names
684
+ for item in raw:
685
+ if isinstance(item, dict):
686
+ name = item.get("name")
687
+ if isinstance(name, str):
688
+ names.add(name)
689
+ elif isinstance(item, str):
690
+ names.add(item)
691
+ return names
692
+
693
+
694
+ def _milestone_name(issue: dict[str, Any]) -> str:
695
+ """Return the issue's milestone title (empty string when absent).
696
+
697
+ The GitHub REST issues payload shapes milestone info as
698
+ ``{ "title": <str>, ... }``; some upstreams or test fixtures pass
699
+ bare strings or a ``name`` alias. Tolerant of all three shapes;
700
+ returns ``""`` (never ``None``) so downstream equality checks stay
701
+ type-safe.
702
+ """
703
+ raw = issue.get("milestone")
704
+ if isinstance(raw, dict):
705
+ title = raw.get("title")
706
+ if isinstance(title, str):
707
+ return title
708
+ alt = raw.get("name")
709
+ if isinstance(alt, str):
710
+ return alt
711
+ return ""
712
+ if isinstance(raw, str):
713
+ return raw
714
+ return ""
715
+
716
+
717
+ def _ts_after(stamp: Any, cutoff: datetime) -> bool:
718
+ if not isinstance(stamp, str) or not stamp:
719
+ return False
720
+ try:
721
+ dt = _parse_iso(stamp)
722
+ except (ValueError, TypeError):
723
+ return False
724
+ if dt.tzinfo is None:
725
+ dt = dt.replace(tzinfo=UTC)
726
+ return dt >= cutoff
727
+
728
+
729
+ # ---------------------------------------------------------------------------
730
+ # Coverage denominator cache
731
+ # ---------------------------------------------------------------------------
732
+
733
+
734
+ def coverage_path(
735
+ source: str,
736
+ repo: str,
737
+ *,
738
+ project_root: Path | None = None,
739
+ cache_root: Path | None = None,
740
+ ) -> Path:
741
+ """Return the ``coverage.json`` path for ``<source>/<repo>``."""
742
+ if "/" not in repo:
743
+ raise ValueError(f"repo must be 'owner/name'; got {repo!r}")
744
+ root = cache_root
745
+ if root is None:
746
+ root = (project_root or Path.cwd()) / CACHE_DIR_NAME
747
+ owner, name = repo.split("/", 1)
748
+ return Path(root) / source / owner / name / COVERAGE_FILENAME
749
+
750
+
751
+ def coverage_ttl_hours() -> int:
752
+ """Return the configured TTL (env-overridable, defaults to 24)."""
753
+ raw = os.environ.get(ENV_COVERAGE_TTL_HOURS, "")
754
+ if not raw:
755
+ return DEFAULT_COVERAGE_TTL_HOURS
756
+ try:
757
+ value = int(raw)
758
+ if value < 0:
759
+ raise ValueError
760
+ return value
761
+ except ValueError:
762
+ return DEFAULT_COVERAGE_TTL_HOURS
763
+
764
+
765
+ def write_coverage_denominator(
766
+ path: Path,
767
+ *,
768
+ count: int,
769
+ subscription_hash_value: str,
770
+ fetched_at: datetime | None = None,
771
+ ) -> CoverageRecord:
772
+ """Write the denominator record at ``path`` and return it.
773
+
774
+ Recompute trigger callers (``triage:bootstrap``,
775
+ ``triage:scope --refresh-denominator``, subscription-hash change)
776
+ invoke this. The path's parent directories are created on demand
777
+ so first-write does not require a pre-existing cache layout.
778
+ """
779
+ if count < 0:
780
+ raise ValueError(f"count must be >= 0; got {count}")
781
+ if not subscription_hash_value:
782
+ raise ValueError("subscription_hash_value must be a non-empty string")
783
+ stamp = _utc_iso(fetched_at)
784
+ path.parent.mkdir(parents=True, exist_ok=True)
785
+ payload: dict[str, Any] = {
786
+ "count": int(count),
787
+ "fetched_at": stamp,
788
+ "subscription_hash": subscription_hash_value,
789
+ }
790
+ path.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
791
+ return CoverageRecord(
792
+ count=int(count),
793
+ fetched_at=stamp,
794
+ subscription_hash=subscription_hash_value,
795
+ stale=False,
796
+ age_hours=0.0,
797
+ )
798
+
799
+
800
+ def read_coverage_denominator(
801
+ path: Path,
802
+ *,
803
+ current_hash: str,
804
+ ttl_hours: int | None = None,
805
+ now: datetime | None = None,
806
+ ) -> CoverageRecord | None:
807
+ """Read the denominator record at ``path``.
808
+
809
+ Returns ``None`` when the file does not exist or is malformed --
810
+ callers MUST treat that as a cache miss and render ``?``.
811
+
812
+ Returns a :class:`CoverageRecord` with ``stale=True`` when EITHER
813
+ the TTL has elapsed OR the stored ``subscription_hash`` mismatches
814
+ ``current_hash``. Reads NEVER trigger a recompute (Decision 3); the
815
+ record is returned so callers can decide whether to display the
816
+ cached count or fall back to ``?``.
817
+ """
818
+ if not path.is_file():
819
+ return None
820
+ try:
821
+ data = json.loads(path.read_text(encoding="utf-8"))
822
+ except (json.JSONDecodeError, OSError):
823
+ return None
824
+ if not isinstance(data, dict):
825
+ return None
826
+ count = data.get("count")
827
+ fetched_at = data.get("fetched_at")
828
+ stored_hash = data.get("subscription_hash")
829
+ if not isinstance(count, int) or count < 0:
830
+ return None
831
+ if not isinstance(fetched_at, str) or not fetched_at:
832
+ return None
833
+ if not isinstance(stored_hash, str) or not stored_hash:
834
+ return None
835
+
836
+ effective_ttl = coverage_ttl_hours() if ttl_hours is None else max(0, int(ttl_hours))
837
+ now_dt = now or _utc_now()
838
+ try:
839
+ fetched_dt = _parse_iso(fetched_at)
840
+ if fetched_dt.tzinfo is None:
841
+ fetched_dt = fetched_dt.replace(tzinfo=UTC)
842
+ except (ValueError, TypeError):
843
+ return None
844
+ age_seconds = max(0.0, (now_dt - fetched_dt).total_seconds())
845
+ age_hours = age_seconds / 3600.0
846
+ ttl_stale = effective_ttl > 0 and age_hours > effective_ttl
847
+ hash_stale = stored_hash != current_hash
848
+
849
+ return CoverageRecord(
850
+ count=count,
851
+ fetched_at=fetched_at,
852
+ subscription_hash=stored_hash,
853
+ stale=bool(ttl_stale or hash_stale),
854
+ age_hours=age_hours,
855
+ )
856
+
857
+
858
+ def format_coverage_display(
859
+ numerator: int, record: CoverageRecord | None
860
+ ) -> str:
861
+ """Return ``"<num>/<denom>"`` or ``"<num>/?"`` per Decision 3.
862
+
863
+ Stale records (``record.stale=True``) and missing records both
864
+ surface as ``?`` -- the literal question mark is the contractual
865
+ surface for ``triage:summary`` / ``triage:scope`` read paths so
866
+ operators see immediately that the denominator is not authoritative.
867
+ """
868
+ if record is None or record.stale:
869
+ return f"{numerator}/?"
870
+ return f"{numerator}/{record.count}"
871
+
872
+
873
+ # ---------------------------------------------------------------------------
874
+ # vBRIEF reference helper + --list renderer (D14 / #1133 split)
875
+ # ---------------------------------------------------------------------------
876
+ #
877
+ # The implementations live in ``scripts/_triage_scope_renderers.py`` to
878
+ # keep this module under the 1000-line MUST cap from ``coding/coding.md``
879
+ # after D14 (#1133) added the milestone rule type + ignore-list
880
+ # surface. Re-exported here so existing call sites and tests that
881
+ # ``import triage_scope`` keep working unchanged.
882
+
883
+ from _triage_scope_renderers import ( # noqa: E402,F401,I001
884
+ extract_referenced_issues,
885
+ _render_rule,
886
+ )
887
+ from _triage_scope_renderers import render_list as _render_list_impl # noqa: E402,I001
888
+
889
+
890
+ def render_list(
891
+ rules: Iterable[dict[str, Any]],
892
+ *,
893
+ project_root: Path | None = None,
894
+ is_default: bool = False,
895
+ ) -> str:
896
+ """Re-export wrapper around :func:`_triage_scope_renderers.render_list`.
897
+
898
+ Threads :func:`subscription_hash` through as the hash callable so the
899
+ renderer module does not need to import this module back (which
900
+ would create a circular import). All other args pass through verbatim.
901
+ """
902
+ return _render_list_impl(
903
+ rules,
904
+ subscription_hash_fn=subscription_hash,
905
+ project_root=project_root,
906
+ is_default=is_default,
907
+ )
908
+
909
+
910
+ # ---------------------------------------------------------------------------
911
+ # CLI shim helpers + entry point
912
+ # ---------------------------------------------------------------------------
913
+ #
914
+ # The argparse setup + command dispatcher live in ``scripts/_triage_scope_cli.py``
915
+ # so this module stays under the 1000-line MUST cap from ``coding/coding.md``.
916
+ # The helpers below are the small predicates the CLI calls back into; they
917
+ # are kept here because tests in ``tests/test_triage_scope.py`` reference
918
+ # them directly.
919
+
920
+
921
+ def _is_default_applied(data: dict[str, Any] | None) -> bool:
922
+ """True when ``plan.policy.triageScope`` is unset / non-list / empty."""
923
+ if not isinstance(data, dict):
924
+ return True
925
+ plan = data.get("plan")
926
+ if not isinstance(plan, dict):
927
+ return True
928
+ policy = plan.get("policy")
929
+ if not isinstance(policy, dict):
930
+ return True
931
+ scope = policy.get("triageScope")
932
+ return bool(not isinstance(scope, list) or not scope)
933
+
934
+
935
+ def _get_raw_scope(data: dict[str, Any] | None) -> Any:
936
+ """Return the raw ``plan.policy.triageScope`` payload (untyped)."""
937
+ if not isinstance(data, dict):
938
+ return None
939
+ plan = data.get("plan")
940
+ if not isinstance(plan, dict):
941
+ return None
942
+ policy = plan.get("policy")
943
+ if not isinstance(policy, dict):
944
+ return None
945
+ return policy.get("triageScope")
946
+
947
+
948
+ def validate_triage_scope_on_plan(plan: Any, filepath: Any) -> list[str]:
949
+ """vbrief_validate hook: validate ``plan.policy.triageScope`` (#1131).
950
+
951
+ Returns formatted error strings prefixed with ``<filepath>:`` so
952
+ ``vbrief_validate.validate_project_definition`` can splice them into
953
+ its existing error list without re-formatting. An unset / missing
954
+ scope returns an empty list (default behaviour per Decision 5).
955
+ """
956
+ out: list[str] = []
957
+ policy = plan.get("policy") if isinstance(plan, dict) else None
958
+ raw_scope = policy.get("triageScope") if isinstance(policy, dict) else None
959
+ if raw_scope is None:
960
+ return out
961
+ errors, _warnings = validate_scope_rules(raw_scope)
962
+ for err in errors:
963
+ out.append(f"{filepath}: {err} (#1131)")
964
+ return out
965
+
966
+
967
+ # ---------------------------------------------------------------------------
968
+ # D14 / #1133: typed ``plan.policy.triageScopeIgnores[]`` foundation.
969
+ # ---------------------------------------------------------------------------
970
+ #
971
+ # Validator + resolver + vbrief_validate hook live in
972
+ # ``scripts/_triage_scope_ignores.py`` so this module stays under the
973
+ # 1000-line MUST cap. Re-exported here for existing call sites.
974
+
975
+ from _triage_scope_ignores import ( # noqa: E402,F401,I001
976
+ validate_scope_ignores,
977
+ resolve_scope_ignores,
978
+ validate_triage_scope_ignores_on_plan,
979
+ )
980
+
981
+
982
+ def main(argv: list[str] | None = None) -> int:
983
+ """CLI entry point. Delegates to :mod:`_triage_scope_cli`."""
984
+ import sys as _sys
985
+
986
+ # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
987
+ from triage_help import intercept_help
988
+
989
+ rc = intercept_help("triage_scope", argv)
990
+ if rc is not None:
991
+ return rc
992
+
993
+ from _triage_scope_cli import run_cli # local import: 1000-line cap
994
+
995
+ return run_cli(argv, _sys.modules[__name__])
996
+
997
+
998
+ if __name__ == "__main__":
999
+ sys.exit(main())