luria 0.1.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.
- luria/__init__.py +11 -0
- luria/adr_index.py +400 -0
- luria/adr_pending.py +156 -0
- luria/badges.py +130 -0
- luria/cli.py +67 -0
- luria/collect.py +154 -0
- luria/config.py +560 -0
- luria/directives.py +253 -0
- luria/doc_refs.py +705 -0
- luria/init.py +104 -0
- luria/journal.py +283 -0
- luria/link_refs.py +48 -0
- luria/lint.py +316 -0
- luria/parallel.py +64 -0
- luria/ref_status.py +524 -0
- luria/remotes.py +527 -0
- luria/reports.py +176 -0
- luria/template/.github/workflows/docs.yml +54 -0
- luria/template/CLAUDE.md +136 -0
- luria/template/docs/README.md +16 -0
- luria/template/luria.toml +81 -0
- luria/template/record/changelog.d/_template.md +27 -0
- luria/template/record/decisions.d/README.stub +31 -0
- luria/template/record/decisions.d/_template.md +80 -0
- luria/template/record/decisions.d/tags.yaml +14 -0
- luria/template/record/devlog.d/_template.md +34 -0
- luria/template/record/principles.d/DP-001.md +27 -0
- luria/template/record/principles.d/DP-002.md +27 -0
- luria/template/record/principles.d/DP-003.md +22 -0
- luria/template/record/principles.d/DP-004.md +21 -0
- luria/template/record/principles.d/DP-005.md +21 -0
- luria/template/record/principles.d/README.stub +28 -0
- luria/template/record/principles.d/_template.md +80 -0
- luria-0.1.0.dist-info/METADATA +168 -0
- luria-0.1.0.dist-info/RECORD +38 -0
- luria-0.1.0.dist-info/WHEEL +4 -0
- luria-0.1.0.dist-info/entry_points.txt +2 -0
- luria-0.1.0.dist-info/licenses/LICENSE +21 -0
luria/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Luria — a project's memory, kept where the next collaborator will find it.
|
|
2
|
+
|
|
3
|
+
The four layers (design principles, decisions, changelog, devlog), the fragment
|
|
4
|
+
convention that keeps them conflict-free, the generated views, and the lint that
|
|
5
|
+
stops all of it from drifting.
|
|
6
|
+
|
|
7
|
+
Public surface is the CLI (`luria --help`); the modules are importable for
|
|
8
|
+
projects that want to extend a check rather than replace it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
luria/adr_index.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""`luria index` — render every scheme's view from its fragments.
|
|
3
|
+
|
|
4
|
+
luria index # write the views
|
|
5
|
+
luria index --check # exit 1 if any of them is stale
|
|
6
|
+
|
|
7
|
+
A scheme's view used to be hand-maintained: every decision-bearing branch
|
|
8
|
+
appended a row to the same table and a link to the same category list. That
|
|
9
|
+
makes it the shared-file lock [DP-2](../docs/design-principles.md#dp-2) names,
|
|
10
|
+
and it *drifts*, because the row duplicates data the document already owns — in
|
|
11
|
+
the corpus this was extracted from, 45 of 155 rows disagreed with their own
|
|
12
|
+
decision's status (ADR-004).
|
|
13
|
+
|
|
14
|
+
So the view is generated. Each document carries YAML frontmatter, and a scheme
|
|
15
|
+
declares how its documents are rendered (ADR-012):
|
|
16
|
+
|
|
17
|
+
render = "index" the browsable shape — a table plus per-tag pages
|
|
18
|
+
docs/decisions/README.md stub prose + category lists + the table
|
|
19
|
+
docs/decisions/tags/<tag>.md one page per tag
|
|
20
|
+
(sources in record/decisions.d/ — the view directory holds only what
|
|
21
|
+
this generator wrote, which is what lets the lint call anything else
|
|
22
|
+
in it an error, ADR-021)
|
|
23
|
+
|
|
24
|
+
render = "document" the read-as-a-whole shape — bodies concatenated
|
|
25
|
+
docs/design-principles.md stub prose + every principle in order
|
|
26
|
+
|
|
27
|
+
Adding a document means adding ONE file. Adding a *tag* means using it in one;
|
|
28
|
+
`tags.yaml` only supplies ordering and a blurb, so an unlisted tag still renders.
|
|
29
|
+
|
|
30
|
+
`--check` is what makes idempotency enforceable: `luria lint` runs it, so a stale
|
|
31
|
+
view is a lint failure rather than a silent divergence — which only works because
|
|
32
|
+
a generated view's sources persist, unlike a collected one's (ADR-012).
|
|
33
|
+
|
|
34
|
+
The stub/placeholder shape is borrowed from dmarx/bench-warmers, which solved
|
|
35
|
+
the same problem for a brainstorming repo: prose lives in a `.stub`, the
|
|
36
|
+
generator substitutes `{placeholders}`, and tags get their own generated pages.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import os.path
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
import yaml
|
|
48
|
+
|
|
49
|
+
from .config import current
|
|
50
|
+
|
|
51
|
+
TITLE_RE = re.compile(r"^#\s*[A-Z]+-\d+\s*(?::|—|-)\s*")
|
|
52
|
+
TABLE_HEAD = "| # | Title | Status |\n|---|---|---|\n"
|
|
53
|
+
|
|
54
|
+
# Used when a project has no `README.stub`. The stub exists so prose lives in
|
|
55
|
+
# markdown rather than in this generator; not having written one yet shouldn't
|
|
56
|
+
# stop the index from building.
|
|
57
|
+
DEFAULT_DOCUMENT_STUB = """# Design principles
|
|
58
|
+
|
|
59
|
+
<!-- GENERATED by `luria index` — edit the fragments, not this file. -->
|
|
60
|
+
|
|
61
|
+
{principles}
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DEFAULT_STUB = """# Architecture decision records
|
|
65
|
+
|
|
66
|
+
<!-- GENERATED below this line by `luria index` — edit README.stub instead. -->
|
|
67
|
+
|
|
68
|
+
{categories}
|
|
69
|
+
|
|
70
|
+
{table}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# A markdown link with a *relative* target: not an anchor, not root-relative,
|
|
74
|
+
# not a URL scheme. Those are the only ones a change of output directory moves.
|
|
75
|
+
RELATIVE_LINK_RE = re.compile(r"(?<=\]\()(?![#/]|[A-Za-z][A-Za-z0-9+.-]*:)([^)\s]+)")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def rebase_links(text: str, prefix: str) -> str:
|
|
79
|
+
"""Rewrite relative link targets in `text` for an output `prefix` away.
|
|
80
|
+
|
|
81
|
+
Summaries are authored relative to the scheme's *source* directory — the
|
|
82
|
+
same base as the ADR body they were lifted from — and this index renders
|
|
83
|
+
them into the view directory and again one level down in `tags/<tag>.md`.
|
|
84
|
+
Owning the rendering is what lets a summary carry links at all: without
|
|
85
|
+
this, no single relative target could be correct everywhere (ADR-005)."""
|
|
86
|
+
if not prefix:
|
|
87
|
+
return text
|
|
88
|
+
return RELATIVE_LINK_RE.sub(lambda m: prefix + m.group(1), text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def prefix_for(scheme, out_dir: Path) -> str:
|
|
92
|
+
"""The rebase prefix for text authored in `scheme.dir`, rendered into
|
|
93
|
+
`out_dir`. "" when they are the same place — the collocated layout — so
|
|
94
|
+
the old output is byte-identical where nothing moved."""
|
|
95
|
+
rel = os.path.relpath(scheme.dir, out_dir)
|
|
96
|
+
return "" if rel == "." else rel + "/"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
100
|
+
"""Split leading `---` YAML frontmatter from the body. Missing frontmatter
|
|
101
|
+
yields an empty dict so a half-migrated tree still renders — lint reports
|
|
102
|
+
the omission rather than the build crashing on it."""
|
|
103
|
+
if not text.startswith("---\n"):
|
|
104
|
+
return {}, text
|
|
105
|
+
end = text.find("\n---\n", 3)
|
|
106
|
+
if end == -1:
|
|
107
|
+
return {}, text
|
|
108
|
+
return yaml.safe_load(text[4 : end + 1]) or {}, text[end + 5 :]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Adr:
|
|
112
|
+
def __init__(self, path: Path, scheme=None):
|
|
113
|
+
scheme = scheme or current().schemes["ADR"]
|
|
114
|
+
self.path = path
|
|
115
|
+
self.scheme = scheme
|
|
116
|
+
self.prefix = scheme.prefix
|
|
117
|
+
self.number = scheme.number_of(path)
|
|
118
|
+
self.meta, body = parse_frontmatter(path.read_text())
|
|
119
|
+
# `title:` is the source of truth; the body's H1 is the fallback, so a
|
|
120
|
+
# document written before the field existed — or in a project that
|
|
121
|
+
# hasn't adopted it — still renders a title rather than a blank cell
|
|
122
|
+
# (ADR-013).
|
|
123
|
+
first = next((ln for ln in body.splitlines() if ln.startswith("#")), "")
|
|
124
|
+
self.heading = TITLE_RE.sub("", first).strip()
|
|
125
|
+
self.title = str(self.meta.get("title") or "").strip() or self.heading
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def code(self) -> str:
|
|
129
|
+
return f"{self.prefix}-{self.number:03d}"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def status(self) -> str:
|
|
133
|
+
return str(self.meta.get("status", "")).strip()
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def tags(self) -> list[str]:
|
|
137
|
+
return [str(t).strip().lower() for t in (self.meta.get("tags") or [])]
|
|
138
|
+
|
|
139
|
+
def cell(self, prefix: str = "") -> str:
|
|
140
|
+
"""The table's middle column: the summary when there is one, else the
|
|
141
|
+
title. A row has always been one blob, not a title plus a description —
|
|
142
|
+
keeping that shape is what made the migration byte-identical.
|
|
143
|
+
|
|
144
|
+
A summary may carry relative links, written — like the ADR's body —
|
|
145
|
+
relative to the scheme's source directory. `prefix` rebases them for
|
|
146
|
+
output that renders somewhere else (ADR-005); it is the same prefix
|
|
147
|
+
the row's own ADR link already took."""
|
|
148
|
+
return rebase_links(str(self.meta.get("summary") or self.title).strip(), prefix)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def version(self) -> int:
|
|
152
|
+
"""Which revision of this document's claim you are reading.
|
|
153
|
+
|
|
154
|
+
Standard frontmatter for every scheme (ADR-016), not just principles.
|
|
155
|
+
A decision is superseded rather than rewritten, so its version moves
|
|
156
|
+
rarely — but "rarely" is not "never": a decision whose *scope* widens
|
|
157
|
+
without its choice changing is a revision, and that is exactly the case
|
|
158
|
+
a reader needs told apart from a fresh decision."""
|
|
159
|
+
return int(self.meta.get("version", 1) or 1)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def influenced_by(self) -> list[str]:
|
|
163
|
+
raw = self.meta.get("influenced_by") or []
|
|
164
|
+
return [str(x).strip() for x in raw]
|
|
165
|
+
|
|
166
|
+
def row(self, prefix: str = "") -> str:
|
|
167
|
+
# unresolved-ok-block: ADR-100 — a stand-in number in the example below
|
|
168
|
+
# Every rendered field is rebased, not just the ADR's own link: a
|
|
169
|
+
# "Superseded — by [ADR-100](…)" note is prose too, and its link was
|
|
170
|
+
# silently broken on the tag pages (four of them) until this existed.
|
|
171
|
+
#
|
|
172
|
+
# The version is shown only when it isn't 1. A column of 1s teaches
|
|
173
|
+
# nothing; the field exists so a document that HAS been revised says so
|
|
174
|
+
# where it is read (ADR-016).
|
|
175
|
+
version = f" v{self.version}" if self.version > 1 else ""
|
|
176
|
+
return (f"| [{self.code}]({prefix}{self.path.name}){version} "
|
|
177
|
+
f"| {self.cell(prefix)} | {rebase_links(self.status, prefix)} |")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_adrs() -> list[Adr]:
|
|
181
|
+
"""The ADR scheme's documents. Kept as its own name because the pending
|
|
182
|
+
report is about decisions specifically, not about every scheme."""
|
|
183
|
+
return load_scheme(current().schemes["ADR"])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_scheme(scheme) -> list[Adr]:
|
|
187
|
+
return [Adr(p, scheme) for p in scheme.documents().values()]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def tag_order(adrs: list[Adr], scheme=None) -> list[tuple[str, dict]]:
|
|
191
|
+
"""Declared tags first, in tags.yaml order; then any undeclared tag an ADR
|
|
192
|
+
actually uses, alphabetically. Using a new tag must never require a code
|
|
193
|
+
change — that's the whole point of pushing categories down onto the ADRs."""
|
|
194
|
+
tags_file = scheme.tags_yaml if scheme else current().tags_yaml
|
|
195
|
+
declared = yaml.safe_load(tags_file.read_text()) if tags_file.exists() else {}
|
|
196
|
+
declared = declared or {}
|
|
197
|
+
used = {t for a in adrs for t in a.tags}
|
|
198
|
+
ordered = [(t, declared[t] or {}) for t in declared if t in used]
|
|
199
|
+
ordered += [(t, {}) for t in sorted(used - set(declared))]
|
|
200
|
+
return ordered
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def render_categories(adrs: list[Adr], tags: list[tuple[str, dict]],
|
|
204
|
+
prefix: str = "") -> str:
|
|
205
|
+
blocks = []
|
|
206
|
+
for tag, meta in tags:
|
|
207
|
+
listed = [a for a in adrs if tag in a.tags]
|
|
208
|
+
label = meta.get("label", tag.title())
|
|
209
|
+
blurb = f" — {meta['blurb']}" if meta.get("blurb") else ""
|
|
210
|
+
links = " · ".join(f"[{a.number:03d}]({prefix}{a.path.name})" for a in listed)
|
|
211
|
+
blocks.append(f"**[{label}](tags/{tag}.md)** ({len(listed)}){blurb}:\n{links}")
|
|
212
|
+
return "\n\n".join(blocks)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_index(adrs: list[Adr], tags: list[tuple[str, dict]],
|
|
216
|
+
scheme=None) -> str:
|
|
217
|
+
scheme = scheme or current().schemes["ADR"]
|
|
218
|
+
prefix = prefix_for(scheme, scheme.view)
|
|
219
|
+
table = TABLE_HEAD + "\n".join(a.row(prefix) for a in adrs) + "\n"
|
|
220
|
+
stub = scheme.stub
|
|
221
|
+
prose = stub.read_text() if stub.exists() else DEFAULT_STUB
|
|
222
|
+
return (prose.replace("{categories}", render_categories(adrs, tags, prefix))
|
|
223
|
+
.replace("{table}", table))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_tag_page(tag: str, meta: dict, adrs: list[Adr],
|
|
227
|
+
scheme=None) -> str:
|
|
228
|
+
scheme = scheme or current().schemes["ADR"]
|
|
229
|
+
prefix = prefix_for(scheme, scheme.tag_dir)
|
|
230
|
+
label = meta.get("label", tag.title())
|
|
231
|
+
listed = [a for a in adrs if tag in a.tags]
|
|
232
|
+
blurb = f"\n{meta['blurb'].capitalize()}.\n" if meta.get("blurb") else ""
|
|
233
|
+
return (
|
|
234
|
+
f"<!-- GENERATED by `luria index` — do not edit. -->\n\n"
|
|
235
|
+
f"# ADRs tagged `{tag}`\n"
|
|
236
|
+
f"{blurb}\n"
|
|
237
|
+
f"{len(listed)} of {len(adrs)} decisions. Back to the [full index](../README.md).\n\n"
|
|
238
|
+
+ TABLE_HEAD
|
|
239
|
+
+ "\n".join(a.row(prefix=prefix) for a in listed)
|
|
240
|
+
+ "\n"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def render_document(scheme, docs: list[Adr]) -> str:
|
|
245
|
+
"""Every document's body, in number order, as one page.
|
|
246
|
+
|
|
247
|
+
The right shape when the set is read *as a whole* rather than browsed one at
|
|
248
|
+
a time — which is what a principles document is: people cite "DP-3" and then
|
|
249
|
+
read it in the context of its neighbours. The metadata line is what the
|
|
250
|
+
fragment frontmatter buys: a version, so a revised principle says so
|
|
251
|
+
(ADR-012), and the decisions whose experience produced it."""
|
|
252
|
+
stub = scheme.stub
|
|
253
|
+
head = stub.read_text() if stub.exists() else DEFAULT_DOCUMENT_STUB
|
|
254
|
+
base = scheme.output.parent if scheme.output else scheme.dir
|
|
255
|
+
parts = []
|
|
256
|
+
for doc in docs:
|
|
257
|
+
body = parse_frontmatter(doc.path.read_text())[1].strip()
|
|
258
|
+
# Demote the fragment's own H1 to the assembled document's H2, and
|
|
259
|
+
# renumber it into the reader's numbering rather than the file's code.
|
|
260
|
+
body = re.sub(r"^#\s*[A-Z]+-\d+\s*(?::|—|-)\s*",
|
|
261
|
+
f"## {doc.number}. ", body, count=1)
|
|
262
|
+
# A stable anchor, because the heading is not one. A principle is a
|
|
263
|
+
# living document — two of the eight here have been reworded already —
|
|
264
|
+
# and a heading-derived anchor stops resolving the moment the wording
|
|
265
|
+
# moves, silently, which is the fail-stale polarity DP-3 rules out.
|
|
266
|
+
# This one is keyed to the number, which is the thing that never moves.
|
|
267
|
+
body = f'<a name="{doc.prefix.lower()}-{doc.number}"></a>\n\n{body}'
|
|
268
|
+
meta = [f"*v{doc.version}"]
|
|
269
|
+
if doc.influenced_by:
|
|
270
|
+
meta.append("shaped by " + ", ".join(
|
|
271
|
+
f"[{code}]({target})" if (target := _link(code, base)) else code
|
|
272
|
+
for code in doc.influenced_by))
|
|
273
|
+
if doc.status != scheme.active:
|
|
274
|
+
meta.append(f"**{doc.status}**")
|
|
275
|
+
if origin := str(doc.meta.get("origin", "")).strip():
|
|
276
|
+
meta.append(f"origin: {origin.rstrip('.')}")
|
|
277
|
+
parts.append(f"{body}\n\n{' · '.join(meta)}*")
|
|
278
|
+
return head.replace("{principles}", "\n\n".join(parts))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _link(code: str, base: Path) -> str:
|
|
282
|
+
"""`ADR-004` → a link to that decision, relative to `base`.
|
|
283
|
+
|
|
284
|
+
Relative to *where the text renders*, not where the fragment lives — the
|
|
285
|
+
same rule the reference fixer follows for anything assembled elsewhere
|
|
286
|
+
(ADR-005). An unresolvable code yields "", and the caller renders the bare
|
|
287
|
+
code rather than a link to nothing (DP-1: say what you can, don't invent)."""
|
|
288
|
+
try:
|
|
289
|
+
prefix, number = code.split("-")
|
|
290
|
+
path = current().schemes[prefix.upper()].documents().get(int(number))
|
|
291
|
+
except (ValueError, KeyError):
|
|
292
|
+
return ""
|
|
293
|
+
return os.path.relpath(path, base) if path else ""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def view_dirs() -> list[Path]:
|
|
297
|
+
"""Every directory the generator owns outright. Anything in one of these
|
|
298
|
+
it didn't render is an orphan — a stale tag page, a book from an old
|
|
299
|
+
granularity, or a hand-written file that will read as generated (ADR-021).
|
|
300
|
+
A collocated scheme (no separate `output`) contributes only its tag dir,
|
|
301
|
+
because its view directory also holds the sources."""
|
|
302
|
+
cfg = current()
|
|
303
|
+
dirs: list[Path] = []
|
|
304
|
+
for s in cfg.schemes.values():
|
|
305
|
+
if s.render != "index":
|
|
306
|
+
continue
|
|
307
|
+
dirs.append(s.tag_dir)
|
|
308
|
+
if s.view != s.dir:
|
|
309
|
+
dirs.append(s.view)
|
|
310
|
+
dirs += [j.output for j in cfg.journals.values()]
|
|
311
|
+
return dirs
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def orphans(rendered: dict[Path, str]) -> list[Path]:
|
|
315
|
+
return [p for d in view_dirs() if d.is_dir()
|
|
316
|
+
for p in sorted(d.glob("*.md")) if p not in rendered]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _render_scheme(scheme) -> dict[Path, str]:
|
|
320
|
+
docs = load_scheme(scheme)
|
|
321
|
+
if scheme.render == "document":
|
|
322
|
+
return {scheme.output: render_document(scheme, docs)} if scheme.output else {}
|
|
323
|
+
tags = tag_order(docs, scheme)
|
|
324
|
+
out = {scheme.index_path: render_index(docs, tags, scheme)}
|
|
325
|
+
for tag, meta in tags:
|
|
326
|
+
out[scheme.tag_dir / f"{tag}.md"] = render_tag_page(tag, meta, docs, scheme)
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def outputs() -> dict[Path, str]:
|
|
331
|
+
"""Every generated view, across every scheme — one place, so the lint's
|
|
332
|
+
staleness check covers a new scheme the moment it is configured.
|
|
333
|
+
|
|
334
|
+
Rendered wide (ADR-026): each scheme and each journal is an independent
|
|
335
|
+
pure function of the tree, so they run as parallel units. `pmap` returns
|
|
336
|
+
in input order, which is what keeps the merged dict — and therefore the
|
|
337
|
+
staleness diff — deterministic."""
|
|
338
|
+
from . import journal
|
|
339
|
+
from .parallel import pmap
|
|
340
|
+
cfg = current()
|
|
341
|
+
units = [lambda s=s: _render_scheme(s) for s in cfg.schemes.values()]
|
|
342
|
+
units += [lambda j=j: journal.outputs_for(j) for j in cfg.journals.values()]
|
|
343
|
+
out: dict[Path, str] = {}
|
|
344
|
+
for rendered in pmap(lambda u: u(), units):
|
|
345
|
+
out.update(rendered)
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main() -> int:
|
|
350
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
351
|
+
ap.add_argument("--check", action="store_true", help="exit 1 if anything is stale")
|
|
352
|
+
args = ap.parse_args()
|
|
353
|
+
|
|
354
|
+
rendered = outputs()
|
|
355
|
+
if args.check:
|
|
356
|
+
stale = [p for p, text in rendered.items()
|
|
357
|
+
if not p.exists() or p.read_text() != text]
|
|
358
|
+
# Anything in a view directory the generator didn't render is stale
|
|
359
|
+
# too — a tag page whose tag is gone, a book from an old granularity.
|
|
360
|
+
stale += orphans(rendered)
|
|
361
|
+
from . import badges
|
|
362
|
+
readme = badges.readme()
|
|
363
|
+
if readme.exists() and badges.OPEN in (text := readme.read_text()) \
|
|
364
|
+
and badges.rewrite(text) != text:
|
|
365
|
+
stale.append(readme)
|
|
366
|
+
if stale:
|
|
367
|
+
print("stale (run `luria index`):", file=sys.stderr)
|
|
368
|
+
for p in sorted(stale):
|
|
369
|
+
print(f" {current().rel(p)}", file=sys.stderr)
|
|
370
|
+
return 1
|
|
371
|
+
print("luria index: current")
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
cfg = current()
|
|
375
|
+
for stale_file in orphans(rendered):
|
|
376
|
+
stale_file.unlink()
|
|
377
|
+
for p, text in rendered.items():
|
|
378
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
p.write_text(text)
|
|
380
|
+
# Name every scheme, not just the decisions: a project that adds one wants
|
|
381
|
+
# to see it counted, and a scheme silently rendering nothing is the failure
|
|
382
|
+
# this line exists to make visible (DP-1).
|
|
383
|
+
from . import journal
|
|
384
|
+
filed = {n: len(journal.entries(j)) for n, j in sorted(cfg.journals.items())}
|
|
385
|
+
counted = ", ".join(
|
|
386
|
+
[f"{len(load_scheme(s))} {p}s" for p, s in sorted(cfg.schemes.items())]
|
|
387
|
+
+ [f"{c} {n} entr{'y' if c == 1 else 'ies'}" for n, c in filed.items()])
|
|
388
|
+
print(f"Wrote {len(rendered)} file(s) from {counted}.")
|
|
389
|
+
# The README's badge counts are derived from the same frontmatter, so they
|
|
390
|
+
# are regenerated here rather than by a command someone has to remember
|
|
391
|
+
# (ADR-018). A project with no badge region is left alone.
|
|
392
|
+
from . import badges
|
|
393
|
+
readme = badges.readme()
|
|
394
|
+
if readme.exists() and badges.OPEN in (text := readme.read_text()):
|
|
395
|
+
readme.write_text(badges.rewrite(text))
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
raise SystemExit(main())
|
luria/adr_pending.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report ADRs still awaiting a decision, oldest first (ADR-007).
|
|
3
|
+
|
|
4
|
+
luria pending # the table
|
|
5
|
+
luria pending --stale-days 30 # a tighter "overdue" line
|
|
6
|
+
luria pending --as-of 2026-08-03 # fixed clock, for tests
|
|
7
|
+
|
|
8
|
+
`Proposed` and `Deferred` are the two statuses that describe an *open* question:
|
|
9
|
+
"we haven't decided" and "we decided not to decide yet". Both are legitimate —
|
|
10
|
+
[ADR-003](../record/decisions.d/ADR-003.md) added `Deferred` precisely
|
|
11
|
+
so postponement could be stated rather than faked. What neither status records is
|
|
12
|
+
*how long*, and that is the whole signal: a decision proposed a week ago is
|
|
13
|
+
pending, the same one a year later is either overdue or was quietly settled in
|
|
14
|
+
code and never written down. The status field can't drift toward the truth on its
|
|
15
|
+
own, because nothing about "still Proposed" ever fails.
|
|
16
|
+
|
|
17
|
+
So the report supplies the missing axis — age — and one more that turns a list
|
|
18
|
+
into a priority: how often the ADR is cited. An old proposal nothing references
|
|
19
|
+
is a stalled idea; an old proposal 32 files cite is a decision the codebase has
|
|
20
|
+
already made without saying so.
|
|
21
|
+
|
|
22
|
+
The count here is every citation, acknowledged or not, because an acknowledged
|
|
23
|
+
one still means the codebase depends on the answer. `make ref-status` lists only
|
|
24
|
+
the unacknowledged ones, so its total is smaller — the headline names both so
|
|
25
|
+
the two reports visibly reconcile.
|
|
26
|
+
|
|
27
|
+
Like the non-Active reference report this ships beside, it warns and never
|
|
28
|
+
fails. An ADR can be legitimately open for a long time; only a human can say
|
|
29
|
+
which of these is overdue.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import datetime as dt
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from . import adr_index as builder
|
|
42
|
+
from . import ref_status
|
|
43
|
+
from .config import current
|
|
44
|
+
|
|
45
|
+
UNDECIDED = ("Proposed", "Deferred")
|
|
46
|
+
DEFAULT_STALE_DAYS = 90
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Pending:
|
|
51
|
+
code: str # `ADR-012`, `DP-004` — every scheme, not just one
|
|
52
|
+
number: int
|
|
53
|
+
status: str
|
|
54
|
+
title: str
|
|
55
|
+
date: dt.date | None
|
|
56
|
+
cites: int
|
|
57
|
+
unacknowledged: int # of those, the ones `make ref-status` still lists
|
|
58
|
+
path: Path
|
|
59
|
+
|
|
60
|
+
def age(self, today: dt.date) -> int | None:
|
|
61
|
+
return None if self.date is None else (today - self.date).days
|
|
62
|
+
|
|
63
|
+
def is_stale(self, today: dt.date, stale_days: int) -> bool:
|
|
64
|
+
age = self.age(today)
|
|
65
|
+
return age is not None and age >= stale_days
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _date(meta: dict) -> dt.date | None:
|
|
69
|
+
raw = meta.get("date")
|
|
70
|
+
if isinstance(raw, dt.date):
|
|
71
|
+
return raw
|
|
72
|
+
try:
|
|
73
|
+
return dt.date.fromisoformat(str(raw))
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pending() -> list[Pending]:
|
|
79
|
+
"""Every undecided document in every scheme, oldest first; undated ones
|
|
80
|
+
last — a document with no `date:` can't be aged, which is itself worth
|
|
81
|
+
seeing.
|
|
82
|
+
|
|
83
|
+
Not just decisions. A `Proposed` principle is an open question in exactly
|
|
84
|
+
the same way, and a report that covered one scheme would go quietly blind
|
|
85
|
+
the day a project configured a second (ADR-018)."""
|
|
86
|
+
cited = ref_status.scan().cited
|
|
87
|
+
rows = []
|
|
88
|
+
for scheme in current().schemes.values():
|
|
89
|
+
for doc in builder.load_scheme(scheme):
|
|
90
|
+
status = re.split(r"\s+—\s+", doc.status, maxsplit=1)[0]
|
|
91
|
+
if status not in UNDECIDED:
|
|
92
|
+
continue
|
|
93
|
+
sites = cited.get(doc.code, [])
|
|
94
|
+
rows.append(Pending(doc.code, doc.number, status, doc.title,
|
|
95
|
+
_date(doc.meta), len(sites),
|
|
96
|
+
sum(1 for c in sites if c.excused_by is None),
|
|
97
|
+
doc.path))
|
|
98
|
+
return sorted(rows, key=lambda r: (r.date is None, r.date, -r.cites, r.code))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def table(rows: list[Pending], today: dt.date, stale_days: int) -> list[str]:
|
|
102
|
+
if not rows:
|
|
103
|
+
return []
|
|
104
|
+
width = max(len(r.title) for r in rows)
|
|
105
|
+
code_width = max(len(r.code) for r in rows)
|
|
106
|
+
lines = [f"{'age':>6} {'status':<8} {'code':<{code_width}} "
|
|
107
|
+
f"{'cites':>5} title"]
|
|
108
|
+
for r in rows:
|
|
109
|
+
age = r.age(today)
|
|
110
|
+
mark = "!" if r.is_stale(today, stale_days) else " "
|
|
111
|
+
lines.append(
|
|
112
|
+
f"{(f'{age}d' if age is not None else '?'):>6}{mark} {r.status:<8} "
|
|
113
|
+
f"{r.code:<{code_width}} {r.cites:>5} {r.title[:width]}"
|
|
114
|
+
)
|
|
115
|
+
return lines
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def headline(rows: list[Pending], today: dt.date, stale_days: int) -> str:
|
|
119
|
+
if not rows:
|
|
120
|
+
return "pending decisions: none — every document is decided"
|
|
121
|
+
ages = [a for a in (r.age(today) for r in rows) if a is not None]
|
|
122
|
+
stale = sum(1 for r in rows if r.is_stale(today, stale_days))
|
|
123
|
+
undated = sum(1 for r in rows if r.date is None)
|
|
124
|
+
loud = sum(1 for r in rows if r.unacknowledged)
|
|
125
|
+
parts = [f"{len(rows)} undecided document(s)"]
|
|
126
|
+
if ages:
|
|
127
|
+
parts.append(f"oldest {max(ages)} days")
|
|
128
|
+
if stale:
|
|
129
|
+
parts.append(f"{stale} over {stale_days} days")
|
|
130
|
+
if undated:
|
|
131
|
+
parts.append(f"{undated} undated")
|
|
132
|
+
# Printed next to the reference report's count, these two look like an
|
|
133
|
+
# off-by-one and are not: that report only lists documents with an
|
|
134
|
+
# UNACKNOWLEDGED citation, which is exactly this number.
|
|
135
|
+
if loud != len(rows):
|
|
136
|
+
parts.append(f"{loud} with unacknowledged references")
|
|
137
|
+
return "pending decisions: " + ", ".join(parts)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> int:
|
|
141
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
142
|
+
ap.add_argument("--stale-days", type=int, default=current().stale_days,
|
|
143
|
+
help="flag rows at least this old")
|
|
144
|
+
ap.add_argument("--as-of", help="treat this ISO date as today")
|
|
145
|
+
args = ap.parse_args()
|
|
146
|
+
|
|
147
|
+
today = dt.date.fromisoformat(args.as_of) if args.as_of else dt.date.today()
|
|
148
|
+
rows = pending()
|
|
149
|
+
print(headline(rows, today, args.stale_days), file=sys.stderr)
|
|
150
|
+
for line in table(rows, today, args.stale_days):
|
|
151
|
+
print(f" {line}", file=sys.stderr)
|
|
152
|
+
return 0 # a report, not a gate
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|