kingmadoc 0.2.0__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.
- kingmadoc/__init__.py +11 -0
- kingmadoc/about.py +49 -0
- kingmadoc/adr.py +147 -0
- kingmadoc/cli.py +631 -0
- kingmadoc/config.py +531 -0
- kingmadoc/d2_binary.py +164 -0
- kingmadoc/diagrams/__init__.py +36 -0
- kingmadoc/diagrams/base.py +481 -0
- kingmadoc/diagrams/d2.py +250 -0
- kingmadoc/diagrams/mermaid.py +285 -0
- kingmadoc/diagrams/plantuml.py +226 -0
- kingmadoc/documents.py +134 -0
- kingmadoc/exceptions.py +45 -0
- kingmadoc/explain.py +278 -0
- kingmadoc/facts/__init__.py +6 -0
- kingmadoc/facts/branch.py +82 -0
- kingmadoc/facts/collect.py +218 -0
- kingmadoc/facts/data_model.py +264 -0
- kingmadoc/facts/js_modules.py +171 -0
- kingmadoc/facts/projects.py +34 -0
- kingmadoc/facts/routes.py +252 -0
- kingmadoc/facts/services.py +49 -0
- kingmadoc/git.py +46 -0
- kingmadoc/naming.py +47 -0
- kingmadoc/plan/__init__.py +1 -0
- kingmadoc/plan/analyzer.py +659 -0
- kingmadoc/plan/dependencies.py +131 -0
- kingmadoc/plan/generator.py +481 -0
- kingmadoc/plan/models.py +197 -0
- kingmadoc/plandoc.py +182 -0
- kingmadoc/render.py +184 -0
- kingmadoc/skills/explaining-code/SKILL.md +214 -0
- kingmadoc/skills/explaining-code/reference/arc42.md +202 -0
- kingmadoc/skills/explaining-code/reference/c4-model.md +341 -0
- kingmadoc/skills/explaining-code/reference/c4.md +133 -0
- kingmadoc/skills/explaining-code/reference/models.md +314 -0
- kingmadoc/skills/explaining-code/reference/split.md +138 -0
- kingmadoc/skills/kingmadoc/SKILL.md +200 -0
- kingmadoc/skills/kingmadoc/reference/diagram-rules.md +41 -0
- kingmadoc/skills/kingmadoc/reference/formats.md +195 -0
- kingmadoc/skills.py +227 -0
- kingmadoc/templates/adr.md.j2 +26 -0
- kingmadoc/templates/domain_design.md.j2 +18 -0
- kingmadoc/templates/functional_design.md.j2 +86 -0
- kingmadoc/templates/plan_default.md.j2 +113 -0
- kingmadoc/templates/security_design.md.j2 +18 -0
- kingmadoc/templates/technical_design.md.j2 +104 -0
- kingmadoc/templating.py +90 -0
- kingmadoc/verify/__init__.py +1 -0
- kingmadoc/verify/changes.py +81 -0
- kingmadoc/verify/commands.py +143 -0
- kingmadoc/verify/deviations.py +115 -0
- kingmadoc/verify/locate.py +60 -0
- kingmadoc/verify/report.py +124 -0
- kingmadoc/vscode.py +73 -0
- kingmadoc-0.2.0.dist-info/METADATA +253 -0
- kingmadoc-0.2.0.dist-info/RECORD +60 -0
- kingmadoc-0.2.0.dist-info/WHEEL +4 -0
- kingmadoc-0.2.0.dist-info/entry_points.txt +2 -0
- kingmadoc-0.2.0.dist-info/licenses/LICENSE +21 -0
kingmadoc/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""KingmaDoc: feature documentation for AI coding agents."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Set at build time from the git tag (hatch-vcs), see pyproject.toml.
|
|
7
|
+
__version__ = metadata.version("kingmadoc")
|
|
8
|
+
except metadata.PackageNotFoundError: # a source tree that was never installed
|
|
9
|
+
__version__ = "0+unknown"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
kingmadoc/about.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Which KingmaDoc build is installed (shown by ``kingmadoc --version``).
|
|
2
|
+
|
|
3
|
+
pip records how a package was installed (PEP 610, ``direct_url.json``): for a git install
|
|
4
|
+
that includes the commit, so an update is visible even when the version number is the
|
|
5
|
+
same.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from importlib import metadata
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from kingmadoc import __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def version_text() -> str:
|
|
18
|
+
"""Return the version, plus the git commit or ``editable`` when known.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
e.g. ``"0.2.0.dev0 (git 29244f7)"``, ``"0.2.0.dev0 (editable)"`` or ``"0.2.0.dev0"``.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
raw = _distribution().read_text("direct_url.json")
|
|
25
|
+
except metadata.PackageNotFoundError:
|
|
26
|
+
return __version__
|
|
27
|
+
if not raw:
|
|
28
|
+
return __version__
|
|
29
|
+
try:
|
|
30
|
+
info = json.loads(raw)
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
return __version__
|
|
33
|
+
if not isinstance(info, dict):
|
|
34
|
+
return __version__
|
|
35
|
+
commit = _section(info, "vcs_info").get("commit_id")
|
|
36
|
+
if isinstance(commit, str) and commit:
|
|
37
|
+
return f"{__version__} (git {commit[:7]})"
|
|
38
|
+
if _section(info, "dir_info").get("editable") is True:
|
|
39
|
+
return f"{__version__} (editable)"
|
|
40
|
+
return __version__
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _section(info: dict[str, Any], key: str) -> dict[str, Any]:
|
|
44
|
+
value = info.get(key)
|
|
45
|
+
return value if isinstance(value, dict) else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _distribution() -> Any:
|
|
49
|
+
return metadata.distribution("kingmadoc")
|
kingmadoc/adr.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Architecture Decision Records: ``kingmadoc adr "<title>"`` → ``docs/adr/<NNNN>-<slug>.md``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from jinja2 import TemplateError
|
|
11
|
+
|
|
12
|
+
from kingmadoc.config import FeatureDocConfig
|
|
13
|
+
from kingmadoc.exceptions import AdrError
|
|
14
|
+
from kingmadoc.naming import slugify
|
|
15
|
+
from kingmadoc.templating import load_template
|
|
16
|
+
|
|
17
|
+
ADR_DIR = Path("docs/adr")
|
|
18
|
+
ADR_TEMPLATE = "adr.md.j2"
|
|
19
|
+
STATUSES: tuple[str, ...] = ("proposed", "accepted", "rejected", "superseded")
|
|
20
|
+
|
|
21
|
+
# Zero-padded to four digits (the common ADR convention); wider numbers keep sorting
|
|
22
|
+
# correctly up to 9999 and still parse beyond it.
|
|
23
|
+
NUMBER_WIDTH = 4
|
|
24
|
+
ADR_FILE = re.compile(r"(\d+)-.+\.md")
|
|
25
|
+
# Numbers in this range look like years ("2024-q3-review.md"); such files only count as
|
|
26
|
+
# ADRs when their first line is an ADR heading with that number ("# 2024. ...").
|
|
27
|
+
YEAR_LIKE = range(1900, 2101)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def next_number(existing_names: Iterable[str]) -> int:
|
|
31
|
+
"""Return the number for a new ADR: one above the highest existing number.
|
|
32
|
+
|
|
33
|
+
Numbers are never reused, even when an ADR was deleted, so links stay stable.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
existing_names: File names in the ADR directory.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
``1`` if there are no ADRs yet.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> next_number(["0001-use-postgres.md", "0003-drop-redis.md", "README.md"])
|
|
43
|
+
4
|
|
44
|
+
"""
|
|
45
|
+
numbers = [int(m.group(1)) for n in existing_names if (m := ADR_FILE.fullmatch(n))]
|
|
46
|
+
return max(numbers, default=0) + 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def adr_filename(number: int, title: str) -> str:
|
|
50
|
+
"""Return ``<NNNN>-<slug>.md`` for an ADR.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
number: ADR number.
|
|
54
|
+
title: ADR title (the slug is derived from it).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The file name.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> adr_filename(7, "Use PostgreSQL for user data")
|
|
61
|
+
'0007-use-postgresql-for-user-data.md'
|
|
62
|
+
"""
|
|
63
|
+
return f"{number:0{NUMBER_WIDTH}d}-{slugify(title)}.md"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render_adr(
|
|
67
|
+
root: Path,
|
|
68
|
+
number: int,
|
|
69
|
+
title: str,
|
|
70
|
+
*,
|
|
71
|
+
status: str = "proposed",
|
|
72
|
+
today: date,
|
|
73
|
+
template: str = ADR_TEMPLATE,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Render an ADR in the standard format (title, date, status, context, decision,
|
|
76
|
+
consequences). Pure apart from reading the template; the caller passes the date.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
root: Project root (for project-local templates).
|
|
80
|
+
number: ADR number.
|
|
81
|
+
title: Decision title.
|
|
82
|
+
status: One of :data:`STATUSES`.
|
|
83
|
+
today: Date of the decision record.
|
|
84
|
+
template: Bundled template name or explicit path (``adr.template`` in the config).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The rendered Markdown document.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ConfigError: If the configured template path does not exist or is not a file.
|
|
91
|
+
AdrError: If the title is blank, the status unknown, or the template fails.
|
|
92
|
+
"""
|
|
93
|
+
title = " ".join(title.split())
|
|
94
|
+
if not title:
|
|
95
|
+
raise AdrError("The ADR title must not be empty")
|
|
96
|
+
if status not in STATUSES:
|
|
97
|
+
raise AdrError(f"Unknown ADR status {status!r}; use one of {', '.join(STATUSES)}")
|
|
98
|
+
try:
|
|
99
|
+
return load_template(template, root).render(
|
|
100
|
+
number=f"{number:0{NUMBER_WIDTH}d}",
|
|
101
|
+
title=title,
|
|
102
|
+
status=status,
|
|
103
|
+
statuses=STATUSES,
|
|
104
|
+
date=today.isoformat(),
|
|
105
|
+
)
|
|
106
|
+
except TemplateError as exc:
|
|
107
|
+
raise AdrError(f"Cannot render template {template!r}: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def adr_path(root: Path, config: FeatureDocConfig, title: str) -> tuple[int, Path]:
|
|
111
|
+
"""Pick the number and path for a new ADR in ``<root>/docs/adr``.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
root: Project root.
|
|
115
|
+
config: KingmaDoc configuration.
|
|
116
|
+
title: ADR title.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
``(number, path)``.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
AdrError: If ADRs are not enabled in the config.
|
|
123
|
+
"""
|
|
124
|
+
if not config.adr.enabled:
|
|
125
|
+
raise AdrError(
|
|
126
|
+
"ADRs are disabled. Enable them in .featuredoc.yml:\n adr:\n enabled: true"
|
|
127
|
+
)
|
|
128
|
+
directory = root / ADR_DIR
|
|
129
|
+
names = [p.name for p in directory.iterdir() if _is_adr(p)] if directory.is_dir() else []
|
|
130
|
+
number = next_number(names)
|
|
131
|
+
return number, directory / adr_filename(number, title)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_adr(path: Path) -> bool:
|
|
135
|
+
"""Whether ``path`` is an ADR (see :data:`YEAR_LIKE` for date-named files)."""
|
|
136
|
+
match = ADR_FILE.fullmatch(path.name)
|
|
137
|
+
if not match or not path.is_file():
|
|
138
|
+
return False
|
|
139
|
+
number = int(match.group(1))
|
|
140
|
+
if number not in YEAR_LIKE:
|
|
141
|
+
return True
|
|
142
|
+
try:
|
|
143
|
+
with path.open(encoding="utf-8", errors="ignore") as handle:
|
|
144
|
+
first_line = handle.readline()
|
|
145
|
+
except OSError:
|
|
146
|
+
return False
|
|
147
|
+
return re.match(rf"#\s*0*{number}\b", first_line) is not None
|