Navegador

navegador / navegador / adr.py
Blame History Raw 143 lines
1
"""
2
ADR ingestion — structured Architecture Decision Records as Decision nodes.
3
4
Parses ADR markdown files in standard MADR format (or a relaxed variant)
5
and creates Decision nodes in the navegador knowledge graph.
6
7
Usage:
8
from navegador.adr import ADRIngester
9
10
ingester = ADRIngester(store)
11
stats = ingester.ingest("/path/to/docs/decisions")
12
"""
13
14
from __future__ import annotations
15
16
import logging
17
import re
18
from pathlib import Path
19
from typing import Any
20
21
from navegador.graph.schema import NodeLabel
22
from navegador.graph.store import GraphStore
23
24
logger = logging.getLogger(__name__)
25
26
# ── Regex helpers ─────────────────────────────────────────────────────────────
27
28
_H1 = re.compile(r"^#\s+(.+)$", re.MULTILINE)
29
_STATUS = re.compile(r"^#{1,3}\s+Status\s*\n+(.+?)(?=\n#{1,3}\s|\Z)", re.MULTILINE | re.DOTALL)
30
_RATIONALE = re.compile(
31
r"^#{1,3}\s+Rationale\s*\n+(.+?)(?=\n#{1,3}\s|\Z)",
32
re.MULTILINE | re.DOTALL,
33
)
34
_RATIONALE_FALLBACK = re.compile(
35
r"^#{1,3}\s+(?:Decision Outcome|Decision)\s*\n+(.+?)(?=\n#{1,3}\s|\Z)",
36
re.MULTILINE | re.DOTALL,
37
)
38
_DATE = re.compile(r"(?:Date|date)[:\s]+(\d{4}-\d{2}-\d{2})", re.MULTILINE)
39
_ADR_NUMBER = re.compile(r"(\d+)", re.ASCII)
40
41
42
class ADRIngester:
43
"""
44
Ingest ADR markdown files from a directory.
45
46
Expected file naming convention: NNNN-short-title.md (e.g. 0001-use-falkordb.md)
47
Each file is parsed for title, status, rationale, and date, then stored
48
as a Decision node.
49
"""
50
51
def __init__(self, store: GraphStore) -> None:
52
self.store = store
53
54
# ── Public API ────────────────────────────────────────────────────────────
55
56
def ingest(self, adr_dir: str | Path) -> dict[str, Any]:
57
"""
58
Parse all ``*.md`` files in *adr_dir* and upsert Decision nodes.
59
60
Returns a stats dict: decisions, skipped.
61
"""
62
adr_dir = Path(adr_dir)
63
if not adr_dir.is_dir():
64
logger.warning("ADRIngester: %s is not a directory", adr_dir)
65
return {"decisions": 0, "skipped": 0}
66
67
md_files = sorted(adr_dir.glob("*.md"))
68
decisions = 0
69
skipped = 0
70
71
for md_file in md_files:
72
try:
73
parsed = self._parse_adr(md_file)
74
if parsed is None:
75
skipped += 1
76
continue
77
self.store.create_node(NodeLabel.Decision, parsed)
78
decisions += 1
79
logger.debug("ADR: %s", parsed["name"])
80
except Exception as exc: # noqa: BLE001
81
logger.warning("ADRIngester: failed to parse %s: %s", md_file, exc)
82
skipped += 1
83
84
stats = {"decisions": decisions, "skipped": skipped}
85
logger.info("ADRIngester: %s", stats)
86
return stats
87
88
# ── Parsing ───────────────────────────────────────────────────────────────
89
90
def _parse_adr(self, path: Path) -> dict[str, Any] | None:
91
"""
92
Parse a single ADR markdown file.
93
94
Returns a dict suitable for create_node(NodeLabel.Decision, ...) or
95
None if the file does not look like an ADR.
96
"""
97
text = path.read_text(encoding="utf-8", errors="replace")
98
99
# Title from first H1 heading
100
h1_match = _H1.search(text)
101
if h1_match is None:
102
return None
103
title = h1_match.group(1).strip()
104
105
# Strip leading ADR number from title if present (e.g. "1. Use FalkorDB")
106
title = re.sub(r"^\d+[.\s]+", "", title).strip()
107
108
# Status
109
status = "accepted"
110
st_match = _STATUS.search(text)
111
if st_match:
112
raw_status = st_match.group(1).strip().lower().splitlines()[0]
113
for known in ("proposed", "accepted", "deprecated", "superseded", "rejected"):
114
if known in raw_status:
115
status = known
116
break
117
118
# Rationale — prefer explicit ## Rationale section, fall back to ## Decision
119
rationale = ""
120
rat_match = _RATIONALE.search(text) or _RATIONALE_FALLBACK.search(text)
121
if rat_match:
122
rationale = rat_match.group(1).strip()
123
124
# Date
125
date = ""
126
date_match = _DATE.search(text)
127
if date_match:
128
date = date_match.group(1)
129
130
# Use the filename stem as the canonical name (stable, unique)
131
name = path.stem
132
133
return {
134
"name": name,
135
"description": title,
136
"domain": "",
137
"status": status,
138
"rationale": rationale,
139
"alternatives": "",
140
"date": date,
141
"file_path": str(path),
142
}
143

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button