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.
Files changed (84) hide show
  1. whykit/__init__.py +21 -0
  2. whykit/adopt.py +776 -0
  3. whykit/backlinks.py +106 -0
  4. whykit/check.py +320 -0
  5. whykit/ci_formats.py +283 -0
  6. whykit/cli.py +1719 -0
  7. whykit/completion.py +193 -0
  8. whykit/config.py +233 -0
  9. whykit/console.py +217 -0
  10. whykit/context.py +204 -0
  11. whykit/contract.py +210 -0
  12. whykit/contract_schemas/backlinks-report.schema.json +61 -0
  13. whykit/contract_schemas/context-bundle.schema.json +27 -0
  14. whykit/contract_schemas/context-pack.schema.json +29 -0
  15. whykit/contract_schemas/impact-report.schema.json +122 -0
  16. whykit/contract_schemas/query-result.schema.json +105 -0
  17. whykit/contract_schemas/status-report.schema.json +176 -0
  18. whykit/contract_schemas/trace-report.schema.json +211 -0
  19. whykit/diff.py +823 -0
  20. whykit/evidence.py +178 -0
  21. whykit/explorer_index.py +554 -0
  22. whykit/graph.py +318 -0
  23. whykit/immutability.py +624 -0
  24. whykit/impact.py +272 -0
  25. whykit/init_layout.py +205 -0
  26. whykit/io.py +563 -0
  27. whykit/lint.py +2190 -0
  28. whykit/lsp.py +1241 -0
  29. whykit/markdown_code.py +107 -0
  30. whykit/mcp_server.py +2289 -0
  31. whykit/messages.py +46 -0
  32. whykit/pack.py +294 -0
  33. whykit/parse_cache.py +752 -0
  34. whykit/placeholders.py +84 -0
  35. whykit/promote.py +651 -0
  36. whykit/py.typed +0 -0
  37. whykit/query.py +174 -0
  38. whykit/review.py +527 -0
  39. whykit/rule_policy.py +1063 -0
  40. whykit/rules.py +274 -0
  41. whykit/scaffold.py +784 -0
  42. whykit/sensitivity.py +95 -0
  43. whykit/snapshot.py +319 -0
  44. whykit/status.py +167 -0
  45. whykit/tables.py +120 -0
  46. whykit/template/00-context/README.md +17 -0
  47. whykit/template/00-context/company.md +52 -0
  48. whykit/template/00-context/evidence-register.md +34 -0
  49. whykit/template/00-context/goals.md +38 -0
  50. whykit/template/00-context/review-log.md +22 -0
  51. whykit/template/00-context/terminology.md +34 -0
  52. whykit/template/01-strategy/README.md +7 -0
  53. whykit/template/02-discoverability/README.md +10 -0
  54. whykit/template/03-website/README.md +7 -0
  55. whykit/template/04-automation/README.md +10 -0
  56. whykit/template/05-operations/README.md +19 -0
  57. whykit/template/06-decisions/README.md +11 -0
  58. whykit/template/06-decisions/decision-log.md +38 -0
  59. whykit/template/07-research/README.md +10 -0
  60. whykit/template/AGENTS.md +163 -0
  61. whykit/template/Home.md +57 -0
  62. whykit/template/INTEROP.md +117 -0
  63. whykit/template/OBSIDIAN.md +92 -0
  64. whykit/template/README.md +51 -0
  65. whykit/template/assets/README.md +6 -0
  66. whykit/template/reports/README.md +7 -0
  67. whykit/template/templates/README.md +7 -0
  68. whykit/template/templates/automation-spec-template.md +70 -0
  69. whykit/template/templates/decision-record-template.md +65 -0
  70. whykit/template/templates/document-template.md +55 -0
  71. whykit/template/templates/experiment-template.md +62 -0
  72. whykit/template/templates/meeting-note-template.md +47 -0
  73. whykit/template/templates/page-brief-template.md +63 -0
  74. whykit/template/templates/research-note-template.md +53 -0
  75. whykit/template/templates/source-ingestion-template.md +43 -0
  76. whykit/template/whykit.toml +39 -0
  77. whykit/trace.py +272 -0
  78. whykit/vault_index.py +190 -0
  79. whykit-0.3.1.dist-info/METADATA +162 -0
  80. whykit-0.3.1.dist-info/RECORD +84 -0
  81. whykit-0.3.1.dist-info/WHEEL +4 -0
  82. whykit-0.3.1.dist-info/entry_points.txt +3 -0
  83. whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
  84. 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