@deftai/directive-content 0.59.0 → 0.60.0

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 (184) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +48 -58
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scm/github.md +20 -2
  10. package/tasks/change.yml +16 -31
  11. package/tasks/ci.yml +8 -0
  12. package/tasks/commit.yml +12 -19
  13. package/tasks/core.yml +10 -0
  14. package/tasks/engine.yml +42 -0
  15. package/tasks/framework.yml +3 -0
  16. package/tasks/install.yml +20 -19
  17. package/tasks/migrate.yml +26 -15
  18. package/tasks/project.yml +16 -0
  19. package/tasks/toolchain.yml +15 -5
  20. package/tasks/vbrief.yml +4 -3
  21. package/tasks/verify.yml +12 -14
  22. package/scripts/_agents_md.py +0 -494
  23. package/scripts/_cache_fetch.py +0 -635
  24. package/scripts/_cache_quota.py +0 -529
  25. package/scripts/_cache_refresh.py +0 -163
  26. package/scripts/_cache_validate.py +0 -209
  27. package/scripts/_content_root.py +0 -42
  28. package/scripts/_doctor_state.py +0 -277
  29. package/scripts/_event_detect.py +0 -305
  30. package/scripts/_events.py +0 -514
  31. package/scripts/_lifecycle_hygiene.py +0 -568
  32. package/scripts/_pathspec.py +0 -91
  33. package/scripts/_policy_show_cli.py +0 -266
  34. package/scripts/_precutover.py +0 -92
  35. package/scripts/_project_context.py +0 -224
  36. package/scripts/_project_definition_io.py +0 -164
  37. package/scripts/_relocate_snapshot.py +0 -209
  38. package/scripts/_relocate_states.py +0 -343
  39. package/scripts/_resolve_preflight_path.py +0 -152
  40. package/scripts/_safe_subprocess.py +0 -167
  41. package/scripts/_session_start_hook.py +0 -205
  42. package/scripts/_sor_gate_diff.py +0 -365
  43. package/scripts/_stdio_utf8.py +0 -59
  44. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  45. package/scripts/_triage_classify_cli.py +0 -122
  46. package/scripts/_triage_queue_cli.py +0 -625
  47. package/scripts/_triage_scope_cli.py +0 -343
  48. package/scripts/_triage_scope_drift_cli.py +0 -121
  49. package/scripts/_triage_scope_ignores.py +0 -286
  50. package/scripts/_triage_scope_milestone.py +0 -432
  51. package/scripts/_triage_scope_mutations.py +0 -337
  52. package/scripts/_triage_scope_renderers.py +0 -207
  53. package/scripts/_triage_smoketest_stages.py +0 -674
  54. package/scripts/_triage_subscribe_cli.py +0 -140
  55. package/scripts/_triage_welcome_cli.py +0 -421
  56. package/scripts/_vbrief_build.py +0 -239
  57. package/scripts/_vbrief_fidelity.py +0 -479
  58. package/scripts/_vbrief_legacy.py +0 -589
  59. package/scripts/_vbrief_reconciliation.py +0 -883
  60. package/scripts/_vbrief_routing.py +0 -277
  61. package/scripts/_vbrief_safety.py +0 -778
  62. package/scripts/_vbrief_sources.py +0 -312
  63. package/scripts/_vbrief_speckit.py +0 -262
  64. package/scripts/_vbrief_story_quality.py +0 -353
  65. package/scripts/_vbrief_validation.py +0 -299
  66. package/scripts/build_dist.py +0 -412
  67. package/scripts/cache.py +0 -1078
  68. package/scripts/cache_scanner.py +0 -745
  69. package/scripts/candidates_log.py +0 -432
  70. package/scripts/capacity_backfill.py +0 -680
  71. package/scripts/capacity_show.py +0 -653
  72. package/scripts/ci_local.py +0 -689
  73. package/scripts/code_structure_validate.py +0 -765
  74. package/scripts/codebase_default_extractor.py +0 -495
  75. package/scripts/codebase_map.py +0 -304
  76. package/scripts/codebase_map_fresh.py +0 -104
  77. package/scripts/codebase_projection_registry.py +0 -94
  78. package/scripts/codebase_provider.py +0 -582
  79. package/scripts/doctor.py +0 -2552
  80. package/scripts/framework_commands.py +0 -505
  81. package/scripts/gh_rest.py +0 -882
  82. package/scripts/github_auth_modes.py +0 -437
  83. package/scripts/github_body.py +0 -292
  84. package/scripts/ip_risk.py +0 -531
  85. package/scripts/issue_emit.py +0 -670
  86. package/scripts/issue_ingest.py +0 -1064
  87. package/scripts/migrate_preflight.py +0 -418
  88. package/scripts/migrate_vbrief.py +0 -2677
  89. package/scripts/monitor_pr.py +0 -401
  90. package/scripts/pack_migrate_lessons.py +0 -336
  91. package/scripts/pack_migrate_patterns.py +0 -254
  92. package/scripts/pack_migrate_rules.py +0 -350
  93. package/scripts/pack_migrate_skills.py +0 -423
  94. package/scripts/pack_migrate_strategies.py +0 -311
  95. package/scripts/pack_migrate_swarm_spec.py +0 -250
  96. package/scripts/pack_render.py +0 -434
  97. package/scripts/packs_slice.py +0 -712
  98. package/scripts/platform_capabilities.py +0 -336
  99. package/scripts/policy.py +0 -2826
  100. package/scripts/policy_set.py +0 -324
  101. package/scripts/pr_check_closing_keywords.py +0 -524
  102. package/scripts/pr_check_protected_issues.py +0 -267
  103. package/scripts/pr_merge_readiness.py +0 -1004
  104. package/scripts/pr_wait_mergeable.py +0 -669
  105. package/scripts/prd_render.py +0 -159
  106. package/scripts/preflight_architecture_sor.py +0 -974
  107. package/scripts/preflight_branch.py +0 -289
  108. package/scripts/preflight_cache.py +0 -974
  109. package/scripts/preflight_gh.py +0 -721
  110. package/scripts/preflight_implementation.py +0 -272
  111. package/scripts/preflight_story_start.py +0 -838
  112. package/scripts/preflight_wip_cap.py +0 -149
  113. package/scripts/probe_session.py +0 -545
  114. package/scripts/project_render.py +0 -293
  115. package/scripts/quarantine_ext.py +0 -237
  116. package/scripts/reconcile_issues.py +0 -1442
  117. package/scripts/refresh-path.ps1 +0 -107
  118. package/scripts/release.py +0 -2030
  119. package/scripts/release_e2e.py +0 -1011
  120. package/scripts/release_publish.py +0 -486
  121. package/scripts/release_rollback.py +0 -980
  122. package/scripts/relocate.py +0 -1034
  123. package/scripts/resolve_changelog_unreleased.py +0 -667
  124. package/scripts/resolve_version.py +0 -490
  125. package/scripts/resume_conditions.py +0 -706
  126. package/scripts/ritual_sentinel.py +0 -609
  127. package/scripts/roadmap_render.py +0 -635
  128. package/scripts/rule_ownership_lint.py +0 -325
  129. package/scripts/scm.py +0 -591
  130. package/scripts/scope_audit_log.py +0 -387
  131. package/scripts/scope_decompose.py +0 -654
  132. package/scripts/scope_demote.py +0 -509
  133. package/scripts/scope_lifecycle.py +0 -1126
  134. package/scripts/scope_undo.py +0 -772
  135. package/scripts/session_start.py +0 -406
  136. package/scripts/setup_ghx.py +0 -339
  137. package/scripts/setup_windows.ps1 +0 -220
  138. package/scripts/slice_audit.py +0 -585
  139. package/scripts/slice_record.py +0 -530
  140. package/scripts/slice_record_existing.py +0 -692
  141. package/scripts/slug_normalize.py +0 -178
  142. package/scripts/spec_render.py +0 -477
  143. package/scripts/spec_validate.py +0 -238
  144. package/scripts/subagent_monitor.py +0 -658
  145. package/scripts/swarm_complete_cohort.py +0 -644
  146. package/scripts/swarm_launch.py +0 -1206
  147. package/scripts/swarm_readiness.py +0 -554
  148. package/scripts/swarm_verify_review_clean.py +0 -438
  149. package/scripts/swarm_worktrees.py +0 -497
  150. package/scripts/toolchain-check.py +0 -52
  151. package/scripts/triage_actions.py +0 -871
  152. package/scripts/triage_bootstrap.py +0 -1153
  153. package/scripts/triage_bulk.py +0 -630
  154. package/scripts/triage_classify.py +0 -932
  155. package/scripts/triage_help.py +0 -1685
  156. package/scripts/triage_queue.py +0 -1944
  157. package/scripts/triage_reconcile.py +0 -581
  158. package/scripts/triage_refresh.py +0 -643
  159. package/scripts/triage_scope.py +0 -999
  160. package/scripts/triage_scope_drift.py +0 -575
  161. package/scripts/triage_smoketest.py +0 -396
  162. package/scripts/triage_subscribe.py +0 -399
  163. package/scripts/triage_summary.py +0 -1011
  164. package/scripts/triage_welcome.py +0 -1178
  165. package/scripts/ts_check_lane.py +0 -86
  166. package/scripts/validate-links.py +0 -64
  167. package/scripts/validate_strategy_output.py +0 -212
  168. package/scripts/vbrief_activate.py +0 -228
  169. package/scripts/vbrief_migrate_conformance.py +0 -368
  170. package/scripts/vbrief_reconcile_graph.py +0 -306
  171. package/scripts/vbrief_reconcile_labels.py +0 -460
  172. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  173. package/scripts/vbrief_validate.py +0 -1144
  174. package/scripts/verify-stubs.py +0 -61
  175. package/scripts/verify_capacity.py +0 -160
  176. package/scripts/verify_encoding.py +0 -699
  177. package/scripts/verify_hooks_installed.py +0 -206
  178. package/scripts/verify_investigation.py +0 -360
  179. package/scripts/verify_judgment_gates.py +0 -827
  180. package/scripts/verify_no_task_runtime.py +0 -171
  181. package/scripts/verify_scm_boundary.py +0 -509
  182. package/scripts/verify_session_ritual.py +0 -389
  183. package/scripts/verify_tools.py +0 -426
  184. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,999 +0,0 @@
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())