@deftai/directive-content 0.59.0 → 0.61.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.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,575 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""triage_scope_drift.py -- subscription drift detection (D14 / #1133).
|
|
3
|
-
|
|
4
|
-
Walks the unified ``.deft-cache/github-issue/<owner>/<repo>/<N>/raw.json``
|
|
5
|
-
mirror (#883 Story 2) and computes:
|
|
6
|
-
|
|
7
|
-
* ``unsubscribed-labels``: labels appearing on >= ``_DRIFT_MIN_ISSUES``
|
|
8
|
-
cached issues whose latest state is ``open`` AND that are NOT covered
|
|
9
|
-
by any active ``plan.policy.triageScope[]`` rule.
|
|
10
|
-
* ``unsubscribed-milestones``: milestones with >= ``_DRIFT_MIN_ISSUES``
|
|
11
|
-
open cached issues NOT covered by any ``milestone`` rule (D14 / #1133
|
|
12
|
-
v1 exact-match shape).
|
|
13
|
-
|
|
14
|
-
The threshold is a framework constant at module top per umbrella #1119
|
|
15
|
-
section 12 framework-vs-consumer boundary; consumer tunability (e.g.
|
|
16
|
-
``plan.policy.driftMinIssues``) is explicitly v2 scope.
|
|
17
|
-
|
|
18
|
-
Entries that the operator has explicitly chosen to ignore via
|
|
19
|
-
``plan.policy.triageScopeIgnores[]`` are suppressed from the surfaced
|
|
20
|
-
counts AND from the rendered output (D14c / #1182 will introduce
|
|
21
|
-
sunset-on / mass-edit tuning verbs on top of this foundation).
|
|
22
|
-
|
|
23
|
-
Public surface:
|
|
24
|
-
|
|
25
|
-
* :data:`_DRIFT_MIN_ISSUES` -- the v1 threshold (3).
|
|
26
|
-
* :class:`DriftReport` -- frozen dataclass with per-signal counts and
|
|
27
|
-
the total surfaced issue count (the number D2's one-liner segment
|
|
28
|
-
consumes).
|
|
29
|
-
* :func:`compute_drift` -- read-only computation; never mutates state.
|
|
30
|
-
* :func:`render_drift_report` -- human-readable rendering of a report.
|
|
31
|
-
* :func:`add_ignore` -- atomic mutation that appends a
|
|
32
|
-
``{label|milestone: <name>}`` entry to
|
|
33
|
-
``plan.policy.triageScopeIgnores[]``.
|
|
34
|
-
|
|
35
|
-
CLI shim lives at ``scripts/_triage_scope_drift_cli.py`` so this module
|
|
36
|
-
stays under the 1000-line MUST cap from ``coding/coding.md``.
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
from __future__ import annotations
|
|
40
|
-
|
|
41
|
-
import contextlib
|
|
42
|
-
import json
|
|
43
|
-
import sys
|
|
44
|
-
from dataclasses import dataclass, field
|
|
45
|
-
from pathlib import Path
|
|
46
|
-
from typing import Any
|
|
47
|
-
|
|
48
|
-
# Sibling imports
|
|
49
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
50
|
-
|
|
51
|
-
# UTF-8 self-reconfigure (mirrors triage_scope.py / triage_summary.py).
|
|
52
|
-
for _stream in (sys.stdout, sys.stderr):
|
|
53
|
-
if hasattr(_stream, "reconfigure"):
|
|
54
|
-
with contextlib.suppress(AttributeError, ValueError):
|
|
55
|
-
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# ---------------------------------------------------------------------------
|
|
59
|
-
# Constants
|
|
60
|
-
# ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
#: Framework drift-threshold (D14 / #1133). A label or milestone is
|
|
63
|
-
#: surfaced as drift only if at least this many currently-open cached
|
|
64
|
-
#: issues carry it AND it is not covered by the active subscription.
|
|
65
|
-
#: The constant lives here so future tunability (``plan.policy.driftMinIssues``,
|
|
66
|
-
#: v2 scope) has a single source of truth to override.
|
|
67
|
-
_DRIFT_MIN_ISSUES: int = 3
|
|
68
|
-
|
|
69
|
-
#: Cache directory + source name. Mirrors ``triage_summary.CACHE_DIR_NAME``
|
|
70
|
-
#: + ``CACHE_SOURCE`` so the drift detector reads the same layout the
|
|
71
|
-
#: summary verb consumes.
|
|
72
|
-
CACHE_DIR_NAME = ".deft-cache"
|
|
73
|
-
CACHE_SOURCE = "github-issue"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# ---------------------------------------------------------------------------
|
|
77
|
-
# Dataclasses
|
|
78
|
-
# ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@dataclass(frozen=True)
|
|
82
|
-
class DriftReport:
|
|
83
|
-
"""Structured drift report.
|
|
84
|
-
|
|
85
|
-
Two parallel mappings (label/milestone -> issue count) plus the
|
|
86
|
-
aggregate ``total`` that D2's ``[scope-drift] N`` segment renders.
|
|
87
|
-
The total equals the number of distinct open cached issues that
|
|
88
|
-
would join the subscription if every surfaced signal were opted
|
|
89
|
-
into (NOT the sum of counts: an issue with two unsubscribed labels
|
|
90
|
-
counts once).
|
|
91
|
-
"""
|
|
92
|
-
|
|
93
|
-
labels: dict[str, int] = field(default_factory=dict)
|
|
94
|
-
milestones: dict[str, int] = field(default_factory=dict)
|
|
95
|
-
total: int = 0
|
|
96
|
-
threshold: int = _DRIFT_MIN_ISSUES
|
|
97
|
-
|
|
98
|
-
def is_empty(self) -> bool:
|
|
99
|
-
"""True when neither labels nor milestones have any surfaced drift."""
|
|
100
|
-
return not self.labels and not self.milestones
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# ---------------------------------------------------------------------------
|
|
104
|
-
# Cache walker
|
|
105
|
-
# ---------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _iter_cache_issues(cache_root: Path) -> list[dict[str, Any]]:
|
|
109
|
-
"""Walk ``<cache_root>/github-issue/<owner>/<repo>/<N>/raw.json``.
|
|
110
|
-
|
|
111
|
-
Returns the list of raw GitHub-issue payloads (each a dict). Bad /
|
|
112
|
-
missing files are silently skipped -- the drift detector MUST NOT
|
|
113
|
-
crash on a torn cache, mirroring the tolerance contract in
|
|
114
|
-
``triage_summary.read_audit_log``.
|
|
115
|
-
"""
|
|
116
|
-
base = cache_root / CACHE_SOURCE
|
|
117
|
-
if not base.is_dir():
|
|
118
|
-
return []
|
|
119
|
-
out: list[dict[str, Any]] = []
|
|
120
|
-
for owner_dir in sorted(base.iterdir(), key=lambda p: p.name):
|
|
121
|
-
if not owner_dir.is_dir():
|
|
122
|
-
continue
|
|
123
|
-
for repo_dir in sorted(owner_dir.iterdir(), key=lambda p: p.name):
|
|
124
|
-
if not repo_dir.is_dir():
|
|
125
|
-
continue
|
|
126
|
-
for issue_dir in sorted(repo_dir.iterdir(), key=lambda p: p.name):
|
|
127
|
-
if not issue_dir.is_dir() or not issue_dir.name.isdecimal():
|
|
128
|
-
continue
|
|
129
|
-
raw_path = issue_dir / "raw.json"
|
|
130
|
-
if not raw_path.is_file():
|
|
131
|
-
continue
|
|
132
|
-
try:
|
|
133
|
-
data = json.loads(raw_path.read_text(encoding="utf-8"))
|
|
134
|
-
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
135
|
-
continue
|
|
136
|
-
if isinstance(data, dict):
|
|
137
|
-
out.append(data)
|
|
138
|
-
return out
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _extract_labels(issue: dict[str, Any]) -> set[str]:
|
|
142
|
-
raw = issue.get("labels")
|
|
143
|
-
if not isinstance(raw, list):
|
|
144
|
-
return set()
|
|
145
|
-
names: set[str] = set()
|
|
146
|
-
for item in raw:
|
|
147
|
-
if isinstance(item, dict):
|
|
148
|
-
name = item.get("name")
|
|
149
|
-
if isinstance(name, str) and name:
|
|
150
|
-
names.add(name)
|
|
151
|
-
elif isinstance(item, str) and item:
|
|
152
|
-
names.add(item)
|
|
153
|
-
return names
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _extract_milestone(issue: dict[str, Any]) -> str:
|
|
157
|
-
raw = issue.get("milestone")
|
|
158
|
-
if isinstance(raw, dict):
|
|
159
|
-
title = raw.get("title")
|
|
160
|
-
if isinstance(title, str) and title:
|
|
161
|
-
return title
|
|
162
|
-
alt = raw.get("name")
|
|
163
|
-
if isinstance(alt, str) and alt:
|
|
164
|
-
return alt
|
|
165
|
-
elif isinstance(raw, str) and raw:
|
|
166
|
-
return raw
|
|
167
|
-
return ""
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def _extract_author(issue: dict[str, Any]) -> str:
|
|
171
|
-
"""Return the cached issue's author login (D14c / #1182).
|
|
172
|
-
|
|
173
|
-
GitHub REST issues payload shapes the author as
|
|
174
|
-
``{ "user": { "login": "<name>", ... }, ... }``. We tolerate the
|
|
175
|
-
bare-string and ``{author: <str>}`` shapes for fixture flexibility.
|
|
176
|
-
Returns ``""`` (never ``None``) so downstream membership checks
|
|
177
|
-
stay type-safe.
|
|
178
|
-
"""
|
|
179
|
-
user = issue.get("user")
|
|
180
|
-
if isinstance(user, dict):
|
|
181
|
-
login = user.get("login")
|
|
182
|
-
if isinstance(login, str) and login:
|
|
183
|
-
return login
|
|
184
|
-
author = issue.get("author")
|
|
185
|
-
if isinstance(author, dict):
|
|
186
|
-
login = author.get("login")
|
|
187
|
-
if isinstance(login, str) and login:
|
|
188
|
-
return login
|
|
189
|
-
if isinstance(author, str) and author:
|
|
190
|
-
return author
|
|
191
|
-
return ""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _is_open(issue: dict[str, Any]) -> bool:
|
|
195
|
-
return issue.get("state", "open") == "open"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
# ---------------------------------------------------------------------------
|
|
199
|
-
# Subscription coverage helpers
|
|
200
|
-
# ---------------------------------------------------------------------------
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def _subscribed_labels(rules: list[dict[str, Any]]) -> set[str]:
|
|
204
|
-
"""Return the set of label names covered by any ``labels`` rule.
|
|
205
|
-
|
|
206
|
-
Both ``any-of`` and ``all-of`` shapes contribute -- the question
|
|
207
|
-
the drift detector asks is "does the subscription mention this
|
|
208
|
-
label at all?", not "does the subscription match issues with this
|
|
209
|
-
label?". A label appearing in ``all-of`` still suppresses drift
|
|
210
|
-
because the operator obviously already knows about it.
|
|
211
|
-
"""
|
|
212
|
-
out: set[str] = set()
|
|
213
|
-
for rule in rules:
|
|
214
|
-
if not isinstance(rule, dict) or rule.get("rule") != "labels":
|
|
215
|
-
continue
|
|
216
|
-
for key in ("any-of", "all-of"):
|
|
217
|
-
value = rule.get(key)
|
|
218
|
-
if isinstance(value, list):
|
|
219
|
-
for label in value:
|
|
220
|
-
if isinstance(label, str) and label:
|
|
221
|
-
out.add(label)
|
|
222
|
-
return out
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _subscribed_milestones(
|
|
226
|
-
rules: list[dict[str, Any]],
|
|
227
|
-
*,
|
|
228
|
-
open_milestones_snapshot: set[str] | None = None,
|
|
229
|
-
) -> set[str]:
|
|
230
|
-
"""Return milestone names covered by ``milestone`` rules.
|
|
231
|
-
|
|
232
|
-
Recognises all three D14b (#1181) variants:
|
|
233
|
-
|
|
234
|
-
* ``{name: "<n>"}`` -- single exact name (D14 v1).
|
|
235
|
-
* ``{any-of: ["<n1>", ...]}`` -- explicit list.
|
|
236
|
-
* ``{is-open: true}`` -- subscribes to whatever is currently open
|
|
237
|
-
upstream; the caller pre-fetches the open snapshot and passes it
|
|
238
|
-
in via ``open_milestones_snapshot`` so the drift detector
|
|
239
|
-
consults the same set the evaluator does.
|
|
240
|
-
"""
|
|
241
|
-
from _triage_scope_milestone import (
|
|
242
|
-
collect_milestone_subscribed_names,
|
|
243
|
-
rules_request_is_open,
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
out = collect_milestone_subscribed_names(rules)
|
|
247
|
-
if rules_request_is_open(rules) and open_milestones_snapshot:
|
|
248
|
-
out |= set(open_milestones_snapshot)
|
|
249
|
-
return out
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# ---------------------------------------------------------------------------
|
|
253
|
-
# Public API: compute / render / mutate
|
|
254
|
-
# ---------------------------------------------------------------------------
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def compute_drift(
|
|
258
|
-
project_root: Path,
|
|
259
|
-
*,
|
|
260
|
-
cache_root: Path | None = None,
|
|
261
|
-
threshold: int | None = None,
|
|
262
|
-
open_milestones_fetcher: Any = None,
|
|
263
|
-
) -> DriftReport:
|
|
264
|
-
"""Compute the drift report for a project.
|
|
265
|
-
|
|
266
|
-
``cache_root`` defaults to ``<project_root>/.deft-cache``.
|
|
267
|
-
``threshold`` defaults to :data:`_DRIFT_MIN_ISSUES`; passing an
|
|
268
|
-
override is supported for tests but consumers SHOULD let the
|
|
269
|
-
framework default stand (D14 / #1133 ships the threshold as a
|
|
270
|
-
framework constant; per-consumer tunability is v2 scope).
|
|
271
|
-
|
|
272
|
-
``open_milestones_fetcher`` is the D14b (#1181) injection point:
|
|
273
|
-
when any ``milestone {is-open: true}`` rule is present, the drift
|
|
274
|
-
detector fetches the upstream open-milestones snapshot once and
|
|
275
|
-
excludes those names from the surfaced drift. When omitted, the
|
|
276
|
-
default ``gh api repos/<owner>/<name>/milestones?state=open``
|
|
277
|
-
fetcher is used (best-effort; failures degrade to an empty
|
|
278
|
-
snapshot per the evaluator's contract).
|
|
279
|
-
|
|
280
|
-
Read-only: never mutates PROJECT-DEFINITION, the cache, or the
|
|
281
|
-
audit log. Empty cache yields an empty report (``total == 0``).
|
|
282
|
-
"""
|
|
283
|
-
from triage_scope import resolve_scope_ignores, resolve_scope_rules
|
|
284
|
-
|
|
285
|
-
resolved_cache_root = cache_root or (project_root / CACHE_DIR_NAME)
|
|
286
|
-
effective_threshold = (
|
|
287
|
-
threshold if threshold is not None and threshold > 0 else _DRIFT_MIN_ISSUES
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
issues = _iter_cache_issues(resolved_cache_root)
|
|
291
|
-
rules = resolve_scope_rules(project_root)
|
|
292
|
-
ignores = resolve_scope_ignores(project_root)
|
|
293
|
-
|
|
294
|
-
# D14b (#1181): resolve the open-milestones snapshot once when any
|
|
295
|
-
# rule asks for ``is-open: true``; an unavailable snapshot degrades
|
|
296
|
-
# to empty (drift still surfaces the milestone in that case so the
|
|
297
|
-
# operator sees the network failure indirectly).
|
|
298
|
-
open_ms_snapshot: set[str] = set()
|
|
299
|
-
from _triage_scope_milestone import (
|
|
300
|
-
default_open_milestones_fetcher,
|
|
301
|
-
infer_repo_from_issues,
|
|
302
|
-
rules_request_is_open,
|
|
303
|
-
)
|
|
304
|
-
if rules_request_is_open(rules):
|
|
305
|
-
if open_milestones_fetcher is not None:
|
|
306
|
-
try:
|
|
307
|
-
raw = open_milestones_fetcher()
|
|
308
|
-
except Exception: # noqa: BLE001
|
|
309
|
-
raw = set()
|
|
310
|
-
open_ms_snapshot = (
|
|
311
|
-
set(raw)
|
|
312
|
-
if isinstance(raw, (set, frozenset, list, tuple))
|
|
313
|
-
else set()
|
|
314
|
-
)
|
|
315
|
-
else:
|
|
316
|
-
inferred_repo = infer_repo_from_issues(issues)
|
|
317
|
-
open_ms_snapshot = default_open_milestones_fetcher(inferred_repo)
|
|
318
|
-
|
|
319
|
-
# `all-open` subscribes to every currently-open upstream issue by
|
|
320
|
-
# definition (umbrella section 12 framework default when
|
|
321
|
-
# ``plan.policy.triageScope[]`` is unset / missing). Under that
|
|
322
|
-
# rule every cached open issue is already in scope, so no label
|
|
323
|
-
# or milestone can be "unsubscribed" -- the drift detector would
|
|
324
|
-
# otherwise spuriously flag every label/milestone on >=3 cached
|
|
325
|
-
# open issues for the entire default-config consumer base.
|
|
326
|
-
# Short-circuit to an empty report so D2's `[scope-drift] N`
|
|
327
|
-
# segment stays suppressed (segment renders only when N > 0).
|
|
328
|
-
if any(isinstance(r, dict) and r.get("rule") == "all-open" for r in rules):
|
|
329
|
-
return DriftReport(threshold=effective_threshold)
|
|
330
|
-
|
|
331
|
-
subscribed_labels = _subscribed_labels(rules)
|
|
332
|
-
subscribed_milestones = _subscribed_milestones(
|
|
333
|
-
rules, open_milestones_snapshot=open_ms_snapshot
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
label_counts: dict[str, int] = {}
|
|
337
|
-
milestone_counts: dict[str, int] = {}
|
|
338
|
-
# Track which issues are surfaced under any drift signal so
|
|
339
|
-
# ``total`` counts distinct issues, not signal-occurrences.
|
|
340
|
-
surfaced_issues: set[tuple[str, int]] = set()
|
|
341
|
-
# D14c / #1182: issues whose `user.login` matches a
|
|
342
|
-
# `{rule: author, any-of: [...]}` ignore entry are dropped from
|
|
343
|
-
# the drift surface entirely -- the operator already told us they
|
|
344
|
-
# don't care about this author's issues (canonical case: dependabot
|
|
345
|
-
# / renovate noise on a consumer's repo).
|
|
346
|
-
ignored_authors = ignores.get("authors", set())
|
|
347
|
-
|
|
348
|
-
for issue in issues:
|
|
349
|
-
if not _is_open(issue):
|
|
350
|
-
continue
|
|
351
|
-
number = issue.get("number")
|
|
352
|
-
if not isinstance(number, int):
|
|
353
|
-
continue
|
|
354
|
-
if ignored_authors and _extract_author(issue) in ignored_authors:
|
|
355
|
-
continue
|
|
356
|
-
labels = _extract_labels(issue)
|
|
357
|
-
for label in labels:
|
|
358
|
-
if label in subscribed_labels or label in ignores["labels"]:
|
|
359
|
-
continue
|
|
360
|
-
label_counts[label] = label_counts.get(label, 0) + 1
|
|
361
|
-
milestone = _extract_milestone(issue)
|
|
362
|
-
if (
|
|
363
|
-
milestone
|
|
364
|
-
and milestone not in subscribed_milestones
|
|
365
|
-
and milestone not in ignores["milestones"]
|
|
366
|
-
):
|
|
367
|
-
milestone_counts[milestone] = milestone_counts.get(milestone, 0) + 1
|
|
368
|
-
|
|
369
|
-
surfaced_labels = {
|
|
370
|
-
label: count
|
|
371
|
-
for label, count in label_counts.items()
|
|
372
|
-
if count >= effective_threshold
|
|
373
|
-
}
|
|
374
|
-
surfaced_milestones = {
|
|
375
|
-
name: count
|
|
376
|
-
for name, count in milestone_counts.items()
|
|
377
|
-
if count >= effective_threshold
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
# Re-walk to compute the distinct-issue total -- an issue counts
|
|
381
|
-
# toward ``total`` if any of its labels / its milestone is surfaced.
|
|
382
|
-
# Author-ignored issues are excluded here too so the total stays
|
|
383
|
-
# consistent with the surfaced signals.
|
|
384
|
-
for issue in issues:
|
|
385
|
-
if not _is_open(issue):
|
|
386
|
-
continue
|
|
387
|
-
number = issue.get("number")
|
|
388
|
-
if not isinstance(number, int):
|
|
389
|
-
continue
|
|
390
|
-
if ignored_authors and _extract_author(issue) in ignored_authors:
|
|
391
|
-
continue
|
|
392
|
-
repo_key = _issue_repo_key(issue)
|
|
393
|
-
labels = _extract_labels(issue)
|
|
394
|
-
milestone = _extract_milestone(issue)
|
|
395
|
-
if any(label in surfaced_labels for label in labels) or (
|
|
396
|
-
milestone and milestone in surfaced_milestones
|
|
397
|
-
):
|
|
398
|
-
surfaced_issues.add((repo_key, number))
|
|
399
|
-
|
|
400
|
-
return DriftReport(
|
|
401
|
-
labels=dict(sorted(surfaced_labels.items())),
|
|
402
|
-
milestones=dict(sorted(surfaced_milestones.items())),
|
|
403
|
-
total=len(surfaced_issues),
|
|
404
|
-
threshold=effective_threshold,
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def _issue_repo_key(issue: dict[str, Any]) -> str:
|
|
409
|
-
"""Best-effort repo identifier for a cached issue.
|
|
410
|
-
|
|
411
|
-
Tries ``repository_url`` (the canonical REST field), falls back to
|
|
412
|
-
``html_url``, finally to the empty string. Only used to dedupe the
|
|
413
|
-
distinct-issue total when an operator caches the same issue number
|
|
414
|
-
under two different repos; consumers with a single repo see ``""``
|
|
415
|
-
consistently and the dedupe degrades to a per-number set.
|
|
416
|
-
"""
|
|
417
|
-
for key in ("repository_url", "html_url"):
|
|
418
|
-
value = issue.get(key)
|
|
419
|
-
if isinstance(value, str) and value:
|
|
420
|
-
return value
|
|
421
|
-
return ""
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def render_drift_report(report: DriftReport) -> str:
|
|
425
|
-
"""Render a human-readable view of the report.
|
|
426
|
-
|
|
427
|
-
Format (#1133 issue body, lightly adapted)::
|
|
428
|
-
|
|
429
|
-
[scope-drift] labels not in subscription:
|
|
430
|
-
priority:p0 (12 open issues)
|
|
431
|
-
compat:breaking (4 open issues)
|
|
432
|
-
[scope-drift] milestones not in subscription:
|
|
433
|
-
v2.0-blocker (7 open issues)
|
|
434
|
-
|
|
435
|
-
To subscribe:
|
|
436
|
-
task triage:subscribe -- --label=priority:p0
|
|
437
|
-
task triage:subscribe -- --milestone=v2.0-blocker
|
|
438
|
-
|
|
439
|
-
To suppress (record explicit ignore):
|
|
440
|
-
task triage:scope-drift -- --ignore-label=priority:p0
|
|
441
|
-
task triage:scope-drift -- --ignore-milestone=v2.0-blocker
|
|
442
|
-
|
|
443
|
-
Empty reports render a brief "no drift" notice so the operator can
|
|
444
|
-
distinguish "ran, none surfaced" from "task failed silently".
|
|
445
|
-
"""
|
|
446
|
-
if report.is_empty():
|
|
447
|
-
return (
|
|
448
|
-
"[scope-drift] no unsubscribed labels / milestones found "
|
|
449
|
-
f"(threshold: >= {report.threshold} cached open issues)."
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
lines: list[str] = []
|
|
453
|
-
if report.labels:
|
|
454
|
-
lines.append("[scope-drift] labels not in subscription:")
|
|
455
|
-
width = max(len(name) for name in report.labels)
|
|
456
|
-
for name, count in report.labels.items():
|
|
457
|
-
lines.append(f" {name.ljust(width)} ({count} open issues)")
|
|
458
|
-
if report.milestones:
|
|
459
|
-
if lines:
|
|
460
|
-
lines.append("")
|
|
461
|
-
lines.append("[scope-drift] milestones not in subscription:")
|
|
462
|
-
width = max(len(name) for name in report.milestones)
|
|
463
|
-
for name, count in report.milestones.items():
|
|
464
|
-
lines.append(f" {name.ljust(width)} ({count} open issues)")
|
|
465
|
-
|
|
466
|
-
lines.append("")
|
|
467
|
-
lines.append("To subscribe:")
|
|
468
|
-
for name in report.labels:
|
|
469
|
-
lines.append(f" task triage:subscribe -- --label={name}")
|
|
470
|
-
for name in report.milestones:
|
|
471
|
-
lines.append(f" task triage:subscribe -- --milestone={name}")
|
|
472
|
-
|
|
473
|
-
lines.append("")
|
|
474
|
-
lines.append("To suppress (record explicit ignore):")
|
|
475
|
-
for name in report.labels:
|
|
476
|
-
lines.append(f" task triage:scope-drift -- --ignore-label={name}")
|
|
477
|
-
for name in report.milestones:
|
|
478
|
-
lines.append(f" task triage:scope-drift -- --ignore-milestone={name}")
|
|
479
|
-
|
|
480
|
-
return "\n".join(lines)
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def add_ignore(
|
|
484
|
-
project_root: Path,
|
|
485
|
-
*,
|
|
486
|
-
label: str | None = None,
|
|
487
|
-
milestone: str | None = None,
|
|
488
|
-
) -> tuple[bool, str]:
|
|
489
|
-
"""Append a ``{label|milestone: <name>}`` entry to ``plan.policy.triageScopeIgnores[]``.
|
|
490
|
-
|
|
491
|
-
Exactly one of ``label`` / ``milestone`` MUST be set. Returns
|
|
492
|
-
``(changed, message)`` -- ``changed`` is False when the entry is
|
|
493
|
-
already present (idempotent contract). Writes atomically via
|
|
494
|
-
``os.replace`` so a crash mid-write leaves the file untouched.
|
|
495
|
-
|
|
496
|
-
Raises ``ValueError`` when both / neither argument is supplied or
|
|
497
|
-
when the value is empty.
|
|
498
|
-
"""
|
|
499
|
-
if (label is None) == (milestone is None):
|
|
500
|
-
raise ValueError(
|
|
501
|
-
"add_ignore() requires exactly one of label= / milestone="
|
|
502
|
-
)
|
|
503
|
-
key = "label" if label is not None else "milestone"
|
|
504
|
-
value = (label if label is not None else milestone) or ""
|
|
505
|
-
if not isinstance(value, str) or not value.strip():
|
|
506
|
-
raise ValueError(f"{key} must be a non-empty string; got {value!r}")
|
|
507
|
-
|
|
508
|
-
from _project_definition_io import (
|
|
509
|
-
atomic_write_project_definition,
|
|
510
|
-
load_project_definition_for_mutation,
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
data, path = load_project_definition_for_mutation(project_root)
|
|
514
|
-
plan = data.setdefault("plan", {})
|
|
515
|
-
if not isinstance(plan, dict):
|
|
516
|
-
raise ValueError(
|
|
517
|
-
f"PROJECT-DEFINITION at {path} has a non-object 'plan' key"
|
|
518
|
-
)
|
|
519
|
-
policy = plan.setdefault("policy", {})
|
|
520
|
-
if not isinstance(policy, dict):
|
|
521
|
-
raise ValueError(
|
|
522
|
-
f"PROJECT-DEFINITION at {path} has a non-object 'plan.policy' key"
|
|
523
|
-
)
|
|
524
|
-
raw = policy.setdefault("triageScopeIgnores", [])
|
|
525
|
-
if not isinstance(raw, list):
|
|
526
|
-
raise ValueError(
|
|
527
|
-
f"PROJECT-DEFINITION at {path} has a non-list 'plan.policy.triageScopeIgnores'"
|
|
528
|
-
)
|
|
529
|
-
|
|
530
|
-
before = json.loads(json.dumps(raw))
|
|
531
|
-
for entry in raw:
|
|
532
|
-
if isinstance(entry, dict) and entry.get(key) == value:
|
|
533
|
-
return False, f"already-ignored ({key}={value})"
|
|
534
|
-
|
|
535
|
-
raw.append({key: value})
|
|
536
|
-
atomic_write_project_definition(path, data)
|
|
537
|
-
after = json.loads(json.dumps(raw))
|
|
538
|
-
# D14c (#1182): emit an audit entry on every successful mutation so
|
|
539
|
-
# the ignore-list surface shares the subscription-history.jsonl
|
|
540
|
-
# trail subscribe / unsubscribe write. Failure to import is
|
|
541
|
-
# tolerated -- the audit sidecar is observability, not load-bearing.
|
|
542
|
-
try:
|
|
543
|
-
from triage_subscribe import record_subscription_change
|
|
544
|
-
|
|
545
|
-
record_subscription_change(
|
|
546
|
-
project_root,
|
|
547
|
-
op=f"ignore-{key}",
|
|
548
|
-
label=value if key == "label" else None,
|
|
549
|
-
milestone=value if key == "milestone" else None,
|
|
550
|
-
before=before,
|
|
551
|
-
after=after,
|
|
552
|
-
)
|
|
553
|
-
except Exception: # pragma: no cover -- observability is best-effort
|
|
554
|
-
pass
|
|
555
|
-
return True, f"added ignore ({key}={value})"
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
def main(argv: list[str] | None = None) -> int:
|
|
559
|
-
"""CLI entry point. Delegates to :mod:`_triage_scope_drift_cli`."""
|
|
560
|
-
import sys as _sys
|
|
561
|
-
|
|
562
|
-
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
563
|
-
from triage_help import intercept_help
|
|
564
|
-
|
|
565
|
-
rc = intercept_help("triage_scope_drift", argv)
|
|
566
|
-
if rc is not None:
|
|
567
|
-
return rc
|
|
568
|
-
|
|
569
|
-
from _triage_scope_drift_cli import run_cli # local import: 1000-line cap
|
|
570
|
-
|
|
571
|
-
return run_cli(argv, _sys.modules[__name__])
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if __name__ == "__main__":
|
|
575
|
-
sys.exit(main())
|