-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.py
More file actions
190 lines (159 loc) · 7.64 KB
/
Copy pathindex.py
File metadata and controls
190 lines (159 loc) · 7.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# ruff: noqa: INP001, D212 # planning/ is not a Python package; D212/D213 conflict differs from faststream-outbox
"""
Generate the planning index from frontmatter.
Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's
``design.md``, falling back to ``change.md``) and ``planning/decisions/*.md``,
reads their frontmatter, and prints a Markdown listing to stdout — changes
then decisions, newest-first. Never writes a file:
the listing is a query over the files, not a committed artifact.
``date`` and ``slug`` are derived from the directory / file name, not
frontmatter — the name is the single source of truth for both.
"""
import pathlib
import re
import sys
CHANGES_DIR = pathlib.Path(__file__).parent / "changes"
DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions"
VALID_DECISION_STATUS = {"accepted", "superseded"}
BUNDLE_RE = re.compile(r"^(?P<date>\d{4}-\d{2}-\d{2})\.\d{2}-(?P<slug>.+)$")
DECISION_RE = re.compile(r"^(?P<date>\d{4}-\d{2}-\d{2})-(?P<slug>.+)$")
ALLOWED_BUNDLE_FILES = {"design.md", "plan.md", "change.md"}
SPEC_REQUIRED = ("summary",)
DECISION_REQUIRED = ("status", "summary")
def parse_frontmatter(text: str) -> dict[str, str]:
"""Parse a single-line-scalar YAML frontmatter block into a dict."""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}
fields: dict[str, str] = {}
for line in lines[1:]:
if line.strip() == "---":
break
if line[:1] in (" ", "\t"):
continue
key, sep, value = line.partition(": ")
if not sep:
continue
cleaned = value.strip().strip('"').strip("'")
fields[key.strip()] = "" if cleaned == "null" else cleaned
return fields
def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]:
"""Inject ``date``/``slug`` derived from a dir/file name into ``fields``."""
match = pattern.match(name)
if match:
fields["date"] = match.group("date")
fields["slug"] = match.group("slug")
return fields
def load_bundles() -> list[dict[str, str]]:
"""Read each bundle's summary; derive date/slug from the directory name."""
bundles: list[dict[str, str]] = []
for bundle in sorted(CHANGES_DIR.iterdir()):
if not bundle.is_dir():
continue
spec = bundle / "design.md"
if not spec.exists():
spec = bundle / "change.md"
if not spec.exists():
continue
fields = _named(parse_frontmatter(spec.read_text(encoding="utf-8")), bundle.name, BUNDLE_RE)
fields["path"] = f"changes/{bundle.name}/{spec.name}"
fields["name"] = bundle.name
bundles.append(fields)
return bundles
def load_decisions() -> list[dict[str, str]]:
"""Read each decision's frontmatter; derive date/slug from the file name."""
decisions: list[dict[str, str]] = []
if not DECISIONS_DIR.is_dir():
return decisions
for path in sorted(DECISIONS_DIR.glob("*.md")):
if path.name == "README.md" or path.name.startswith("_"):
continue
fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE)
fields["path"] = f"decisions/{path.name}"
fields["name"] = path.stem
decisions.append(fields)
return decisions
def format_row(bundle: dict[str, str]) -> str:
"""Render one bundle as a Markdown list item."""
slug = bundle.get("slug", "?")
path = bundle.get("path", "")
date = bundle.get("date", "")
summary = bundle.get("summary") or "(no summary)"
line = f"- **[{slug}]({path})** ({date}) — {summary}"
if bundle.get("supersedes"):
line += f" _(supersedes {bundle['supersedes']})_"
if bundle.get("superseded_by"):
line += f" _(superseded by {bundle['superseded_by']})_"
return line
def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str:
"""Render the full Markdown listing: changes then decisions, newest-first."""
out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""]
change_rows = sorted(bundles, key=lambda b: b.get("name", ""), reverse=True)
out += [format_row(b) for b in change_rows] if change_rows else ["_None._"]
out += ["", "## Decisions", ""]
decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True)
out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"]
out.append("")
return "\n".join(out).rstrip() + "\n"
def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations: list[str]) -> None:
"""Append a violation for each required key that is absent or empty."""
violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key))
def _check_spec_file(path: pathlib.Path, rel: str, violations: list[str]) -> None:
"""Validate a design.md / change.md spec file (requires `summary`)."""
fields = parse_frontmatter(path.read_text(encoding="utf-8"))
_require(fields, SPEC_REQUIRED, rel, violations)
def _check_bundle(bundle: pathlib.Path, violations: list[str]) -> None:
"""Validate one change bundle directory."""
rel = f"changes/{bundle.name}"
if BUNDLE_RE.match(bundle.name) is None:
violations.append(f"{rel}: directory name is not 'YYYY-MM-DD.NN-slug'")
violations.extend(
f"{rel}/{child.name}: unexpected file in bundle (allowed: {', '.join(sorted(ALLOWED_BUNDLE_FILES))})"
for child in sorted(bundle.iterdir())
if child.name not in ALLOWED_BUNDLE_FILES
)
design = bundle / "design.md"
change = bundle / "change.md"
if not design.exists() and not change.exists():
violations.append(f"{rel}: bundle has neither design.md nor change.md")
for spec_file in (design, change):
if spec_file.exists():
_check_spec_file(spec_file, f"{rel}/{spec_file.name}", violations)
# plan.md carries no frontmatter — its identity comes from the bundle dir.
def _check_decision(path: pathlib.Path, violations: list[str]) -> None:
"""Validate one decision file (requires `status` + `summary`)."""
rel = f"decisions/{path.name}"
if DECISION_RE.match(path.stem) is None:
violations.append(f"{rel}: file name is not 'YYYY-MM-DD-slug.md'")
fields = parse_frontmatter(path.read_text(encoding="utf-8"))
_require(fields, DECISION_REQUIRED, rel, violations)
status = fields.get("status", "")
if status and status not in VALID_DECISION_STATUS:
violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})")
def check() -> list[str]:
"""Validate every bundle and decision; return the list of violation strings."""
violations: list[str] = []
for bundle in sorted(CHANGES_DIR.iterdir()):
if bundle.is_dir():
_check_bundle(bundle, violations)
if DECISIONS_DIR.is_dir():
for path in sorted(DECISIONS_DIR.glob("*.md")):
if path.name == "README.md" or path.name.startswith("_"):
continue
_check_decision(path, violations)
return violations
def main() -> int:
"""Print the listing to stdout, or validate bundles with --check."""
if "--check" in sys.argv[1:]:
violations = check()
if violations:
sys.stderr.write(f"planning: {len(violations)} violation(s)\n")
for violation in violations:
sys.stderr.write(f" - {violation}\n")
return 1
sys.stdout.write("planning: OK\n")
return 0
sys.stdout.write(render(load_bundles(), load_decisions()))
return 0
if __name__ == "__main__":
raise SystemExit(main())