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 ADDED
@@ -0,0 +1,3 @@
1
+ """Planning ledger for agent-driven development. See docs/ for the design."""
2
+
3
+ __version__ = "0.0.1"
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