-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.py
More file actions
183 lines (154 loc) · 7.45 KB
/
Copy pathindex.py
File metadata and controls
183 lines (154 loc) · 7.45 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
# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/)
"""Generate the planning index from frontmatter.
Run via ``just index``. Globs ``planning/changes/*.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 file name, not
frontmatter — the name is the single source of truth for both.
"""
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).parent
VALID_DECISION_STATUS = {"accepted", "superseded"}
CHANGE_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>.+)$")
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 file name into ``fields``."""
match = pattern.match(name)
if match:
fields["date"] = match.group("date")
fields["slug"] = match.group("slug")
return fields
def load_changes(root: pathlib.Path) -> list[dict[str, str]]:
"""Read each change file's summary; derive date/slug from the file name."""
changes_dir = root / "changes"
changes: list[dict[str, str]] = []
if not changes_dir.is_dir():
return changes
for path in sorted(changes_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, CHANGE_RE)
fields["path"] = f"changes/{path.name}"
fields["name"] = path.stem
changes.append(fields)
return changes
def load_decisions(root: pathlib.Path) -> list[dict[str, str]]:
"""Read each decision's frontmatter; derive date/slug from the file name."""
decisions_dir = root / "decisions"
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(row: dict[str, str]) -> str:
"""Render one change or decision as a Markdown list item."""
slug = row.get("slug", "?")
path = row.get("path", "")
date = row.get("date", "")
summary = row.get("summary") or "(no summary)"
line = f"- **[{slug}]({path})** ({date}) — {summary}"
if row.get("supersedes"):
line += f" _(supersedes {row['supersedes']})_"
if row.get("superseded_by"):
line += f" _(superseded by {row['superseded_by']})_"
return line
def render(changes: 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(changes, 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_change(path: pathlib.Path, violations: list[str]) -> None:
"""Validate one change file (requires `summary`)."""
rel = f"changes/{path.name}"
if CHANGE_RE.match(path.stem) is None:
violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'")
fields = parse_frontmatter(path.read_text(encoding="utf-8"))
_require(fields, SPEC_REQUIRED, rel, violations)
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(root: pathlib.Path) -> list[str]:
"""Validate every change file and decision; return the list of violation strings."""
violations: list[str] = []
changes_dir = root / "changes"
decisions_dir = root / "decisions"
if changes_dir.is_dir():
for path in sorted(changes_dir.iterdir()):
if path.is_dir():
violations.append(
f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files "
f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)"
)
continue
if path.name == "README.md" or path.name.startswith(("_", ".")):
continue
if path.suffix != ".md":
violations.append(f"changes/{path.name}: unexpected non-md file in changes/")
else:
_check_change(path, 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(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int:
"""Print the listing to stdout, or validate change files and decisions with --check."""
argv = sys.argv[1:] if argv is None else argv
root = ROOT if root is None else root
if "--check" in argv:
violations = check(root)
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_changes(root), load_decisions(root)))
return 0
if __name__ == "__main__":
raise SystemExit(main())