@deftai/directive-content 0.55.1 → 0.56.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 +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +2 -2
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pr_check_closing_keywords.py -- Pre-PR closing-keyword negation-context lint (#737).
|
|
4
|
+
|
|
5
|
+
Scans a pull request body AND every commit message in the PR for GitHub
|
|
6
|
+
closing-keyword tokens (``close|closes|closed|fix|fixes|fixed|resolve|
|
|
7
|
+
resolves|resolved``) immediately followed by ``#\\d+`` that appear inside
|
|
8
|
+
a negation, quotation, example, or fenced-code-block context. GitHub's
|
|
9
|
+
closing-keyword parser is substring-based (Layer 1 / Layer 2 / Layer 3
|
|
10
|
+
recurrence record: #167, #698, #701, #735), so a phrase like
|
|
11
|
+
``DOES NOT CLOSE #734`` typed in a PR body or squash-commit footer will
|
|
12
|
+
auto-close the issue regardless of the surrounding semantics.
|
|
13
|
+
|
|
14
|
+
This lint is the **Layer 0 (prevention)** counterpart to the existing
|
|
15
|
+
**Layer 3 (recovery)** ``scripts/pr_check_protected_issues.py`` from
|
|
16
|
+
#701. It refuses to push a PR that contains any negation-context hit so
|
|
17
|
+
the operator can rewrite the wording before GitHub ever sees the body.
|
|
18
|
+
|
|
19
|
+
Background
|
|
20
|
+
----------
|
|
21
|
+
- #167 (Layer 1): post-merge close-verify check (some intended closes
|
|
22
|
+
silently fail to fire).
|
|
23
|
+
- #698 (Layer 2): substring match on PR body even inside negation
|
|
24
|
+
parentheticals (incident: PR #697 auto-closed #642).
|
|
25
|
+
- #701 (Layer 3): persistent ``closingIssuesReferences`` link survives
|
|
26
|
+
body / commit-message edits (incidents: PR #700 closed #233; PR #401
|
|
27
|
+
closed #642).
|
|
28
|
+
- #735: PR squash body contained ``DOES NOT CLOSE #734`` and auto-closed
|
|
29
|
+
#734 (umbrella for #737); manual reopen required. This script is the
|
|
30
|
+
structural gap-closer.
|
|
31
|
+
|
|
32
|
+
Usage
|
|
33
|
+
-----
|
|
34
|
+
# Online: fetch PR <N> body + commit messages via gh.
|
|
35
|
+
uv run python scripts/pr_check_closing_keywords.py --pr 735
|
|
36
|
+
|
|
37
|
+
# Offline: lint pre-staged body / commits files (CI / pre-push hooks).
|
|
38
|
+
uv run python scripts/pr_check_closing_keywords.py \\
|
|
39
|
+
--body-file ./pr-body.md \\
|
|
40
|
+
--commits-file ./commits.txt
|
|
41
|
+
|
|
42
|
+
# Allow seeded false-positives by listing the issue numbers that are
|
|
43
|
+
# known-safe (e.g. test-fixture wordings).
|
|
44
|
+
uv run python scripts/pr_check_closing_keywords.py \\
|
|
45
|
+
--pr 735 --allow-known-false-positives 999,1000
|
|
46
|
+
|
|
47
|
+
Exit codes
|
|
48
|
+
----------
|
|
49
|
+
0 -- clean (no negation-context hits found)
|
|
50
|
+
1 -- one or more negation-context hits found (refuse to push)
|
|
51
|
+
2 -- configuration error (bad args, gh missing, file unreadable, ...)
|
|
52
|
+
|
|
53
|
+
Pure stdlib + ``gh`` CLI; no third-party deps. Story: #737.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import argparse
|
|
59
|
+
import json
|
|
60
|
+
import re
|
|
61
|
+
import subprocess
|
|
62
|
+
import sys
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
|
|
66
|
+
# ---- Exit codes -------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
EXIT_OK = 0
|
|
69
|
+
EXIT_HITS_FOUND = 1
|
|
70
|
+
EXIT_CONFIG_ERROR = 2
|
|
71
|
+
|
|
72
|
+
# ---- Closing-keyword + context patterns -------------------------------------
|
|
73
|
+
|
|
74
|
+
# GitHub's documented closing-keyword set
|
|
75
|
+
# (https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword).
|
|
76
|
+
# We match the bare verb (the parser is whitespace-greedy) plus an
|
|
77
|
+
# immediately-following ``#\d+``. The two-character ``\b`` anchors guard
|
|
78
|
+
# against partial-word collisions (``unclosed`` would not match).
|
|
79
|
+
CLOSING_KEYWORD_RE = re.compile(
|
|
80
|
+
r"\b(?P<keyword>close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)"
|
|
81
|
+
r"\s+#(?P<number>\d+)\b",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Negation markers we look for in the +/-20 char window AROUND the hit.
|
|
86
|
+
# Each marker is an entire substring -- the regex uses ``\b`` where
|
|
87
|
+
# meaningful but stays loose enough to catch capitalised / spaced
|
|
88
|
+
# variants (``DOES NOT``, ``cannot``, ``WITHOUT``, etc.). The list is
|
|
89
|
+
# intentionally generous; the cost of a false-positive is one
|
|
90
|
+
# ``--allow-known-false-positives`` flag, the cost of a missed
|
|
91
|
+
# negation is a real auto-close that the script was supposed to
|
|
92
|
+
# prevent.
|
|
93
|
+
_NEGATION_MARKERS: tuple[re.Pattern[str], ...] = (
|
|
94
|
+
re.compile(r"\bnot\s+", re.IGNORECASE),
|
|
95
|
+
re.compile(r"n't\s+", re.IGNORECASE),
|
|
96
|
+
re.compile(r"\bnever\s+", re.IGNORECASE),
|
|
97
|
+
# Greptile P2: the trailing ``?`` made ``not`` optional, so a literal
|
|
98
|
+
# ``intentionally Closes #N`` (author explicitly calling out a
|
|
99
|
+
# deliberate close) was mis-classified as a negation context. Drop
|
|
100
|
+
# the ``?`` so only ``intentionally not ...`` matches.
|
|
101
|
+
re.compile(r"\bintentionally\s+not\s+", re.IGNORECASE),
|
|
102
|
+
re.compile(r"\bdoes\s+not\b", re.IGNORECASE),
|
|
103
|
+
re.compile(r"\bdo\s+not\b", re.IGNORECASE),
|
|
104
|
+
re.compile(r"\bwon't\b", re.IGNORECASE),
|
|
105
|
+
re.compile(r"\bcannot\b", re.IGNORECASE),
|
|
106
|
+
re.compile(r"\bWITHOUT\b"),
|
|
107
|
+
re.compile(r"\bEXCEPT\b"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Quotation context markers (any of these inside the +/-20 char window
|
|
111
|
+
# treats the hit as quoted). Backticks + ASCII / curly quotes.
|
|
112
|
+
_QUOTE_MARKERS: tuple[str, ...] = ("`", "'", '"', "\u2018", "\u2019", "\u201c", "\u201d")
|
|
113
|
+
|
|
114
|
+
# Example / illustrative-context markers in the +/-20 char window.
|
|
115
|
+
_EXAMPLE_MARKERS: tuple[re.Pattern[str], ...] = (
|
|
116
|
+
re.compile(r"\be\.g\.", re.IGNORECASE),
|
|
117
|
+
re.compile(r"\bi\.e\.", re.IGNORECASE),
|
|
118
|
+
re.compile(r"\bfor\s+example\b", re.IGNORECASE),
|
|
119
|
+
re.compile(r"\bsuch\s+as\b", re.IGNORECASE),
|
|
120
|
+
re.compile(r"\blike\b", re.IGNORECASE),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Blockquote prefix marker -- if the line starts with ``> `` the hit is
|
|
124
|
+
# inside a Markdown blockquote.
|
|
125
|
+
_BLOCKQUOTE_RE = re.compile(r"^\s*>\s", re.MULTILINE)
|
|
126
|
+
|
|
127
|
+
# Code-fence boundary regex (triple-backticks, optionally with a
|
|
128
|
+
# language tag). We track open/close balance to know whether a hit is
|
|
129
|
+
# inside a fenced block.
|
|
130
|
+
_CODE_FENCE_RE = re.compile(r"^```", re.MULTILINE)
|
|
131
|
+
|
|
132
|
+
# Window radius for negation / quotation / example detection (the
|
|
133
|
+
# vBRIEF specifies +/-20 char).
|
|
134
|
+
_WINDOW_RADIUS = 20
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- Hit dataclass ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Hit:
|
|
142
|
+
"""A single closing-keyword + #number occurrence in some text."""
|
|
143
|
+
source: str # "pr-body" or "commit:<sha-or-index>"
|
|
144
|
+
keyword: str # the verb that matched (case preserved)
|
|
145
|
+
issue_number: int
|
|
146
|
+
context: str # short snippet around the hit
|
|
147
|
+
reason: str # human-readable category
|
|
148
|
+
|
|
149
|
+
def render(self) -> str:
|
|
150
|
+
return (
|
|
151
|
+
f" [{self.source}] {self.reason}: "
|
|
152
|
+
f"\"...{self.context}...\" -> {self.keyword} #{self.issue_number}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---- Detection helpers ------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _line_starting_at(text: str, offset: int) -> str:
|
|
160
|
+
"""Return the line of ``text`` containing the byte ``offset``."""
|
|
161
|
+
line_start = text.rfind("\n", 0, offset) + 1
|
|
162
|
+
line_end = text.find("\n", offset)
|
|
163
|
+
if line_end == -1:
|
|
164
|
+
line_end = len(text)
|
|
165
|
+
return text[line_start:line_end]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_inside_code_fence(text: str, offset: int) -> bool:
|
|
169
|
+
"""Return True when ``offset`` falls inside a triple-backtick fence."""
|
|
170
|
+
fences_before = list(_CODE_FENCE_RE.finditer(text[:offset]))
|
|
171
|
+
return len(fences_before) % 2 == 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _classify_hit(text: str, match: re.Match[str]) -> str | None:
|
|
175
|
+
"""Classify the hit's context.
|
|
176
|
+
|
|
177
|
+
Returns one of ``"negation"`` / ``"quotation"`` / ``"example"`` /
|
|
178
|
+
``"code-block"`` / ``"blockquote"`` when the surrounding context
|
|
179
|
+
flags the hit as a false-positive risk; returns ``None`` when the
|
|
180
|
+
hit is a true-positive (real closing keyword the operator wants to
|
|
181
|
+
fire). The caller treats only non-None classifications as findings.
|
|
182
|
+
"""
|
|
183
|
+
start, end = match.start(), match.end()
|
|
184
|
+
# Code-fence context first -- it dominates other classifications.
|
|
185
|
+
if _is_inside_code_fence(text, start):
|
|
186
|
+
return "code-block"
|
|
187
|
+
|
|
188
|
+
# Blockquote context -- if the entire line begins with ``> ``.
|
|
189
|
+
line = _line_starting_at(text, start)
|
|
190
|
+
if _BLOCKQUOTE_RE.match(line):
|
|
191
|
+
return "blockquote"
|
|
192
|
+
|
|
193
|
+
# Local +/-WINDOW_RADIUS window around the hit.
|
|
194
|
+
win_start = max(0, start - _WINDOW_RADIUS)
|
|
195
|
+
win_end = min(len(text), end + _WINDOW_RADIUS)
|
|
196
|
+
window = text[win_start:win_end]
|
|
197
|
+
# The keyword's offset within the window.
|
|
198
|
+
kw_offset = start - win_start
|
|
199
|
+
|
|
200
|
+
# Negation markers anywhere in the window.
|
|
201
|
+
for negation in _NEGATION_MARKERS:
|
|
202
|
+
for m in negation.finditer(window):
|
|
203
|
+
# Negation must precede the closing keyword (left of it) AND
|
|
204
|
+
# be within ~20 chars of the keyword to count.
|
|
205
|
+
if m.end() <= kw_offset:
|
|
206
|
+
return "negation"
|
|
207
|
+
|
|
208
|
+
# Quotation markers immediately surrounding the keyword.
|
|
209
|
+
# Specifically: a quote char in the 5 chars BEFORE the keyword AND
|
|
210
|
+
# one in the closing-keyword segment region (i.e. the keyword token
|
|
211
|
+
# is wrapped). This is a tight check to avoid quoting an entire
|
|
212
|
+
# paragraph triggering a false-positive on every keyword inside.
|
|
213
|
+
pre = text[max(0, start - 3) : start]
|
|
214
|
+
post = text[end : min(len(text), end + 3)]
|
|
215
|
+
if any(q in pre for q in _QUOTE_MARKERS) and any(q in post for q in _QUOTE_MARKERS):
|
|
216
|
+
return "quotation"
|
|
217
|
+
# Backticks specifically can appear on either side (single-side
|
|
218
|
+
# backticks like ``` `Closes #N` ``` are also quotation context).
|
|
219
|
+
if "`" in pre and "`" in post:
|
|
220
|
+
return "quotation"
|
|
221
|
+
|
|
222
|
+
# Example / illustrative markers in the window LEADING UP TO the
|
|
223
|
+
# keyword (e.g. "e.g. Closes #N" -- "e.g." precedes the keyword).
|
|
224
|
+
for example in _EXAMPLE_MARKERS:
|
|
225
|
+
for m in example.finditer(window):
|
|
226
|
+
if m.end() <= kw_offset:
|
|
227
|
+
return "example"
|
|
228
|
+
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def find_hits(text: str, source: str) -> list[Hit]:
|
|
233
|
+
"""Return all negation/quotation/example/code-block hits in ``text``."""
|
|
234
|
+
hits: list[Hit] = []
|
|
235
|
+
for match in CLOSING_KEYWORD_RE.finditer(text):
|
|
236
|
+
category = _classify_hit(text, match)
|
|
237
|
+
if category is None:
|
|
238
|
+
continue
|
|
239
|
+
# Build a short context snippet (+/- 30 chars) for the diagnostic.
|
|
240
|
+
snippet_start = max(0, match.start() - 30)
|
|
241
|
+
snippet_end = min(len(text), match.end() + 30)
|
|
242
|
+
context = text[snippet_start:snippet_end].replace("\n", " ")
|
|
243
|
+
hits.append(
|
|
244
|
+
Hit(
|
|
245
|
+
source=source,
|
|
246
|
+
keyword=match.group("keyword"),
|
|
247
|
+
issue_number=int(match.group("number")),
|
|
248
|
+
context=context,
|
|
249
|
+
reason=category,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return hits
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---- Input collection -------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def fetch_pr_body(pr_number: int, repo: str | None = None) -> str | None:
|
|
259
|
+
"""Fetch the PR body via ``gh pr view --json body``.
|
|
260
|
+
|
|
261
|
+
Returns the body string on success, or ``None`` on external error.
|
|
262
|
+
"""
|
|
263
|
+
cmd = ["gh", "pr", "view", str(pr_number), "--json", "body"]
|
|
264
|
+
if repo:
|
|
265
|
+
cmd.extend(["--repo", repo])
|
|
266
|
+
try:
|
|
267
|
+
result = subprocess.run(
|
|
268
|
+
cmd, capture_output=True, text=True, timeout=30, check=False
|
|
269
|
+
)
|
|
270
|
+
except FileNotFoundError:
|
|
271
|
+
print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
|
|
272
|
+
return None
|
|
273
|
+
except subprocess.TimeoutExpired:
|
|
274
|
+
print(f"Error: gh CLI timed out fetching PR #{pr_number}.", file=sys.stderr)
|
|
275
|
+
return None
|
|
276
|
+
if result.returncode != 0:
|
|
277
|
+
print(
|
|
278
|
+
f"Error: gh CLI failed fetching PR #{pr_number}: "
|
|
279
|
+
f"{result.stderr.strip()}",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
return None
|
|
283
|
+
try:
|
|
284
|
+
payload = json.loads(result.stdout)
|
|
285
|
+
except json.JSONDecodeError as exc:
|
|
286
|
+
print(f"Error: failed to parse gh CLI output: {exc}", file=sys.stderr)
|
|
287
|
+
return None
|
|
288
|
+
# Greptile P1: GitHub returns ``{"body": null}`` for PRs without a
|
|
289
|
+
# description; ``payload.get("body", "")`` only substitutes ``""``
|
|
290
|
+
# when the key is ABSENT, so a present-but-null value would yield
|
|
291
|
+
# ``None`` and the isinstance guard below would fire, mis-mapping a
|
|
292
|
+
# valid empty body to ``EXIT_CONFIG_ERROR``. Coerce ``None`` to ``""``
|
|
293
|
+
# before the type guard so the empty-body case lints clean.
|
|
294
|
+
body = payload.get("body") or ""
|
|
295
|
+
if not isinstance(body, str):
|
|
296
|
+
print(
|
|
297
|
+
f"Error: unexpected body shape: {type(body).__name__}",
|
|
298
|
+
file=sys.stderr,
|
|
299
|
+
)
|
|
300
|
+
return None
|
|
301
|
+
return body
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def fetch_pr_commit_messages(
|
|
305
|
+
pr_number: int, repo: str | None = None
|
|
306
|
+
) -> list[str] | None:
|
|
307
|
+
"""Fetch every commit message body from the PR via ``gh pr view --json commits``."""
|
|
308
|
+
cmd = ["gh", "pr", "view", str(pr_number), "--json", "commits"]
|
|
309
|
+
if repo:
|
|
310
|
+
cmd.extend(["--repo", repo])
|
|
311
|
+
try:
|
|
312
|
+
result = subprocess.run(
|
|
313
|
+
cmd, capture_output=True, text=True, timeout=30, check=False
|
|
314
|
+
)
|
|
315
|
+
except FileNotFoundError:
|
|
316
|
+
print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
|
|
317
|
+
return None
|
|
318
|
+
except subprocess.TimeoutExpired:
|
|
319
|
+
print(
|
|
320
|
+
f"Error: gh CLI timed out fetching commits for PR #{pr_number}.",
|
|
321
|
+
file=sys.stderr,
|
|
322
|
+
)
|
|
323
|
+
return None
|
|
324
|
+
if result.returncode != 0:
|
|
325
|
+
print(
|
|
326
|
+
f"Error: gh CLI failed fetching commits: {result.stderr.strip()}",
|
|
327
|
+
file=sys.stderr,
|
|
328
|
+
)
|
|
329
|
+
return None
|
|
330
|
+
try:
|
|
331
|
+
payload = json.loads(result.stdout)
|
|
332
|
+
except json.JSONDecodeError as exc:
|
|
333
|
+
print(f"Error: failed to parse gh CLI output: {exc}", file=sys.stderr)
|
|
334
|
+
return None
|
|
335
|
+
commits = payload.get("commits", [])
|
|
336
|
+
if not isinstance(commits, list):
|
|
337
|
+
print(
|
|
338
|
+
f"Error: unexpected commits shape: {type(commits).__name__}",
|
|
339
|
+
file=sys.stderr,
|
|
340
|
+
)
|
|
341
|
+
return None
|
|
342
|
+
messages: list[str] = []
|
|
343
|
+
for entry in commits:
|
|
344
|
+
if not isinstance(entry, dict):
|
|
345
|
+
continue
|
|
346
|
+
# gh returns ``{"messageHeadline": ..., "messageBody": ...}`` --
|
|
347
|
+
# join both so the lint covers the headline AND the body.
|
|
348
|
+
headline = entry.get("messageHeadline", "")
|
|
349
|
+
body = entry.get("messageBody", "")
|
|
350
|
+
combined = f"{headline}\n{body}".strip()
|
|
351
|
+
if combined:
|
|
352
|
+
messages.append(combined)
|
|
353
|
+
return messages
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def read_text_file(path: Path) -> str | None:
|
|
357
|
+
try:
|
|
358
|
+
return path.read_text(encoding="utf-8")
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
print(f"Error: failed to read {path}: {exc}", file=sys.stderr)
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def read_commits_file(path: Path) -> list[str] | None:
|
|
365
|
+
"""A commits file is a stream of messages separated by ``\\n--END--\\n``.
|
|
366
|
+
|
|
367
|
+
The shape is intentionally simple so an operator can author one with
|
|
368
|
+
a here-doc / temp-file pattern. Empty messages are stripped.
|
|
369
|
+
"""
|
|
370
|
+
text = read_text_file(path)
|
|
371
|
+
if text is None:
|
|
372
|
+
return None
|
|
373
|
+
return [p.strip() for p in text.split("\n--END--\n") if p.strip()]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---- argparse + main --------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _parse_allow_list(values: list[str]) -> set[int]:
|
|
380
|
+
"""Flatten comma-separated and repeated ``--allow-known-false-positives``."""
|
|
381
|
+
out: set[int] = set()
|
|
382
|
+
for chunk in values:
|
|
383
|
+
for tok in chunk.split(","):
|
|
384
|
+
tok = tok.strip().lstrip("#")
|
|
385
|
+
if not tok:
|
|
386
|
+
continue
|
|
387
|
+
if not tok.isdecimal():
|
|
388
|
+
raise ValueError(
|
|
389
|
+
f"Invalid issue number in --allow-known-false-positives: {tok!r}"
|
|
390
|
+
)
|
|
391
|
+
out.add(int(tok))
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
396
|
+
parser = argparse.ArgumentParser(
|
|
397
|
+
prog="pr_check_closing_keywords",
|
|
398
|
+
description=(
|
|
399
|
+
"Pre-PR closing-keyword negation-context lint (#737). Refuses "
|
|
400
|
+
"with exit 1 when any closing-keyword + #N hit lands in a "
|
|
401
|
+
"negation / quotation / example / code-block context."
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
src = parser.add_argument_group("input source (mutually exclusive)")
|
|
405
|
+
src.add_argument(
|
|
406
|
+
"--pr",
|
|
407
|
+
type=int,
|
|
408
|
+
default=None,
|
|
409
|
+
metavar="N",
|
|
410
|
+
help="Pull request number to inspect (online; uses `gh pr view`).",
|
|
411
|
+
)
|
|
412
|
+
src.add_argument(
|
|
413
|
+
"--body-file",
|
|
414
|
+
type=Path,
|
|
415
|
+
default=None,
|
|
416
|
+
metavar="PATH",
|
|
417
|
+
help="Offline mode: read the PR body from this file.",
|
|
418
|
+
)
|
|
419
|
+
src.add_argument(
|
|
420
|
+
"--commits-file",
|
|
421
|
+
type=Path,
|
|
422
|
+
default=None,
|
|
423
|
+
metavar="PATH",
|
|
424
|
+
help=(
|
|
425
|
+
"Offline mode: read commit messages from this file "
|
|
426
|
+
"(messages separated by `\\n--END--\\n`)."
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
parser.add_argument(
|
|
430
|
+
"--repo",
|
|
431
|
+
default=None,
|
|
432
|
+
metavar="OWNER/REPO",
|
|
433
|
+
help="Override the GitHub repo (used only with --pr).",
|
|
434
|
+
)
|
|
435
|
+
parser.add_argument(
|
|
436
|
+
"--allow-known-false-positives",
|
|
437
|
+
action="append",
|
|
438
|
+
default=[],
|
|
439
|
+
metavar="ISSUE_NUMBERS",
|
|
440
|
+
help=(
|
|
441
|
+
"Comma-separated list of issue numbers to suppress as known "
|
|
442
|
+
"false-positives (e.g. test fixtures or documentation that "
|
|
443
|
+
"legitimately discusses the keyword)."
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
return parser
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main(argv: list[str] | None = None) -> int:
|
|
450
|
+
parser = _build_parser()
|
|
451
|
+
args = parser.parse_args(argv)
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
allow_list = _parse_allow_list(args.allow_known_false_positives)
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
457
|
+
return EXIT_CONFIG_ERROR
|
|
458
|
+
|
|
459
|
+
body_text: str | None = None
|
|
460
|
+
commit_messages: list[str] = []
|
|
461
|
+
|
|
462
|
+
if args.pr is not None:
|
|
463
|
+
body_text = fetch_pr_body(args.pr, repo=args.repo)
|
|
464
|
+
if body_text is None:
|
|
465
|
+
return EXIT_CONFIG_ERROR
|
|
466
|
+
msgs = fetch_pr_commit_messages(args.pr, repo=args.repo)
|
|
467
|
+
if msgs is None:
|
|
468
|
+
return EXIT_CONFIG_ERROR
|
|
469
|
+
commit_messages = msgs
|
|
470
|
+
else:
|
|
471
|
+
if args.body_file is None and args.commits_file is None:
|
|
472
|
+
print(
|
|
473
|
+
"Error: must specify --pr OR --body-file / --commits-file.",
|
|
474
|
+
file=sys.stderr,
|
|
475
|
+
)
|
|
476
|
+
return EXIT_CONFIG_ERROR
|
|
477
|
+
if args.body_file is not None:
|
|
478
|
+
text = read_text_file(args.body_file)
|
|
479
|
+
if text is None:
|
|
480
|
+
return EXIT_CONFIG_ERROR
|
|
481
|
+
body_text = text
|
|
482
|
+
if args.commits_file is not None:
|
|
483
|
+
msgs = read_commits_file(args.commits_file)
|
|
484
|
+
if msgs is None:
|
|
485
|
+
return EXIT_CONFIG_ERROR
|
|
486
|
+
commit_messages = msgs
|
|
487
|
+
|
|
488
|
+
hits: list[Hit] = []
|
|
489
|
+
if body_text is not None:
|
|
490
|
+
hits.extend(find_hits(body_text, source="pr-body"))
|
|
491
|
+
for idx, msg in enumerate(commit_messages):
|
|
492
|
+
hits.extend(find_hits(msg, source=f"commit:{idx}"))
|
|
493
|
+
|
|
494
|
+
# Filter the allow-list.
|
|
495
|
+
filtered = [h for h in hits if h.issue_number not in allow_list]
|
|
496
|
+
|
|
497
|
+
if not filtered:
|
|
498
|
+
if hits:
|
|
499
|
+
print(
|
|
500
|
+
f"OK: {len(hits)} hit(s) suppressed by "
|
|
501
|
+
f"--allow-known-false-positives.",
|
|
502
|
+
file=sys.stderr,
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
print(
|
|
506
|
+
"OK: no closing-keyword negation/quotation/example/code-block "
|
|
507
|
+
"hits found.",
|
|
508
|
+
file=sys.stderr,
|
|
509
|
+
)
|
|
510
|
+
return EXIT_OK
|
|
511
|
+
|
|
512
|
+
print(
|
|
513
|
+
f"FAIL: {len(filtered)} closing-keyword negation-context hit(s) found "
|
|
514
|
+
"(see #737). Rewrite the PR body / commit messages to avoid the "
|
|
515
|
+
"trigger token, or pass --allow-known-false-positives to suppress.",
|
|
516
|
+
file=sys.stderr,
|
|
517
|
+
)
|
|
518
|
+
for h in filtered:
|
|
519
|
+
print(h.render(), file=sys.stderr)
|
|
520
|
+
return EXIT_HITS_FOUND
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
if __name__ == "__main__":
|
|
524
|
+
raise SystemExit(main())
|