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 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())