whykit 0.3.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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/promote.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Turn an adopted architecture decision record into a WhyKit decision record.
|
|
2
|
+
|
|
3
|
+
`whykit adopt --write` stages old ADRs under `.import-staging/` and stops: a
|
|
4
|
+
person decides what becomes canonical. Once they have decided, retyping an ADR
|
|
5
|
+
into `whykit new decision` loses its wording and any trace of where it came
|
|
6
|
+
from. `whykit new decision --from FILE` does the mechanical half instead:
|
|
7
|
+
|
|
8
|
+
* recognise the common shapes — MADR, Michael Nygard's layout (also written by
|
|
9
|
+
adr-tools and log4brains), Y-statements and ADRs with Polish headings;
|
|
10
|
+
* copy each recognised section into the matching WhyKit section, and keep the
|
|
11
|
+
complete original text, byte for byte, under "Original record";
|
|
12
|
+
* record provenance in front matter: the source path, its SHA-256, the import
|
|
13
|
+
batch and the status and date the source claimed;
|
|
14
|
+
* list links found in the source under Evidence as TODO candidates, without
|
|
15
|
+
inventing evidence IDs;
|
|
16
|
+
* allocate the next D-NNN and write the record and its decision-log row in one
|
|
17
|
+
transaction.
|
|
18
|
+
|
|
19
|
+
The record always starts as whatever ``--status`` says (``draft`` by default):
|
|
20
|
+
a source that calls itself "Accepted" was accepted somewhere else, by a
|
|
21
|
+
process WhyKit cannot see, so approval stays a human step.
|
|
22
|
+
|
|
23
|
+
Without ``--write`` nothing is written; the mapping is printed so a person can
|
|
24
|
+
check it first.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import re
|
|
30
|
+
import unicodedata
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .adopt import ADR_FILENAME_RE
|
|
35
|
+
from .lint import DECISION_ID_RE, EVIDENCE_ID_RE, is_markdown_name, load_note, _within
|
|
36
|
+
|
|
37
|
+
PRODUCER = "whykit.promote/1"
|
|
38
|
+
STAGING_DIR = ".import-staging"
|
|
39
|
+
MAX_EVIDENCE_CANDIDATES = 20
|
|
40
|
+
|
|
41
|
+
# WhyKit section a source heading maps to. Keys are folded with `_fold`
|
|
42
|
+
# (lower case, diacritics and punctuation removed), so "Rozważane opcje",
|
|
43
|
+
# "rozwazane opcje:" and "## 2. Rozważane opcje" are one heading.
|
|
44
|
+
TARGETS: dict[str, tuple[str, ...]] = {
|
|
45
|
+
"status": ("status", "stan", "decision status", "status decyzji"),
|
|
46
|
+
"context": (
|
|
47
|
+
"context", "context and problem statement", "problem statement", "problem", "background",
|
|
48
|
+
"issue", "motivation", "kontekst", "kontekst i problem", "problem i kontekst", "tlo",
|
|
49
|
+
"opis problemu", "kontekst i opis problemu",
|
|
50
|
+
),
|
|
51
|
+
"drivers": (
|
|
52
|
+
"decision drivers", "drivers", "forces", "requirements", "czynniki decyzyjne", "czynniki",
|
|
53
|
+
"wymagania", "kryteria", "kryteria decyzji",
|
|
54
|
+
),
|
|
55
|
+
"options": (
|
|
56
|
+
"considered options", "options", "options considered", "alternatives", "alternatives considered",
|
|
57
|
+
"rozwazane opcje", "rozwazane alternatywy", "alternatywy", "opcje", "warianty", "rozwazane warianty",
|
|
58
|
+
),
|
|
59
|
+
"decision": (
|
|
60
|
+
"decision", "decision outcome", "outcome", "decyzja", "wynik decyzji", "rozstrzygniecie",
|
|
61
|
+
"podjeta decyzja",
|
|
62
|
+
),
|
|
63
|
+
"rationale": ("rationale", "justification", "reasoning", "why", "uzasadnienie", "powody", "dlaczego"),
|
|
64
|
+
"consequences": ("consequences", "implications", "konsekwencje", "skutki", "nastepstwa"),
|
|
65
|
+
"positive": (
|
|
66
|
+
"positive consequences", "positive", "pros", "benefits", "pozytywne konsekwencje", "korzysci",
|
|
67
|
+
"zalety", "plusy",
|
|
68
|
+
),
|
|
69
|
+
"negative": (
|
|
70
|
+
"negative consequences", "negative", "cons", "drawbacks", "trade offs", "tradeoffs", "risks",
|
|
71
|
+
"negatywne konsekwencje", "wady", "ryzyka", "koszty", "minusy",
|
|
72
|
+
),
|
|
73
|
+
"links": (
|
|
74
|
+
"links", "references", "more information", "related", "see also", "linki", "odnosniki",
|
|
75
|
+
"zrodla", "powiazane", "wiecej informacji", "dodatkowe informacje",
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
POLISH_HEADINGS = frozenset({
|
|
79
|
+
"stan", "status decyzji", "kontekst", "kontekst i problem", "problem i kontekst", "tlo", "opis problemu",
|
|
80
|
+
"kontekst i opis problemu", "czynniki decyzyjne", "czynniki", "wymagania", "kryteria", "kryteria decyzji",
|
|
81
|
+
"rozwazane opcje", "rozwazane alternatywy", "alternatywy", "opcje", "warianty", "rozwazane warianty",
|
|
82
|
+
"decyzja", "wynik decyzji", "rozstrzygniecie", "podjeta decyzja", "uzasadnienie", "powody", "dlaczego",
|
|
83
|
+
"konsekwencje", "skutki", "nastepstwa", "pozytywne konsekwencje", "korzysci", "zalety", "plusy",
|
|
84
|
+
"negatywne konsekwencje", "wady", "ryzyka", "koszty", "minusy", "linki", "odnosniki", "zrodla",
|
|
85
|
+
"powiazane", "wiecej informacji", "dodatkowe informacje",
|
|
86
|
+
})
|
|
87
|
+
MADR_HEADINGS = frozenset({
|
|
88
|
+
"context and problem statement", "decision drivers", "considered options", "decision outcome",
|
|
89
|
+
"positive consequences", "negative consequences", "pros and cons of the options", "more information",
|
|
90
|
+
})
|
|
91
|
+
SUBSECTION_TARGETS = frozenset({"positive", "negative", "consequences", "rationale"})
|
|
92
|
+
SECTION_LABELS = {
|
|
93
|
+
"context": "Context", "drivers": "Context (decision drivers)", "decision": "Decision",
|
|
94
|
+
"rationale": "Rationale", "options": "Alternatives considered", "consequences": "Consequences",
|
|
95
|
+
"positive": "Consequences / Positive", "negative": "Consequences / Negative and trade-offs",
|
|
96
|
+
"links": "Evidence (candidates)", "status": "provenance.source_status",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.+?)[ \t#]*$")
|
|
100
|
+
FENCE_OPEN_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
101
|
+
META_LINE_RE = re.compile(
|
|
102
|
+
r"^\s*(?:[-*+]\s+)?\**(status|date|data|stan|deciders|decyzja podjęta przez|decydenci)\**\s*:\s*\**\s*(.+?)\s*$",
|
|
103
|
+
re.I,
|
|
104
|
+
)
|
|
105
|
+
# ADR numbering in a title: "7. Use queues", "0007 - Use queues", "ADR-0007: Use
|
|
106
|
+
# queues". A bare number followed by a word ("2024 roadmap") is part of the title.
|
|
107
|
+
TITLE_NUMBER_RE = re.compile(
|
|
108
|
+
r"^(?:adr[\s_-]*\d{1,6}\s*[.:)\]—–-]?\s*|\d{1,6}\s*[.:)\]]\s*|\d{1,6}\s+[—–-]\s+)",
|
|
109
|
+
re.I,
|
|
110
|
+
)
|
|
111
|
+
URL_RE = re.compile(r"https?://[^\s<>()\[\]`'\"|]+[^\s<>()\[\]`'\".,;:!?|]")
|
|
112
|
+
WIKILINK_TEXT_RE = re.compile(r"(?<!`)\[\[[^\]\n]+\]\](?!`)")
|
|
113
|
+
RECORD_ID_TEXT_RE = re.compile(r"(?<![`\w-])(?:D|E)-[0-9]{3,}\b(?!`)")
|
|
114
|
+
GOOD_BAD_RE = re.compile(
|
|
115
|
+
r"^(good|bad|neutral|dobrze|źle|zle|neutralnie)\b\s*[,:]?\s*(?:(?:because|since|ponieważ|bo|gdyż)\b\s*)?", re.I,
|
|
116
|
+
)
|
|
117
|
+
BECAUSE_RE = re.compile(r"^(?P<head>.+?),?\s+(?P<word>because|since|ponieważ|bo|gdyż)\s+(?P<why>.+)$", re.S | re.I)
|
|
118
|
+
CHOSEN_RE = re.compile(r"(?:chosen option|wybrana opcja|wybrany wariant)\s*:?\s*[\"“„']?(?P<name>[^\"”',\n]+)", re.I)
|
|
119
|
+
|
|
120
|
+
# "In the context of <use case>, facing <concern>, we decided for <option>
|
|
121
|
+
# [and neglected <others>], to achieve <quality>[, accepting <downside>]
|
|
122
|
+
# [, because <reason>]." Zimmermann's Y-statement, plus the Polish form.
|
|
123
|
+
Y_STATEMENT_RES = (
|
|
124
|
+
re.compile(
|
|
125
|
+
r"in the context of\s+(?P<context>.+?),\s*facing\s+(?P<facing>.+?),\s*we decided\s+(?:for|on|to)\s+"
|
|
126
|
+
r"(?P<decision>.+?)(?:,?\s+and neglected\s+(?P<neglected>.+?))?,\s*(?:in order\s+)?to achieve\s+"
|
|
127
|
+
r"(?P<achieve>.+?)(?:,?\s*accepting(?:\s+that)?\s+(?P<accepting>.+?))?"
|
|
128
|
+
r"(?:,?\s*because\s+(?P<because>.+?))?\s*\.?\s*$",
|
|
129
|
+
re.I | re.S,
|
|
130
|
+
),
|
|
131
|
+
re.compile(
|
|
132
|
+
r"w kontek[sś]cie\s+(?P<context>.+?),\s*(?:w obliczu|wobec|mierz[aą]c si[eę] z)\s+(?P<facing>.+?),\s*"
|
|
133
|
+
r"zdecydowali?[sś]my\s+(?:si[eę]\s+na|o)\s+(?P<decision>.+?)"
|
|
134
|
+
r"(?:,?\s+(?:i|oraz)\s+odrzucili?[sś]my\s+(?P<neglected>.+?))?,\s*aby\s+(?:osi[aą]gn[aą][cć]\s+)?"
|
|
135
|
+
r"(?P<achieve>.+?)(?:,?\s*akceptuj[aą]c(?:,?\s+[zż]e)?\s+(?P<accepting>.+?))?"
|
|
136
|
+
r"(?:,?\s*poniewa[zż]\s+(?P<because>.+?))?\s*\.?\s*$",
|
|
137
|
+
re.I | re.S,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_FOLD = str.maketrans({"ł": "l", "Ł": "L", "ø": "o", "ß": "ss", "æ": "ae", "œ": "oe"})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _fold(heading: str) -> str:
|
|
145
|
+
text = unicodedata.normalize("NFKD", heading.translate(_FOLD))
|
|
146
|
+
text = "".join(ch for ch in text if not unicodedata.combining(ch)).casefold()
|
|
147
|
+
text = re.sub(r"^\s*(?:\d+(?:\.\d+)*[.)]?|[ivx]+\.)\s+", "", text)
|
|
148
|
+
text = re.sub(r"[^a-z0-9]+", " ", text)
|
|
149
|
+
return " ".join(text.split())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_ALIASES = {alias: target for target, aliases in TARGETS.items() for alias in aliases}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PromotionError(ValueError):
|
|
156
|
+
"""The file cannot be promoted as asked."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class Section:
|
|
161
|
+
heading: str
|
|
162
|
+
level: int
|
|
163
|
+
lines: list[str] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def text(self) -> str:
|
|
167
|
+
return "\n".join(self.lines).strip("\n")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class Promotion:
|
|
172
|
+
"""What a promotion would write, computed without touching the vault."""
|
|
173
|
+
|
|
174
|
+
source_label: str
|
|
175
|
+
sha256: str
|
|
176
|
+
original: str
|
|
177
|
+
shape: str
|
|
178
|
+
title: str
|
|
179
|
+
sections: dict[str, str]
|
|
180
|
+
alternatives: list[str]
|
|
181
|
+
positive: list[str]
|
|
182
|
+
negative: list[str]
|
|
183
|
+
mapping: list[dict[str, str]]
|
|
184
|
+
unmapped: list[str]
|
|
185
|
+
evidence_candidates: list[str]
|
|
186
|
+
warnings: list[str]
|
|
187
|
+
source_status: str | None = None
|
|
188
|
+
source_date: str | None = None
|
|
189
|
+
source_id: str | None = None
|
|
190
|
+
batch: str | None = None
|
|
191
|
+
batch_path: str | None = None
|
|
192
|
+
in_vault: bool = False
|
|
193
|
+
|
|
194
|
+
def provenance(self, today: str) -> dict[str, object]:
|
|
195
|
+
"""The front-matter ``provenance`` block (see schemas/provenance.schema.json)."""
|
|
196
|
+
block: dict[str, object] = {"producer": PRODUCER}
|
|
197
|
+
if self.batch:
|
|
198
|
+
block["producer_run"] = self.batch
|
|
199
|
+
if self.in_vault:
|
|
200
|
+
block["payload_ref"] = self.source_label
|
|
201
|
+
block["snapshot_hash"] = f"sha256:{self.sha256}"
|
|
202
|
+
block["recorded_at"] = today
|
|
203
|
+
block["human_reviewed"] = False
|
|
204
|
+
block["source_format"] = self.shape
|
|
205
|
+
block["source_path"] = self.batch_path or self.source_label
|
|
206
|
+
block["source_sha256"] = self.sha256
|
|
207
|
+
if self.source_status:
|
|
208
|
+
block["source_status"] = self.source_status
|
|
209
|
+
if self.source_date:
|
|
210
|
+
block["source_date"] = self.source_date
|
|
211
|
+
if self.source_id:
|
|
212
|
+
block["source_id"] = self.source_id
|
|
213
|
+
return block
|
|
214
|
+
|
|
215
|
+
def as_json(self) -> dict[str, object]:
|
|
216
|
+
return {
|
|
217
|
+
"path": self.source_label,
|
|
218
|
+
"sha256": self.sha256,
|
|
219
|
+
"format": self.shape,
|
|
220
|
+
"title": self.title,
|
|
221
|
+
"status": self.source_status,
|
|
222
|
+
"date": self.source_date,
|
|
223
|
+
"original_id": self.source_id,
|
|
224
|
+
"batch": self.batch,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def source_label(vault: Path, path: Path) -> tuple[str, str | None, str | None]:
|
|
229
|
+
"""How the record names its source, without publishing a local home directory.
|
|
230
|
+
|
|
231
|
+
Inside the vault: the vault-relative path, plus the import batch and the
|
|
232
|
+
path inside the adopted folder when the file is staged. Outside it: the
|
|
233
|
+
file name only, the same rule the ingestion record follows.
|
|
234
|
+
"""
|
|
235
|
+
root = vault.resolve()
|
|
236
|
+
resolved = path.resolve()
|
|
237
|
+
if _within(root, resolved):
|
|
238
|
+
relative = resolved.relative_to(root).as_posix()
|
|
239
|
+
parts = relative.split("/")
|
|
240
|
+
if parts[0] == STAGING_DIR and len(parts) >= 3 and not parts[1].startswith("."):
|
|
241
|
+
return relative, parts[1], "/".join(parts[2:])
|
|
242
|
+
return relative, None, None
|
|
243
|
+
return path.name, None, None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _split(text: str) -> tuple[list[str], list[Section]]:
|
|
247
|
+
"""Preamble lines and sections; headings inside code fences are text."""
|
|
248
|
+
preamble: list[str] = []
|
|
249
|
+
sections: list[Section] = []
|
|
250
|
+
fence: str | None = None
|
|
251
|
+
for line in text.splitlines():
|
|
252
|
+
opener = FENCE_OPEN_RE.match(line)
|
|
253
|
+
if fence is not None:
|
|
254
|
+
if opener and opener.group(1)[0] == fence[0] and len(opener.group(1)) >= len(fence):
|
|
255
|
+
fence = None
|
|
256
|
+
elif opener:
|
|
257
|
+
fence = opener.group(1)
|
|
258
|
+
heading = HEADING_RE.match(line) if fence is None and not opener else None
|
|
259
|
+
if heading:
|
|
260
|
+
sections.append(Section(heading.group(2).strip(), len(heading.group(1))))
|
|
261
|
+
elif sections:
|
|
262
|
+
sections[-1].lines.append(line)
|
|
263
|
+
else:
|
|
264
|
+
preamble.append(line)
|
|
265
|
+
return preamble, sections
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _first_paragraph_line(text: str) -> str | None:
|
|
269
|
+
for line in text.splitlines():
|
|
270
|
+
value = line.strip().strip("*_").strip()
|
|
271
|
+
value = re.sub(r"^[-*+]\s+", "", value)
|
|
272
|
+
if value:
|
|
273
|
+
return value
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _list_items(text: str) -> list[str]:
|
|
278
|
+
items = []
|
|
279
|
+
for line in text.splitlines():
|
|
280
|
+
match = re.match(r"^\s*(?:[-*+]|\d+[.)])\s+(.+)$", line)
|
|
281
|
+
if match and not line.startswith((" ", "\t")):
|
|
282
|
+
items.append(match.group(1).strip())
|
|
283
|
+
return items
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _plain(value: str) -> str:
|
|
287
|
+
value = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", value)
|
|
288
|
+
return " ".join(value.replace("|", "/").strip(" *_`\"“”„'").split())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _neutralise(text: str, warnings: list[str]) -> str:
|
|
292
|
+
"""Keep copied prose from claiming links and IDs in *this* vault.
|
|
293
|
+
|
|
294
|
+
An ADR from another repository may cite `E-012` or link `[[pricing]]`;
|
|
295
|
+
copied verbatim, lint would check those against this vault and fail. They
|
|
296
|
+
stay readable as code spans, and the untouched original keeps them as written.
|
|
297
|
+
"""
|
|
298
|
+
ids = sorted(set(RECORD_ID_TEXT_RE.findall(text)))
|
|
299
|
+
links = WIKILINK_TEXT_RE.findall(text)
|
|
300
|
+
if ids:
|
|
301
|
+
warnings.append(
|
|
302
|
+
f"the source cites {', '.join(ids)}; kept as text, because IDs from another ledger do not "
|
|
303
|
+
"resolve here — cite this vault's records instead"
|
|
304
|
+
)
|
|
305
|
+
text = RECORD_ID_TEXT_RE.sub(lambda m: f"`{m.group(0)}`", text)
|
|
306
|
+
if links:
|
|
307
|
+
warnings.append(f"{len(links)} wikilink(s) kept as text; relink them to notes in this vault")
|
|
308
|
+
text = WIKILINK_TEXT_RE.sub(lambda m: f"`{m.group(0)}`", text)
|
|
309
|
+
return text
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _demote(text: str) -> str:
|
|
313
|
+
"""Nest copied headings below the WhyKit section they now live in."""
|
|
314
|
+
out = []
|
|
315
|
+
fence: str | None = None
|
|
316
|
+
for line in text.splitlines():
|
|
317
|
+
opener = FENCE_OPEN_RE.match(line)
|
|
318
|
+
if fence is not None:
|
|
319
|
+
if opener and opener.group(1)[0] == fence[0] and len(opener.group(1)) >= len(fence):
|
|
320
|
+
fence = None
|
|
321
|
+
out.append(line)
|
|
322
|
+
continue
|
|
323
|
+
if opener:
|
|
324
|
+
fence = opener.group(1)
|
|
325
|
+
out.append(line)
|
|
326
|
+
continue
|
|
327
|
+
heading = HEADING_RE.match(line)
|
|
328
|
+
if heading and len(heading.group(1)) < 4:
|
|
329
|
+
line = "#### " + heading.group(2)
|
|
330
|
+
out.append(line)
|
|
331
|
+
return "\n".join(out)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _title_from(value: str) -> str:
|
|
335
|
+
value = value.strip().strip("#").strip()
|
|
336
|
+
value = re.sub(r"^D-[0-9]{3,}\s*[—–:-]\s*", "", value)
|
|
337
|
+
value = TITLE_NUMBER_RE.sub("", value, count=1).strip()
|
|
338
|
+
return value.strip(" -—–:")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _humanize_stem(stem: str) -> str:
|
|
342
|
+
stem = ADR_FILENAME_RE.sub("", stem)
|
|
343
|
+
words = re.sub(r"[-_]+", " ", stem).strip()
|
|
344
|
+
return words[:1].upper() + words[1:]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _y_statement(body: str) -> dict[str, str] | None:
|
|
348
|
+
flattened = " ".join(
|
|
349
|
+
line.strip().lstrip(">").strip() for line in body.splitlines() if not HEADING_RE.match(line)
|
|
350
|
+
).strip()
|
|
351
|
+
flattened = re.sub(r"[*_]{1,2}", "", flattened)
|
|
352
|
+
for pattern in Y_STATEMENT_RES:
|
|
353
|
+
start = re.search(r"(?i)\b(?:in the context of|w kontek[sś]cie)\b", flattened)
|
|
354
|
+
if not start:
|
|
355
|
+
continue
|
|
356
|
+
sentence = flattened[start.start():]
|
|
357
|
+
end = re.search(r"\.\s+(?=[A-ZĄĆĘŁŃÓŚŹŻ])", sentence)
|
|
358
|
+
if end:
|
|
359
|
+
sentence = sentence[: end.start() + 1]
|
|
360
|
+
match = pattern.match(sentence)
|
|
361
|
+
if match:
|
|
362
|
+
return {key: (value or "").strip() for key, value in match.groupdict().items()}
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _split_names(value: str) -> list[str]:
|
|
367
|
+
parts = re.split(r"\s*(?:,|;|\band\b|\bor\b|\bi\b|\boraz\b|\blub\b)\s*", value)
|
|
368
|
+
return [_plain(part) for part in parts if _plain(part)]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _capital(value: str) -> str:
|
|
372
|
+
return value[:1].upper() + value[1:]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _sentence(value: str) -> str:
|
|
376
|
+
value = value.strip().rstrip(".")
|
|
377
|
+
return (value[:1].upper() + value[1:] + ".") if value else ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def build_promotion(vault: Path, path: Path, *, title: str | None = None) -> Promotion:
|
|
381
|
+
"""Read *path* and work out the decision record it would become."""
|
|
382
|
+
if not is_markdown_name(path.name):
|
|
383
|
+
raise PromotionError(f"not a Markdown file: {path.name}")
|
|
384
|
+
if path.is_symlink() or not path.is_file():
|
|
385
|
+
raise PromotionError(f"not a regular Markdown file: {path}")
|
|
386
|
+
data = path.read_bytes()
|
|
387
|
+
try:
|
|
388
|
+
original = data.decode("utf-8")
|
|
389
|
+
except UnicodeDecodeError as exc:
|
|
390
|
+
raise PromotionError(f"{path.name} is not UTF-8 text") from exc
|
|
391
|
+
if "\0" in original:
|
|
392
|
+
raise PromotionError(f"{path.name} is binary, not Markdown")
|
|
393
|
+
label, batch, batch_path = source_label(vault, path)
|
|
394
|
+
note = load_note(path, text=original)
|
|
395
|
+
front = note.front if note.has_front else {}
|
|
396
|
+
body = original.removeprefix("")
|
|
397
|
+
if note.has_front:
|
|
398
|
+
closing = re.search(r"(?m)^---[ \t]*\r?$", body[3:])
|
|
399
|
+
body = body[3 + closing.end():] if closing else ""
|
|
400
|
+
body = body.replace("\r\n", "\n")
|
|
401
|
+
preamble, sections = _split(body)
|
|
402
|
+
warnings: list[str] = []
|
|
403
|
+
|
|
404
|
+
headings = list(sections)
|
|
405
|
+
title_section: Section | None = None
|
|
406
|
+
if headings and headings[0].level == 1 and _fold(headings[0].heading) not in _ALIASES:
|
|
407
|
+
title_section = headings.pop(0)
|
|
408
|
+
preamble.extend(title_section.lines)
|
|
409
|
+
level = min((section.level for section in headings), default=2)
|
|
410
|
+
|
|
411
|
+
# Fold each section, and each recognised subsection, into a WhyKit target.
|
|
412
|
+
collected: dict[str, list[str]] = {}
|
|
413
|
+
sources: dict[str, list[str]] = {}
|
|
414
|
+
unmapped: list[str] = []
|
|
415
|
+
parent: str | None = None
|
|
416
|
+
for section in headings:
|
|
417
|
+
target = _ALIASES.get(_fold(section.heading))
|
|
418
|
+
if section.level == level:
|
|
419
|
+
parent = target
|
|
420
|
+
if target is None:
|
|
421
|
+
unmapped.append(section.heading)
|
|
422
|
+
continue
|
|
423
|
+
elif not (section.level == level + 1 and target in SUBSECTION_TARGETS):
|
|
424
|
+
# Any other subsection stays inside its parent, heading and all.
|
|
425
|
+
if parent is not None:
|
|
426
|
+
collected[parent].append("#" * section.level + " " + section.heading)
|
|
427
|
+
collected[parent].extend(section.lines)
|
|
428
|
+
continue
|
|
429
|
+
assert target is not None
|
|
430
|
+
collected.setdefault(target, []).extend(section.lines)
|
|
431
|
+
sources.setdefault(target, []).append(section.heading)
|
|
432
|
+
|
|
433
|
+
meta: dict[str, str] = {}
|
|
434
|
+
for line in preamble:
|
|
435
|
+
match = META_LINE_RE.match(line)
|
|
436
|
+
if match:
|
|
437
|
+
meta.setdefault(_fold(match.group(1)), match.group(2).strip())
|
|
438
|
+
|
|
439
|
+
texts = {key: "\n".join(lines).strip("\n") for key, lines in collected.items()}
|
|
440
|
+
texts = {key: value for key, value in texts.items() if value.strip()}
|
|
441
|
+
folded = {_fold(section.heading) for section in headings}
|
|
442
|
+
|
|
443
|
+
y = None if {"context", "decision"} & set(texts) else _y_statement(body)
|
|
444
|
+
if y:
|
|
445
|
+
shape = "y-statement"
|
|
446
|
+
elif folded & MADR_HEADINGS:
|
|
447
|
+
shape = "madr"
|
|
448
|
+
elif folded & POLISH_HEADINGS and {"context", "decision"} & set(texts):
|
|
449
|
+
shape = "polish-adr"
|
|
450
|
+
elif {"context", "decision"} & set(texts):
|
|
451
|
+
shape = "nygard"
|
|
452
|
+
else:
|
|
453
|
+
shape = "unstructured"
|
|
454
|
+
|
|
455
|
+
out: dict[str, str] = {}
|
|
456
|
+
alternatives: list[str] = []
|
|
457
|
+
positive: list[str] = []
|
|
458
|
+
negative: list[str] = []
|
|
459
|
+
mapping: list[dict[str, str]] = []
|
|
460
|
+
|
|
461
|
+
if y:
|
|
462
|
+
out["context"] = _sentence(f"{y['context']}, facing {y['facing']}") if y["context"] else ""
|
|
463
|
+
out["decision"] = _sentence(y["decision"])
|
|
464
|
+
rationale = _sentence(f"to achieve {y['achieve']}") if y["achieve"] else ""
|
|
465
|
+
if y.get("because"):
|
|
466
|
+
rationale = (rationale + " " + _sentence(f"because {y['because']}")).strip()
|
|
467
|
+
out["rationale"] = rationale
|
|
468
|
+
alternatives = _split_names(y.get("neglected") or "")
|
|
469
|
+
if y.get("accepting"):
|
|
470
|
+
negative = [_sentence(y["accepting"])]
|
|
471
|
+
for target in ("context", "decision", "rationale"):
|
|
472
|
+
if out.get(target):
|
|
473
|
+
mapping.append({"section": SECTION_LABELS[target], "from": "Y-statement"})
|
|
474
|
+
if alternatives:
|
|
475
|
+
mapping.append({"section": SECTION_LABELS["options"], "from": "Y-statement (neglected)"})
|
|
476
|
+
if negative:
|
|
477
|
+
mapping.append({"section": SECTION_LABELS["negative"], "from": "Y-statement (accepting)"})
|
|
478
|
+
elif shape != "unstructured":
|
|
479
|
+
for target in ("context", "drivers", "decision", "rationale", "consequences"):
|
|
480
|
+
if target in texts:
|
|
481
|
+
out[target] = texts[target]
|
|
482
|
+
if "decision" in out and "rationale" not in out:
|
|
483
|
+
first = out["decision"].strip().split("\n\n", 1)[0]
|
|
484
|
+
because = BECAUSE_RE.match(" ".join(first.split()))
|
|
485
|
+
if because:
|
|
486
|
+
out["rationale"] = _sentence(f"{because.group('word')} {because.group('why')}")
|
|
487
|
+
sources.setdefault("rationale", []).append(f"{sources['decision'][0]} (the “{because.group('word')}” clause)")
|
|
488
|
+
options = [name for name in (_plain(item) for item in _list_items(texts.get("options", ""))) if name]
|
|
489
|
+
chosen = CHOSEN_RE.search(out.get("decision", ""))
|
|
490
|
+
chosen_name = _fold(chosen.group("name")) if chosen else ""
|
|
491
|
+
if not chosen_name:
|
|
492
|
+
# No "Chosen option:" line: the option the decision text names, if only one.
|
|
493
|
+
decision_text = f" {_fold(out.get('decision', ''))} "
|
|
494
|
+
named = [_fold(name) for name in options if f" {_fold(name)} " in decision_text]
|
|
495
|
+
chosen_name = named[0] if len(named) == 1 else ""
|
|
496
|
+
alternatives = [name for name in options if _fold(name) != chosen_name]
|
|
497
|
+
if "options" in texts and not alternatives and texts["options"].strip():
|
|
498
|
+
warnings.append("the options section has no list items; it is kept only in the original record")
|
|
499
|
+
for key, bucket in (("positive", positive), ("negative", negative)):
|
|
500
|
+
bucket.extend(_plain(item) for item in _list_items(texts.get(key, "")))
|
|
501
|
+
general: list[str] = []
|
|
502
|
+
for item in _list_items(texts.get("consequences", "")):
|
|
503
|
+
tone = GOOD_BAD_RE.match(item)
|
|
504
|
+
if tone and tone.group(1).casefold() in {"good", "dobrze"}:
|
|
505
|
+
positive.append(_capital(_plain(GOOD_BAD_RE.sub("", item))))
|
|
506
|
+
elif tone and tone.group(1).casefold() in {"bad", "źle", "zle"}:
|
|
507
|
+
negative.append(_capital(_plain(GOOD_BAD_RE.sub("", item))))
|
|
508
|
+
else:
|
|
509
|
+
general.append(item)
|
|
510
|
+
if "consequences" in out and (positive or negative) and not general:
|
|
511
|
+
del out["consequences"]
|
|
512
|
+
for target in ("context", "drivers", "decision", "rationale", "options", "consequences", "positive", "negative", "links"):
|
|
513
|
+
if target in sources and (target in out or target in texts):
|
|
514
|
+
mapping.append({"section": SECTION_LABELS[target], "from": ", ".join(sources[target])})
|
|
515
|
+
if "status" in sources:
|
|
516
|
+
mapping.append({"section": SECTION_LABELS["status"], "from": ", ".join(sources["status"])})
|
|
517
|
+
|
|
518
|
+
status = (
|
|
519
|
+
_first_paragraph_line(texts.get("status", ""))
|
|
520
|
+
or meta.get("status") or meta.get("stan")
|
|
521
|
+
or (str(front.get("status")) if front.get("status") else None)
|
|
522
|
+
)
|
|
523
|
+
date = meta.get("date") or meta.get("data") or (str(front.get("date")) if front.get("date") else None)
|
|
524
|
+
declared = str(front.get("decision_id") or "").strip()
|
|
525
|
+
number = ADR_FILENAME_RE.match(path.name)
|
|
526
|
+
numbering = TITLE_NUMBER_RE.match(title_section.heading) if title_section else None
|
|
527
|
+
heading_number = re.search(r"(\d+)", numbering.group(0)) if numbering else None
|
|
528
|
+
source_id = (
|
|
529
|
+
declared if DECISION_ID_RE.fullmatch(declared)
|
|
530
|
+
else number.group(1) if number
|
|
531
|
+
else heading_number.group(1) if heading_number
|
|
532
|
+
else None
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
chosen_title = (title or "").strip()
|
|
536
|
+
if not chosen_title:
|
|
537
|
+
for candidate in (
|
|
538
|
+
str(front.get("title") or ""),
|
|
539
|
+
title_section.heading if title_section else "",
|
|
540
|
+
_humanize_stem(path.stem),
|
|
541
|
+
):
|
|
542
|
+
chosen_title = _title_from(candidate)
|
|
543
|
+
if chosen_title:
|
|
544
|
+
break
|
|
545
|
+
if not chosen_title:
|
|
546
|
+
raise PromotionError("could not derive a title; pass one: whykit new decision \"Title\" --from FILE")
|
|
547
|
+
|
|
548
|
+
candidates: list[str] = []
|
|
549
|
+
for url in URL_RE.findall(body):
|
|
550
|
+
if url not in candidates:
|
|
551
|
+
candidates.append(url)
|
|
552
|
+
if len(candidates) > MAX_EVIDENCE_CANDIDATES:
|
|
553
|
+
warnings.append(f"{len(candidates)} links found; the first {MAX_EVIDENCE_CANDIDATES} are listed as evidence candidates")
|
|
554
|
+
candidates = candidates[:MAX_EVIDENCE_CANDIDATES]
|
|
555
|
+
if EVIDENCE_ID_RE.search(" ".join(candidates)):
|
|
556
|
+
candidates = [url for url in candidates if not EVIDENCE_ID_RE.search(url)]
|
|
557
|
+
|
|
558
|
+
cleaned = {key: _neutralise(_demote(value), warnings) for key, value in out.items() if value.strip()}
|
|
559
|
+
alternatives = [_neutralise(item, warnings) for item in alternatives]
|
|
560
|
+
positive = [_neutralise(item, warnings) for item in positive if item]
|
|
561
|
+
negative = [_neutralise(item, warnings) for item in negative if item]
|
|
562
|
+
if shape == "unstructured":
|
|
563
|
+
warnings.append(
|
|
564
|
+
"no ADR shape recognised (MADR, Nygard, Y-statement or Polish headings); "
|
|
565
|
+
"every WhyKit section keeps its prompt and the text is kept only under Original record"
|
|
566
|
+
)
|
|
567
|
+
for target in ("context", "decision", "rationale"):
|
|
568
|
+
if shape != "unstructured" and not cleaned.get(target):
|
|
569
|
+
warnings.append(f"no {target} section in the source; the {target.capitalize()} prompt is left for a person to answer")
|
|
570
|
+
|
|
571
|
+
return Promotion(
|
|
572
|
+
source_label=label,
|
|
573
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
574
|
+
original=original,
|
|
575
|
+
shape=shape,
|
|
576
|
+
title=chosen_title,
|
|
577
|
+
sections=cleaned,
|
|
578
|
+
alternatives=alternatives,
|
|
579
|
+
positive=positive,
|
|
580
|
+
negative=negative,
|
|
581
|
+
mapping=mapping,
|
|
582
|
+
unmapped=unmapped,
|
|
583
|
+
evidence_candidates=candidates,
|
|
584
|
+
warnings=list(dict.fromkeys(warnings)),
|
|
585
|
+
source_status=_plain(status) if status else None,
|
|
586
|
+
source_date=_plain(date) if date else None,
|
|
587
|
+
source_id=source_id,
|
|
588
|
+
batch=batch,
|
|
589
|
+
batch_path=batch_path,
|
|
590
|
+
in_vault=_within(vault.resolve(), path.resolve()),
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _fence_for(text: str) -> str:
|
|
595
|
+
longest = max((len(run) for run in re.findall(r"`{3,}", text)), default=0)
|
|
596
|
+
return "`" * max(4, longest + 1)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def render_sections(promotion: Promotion, prompts: dict[str, str]) -> dict[str, str]:
|
|
600
|
+
"""Prose for each WhyKit section, falling back to the scaffold prompt."""
|
|
601
|
+
context = promotion.sections.get("context", "")
|
|
602
|
+
drivers = promotion.sections.get("drivers", "")
|
|
603
|
+
if drivers:
|
|
604
|
+
context = (context + "\n\n" if context else "") + "Decision drivers:\n\n" + drivers
|
|
605
|
+
consequences = promotion.sections.get("consequences", "")
|
|
606
|
+
return {
|
|
607
|
+
"Context": context or prompts["Context"],
|
|
608
|
+
"Decision": promotion.sections.get("decision") or prompts["Decision"],
|
|
609
|
+
"Rationale": promotion.sections.get("rationale") or prompts["Rationale"],
|
|
610
|
+
"Consequences": consequences,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def render_original(promotion: Promotion) -> str:
|
|
615
|
+
fence = _fence_for(promotion.original)
|
|
616
|
+
original = promotion.original.removeprefix("").replace("\r\n", "\n").rstrip("\n")
|
|
617
|
+
return (
|
|
618
|
+
"## Original record\n\n"
|
|
619
|
+
f"Promoted from `{promotion.source_label}` (SHA-256 `{promotion.sha256}`, format "
|
|
620
|
+
f"{promotion.shape}). The text below is the source as it was; edit the sections above, not this copy.\n\n"
|
|
621
|
+
f"{fence}markdown\n{original}\n{fence}\n"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def render_provenance(block: dict[str, object]) -> str:
|
|
626
|
+
import json
|
|
627
|
+
|
|
628
|
+
lines = ["provenance:"]
|
|
629
|
+
for key, value in block.items():
|
|
630
|
+
rendered = ("true" if value else "false") if isinstance(value, bool) else json.dumps(str(value), ensure_ascii=False)
|
|
631
|
+
lines.append(f" {key}: {rendered}")
|
|
632
|
+
return "\n".join(lines) + "\n"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def promoted_from(vault: Path, sha256: str) -> str | None:
|
|
636
|
+
"""The decision record already promoted from these exact bytes, if any."""
|
|
637
|
+
folder = vault / "06-decisions"
|
|
638
|
+
if folder.is_symlink() or not folder.is_dir():
|
|
639
|
+
return None
|
|
640
|
+
for path in sorted(folder.iterdir()):
|
|
641
|
+
if not is_markdown_name(path.name) or path.is_symlink() or not path.is_file():
|
|
642
|
+
continue
|
|
643
|
+
try:
|
|
644
|
+
note = load_note(path)
|
|
645
|
+
except (OSError, UnicodeDecodeError):
|
|
646
|
+
continue
|
|
647
|
+
block = note.front.get("provenance") if note.has_front else None
|
|
648
|
+
if isinstance(block, dict) and str(block.get("source_sha256") or "") == sha256:
|
|
649
|
+
declared = str(note.front.get("decision_id") or "").strip()
|
|
650
|
+
return f"{declared or '?'} ({path.relative_to(vault).as_posix()})"
|
|
651
|
+
return None
|
whykit/py.typed
ADDED
|
File without changes
|