perturb 0.0.1__py3-none-any.whl
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.
- perturb/__init__.py +3 -0
- perturb/ack.py +47 -0
- perturb/adr.py +294 -0
- perturb/areas.py +72 -0
- perturb/check.py +244 -0
- perturb/cli.py +1284 -0
- perturb/config.py +111 -0
- perturb/envelope.py +66 -0
- perturb/events.py +217 -0
- perturb/github.py +115 -0
- perturb/graph.py +170 -0
- perturb/graph_export.py +71 -0
- perturb/ids.py +15 -0
- perturb/inbox.py +202 -0
- perturb/init.py +98 -0
- perturb/propose.py +913 -0
- perturb/push.py +56 -0
- perturb/queue.py +65 -0
- perturb/refs.py +45 -0
- perturb/show.py +81 -0
- perturb/stale.py +44 -0
- perturb/sync.py +166 -0
- perturb/transitions.py +47 -0
- perturb-0.0.1.dist-info/METADATA +220 -0
- perturb-0.0.1.dist-info/RECORD +28 -0
- perturb-0.0.1.dist-info/WHEEL +4 -0
- perturb-0.0.1.dist-info/entry_points.txt +2 -0
- perturb-0.0.1.dist-info/licenses/LICENSE +21 -0
perturb/__init__.py
ADDED
perturb/ack.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from perturb.envelope import Refusal
|
|
2
|
+
from perturb.events import EventStore
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def ack(events_dir, *, target, event_ids, all_pending, plan, plan_blob, by, note, now=None):
|
|
6
|
+
kwargs = {} if now is None else {"now": now}
|
|
7
|
+
store = EventStore(events_dir, **kwargs)
|
|
8
|
+
load_result = store.load()
|
|
9
|
+
events_by_id = {e.id: e for e in load_result.events}
|
|
10
|
+
|
|
11
|
+
if all_pending and event_ids:
|
|
12
|
+
raise Refusal("ack_conflicting_selection")
|
|
13
|
+
if not all_pending and not event_ids:
|
|
14
|
+
raise Refusal("ack_no_selection")
|
|
15
|
+
|
|
16
|
+
if all_pending:
|
|
17
|
+
ids_to_ack = [
|
|
18
|
+
e.id for e in load_result.events if e.target == target and e.status == "pending"
|
|
19
|
+
]
|
|
20
|
+
else:
|
|
21
|
+
for event_id in event_ids:
|
|
22
|
+
if event_id not in events_by_id:
|
|
23
|
+
raise Refusal("unknown_event", f"event {event_id!r} not found")
|
|
24
|
+
event = events_by_id[event_id]
|
|
25
|
+
if event.target != target:
|
|
26
|
+
raise Refusal("event_target_mismatch", f"{event_id} targets {event.target}")
|
|
27
|
+
if event.status not in ("pending", "acknowledged"):
|
|
28
|
+
raise Refusal("event_not_pending", f"{event_id} is {event.status!r}")
|
|
29
|
+
ids_to_ack = list(event_ids)
|
|
30
|
+
|
|
31
|
+
acked = []
|
|
32
|
+
for event_id in ids_to_ack:
|
|
33
|
+
was_acknowledged = events_by_id[event_id].status == "acknowledged"
|
|
34
|
+
store.acknowledge(event_id, by=by, plan=plan, plan_blob=plan_blob, note=note)
|
|
35
|
+
acked.append({"id": event_id, "target": target, "was_acknowledged": was_acknowledged})
|
|
36
|
+
|
|
37
|
+
return {"acked": acked}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_ack(data):
|
|
41
|
+
acked = data["acked"]
|
|
42
|
+
target = acked[0]["target"] if acked else ""
|
|
43
|
+
lines = [f"Acked {len(acked)} event(s) for {target}:"]
|
|
44
|
+
for entry in acked:
|
|
45
|
+
suffix = " (re-acked)" if entry["was_acknowledged"] else ""
|
|
46
|
+
lines.append(f" {entry['id']}{suffix}")
|
|
47
|
+
return "\n".join(lines) + "\n"
|
perturb/adr.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdrError(ValueError):
|
|
10
|
+
def __init__(self, reason: str, detail: str = "") -> None:
|
|
11
|
+
super().__init__(detail or reason)
|
|
12
|
+
self.reason = reason
|
|
13
|
+
self.detail = detail
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Consequence:
|
|
18
|
+
id: str
|
|
19
|
+
text: str
|
|
20
|
+
affects: list
|
|
21
|
+
kind: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Adr:
|
|
26
|
+
id: int
|
|
27
|
+
title: str
|
|
28
|
+
status: str
|
|
29
|
+
date: str
|
|
30
|
+
supersedes: list
|
|
31
|
+
areas: list
|
|
32
|
+
consequences: list
|
|
33
|
+
superseded_by: str | None = None
|
|
34
|
+
no_propagation: bool = False
|
|
35
|
+
no_propagation_reason: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_SUPERSEDES_REF = re.compile(r"adr:(\d+)(?:#([A-Za-z0-9][\w.-]*))?")
|
|
39
|
+
_PROSE_ADR_REF = re.compile(r"\bADR[\s:-]*(\d+)", re.IGNORECASE)
|
|
40
|
+
_MARKDOWN_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
|
41
|
+
_STATUS_SEPARATORS = " \t·•|,;—–-"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_supersedes_ref(entry) -> tuple[int, str | None] | None:
|
|
45
|
+
"""`adr:NNNN` → (N, None); `adr:NNNN#consequence-id` → (N, id); anything else → None."""
|
|
46
|
+
if not isinstance(entry, str):
|
|
47
|
+
return None
|
|
48
|
+
m = _SUPERSEDES_REF.fullmatch(entry)
|
|
49
|
+
if m is None:
|
|
50
|
+
return None
|
|
51
|
+
return int(m.group(1)), m.group(2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _whole_adr_numbers(text: str) -> list[int] | None:
|
|
55
|
+
"""The ADR numbers a prose Supersedes line names, when it names nothing but whole ADRs."""
|
|
56
|
+
plain = _MARKDOWN_LINK.sub(r"\1", text)
|
|
57
|
+
numbers = [int(n) for n in _PROSE_ADR_REF.findall(plain)]
|
|
58
|
+
rest = re.sub(r"\band\b|[,;&.\s]", "", _PROSE_ADR_REF.sub("", plain), flags=re.IGNORECASE)
|
|
59
|
+
return numbers if numbers and not rest else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def migrate_adr(text: str, adr_id: int) -> tuple[str, list[str]]:
|
|
63
|
+
"""Rewrite a prose ADR in the structured format. Returns the new text and warnings about
|
|
64
|
+
anything that could not be carried into the front-matter."""
|
|
65
|
+
if text.lstrip().startswith("---"):
|
|
66
|
+
raise AdrError("already_structured", "ADR already has front-matter; not re-migrating")
|
|
67
|
+
|
|
68
|
+
lines = text.split("\n")
|
|
69
|
+
warnings: list[str] = []
|
|
70
|
+
first_heading = next((i for i, line in enumerate(lines) if line.startswith("## ")), len(lines))
|
|
71
|
+
carried: set[int] = set()
|
|
72
|
+
|
|
73
|
+
# Extract title from first # heading, stripping "ADR NNNN [—–-] " prefix
|
|
74
|
+
title = ""
|
|
75
|
+
for i, line in enumerate(lines):
|
|
76
|
+
if line.startswith("# "):
|
|
77
|
+
raw = line[2:].strip()
|
|
78
|
+
raw = re.sub(r"^ADR\s+\d+\s*[—–-]\s*", "", raw)
|
|
79
|
+
title = raw
|
|
80
|
+
carried.add(i)
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
# Extract status and date from the **Status:** line
|
|
84
|
+
status = ""
|
|
85
|
+
date = ""
|
|
86
|
+
for i, line in enumerate(lines):
|
|
87
|
+
if "**Status:**" in line:
|
|
88
|
+
carried.add(i)
|
|
89
|
+
rest = line.split("**Status:**", 1)[1]
|
|
90
|
+
m_status = re.match(r"\s*([A-Za-z]+)", rest)
|
|
91
|
+
if m_status:
|
|
92
|
+
status = m_status.group(1).lower()
|
|
93
|
+
rest = rest[m_status.end() :]
|
|
94
|
+
m_date = re.search(r"\d{4}-\d{2}-\d{2}", rest)
|
|
95
|
+
if m_date:
|
|
96
|
+
date = m_date.group(0)
|
|
97
|
+
rest = rest[: m_date.start()] + rest[m_date.end() :]
|
|
98
|
+
annotation = rest.strip(_STATUS_SEPARATORS)
|
|
99
|
+
if annotation:
|
|
100
|
+
warnings.append(
|
|
101
|
+
f"status line annotation not carried into the front-matter: {annotation}"
|
|
102
|
+
)
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
# Carry a **Supersedes:** line naming whole ADRs; keep any other in the body
|
|
106
|
+
supersedes: list[str] = []
|
|
107
|
+
for i, line in enumerate(lines[:first_heading]):
|
|
108
|
+
if "**Supersedes:**" not in line:
|
|
109
|
+
continue
|
|
110
|
+
named = line.split("**Supersedes:**", 1)[1].strip()
|
|
111
|
+
numbers = _whole_adr_numbers(named)
|
|
112
|
+
if numbers is not None:
|
|
113
|
+
supersedes = [f"adr:{n:04d}" for n in numbers]
|
|
114
|
+
carried.add(i)
|
|
115
|
+
else:
|
|
116
|
+
mentioned = _PROSE_ADR_REF.search(_MARKDOWN_LINK.sub(r"\1", named))
|
|
117
|
+
example = f"adr:{int(mentioned.group(1)):04d}" if mentioned else "adr:NNNN"
|
|
118
|
+
warnings.append(
|
|
119
|
+
f"**Supersedes:** line kept in the body, not carried into supersedes: {named}. "
|
|
120
|
+
f'To supersede one consequence, add supersedes: ["{example}#<consequence-id>"]'
|
|
121
|
+
)
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
preamble = [line for i, line in enumerate(lines[:first_heading]) if i not in carried]
|
|
125
|
+
while preamble and not preamble[0].strip():
|
|
126
|
+
preamble.pop(0)
|
|
127
|
+
while preamble and not preamble[-1].strip():
|
|
128
|
+
preamble.pop()
|
|
129
|
+
|
|
130
|
+
# Extract the Context/Decision body (verbatim slice before ## Consequences)
|
|
131
|
+
body_lines: list[str] = []
|
|
132
|
+
in_body = False
|
|
133
|
+
consequence_lines: list[str] = []
|
|
134
|
+
in_consequences = False
|
|
135
|
+
for line in lines:
|
|
136
|
+
if line.startswith("## Consequences"):
|
|
137
|
+
in_consequences = True
|
|
138
|
+
continue
|
|
139
|
+
if in_consequences and line.startswith("## "):
|
|
140
|
+
break
|
|
141
|
+
if in_consequences:
|
|
142
|
+
consequence_lines.append(line)
|
|
143
|
+
continue
|
|
144
|
+
if not in_body and line.startswith("## ") and not line.startswith("## Consequences"):
|
|
145
|
+
in_body = True
|
|
146
|
+
if in_body:
|
|
147
|
+
body_lines.append(line)
|
|
148
|
+
|
|
149
|
+
bullets = _parse_prose_bullets(consequence_lines)
|
|
150
|
+
|
|
151
|
+
entries = []
|
|
152
|
+
seen_slugs: dict[str, int] = {}
|
|
153
|
+
for bullet_text in bullets:
|
|
154
|
+
affects = list(dict.fromkeys(f"#{m}" for m in re.findall(r"#(\d+)", bullet_text)))
|
|
155
|
+
slug = _consequence_slug(bullet_text)
|
|
156
|
+
if slug in seen_slugs:
|
|
157
|
+
seen_slugs[slug] += 1
|
|
158
|
+
slug = f"{slug}-{seen_slugs[slug]}"
|
|
159
|
+
else:
|
|
160
|
+
seen_slugs[slug] = 1
|
|
161
|
+
entry: dict = {"id": slug, "text": bullet_text, "kind": "decision"}
|
|
162
|
+
if affects:
|
|
163
|
+
entry["affects"] = affects
|
|
164
|
+
entries.append(entry)
|
|
165
|
+
|
|
166
|
+
fm = (
|
|
167
|
+
f"---\nid: {adr_id}\ntitle: {title}\nstatus: {status}\n"
|
|
168
|
+
f"date: {date}\nsupersedes: {json.dumps(supersedes)}\nareas: []\n---\n"
|
|
169
|
+
)
|
|
170
|
+
body = "\n".join(body_lines).rstrip("\n") + "\n" if body_lines else ""
|
|
171
|
+
if preamble:
|
|
172
|
+
body = "\n".join(preamble) + "\n" + ("\n" + body if body else "")
|
|
173
|
+
consequences_yaml = yaml.safe_dump(
|
|
174
|
+
entries, sort_keys=False, default_flow_style=False, allow_unicode=True, width=100
|
|
175
|
+
)
|
|
176
|
+
return f"{fm}\n{body}\n## Consequences\n\n```yaml\n{consequences_yaml}```\n", warnings
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_prose_bullets(lines: list[str]) -> list[str]:
|
|
180
|
+
bullets: list[str] = []
|
|
181
|
+
current: list[str] = []
|
|
182
|
+
for line in lines:
|
|
183
|
+
if line.startswith("- "):
|
|
184
|
+
if current:
|
|
185
|
+
bullets.append(" ".join(current))
|
|
186
|
+
current = [line[2:].rstrip()]
|
|
187
|
+
elif line.startswith("### "):
|
|
188
|
+
continue
|
|
189
|
+
elif line.strip() == "":
|
|
190
|
+
if current:
|
|
191
|
+
bullets.append(" ".join(current))
|
|
192
|
+
current = []
|
|
193
|
+
elif current:
|
|
194
|
+
current.append(line.strip())
|
|
195
|
+
if current:
|
|
196
|
+
bullets.append(" ".join(current))
|
|
197
|
+
return bullets
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _consequence_slug(text: str) -> str:
|
|
201
|
+
cleaned = re.sub(r"#\d+", "", text).lower()
|
|
202
|
+
words = re.findall(r"[A-Za-z0-9]+", cleaned)[:5]
|
|
203
|
+
return "-".join(words)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_adr(text: str) -> Adr:
|
|
207
|
+
lines = text.split("\n")
|
|
208
|
+
try:
|
|
209
|
+
first = lines.index("---")
|
|
210
|
+
second = lines.index("---", first + 1)
|
|
211
|
+
except ValueError:
|
|
212
|
+
raise AdrError("bad_frontmatter", "ADR must begin with a --- front-matter block") from None
|
|
213
|
+
raw_fm = "\n".join(lines[first + 1 : second])
|
|
214
|
+
try:
|
|
215
|
+
fm = yaml.safe_load(raw_fm)
|
|
216
|
+
except yaml.YAMLError as exc:
|
|
217
|
+
raise AdrError("bad_frontmatter", f"front-matter is not valid YAML: {exc}") from exc
|
|
218
|
+
if not isinstance(fm, dict):
|
|
219
|
+
raise AdrError("bad_frontmatter", "front-matter parsed to a non-mapping")
|
|
220
|
+
|
|
221
|
+
for field in ("id", "title", "status", "date"):
|
|
222
|
+
if fm.get(field) is None:
|
|
223
|
+
raise AdrError("missing_field", f"required front-matter field '{field}' is absent")
|
|
224
|
+
|
|
225
|
+
date_val = fm.get("date")
|
|
226
|
+
if isinstance(date_val, datetime.date):
|
|
227
|
+
date_val = str(date_val)
|
|
228
|
+
|
|
229
|
+
consequences = _parse_consequences(lines[second + 1 :])
|
|
230
|
+
|
|
231
|
+
r = fm.get("no-propagation-reason")
|
|
232
|
+
return Adr(
|
|
233
|
+
id=fm.get("id"),
|
|
234
|
+
title=fm.get("title"),
|
|
235
|
+
status=fm.get("status"),
|
|
236
|
+
date=date_val,
|
|
237
|
+
supersedes=fm.get("supersedes") or [],
|
|
238
|
+
areas=fm.get("areas") or [],
|
|
239
|
+
consequences=consequences,
|
|
240
|
+
superseded_by=fm.get("superseded_by"),
|
|
241
|
+
no_propagation=fm.get("no-propagation") is True,
|
|
242
|
+
no_propagation_reason=r if isinstance(r, str) else None,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _parse_consequences(lines: list) -> list:
|
|
247
|
+
in_section = False
|
|
248
|
+
section_lines = []
|
|
249
|
+
for line in lines:
|
|
250
|
+
if line.startswith("## Consequences"):
|
|
251
|
+
in_section = True
|
|
252
|
+
continue
|
|
253
|
+
if in_section and line.startswith("## "):
|
|
254
|
+
break
|
|
255
|
+
if in_section:
|
|
256
|
+
section_lines.append(line)
|
|
257
|
+
|
|
258
|
+
content = "\n".join(section_lines)
|
|
259
|
+
# strip optional ```yaml ... ``` fence
|
|
260
|
+
stripped = content.strip()
|
|
261
|
+
if stripped.startswith("```"):
|
|
262
|
+
first_nl = stripped.index("\n")
|
|
263
|
+
stripped = stripped[first_nl + 1 :]
|
|
264
|
+
if stripped.endswith("```"):
|
|
265
|
+
stripped = stripped[: stripped.rfind("```")]
|
|
266
|
+
|
|
267
|
+
entries = yaml.safe_load(stripped) or []
|
|
268
|
+
if not isinstance(entries, list):
|
|
269
|
+
raise AdrError("bad_consequences", "Consequences block must be a YAML list")
|
|
270
|
+
result = []
|
|
271
|
+
seen_ids: set[str] = set()
|
|
272
|
+
for entry in entries:
|
|
273
|
+
for field in ("id", "text"):
|
|
274
|
+
if entry.get(field) is None:
|
|
275
|
+
raise AdrError(
|
|
276
|
+
"missing_consequence_field",
|
|
277
|
+
f"consequence missing required field '{field}'",
|
|
278
|
+
)
|
|
279
|
+
cid = entry.get("id")
|
|
280
|
+
if cid in seen_ids:
|
|
281
|
+
raise AdrError(
|
|
282
|
+
"duplicate_consequence_id",
|
|
283
|
+
f"consequence id '{cid}' appears more than once",
|
|
284
|
+
)
|
|
285
|
+
seen_ids.add(cid)
|
|
286
|
+
result.append(
|
|
287
|
+
Consequence(
|
|
288
|
+
id=entry.get("id"),
|
|
289
|
+
text=entry.get("text"),
|
|
290
|
+
affects=entry.get("affects") or [],
|
|
291
|
+
kind=entry.get("kind", "decision"),
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
return result
|
perturb/areas.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pathspec
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Area:
|
|
13
|
+
slug: str
|
|
14
|
+
paths: list[str]
|
|
15
|
+
issues_label: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AreaSet:
|
|
20
|
+
areas: list[Area]
|
|
21
|
+
errors: list[str]
|
|
22
|
+
|
|
23
|
+
def touches(self, files: Iterable[str]) -> set[str]:
|
|
24
|
+
file_list = list(files)
|
|
25
|
+
result: set[str] = set()
|
|
26
|
+
for area in self.areas:
|
|
27
|
+
spec = pathspec.PathSpec.from_lines("gitignore", area.paths)
|
|
28
|
+
if any(spec.match_file(f) for f in file_list):
|
|
29
|
+
result.add(area.slug)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
def in_area(
|
|
33
|
+
self,
|
|
34
|
+
labels: Iterable[str] = (),
|
|
35
|
+
plan_areas: Iterable[str] = (),
|
|
36
|
+
) -> set[str]:
|
|
37
|
+
label_set = set(labels)
|
|
38
|
+
plan_area_set = set(plan_areas)
|
|
39
|
+
result: set[str] = set()
|
|
40
|
+
for area in self.areas:
|
|
41
|
+
if area.issues_label in label_set or area.slug in plan_area_set:
|
|
42
|
+
result.add(area.slug)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_areas(path: Path) -> AreaSet:
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return AreaSet([], [])
|
|
49
|
+
doc = yaml.safe_load(path.read_text())
|
|
50
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("areas"), dict):
|
|
51
|
+
return AreaSet([], ["areas.yaml: expected a dict with an 'areas' dict"])
|
|
52
|
+
areas_map: dict = doc["areas"]
|
|
53
|
+
areas: list[Area] = []
|
|
54
|
+
errors: list[str] = []
|
|
55
|
+
for slug, entry in areas_map.items():
|
|
56
|
+
if not isinstance(entry, dict) or "paths" not in entry:
|
|
57
|
+
errors.append(f"area '{slug}': missing 'paths' key, skipped")
|
|
58
|
+
continue
|
|
59
|
+
issues_label = entry.get("issues_label") or f"area:{slug}"
|
|
60
|
+
areas.append(Area(slug=slug, paths=list(entry["paths"]), issues_label=issues_label))
|
|
61
|
+
return AreaSet(areas=areas, errors=errors)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_plan_areas(text: str) -> list[str]:
|
|
65
|
+
parts = text.split("---", 2)
|
|
66
|
+
if len(parts) < 3:
|
|
67
|
+
return []
|
|
68
|
+
fm = yaml.safe_load(parts[1])
|
|
69
|
+
if not isinstance(fm, dict):
|
|
70
|
+
return []
|
|
71
|
+
areas = fm.get("areas")
|
|
72
|
+
return areas if isinstance(areas, list) else []
|
perturb/check.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from perturb.adr import Adr, AdrError, parse_adr, parse_supersedes_ref
|
|
5
|
+
from perturb.areas import AreaSet
|
|
6
|
+
from perturb.inbox import resolve_excerpt
|
|
7
|
+
from perturb.refs import RefError, parse_ref
|
|
8
|
+
from perturb.stale import stale_findings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def adr_findings(adr_dir: Path, graph: dict, area_set: AreaSet | None = None) -> list[dict]:
|
|
12
|
+
findings = []
|
|
13
|
+
parsed: dict[int, Adr] = {}
|
|
14
|
+
|
|
15
|
+
for path in sorted(adr_dir.glob("*.md")):
|
|
16
|
+
try:
|
|
17
|
+
adr = parse_adr(path.read_text())
|
|
18
|
+
except AdrError as exc:
|
|
19
|
+
findings.append(
|
|
20
|
+
{
|
|
21
|
+
"kind": "adr_parse",
|
|
22
|
+
"ref": path.name,
|
|
23
|
+
"detail": str(exc),
|
|
24
|
+
"fix": f"fix the front-matter or body of {path.name}",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
continue
|
|
28
|
+
parsed[adr.id] = adr
|
|
29
|
+
m = re.match(r"(\d+)", path.name)
|
|
30
|
+
if m and int(m.group(1)) != adr.id:
|
|
31
|
+
findings.append(
|
|
32
|
+
{
|
|
33
|
+
"kind": "filename_id_mismatch",
|
|
34
|
+
"ref": path.name,
|
|
35
|
+
"detail": f"filename number {int(m.group(1))} != front-matter id {adr.id}",
|
|
36
|
+
"fix": f"rename the file to {adr.id:04d}-*.md or update the front-matter id",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
if adr.status == "superseded" and not adr.superseded_by:
|
|
40
|
+
findings.append(
|
|
41
|
+
{
|
|
42
|
+
"kind": "superseded_no_link",
|
|
43
|
+
"ref": path.name,
|
|
44
|
+
"detail": "status is superseded but superseded_by is absent",
|
|
45
|
+
"fix": f"add superseded_by: adr:NNNN to the front-matter of {path.name}",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
for consequence in adr.consequences:
|
|
49
|
+
for affects_entry in consequence.affects:
|
|
50
|
+
try:
|
|
51
|
+
ref = parse_ref(affects_entry)
|
|
52
|
+
except RefError:
|
|
53
|
+
continue
|
|
54
|
+
if ref.kind == "issue" and ref.id not in graph.get("issues", {}):
|
|
55
|
+
findings.append(
|
|
56
|
+
{
|
|
57
|
+
"kind": "affects_unresolved",
|
|
58
|
+
"ref": affects_entry,
|
|
59
|
+
"detail": f"issue {affects_entry} not found in graph",
|
|
60
|
+
"fix": (
|
|
61
|
+
f"perturb sync # then re-check, or remove "
|
|
62
|
+
f"{affects_entry} from affects"
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
elif ref.kind == "area" and area_set is not None:
|
|
67
|
+
declared = {a.slug for a in area_set.areas}
|
|
68
|
+
if ref.id not in declared:
|
|
69
|
+
findings.append(
|
|
70
|
+
{
|
|
71
|
+
"kind": "affects_unresolved",
|
|
72
|
+
"ref": affects_entry,
|
|
73
|
+
"detail": f"area {affects_entry} not declared in areas.yaml",
|
|
74
|
+
"fix": (
|
|
75
|
+
f"add {ref.id} to perturb/areas.yaml, or remove "
|
|
76
|
+
f"{affects_entry} from affects"
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Supersedes entries must name an ADR in this directory, and a consequence it has
|
|
82
|
+
for adr in parsed.values():
|
|
83
|
+
own = f"adr:{adr.id:04d}"
|
|
84
|
+
for entry in adr.supersedes:
|
|
85
|
+
target_ref = parse_supersedes_ref(entry)
|
|
86
|
+
if target_ref is None:
|
|
87
|
+
findings.append(
|
|
88
|
+
{
|
|
89
|
+
"kind": "supersedes_invalid",
|
|
90
|
+
"ref": str(entry),
|
|
91
|
+
"detail": f"{own} supersedes {entry!r}, not adr:NNNN or adr:NNNN#id",
|
|
92
|
+
"fix": f"write it as adr:NNNN or adr:NNNN#<consequence-id> in {own}",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
number, consequence_id = target_ref
|
|
97
|
+
target = parsed.get(number)
|
|
98
|
+
if target is None:
|
|
99
|
+
detail = f"{own} supersedes adr:{number:04d}, which is not in {adr_dir}"
|
|
100
|
+
elif consequence_id is not None and consequence_id not in {
|
|
101
|
+
c.id for c in target.consequences
|
|
102
|
+
}:
|
|
103
|
+
detail = f"adr:{number:04d} has no consequence {consequence_id!r}"
|
|
104
|
+
else:
|
|
105
|
+
continue
|
|
106
|
+
findings.append(
|
|
107
|
+
{
|
|
108
|
+
"kind": "supersedes_unresolved",
|
|
109
|
+
"ref": str(entry),
|
|
110
|
+
"detail": detail,
|
|
111
|
+
"fix": f"correct the supersedes entry {entry!r} in {own}",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Backlink check: superseded_by target must list this ADR in its supersedes
|
|
116
|
+
for adr in parsed.values():
|
|
117
|
+
if not adr.superseded_by:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
target_ref = parse_ref(adr.superseded_by)
|
|
121
|
+
except RefError:
|
|
122
|
+
continue
|
|
123
|
+
if target_ref.kind != "adr":
|
|
124
|
+
continue
|
|
125
|
+
target_id = int(target_ref.id)
|
|
126
|
+
target = parsed.get(target_id)
|
|
127
|
+
if target is None:
|
|
128
|
+
continue
|
|
129
|
+
expected_back = f"adr:{adr.id:04d}"
|
|
130
|
+
if expected_back not in target.supersedes:
|
|
131
|
+
findings.append(
|
|
132
|
+
{
|
|
133
|
+
"kind": "supersede_backlink",
|
|
134
|
+
"ref": f"adr:{adr.id:04d}",
|
|
135
|
+
"detail": (
|
|
136
|
+
f"adr:{adr.id:04d} has superseded_by adr:{target_id:04d} "
|
|
137
|
+
f"but adr:{target_id:04d} does not list adr:{adr.id:04d} in supersedes"
|
|
138
|
+
),
|
|
139
|
+
"fix": (f"add adr:{adr.id:04d} to the supersedes list of adr:{target_id:04d}"),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return findings
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def event_findings(events: list, graph: dict, repo_root: Path) -> list[dict]:
|
|
147
|
+
findings = []
|
|
148
|
+
for event in events:
|
|
149
|
+
if event.detail:
|
|
150
|
+
_excerpt, warning = resolve_excerpt(repo_root, event.detail)
|
|
151
|
+
if warning is not None:
|
|
152
|
+
findings.append(
|
|
153
|
+
{
|
|
154
|
+
"kind": "dead_anchor",
|
|
155
|
+
"ref": event.id,
|
|
156
|
+
"detail": warning,
|
|
157
|
+
"fix": f"update the detail field of event {event.id}",
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
if event.status == "pending":
|
|
161
|
+
try:
|
|
162
|
+
ref = parse_ref(event.target)
|
|
163
|
+
except RefError:
|
|
164
|
+
ref = None
|
|
165
|
+
if (
|
|
166
|
+
ref is not None
|
|
167
|
+
and ref.kind == "issue"
|
|
168
|
+
and graph.get("issues", {}).get(ref.id, {}).get("state") == "CLOSED"
|
|
169
|
+
):
|
|
170
|
+
findings.append(
|
|
171
|
+
{
|
|
172
|
+
"kind": "pending_on_closed",
|
|
173
|
+
"ref": event.target,
|
|
174
|
+
"detail": (
|
|
175
|
+
f"event {event.id} is pending but its target {event.target} is closed"
|
|
176
|
+
),
|
|
177
|
+
"fix": (f"perturb dismiss {event.id} # issue {event.target} is closed"),
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
return findings
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def stale_check_findings(planned: list, events: list) -> list[dict]:
|
|
184
|
+
findings = []
|
|
185
|
+
for s in stale_findings(planned, events):
|
|
186
|
+
findings.append(
|
|
187
|
+
{
|
|
188
|
+
"kind": "stale_plan",
|
|
189
|
+
"ref": f"#{s['issue']}",
|
|
190
|
+
"detail": f"plan {s['plan']} has {len(s['stale_events'])} stale event(s)",
|
|
191
|
+
"fix": f"perturb ack # or update the plan for #{s['issue']}",
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
return findings
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def render_check(findings: list[dict]) -> str:
|
|
198
|
+
if not findings:
|
|
199
|
+
return "ok\n"
|
|
200
|
+
lines = []
|
|
201
|
+
for f in findings:
|
|
202
|
+
lines.append(f"{f['kind']} {f['ref']} {f['detail']}")
|
|
203
|
+
lines.append(f" fix: {f['fix']}")
|
|
204
|
+
return "\n".join(lines) + "\n"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def unpropagated_adr_findings(adr_dir: Path, events: list) -> list[dict]:
|
|
208
|
+
findings = []
|
|
209
|
+
for path in sorted(adr_dir.glob("*.md")):
|
|
210
|
+
try:
|
|
211
|
+
adr = parse_adr(path.read_text())
|
|
212
|
+
except AdrError:
|
|
213
|
+
continue
|
|
214
|
+
if adr.status != "accepted":
|
|
215
|
+
continue
|
|
216
|
+
padded = f"adr:{adr.id:04d}"
|
|
217
|
+
if adr.no_propagation:
|
|
218
|
+
reason = adr.no_propagation_reason
|
|
219
|
+
if reason is None or not reason.strip():
|
|
220
|
+
findings.append(
|
|
221
|
+
{
|
|
222
|
+
"kind": "no_propagation_without_reason",
|
|
223
|
+
"ref": path.name,
|
|
224
|
+
"detail": "no-propagation: true needs a non-empty no-propagation-reason",
|
|
225
|
+
"fix": (
|
|
226
|
+
f'add no-propagation-reason: "<why>" to the front-matter of {path.name}'
|
|
227
|
+
),
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
has_event = any(e.source == padded or e.source.startswith(padded + "#") for e in events)
|
|
232
|
+
if not has_event:
|
|
233
|
+
findings.append(
|
|
234
|
+
{
|
|
235
|
+
"kind": "adr_unpropagated",
|
|
236
|
+
"ref": path.name,
|
|
237
|
+
"detail": f"accepted ADR {padded} has no events and no no-propagation flag",
|
|
238
|
+
"fix": (
|
|
239
|
+
f"perturb propose {padded}"
|
|
240
|
+
f" # or set no-propagation: true and no-propagation-reason in {path.name}"
|
|
241
|
+
),
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
return findings
|