diff --git a/AGENTS.md b/AGENTS.md index 46c153d..df8e870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,9 @@ logseq-cli get-todos --page "Project Alpha" # Filter by tag logseq-cli get-todos --tag urgent +# Tasks standing in a date range, including ones carried forward by ((block-ref)) +logseq-cli get-todos --from 2026-09-14 --to 2026-09-16 --json + # Mark as done logseq-cli set-todo-status --id UUID --status DONE @@ -259,7 +262,7 @@ If Logseq is not running, the CLI will print "Cannot connect to Logseq API" and | `get-block` | Resolve block references `((uuid))` | | `search-pages` | Find pages by name | | `smart-query` | Natural language or Datalog queries | -| `get-todos` | List and filter tasks | +| `get-todos` | List and filter tasks; a task carried forward by `((block-ref))` is found on the day it stands and stays one row | | `get-backlinks` | Find pages linking to a page | | `insert-block` | Insert at specific position (after/before/child-of, `--first` for first child); `--keep-ids` preserves `id::` values in a tree | | `find-block` | Find blocks by content; `--limit N` caps the output (what is withheld goes to stderr); `--with-children` prints the subtree | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aeb697..b6baf6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `get-todos --from/--to` now finds a task on every journal it stands in, not + only on the page its block lives on. A task carried forward by a + `((block-ref))` was invisible to any date range: `--from 2026-09-14 --to + 2026-09-16` returned nothing on a graph where three tasks stood in exactly + those journals. Carrying an open task forward by reference is the ordinary + way to work in Logseq — the block exists once, every later occurrence is a + reference to it — so the answer was not merely incomplete, it was empty, and + an empty result looks plausible. + + The fix reads the `:block/refs` relation, which is a real relation and needs + no string matching on the `((uuid))` form. One extra query for the whole + command, roughly 0.17s against a graph with 256 tasks. A task stays **one** + row: `page` and `uuid` still name the original block, and the days it was + carried into are added as `references`. Measured on that graph, a task is + referenced a median of 2 times and one of them 33 times, which is why it is + an array and why it is capped. + + `--refs-limit` (default 10) caps the list per task and the remainder is + reported as `references_withheld`, the same bargain `get-backlinks --limit` + and `find-block --limit` already make — one heavily carried task must not + decide the size of the output, and trimming must not hide that a task has + been carried for months. The default is 10 rather than the 3 used by + `get-backlinks` because an entry here is a date, not a block of text, and + because the measured distribution breaks there: a cap of 3 trims 12 of 58 + carried tasks, a cap of 10 trims 4. `--refs-limit 0` keeps all of them. + `--no-follow-refs` restores the old reading, for callers who want to know + where blocks live rather than where they appear, and skips the read rather + than fetching what it will not use. + + This is a **breaking** change in the sense that matters: a range query can + now return more tasks than before, up to 58 more on the measured graph. + Nothing was removed, and `page`/`uuid` are unchanged. + + A reference on a page carrying no `journal-day` falls out of a range, the + same rule the origin page has followed since 0.11.0 — 44 of 248 reference + occurrences sit on ordinary pages, and letting them through would have + reopened the silent gap that decision closed. See [#15](https://github.com/muellerei/logseq-cli/issues/15). + - A test now holds the README's command tables to the command registry. The twenty missing options below were not the defect — they were the symptom. The defect is that a table is a hand-maintained view of something derivable, and @@ -50,6 +88,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 does not go stale and names the consequence instead of a count — a figure maintained by hand is the same defect this project documents elsewhere. +### Added + +- `examples/carried-over-todos.sh` lists the tasks standing in the last N days, + longest-carried first, and says for each how many journals it has been taken + along and how many of those fall inside the window. That reading only became + possible with the block-ref work above: before it, a task's date was the day + it was first written down, so "how long have I been moving this?" had no + answer in the payload. + + Uses `--refs-limit 0` for the count, which lifts the per-task cap without + widening the window — occurrences before the range stay in + `references_withheld`, and the sum of both is what makes the total a + duration rather than a visible fraction. + +### Fixed + +- `examples/weekly-todos.sh` counted `data.get('tasks', [])`, a key + `get-todos --json` has never emitted — the payload has carried `todos` since + the initial import. The `.get` default swallowed it: the script reported + "Total: 0 open tasks" against any graph and printed an empty per-page + breakdown under it, which reads as a quiet week rather than as a broken + example. It now reads `data['todos']`, so a future rename fails loudly + instead of counting zero. + + Two tests hold both halves — the example may only read keys the payload + carries, and the payload keeps carrying them. Found while checking the + block-ref work above for consistency against the rest of the repo, not by + running the example, which is the part worth noting: an example nobody runs + is documentation that can disagree with its source. + ## [0.12.0] - 2026-09-15 ### Fixed diff --git a/README.md b/README.md index 4dbd7c6..bb00df3 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ logseq-cli get-page --name "My Page" # equivalent | Command | Description | |---------|-------------| -| `get-todos [--page NAME] [--status S] [--tag TAG] [--from DATE] [--to DATE] [--due-from DATE] [--due-to DATE] [--include-done]` | List tasks (page name shown inline in plain-text output). `--from/--to` date a task by the journal page it sits on — when it was written down. `--due-from/--due-to` filter by `SCHEDULED`/`DEADLINE` instead. For a repeating task the next occurrence is derived (Logseq stores only the first) and reported as `next_due` | +| `get-todos [--page NAME] [--status S] [--tag TAG] [--from DATE] [--to DATE] [--due-from DATE] [--due-to DATE] [--include-done] [--refs-limit N] [--no-follow-refs]` | List tasks (page name shown inline in plain-text output). `--from/--to` date a task by every journal it stands in, the page its block lives on and the ones it was carried into by `((block-ref))` alike; `references` names the latter, `--refs-limit` caps that list (0 lifts the cap) and `references_withheld` counts what was left out — with a range that includes occurrences outside it, so lifting the cap does not make the count zero. `--no-follow-refs` reports only where blocks live. `--due-from/--due-to` filter by `SCHEDULED`/`DEADLINE` instead. For a repeating task the next occurrence is derived (Logseq stores only the first) and reported as `next_due` | | `get-properties --page NAME [--property KEY]` | Get page properties | | `doctor` | Health-check: Python, packages, connectivity, token, API, graph kind, graph, config. Exit 0 = ready | | `init [--dry-run] [--force] [--output PATH]` | Write a config file suggested from your graph, with the counts each suggestion rests on | @@ -338,6 +338,14 @@ logseq-cli get-todos --status TODO # TODO [Project Alpha] Finish the tag support UI # DOING [2026-04-22, wednesday] Prepare the 1:1 +# 3b. A task carried forward by ((block-ref)) is found on the day it stands, +# not only on the journal it was first written down in. +logseq-cli get-todos --from 2026-04-20 --to 2026-04-22 +# TODO [2026-03-04, wednesday] Write the migration guide +# also on: 2026-04-22, wednesday; 2026-04-20, monday (+9 more) +# The task is one row: [page] is where the block lives, "also on" where it +# appears. --no-follow-refs reports only the former. + # 4. insert-block --tree: batch-insert a hierarchy in one call logseq-cli insert-block --child-of "$UUID" --tree "### Meeting - Agenda @@ -494,6 +502,39 @@ complete and is not. `get-page` was silent about this until the count was added there too; the same page read through two commands had given two different answers about whether it was whole. +### A task is where it stands, not only where it was written + +A todo block exists once. Carrying it forward into later journals is done with +a `((block-ref))`, and that reference is not a copy — it is the same block in a +second place, which is why checking off the reference checks off the original. +A tool that finds tasks through `:block/page` alone therefore sees only the day +a task was first written down, and a query for this week returns nothing about +the tasks that actually stood in it. The failure is quiet: an empty task list +looks like an empty week. + +Logseq's own `(between ...)` filter reads the same way, which is how the +problem arrives in the forum rather than in a bug tracker — *"the tasks are not +in the journal pages and the between query only looks at the journal page +dates"* +([discuss.logseq.com](https://discuss.logseq.com/t/creating-a-query-for-overdue-tasks/12408)). +The advanced-query answer given there reaches for `:block/refs`, one block +reference at a time. + +So `get-todos` follows that relation by default rather than behind a flag: a +default that answers incompletely is worse than one that costs a read, because +the caller has no way to tell the two apart. The task stays one row — `page` +and `uuid` keep naming the original block, `references` names the days it was +carried into. `--refs-limit` caps that list and `references_withheld` counts +the rest, because a task carried 33 times must not decide the size of the +output, and `--no-follow-refs` restores the older reading for callers who want +to know where blocks live rather than where they appear. + +`references_withheld` counts two things a range query leaves out: occurrences +beyond the cap, and occurrences outside the range itself. Lifting the cap with +`--refs-limit 0` therefore does not drive the count to zero — a task carried +since March still reports the days before the queried week. That is the reading +a range query wants, because the alternative is a task that looks new. + ### Failure has one exit code, and no resume A command exits `0` when it did what it said, and non-zero when it did not. @@ -571,6 +612,7 @@ Date formatting is locale-independent — weekday and month names are always Eng See `examples/` directory: - `backup-graph.sh` - Export all pages as a JSON backup +- `carried-over-todos.sh` - Tasks standing in the last N days, longest-carried first (uses `references` to show how long each has been taken along) - `daily-todos.sh` - Daily TODO overview (suitable for cronjob) - `export-all-pages.sh` - Export all pages as individual JSON files - `export-page.sh` - Export a page as Logseq-compatible markdown diff --git a/examples/carried-over-todos.sh b/examples/carried-over-todos.sh new file mode 100755 index 0000000..06da66d --- /dev/null +++ b/examples/carried-over-todos.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Tasks standing in the last N days, longest-carried first +# +# A task carried forward by ((block-ref)) appears in each journal it was pulled +# into, so `references` counts how many days it has been taken along and +# `references_withheld` how many of those fall outside the window asked about. +# Their sum answers "how long have I been moving this?", which the page a task +# lives on cannot: that only says when it was first written down. +# +# Usage: ./carried-over-todos.sh [DAYS] (default: 14) +# Requires: LOGSEQ_TOKEN or --token, and jq + +set -euo pipefail + +DAYS="${1:-14}" +# BSD date (macOS) and GNU date disagree on relative dates; try both. +FROM=$(date -v-"${DAYS}"d +%Y-%m-%d 2>/dev/null \ + || date -d "${DAYS} days ago" +%Y-%m-%d) +TO=$(date +%Y-%m-%d) + +# One read, reused below. --refs-limit 0 lifts the per-task cap so every +# occurrence inside the window is counted; it does not widen the window, so +# days before ${FROM} stay in references_withheld — which is what makes the +# total meaningful rather than just the visible part. +# Declared before assignment on purpose: `local`/`export` on the same line as +# a command substitution swallows its exit status, and so does a bare +# assignment under `set -e` in some shells. Split, the failure propagates and +# the script stops instead of reporting an empty week. +PAYLOAD="" +PAYLOAD=$(logseq-cli get-todos --from "${FROM}" --to "${TO}" --refs-limit 0 --json) + +echo "=== Tasks standing between ${FROM} and ${TO} ===" +echo + +echo "${PAYLOAD}" | jq -r ' + .todos + | map(. + { + days_seen: ((.references // []) | length), + days_outside: (.references_withheld // 0) + }) + | sort_by(-(.days_seen + .days_outside), .content) + | .[] + | "\(.marker) \(.content | split("\n")[0] | .[0:60])\n" + + " first written: \(.page)\n" + + (if (.days_seen + .days_outside) == 0 + then " not carried — written on the day it stands\n" + else " carried into \(.days_seen + .days_outside) journal(s), " + + "\(.days_seen) in this window\n" + end) +' + +echo "${PAYLOAD}" | jq -r ' + (.todos | length) as $total + | (.todos | map(select(((.references // []) | length) + + (.references_withheld // 0) > 0)) | length) as $carried + | "=== \($total) task(s) stood in this window, \($carried) carried over from " + + "earlier days ===" +' diff --git a/examples/weekly-todos.sh b/examples/weekly-todos.sh index c969b1e..459c54a 100755 --- a/examples/weekly-todos.sh +++ b/examples/weekly-todos.sh @@ -12,7 +12,7 @@ echo "=== Count ===" logseq-cli get-todos --status TODO --status DOING --json "$@" | python3 -c " import sys, json data = json.load(sys.stdin) -tasks = data.get('tasks', []) +tasks = data['todos'] print(f'Total: {len(tasks)} open tasks') pages = {} for t in tasks: diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 73248bd..049d671 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -3606,6 +3606,99 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as click.echo(f" uuid: {new_uuid}") +def _fetch_todo_references(api, markers_str: str) -> dict: + """Map each referenced todo's uuid to the pages its references sit on. + + In Logseq a block reference is not a copy, it is the same block appearing in + a second place: checking off a reference checks off the original. Carrying an + open task forward by ``((uuid))`` is therefore the ordinary way to keep it + alive, and the later journals hold references rather than blocks of their + own. ``:block/refs`` is a real relation, so this needs no string matching on + the ``((uuid))`` form. + + Answers ``{uuid: [(journal_day_or_None, page_name), ...]}``, unordered and + with duplicates intact — the caller decides what a date range keeps and how + the rest is counted, which it cannot do once entries are dropped here. + """ + query = ( + '[:find (pull ?src [:block/uuid]) ' + '(pull ?refp [:block/original-name :block/name :block/journal-day]) ' + ':where [?src :block/marker ?m] ' + f'[(contains? #{{{markers_str}}} ?m)] ' + '[?ref :block/refs ?src] ' + '[?ref :block/page ?refp]]' + ) + occurrences = {} + for row in api.datascript_query(query) or []: + if not (isinstance(row, (list, tuple)) and len(row) >= 2): + continue + src, refp = row[0], row[1] + # A pull answers None, not {}, for an entity carrying none of the + # requested attributes — seen on a live graph, and it is the reference + # pages without a name that hit this. + if not isinstance(src, dict) or not isinstance(refp, dict): + continue + uuid = src.get("uuid") + name = refp.get("original-name") or refp.get("name", "") + if not uuid or not name: + continue + jd = refp.get("journal-day") or refp.get("journalDay") + occurrences.setdefault(uuid, []).append((jd, name)) + return occurrences + + +def _place_references(occurrences, date_start, date_end, limit: int): + """Pick the occurrences to report and count the ones left out. + + Answers ``(names, withheld)``. ``names`` is sorted newest first, because + Datalog guarantees no result order and because the most recent occurrence is + the one a caller reaches for first — the origin is already in ``page``. + + Two things fall out rather than being listed. An occurrence outside a given + range is not an answer to the question asked; and an occurrence on a page + with no ``journal-day`` cannot be shown to fall inside a range at all, the + same rule the origin page already follows. Both are counted in ``withheld`` + instead of vanishing: that a task has been carried for months is worth + knowing even when the dates themselves are not asked for. + """ + dated, undated = [], [] + for jd, name in occurrences: + if jd is None: + undated.append(name) + continue + try: + dated.append((journal_day_to_date(jd), name)) + except (ValueError, TypeError): + # An unparseable journal-day places an occurrence no better than a + # missing one does. + undated.append(name) + + if date_start or date_end: + in_range, out_of_range = [], len(undated) + for d, name in dated: + dt = datetime.datetime.combine(d, datetime.time()) + if (date_start and dt < date_start) or (date_end and dt > date_end): + out_of_range += 1 + else: + in_range.append((d, name)) + kept = [name for _, name in sorted(in_range, key=lambda e: e[0], reverse=True)] + withheld = out_of_range + else: + kept = [name for _, name in sorted(dated, key=lambda e: e[0], reverse=True)] + kept += sorted(undated) + withheld = 0 + + # Two references written on the same day are one occurrence of that day: + # the field names where a task stood, not how often it was typed. + deduped = list(dict.fromkeys(kept)) + withheld += len(kept) - len(deduped) + + if limit and len(deduped) > limit: + withheld += len(deduped) - limit + deduped = deduped[:limit] + return deduped, withheld + + # --------------------------------------------------------------------------- # 21. get-todos # --------------------------------------------------------------------------- @@ -3616,22 +3709,30 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as logseq-cli --token TOKEN get-todos --from 2026-05-01 --to 2026-05-31 --include-done Notes: --status repeatable. Default: TODO, DOING, NOW, LATER (no DONE). - Returns ORIGINAL blocks only — TODO Block-Refs ((uuid)) inside journals are NOT listed. + A task carried forward by a block-ref ((uuid)) is found on the day it stands, + and reported once: "page" and "uuid" stay the original block, "references" + names the other pages it appears on. Following refs costs one extra query for + the whole command, not one per task. --no-follow-refs restores the old reading. Plain-text output: "MARKER [Page] preview" — page name inline, no grouping needed. """) @click.option("--status", multiple=True, default=("TODO", "DOING", "NOW", "LATER"), help="Task status to include (repeatable, default: TODO DOING NOW LATER)") @click.option("--page", "--name", default=None, help="Filter by page name (substring, case-insensitive)") @click.option("--tag", default=None, help="Filter by hashtag (e.g. 'urgent', without #)") -@click.option("--from", "from_date", default=None, help="Only TODOs on or after this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Dates come from the journal page a task sits on, so tasks on ordinary pages are excluded whenever a range is given.") +@click.option("--from", "from_date", default=None, help="Only TODOs on or after this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Dates come from the journal pages a task stands on — the one its block lives on and the ones it was carried into by ((block-ref)) — so tasks found only on ordinary pages are excluded whenever a range is given.") @click.option("--to", "to_date", default=None, help="Only TODOs on or before this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Same page rule as --from.") @click.option("--due-from", "due_from", default=None, help="Only tasks due on or after this date, by SCHEDULED/DEADLINE rather than by the journal page they sit on. Repeating tasks are excluded and reported — Logseq stores their first occurrence, not the next") @click.option("--due-to", "due_to", default=None, help="Only tasks due on or before this date. Same rule as --due-from") @click.option("--include-done", is_flag=True, help="Also include DONE tasks") +@click.option("--refs-limit", "refs_limit", type=int, default=10, show_default=True, + help="Occurrences kept per task in 'references'; 0 lifts the cap. references_withheld counts everything left out, which with --from/--to also includes occurrences outside the range and on pages with no journal-day — so 0 does not make it zero") +@click.option("--no-follow-refs", "no_follow_refs", is_flag=True, + help="Do not resolve block-refs: report only where task blocks live, not where they appear. Saves one read") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @click.pass_context @handle_connection_error -def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, include_done, as_json): +def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, include_done, + refs_limit, no_follow_refs, as_json): """List all TODOs/tasks in the graph.""" api = ctx.obj["api"] @@ -3639,6 +3740,9 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl if include_done: markers.add("DONE") + if refs_limit < 0: + fail("--refs-limit must be 0 or greater (0 lifts the cap).", as_json) + markers_str = " ".join(edn_string(m) for m in sorted(markers)) query = ( '[:find (pull ?b [:block/content :block/marker :block/uuid '':block/scheduled :block/deadline :block/repeated?]) ' @@ -3649,6 +3753,11 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl ) results = api.datascript_query(query) + # One extra read for the whole command, not one per task: the relation is + # queried in bulk and joined below. --no-follow-refs skips it entirely + # rather than fetching what it will not use. + occurrences = {} if no_follow_refs else _fetch_todo_references(api, markers_str) + todos = [] for block_data, page_data in results: content = block_data.get("content", "") @@ -3727,22 +3836,48 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl tag_pattern = re.compile(rf"#\b{re.escape(tag)}\b", re.IGNORECASE) todos = [t for t in todos if tag_pattern.search(t["content"])] - # Filter by date range. A task whose page carries no journal-day cannot be - # shown to fall inside the range, so it falls out of it. Letting it pass - # instead made the filter apply to the journal subset only and stay silent - # about the rest: a range predating the graph still returned every task on - # an ordinary page, and no caller could tell which part had been filtered. + # Resolve block references. A task carried forward by ((uuid)) stands on the + # later day as much as on the day it was written, so its occurrences are + # attached here — before the date filter, which reads them. + date_start = ( + datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) + if from_date else None + ) + date_end = ( + datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) + if to_date else None + ) + for t in todos: + refs = occurrences.get(t["uuid"]) + if not refs: + continue + names, withheld = _place_references(refs, date_start, date_end, refs_limit) + # A task with no occurrence left to report carries no field: a caller + # reading tasks that are not carried forward sees the payload it saw + # before this command learned to follow references. + if names: + t["references"] = names + if withheld: + t["references_withheld"] = withheld + + # Filter by date range. A task counts as inside the range if the journal + # page it sits on is, or if it appears inside it through a reference — + # checking off a reference checks off the original, so both are the same + # task standing on that day. + # + # A page carrying no journal-day cannot be shown to fall inside the range, + # so it falls out of it, and the same rule governs reference pages: 44 of + # 248 reference occurrences measured on a live graph sit on ordinary pages. + # Letting either pass made the filter apply to the journal subset only and + # stay silent about the rest: a range predating the graph still returned + # every task on an ordinary page, and no caller could tell which part had + # been filtered. if from_date or to_date: - date_start = ( - datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) - if from_date else None - ) - date_end = ( - datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) - if to_date else None - ) filtered = [] for t in todos: + if t.get("references"): + filtered.append(t) + continue jd = t.get("_journal_day") if jd is None: continue @@ -3832,6 +3967,19 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl for t in todos: preview = t["content"][:100] + ("..." if len(t["content"]) > 100 else "") click.echo(f" {t['marker']} [{t['page']}] {preview}") + # Named here too, not only in JSON: the gap this closes was + # just as invisible in plain text, and "[Mar 4th]" alone still + # reads as though the task had not been touched since. + refs = t.get("references") + if refs: + withheld = t.get("references_withheld") + more = f" (+{withheld} more)" if withheld else "" + # Semicolons, not commas: a journal page is named + # "2026-09-16, Wednesday", so a comma-separated list of + # them reads as twice as many entries as it holds. + click.echo(f" also on: {'; '.join(refs)}{more}") + elif t.get("references_withheld"): + click.echo(f" also on {t['references_withheld']} other page(s)") # --------------------------------------------------------------------------- diff --git a/tests/test_agent_contract.py b/tests/test_agent_contract.py index c3284a9..cea3bc6 100644 --- a/tests/test_agent_contract.py +++ b/tests/test_agent_contract.py @@ -114,3 +114,55 @@ def test_query_error_body_is_structured_like_transport_errors(self): assert r.stdout == "" payload = json.loads(r.stderr) assert payload["reason"] == "datalog_query_failed" + + +class TestShippedExamplesReadTheRealPayload: + """An example that mis-reads the payload teaches the mistake it makes. + + `examples/weekly-todos.sh` read `data.get('tasks', [])` from the day of the + initial import, while `get-todos --json` has always answered `{"todos": …}`. + The default swallowed it: the script printed "Total: 0 open tasks" against + any graph, which reads as an empty week rather than as a broken script. + """ + + def test_every_example_reads_a_key_the_cli_emits(self): + """Checks every shipped example, not just the one that was wrong. + + Scanning the directory rather than a list means a new example is + covered the day it is added, without anyone remembering to extend + this test. + """ + import pathlib + import re + + payload_keys = {"todos", "count", "repeating_excluded"} + examples = sorted((pathlib.Path(__file__).parent.parent + / "examples").glob("*.sh")) + assert examples, "no example scripts found" + + checked = [] + for script_path in examples: + script = script_path.read_text() + if "get-todos" not in script: + continue + # Only top-level access counts: Python's data['x'] / data.get('x'), + # and jq expressions rooted at the payload. A field read inside a + # todo (.content, .page, .references) is a different contract, + # held by the get-todos tests. + read_keys = set(re.findall(r"data(?:\.get\(|\[)['\"](\w+)['\"]", script)) + read_keys |= {m for m in re.findall(r"^\s*\.(\w+)", script, re.M)} + read_keys |= set(re.findall(r"\(\.(\w+)\s*\|\s*length\)", script)) + unknown = read_keys - payload_keys + assert not unknown, ( + f"{script_path.name} reads keys get-todos never emits: {unknown}") + checked.append(script_path.name) + assert checked, "no example exercises get-todos any more" + + def test_get_todos_json_still_uses_those_keys(self): + """Pins the other half: the example is only right while this holds.""" + api = MagicMock() + api.datascript_query.return_value = [] + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["get-todos", "--json"]) + assert r.exit_code == 0, r.stdout + assert set(json.loads(r.stdout)) == {"todos", "count"} diff --git a/tests/test_datalog_quoting.py b/tests/test_datalog_quoting.py index 1c97a2e..ca02087 100644 --- a/tests/test_datalog_quoting.py +++ b/tests/test_datalog_quoting.py @@ -275,7 +275,12 @@ def test_get_todos_markers(self): r = split_runner().invoke(cli, ["get-todos", "--status", "TODO", "--json"]) assert r.exit_code == 0, r.output assert rec.queries, "no query was built" - assert edn_string("TODO") in rec.queries[0] + # Every query the command builds, not just the first: get-todos issues + # a second one for :block/refs, and it carries the same markers. They + # share one formatted string today, so checking queries[0] alone would + # keep passing if a later change gave the second query its own. + for q in rec.queries: + assert edn_string("TODO") in q, q def test_smart_query_content_search(self): rec = QueryRecorder(result=[]) diff --git a/tests/test_get_todos.py b/tests/test_get_todos.py index 22ff0bc..64d49fb 100644 --- a/tests/test_get_todos.py +++ b/tests/test_get_todos.py @@ -8,13 +8,22 @@ from logseq_cli.cli import cli -def _mock_api_for_todos(todo_rows): +def _mock_api_for_todos(todo_rows, ref_rows=None): """Build a mocked API that returns datascript_query rows for get-todos. - Each row in ``todo_rows`` is (block_dict, page_dict) per the get-todos query. + ``get-todos`` issues two queries: the todos themselves, then — unless + ``--no-follow-refs`` is given — the blocks that reference them. The mock + answers them in that order. + + Each row in ``todo_rows`` is (block_dict, page_dict) per the todo query. + Each row in ``ref_rows`` is (block_dict, page_dict) per the reference + query, where block_dict identifies the *referenced* todo by uuid and + page_dict is the page the reference sits on. """ api = MagicMock() - api.datascript_query.return_value = todo_rows + api.datascript_query.side_effect = lambda q: ( + ref_rows or [] if ":block/refs" in q else todo_rows + ) return api @@ -80,8 +89,9 @@ def test_status_flag_passed_into_query(self): runner = CliRunner() with patch("logseq_cli.cli.LogseqAPI", return_value=api): runner.invoke(cli, ["get-todos", "--status", "DOING"]) - # Verify the query string contained DOING - query = api.datascript_query.call_args[0][0] + # Verify the query string contained DOING. The todo query is the first + # one; the reference query follows it. + query = api.datascript_query.call_args_list[0][0][0] assert "DOING" in query @@ -157,3 +167,413 @@ def test_only_from_still_drops_undated(self): assert not any("plain page" in c for c in contents), ( f"task without a journal date passed a one-sided range: {contents!r}" ) + + +class TestGetTodosBlockReferences: + """A todo carried forward by ``((uuid))`` must be found on the day it stands. + + The defect this guards: ``get-todos`` located a task only through the page + its block lives on. Carrying an open task forward by reference is the + ordinary way to work in Logseq — the block exists once, every later + occurrence is a reference to it — so a date range over those later days + returned nothing at all, with no sign that anything had been left out. + """ + + # The origin block sits outside every range used below, so a todo that + # shows up in one can only have been found through its references. + _ORIGIN = ({"content": "TODO write the migration guide", "marker": "TODO", + "uuid": "u-carried"}, + {"original-name": "Mar 4th, 2026", "name": "mar 4th, 2026", + "journal-day": 20260304}) + + def _ref(self, day, name=None): + return ({"uuid": "u-carried"}, + {"original-name": name or f"journal {day}", "journal-day": day}) + + def test_todo_is_found_through_a_reference(self): + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + assert result.exit_code == 0, result.output + todos = _json.loads(result.output)["todos"] + assert len(todos) == 1, f"todo carried into the range was not found: {todos!r}" + assert todos[0]["uuid"] == "u-carried" + + def test_origin_fields_are_unchanged(self): + """``page`` and ``uuid`` keep naming where the block lives.""" + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert todo["page"] == "Mar 4th, 2026", ( + f"page must stay the origin, got {todo['page']!r}") + assert todo["uuid"] == "u-carried" + + def test_many_references_yield_one_row(self): + """A todo referenced N times is one task, not N tasks.""" + refs = [self._ref(20260317 + i) for i in range(3)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + data = _json.loads(result.output) + assert data["count"] == 1, f"references were not deduplicated: {data!r}" + assert len(data["todos"][0]["references"]) == 3 + + def test_references_are_sorted_newest_first(self): + """Datalog guarantees no order, so the sort has to be explicit.""" + refs = [self._ref(20260317, "Mar 17th, 2026"), + self._ref(20260319, "Mar 19th, 2026"), + self._ref(20260318, "Mar 18th, 2026")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + refs_out = _json.loads(result.output)["todos"][0]["references"] + assert refs_out == ["Mar 19th, 2026", "Mar 18th, 2026", "Mar 17th, 2026"], refs_out + + def test_occurrences_outside_the_range_are_counted_not_listed(self): + """Trimming must not hide that a todo has been carried for months.""" + refs = [self._ref(20260319)] + [self._ref(20260101 + i) for i in range(5)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 1, todo["references"] + assert todo["references_withheld"] == 5, todo + + def test_withheld_is_absent_when_nothing_was_withheld(self): + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert "references_withheld" not in todo, todo + + def test_refs_limit_caps_the_list_and_counts_the_rest(self): + refs = [self._ref(20260301 + i) for i in range(5)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--refs-limit", "2", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 2, todo["references"] + assert todo["references_withheld"] == 3, todo + + def test_refs_limit_zero_keeps_all(self): + refs = [self._ref(20260301 + i) for i in range(5)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--refs-limit", "0", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 5, todo["references"] + assert "references_withheld" not in todo, todo + + def test_no_follow_refs_restores_the_old_reading(self): + """For callers who want where blocks live, not where they appear.""" + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--no-follow-refs", "--from", "2026-03-17", + "--to", "2026-03-19", "--json"]) + data = _json.loads(result.output) + assert data["todos"] == [], f"--no-follow-refs still resolved refs: {data!r}" + + def test_no_follow_refs_issues_no_second_query(self): + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + runner.invoke(cli, ["get-todos", "--no-follow-refs", "--json"]) + assert api.datascript_query.call_count == 1, ( + "--no-follow-refs must not pay for a read it does not use") + + def test_references_on_non_journal_pages_fall_out_of_a_range(self): + """Same rule as the origin page: no journal-day, no place in the range. + + 44 of 248 reference occurrences in the measured graph sit on ordinary + pages. Letting them count would reopen the silent gap this command + already closed for origin pages. + """ + refs = [({"uuid": "u-carried"}, {"original-name": "Project Alpha"})] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + data = _json.loads(result.output) + assert data["todos"] == [], ( + f"a reference on a page with no journal-day entered a range: {data!r}") + + def test_non_journal_reference_is_listed_without_a_range(self): + """Without a range there is nothing to fall outside of.""" + refs = [({"uuid": "u-carried"}, {"original-name": "Project Alpha"})] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Project Alpha"], todo + + def test_a_todo_without_references_has_no_references_field(self): + """Callers reading todos that are not carried see the payload they saw.""" + api = _mock_api_for_todos([self._ORIGIN], []) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert "references" not in todo, todo + assert "references_withheld" not in todo, todo + + def test_page_filter_matches_the_origin_not_the_reference(self): + """--page selects which todos appear; references still count graph-wide. + + The alternative — restricting references to the queried page — would + make the field mean something different per call, and would mean + nothing at all for --tag, which is not a page. + """ + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319, "Mar 19th, 2026")]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--page", "Mar 4th", "--json"]) + todos = _json.loads(result.output)["todos"] + assert len(todos) == 1, todos + assert todos[0]["references"] == ["Mar 19th, 2026"], todos[0] + + def test_a_reference_alone_does_not_invent_a_todo(self): + """Only blocks the todo query returned may appear; refs add no rows.""" + refs = [({"uuid": "u-unknown"}, {"original-name": "Mar 19th, 2026", + "journal-day": 20260319})] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + uuids = [t["uuid"] for t in _json.loads(result.output)["todos"]] + assert uuids == ["u-carried"], uuids + + def test_duplicate_reference_dates_are_collapsed(self): + """Two references on one day are one occurrence of that day.""" + refs = [self._ref(20260319, "Mar 19th, 2026"), + self._ref(20260319, "Mar 19th, 2026")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Mar 19th, 2026"], todo + + def test_plain_text_names_the_occurrences(self): + """The defect was invisible in plain text too, not only in JSON.""" + refs = [self._ref(20260319, "Mar 19th, 2026")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19"]) + assert result.exit_code == 0, result.output + assert "Mar 19th, 2026" in result.output, result.output + + def test_plain_text_separates_occurrences_unambiguously(self): + """Journal names hold a comma, so the list must not be comma-separated. + + A real journal page is named "2026-09-16, Wednesday". Joined with ", " + two of them read as four entries. + """ + refs = [self._ref(20260916, "2026-09-16, Wednesday"), + self._ref(20260914, "2026-09-14, Monday")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos"]) + line = next(l for l in result.output.splitlines() if "also on" in l) + assert "Wednesday; 2026-09-14" in line, ( + f"occurrences are not separably delimited: {line!r}") + + def test_lifting_the_cap_does_not_zero_the_withheld_count(self): + """--refs-limit 0 lifts the cap; it does not widen the range. + + The two are easy to conflate, because "0 keeps all" reads as though the + cap were the only reason an occurrence goes uncounted. It is not: with a + range set, occurrences outside it are withheld too, and that is the + point — a task carried since March must not look new. + """ + refs = [self._ref(20260319)] + [self._ref(20260101 + i) for i in range(3)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--refs-limit", "0", "--from", "2026-03-17", + "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 1, todo["references"] + assert todo["references_withheld"] == 3, ( + f"--refs-limit 0 must not smuggle out-of-range occurrences in: {todo!r}") + + +class TestGetTodosReferenceEdges: + """Boundaries of the reference machinery. + + Every case here was run against the implementation before being written + down, and each was then checked by breaking the branch it covers and + confirming this test is the one that fails. Cases whose plausible + mutations turned out behaviour-equivalent were dropped rather than kept + as decoration. + """ + + _ORIGIN = ({"content": "TODO carried", "marker": "TODO", "uuid": "u-carried"}, + {"original-name": "Mar 4th, 2026", "journal-day": 20260304}) + + def _run(self, args, ref_rows): + api = _mock_api_for_todos([self._ORIGIN], ref_rows) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + return runner.invoke(cli, ["get-todos", *args, "--json"]), api + + def _refs(self, n, start=20260301): + return [({"uuid": "u-carried"}, + {"original-name": f"day {start + i}", "journal-day": start + i}) + for i in range(n)] + + def test_exactly_at_the_limit_withholds_nothing(self): + """The cap is a maximum, not a threshold: 5 kept under a limit of 5.""" + result, _ = self._run(["--refs-limit", "5"], self._refs(5)) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 5, todo["references"] + assert "references_withheld" not in todo, todo + + def test_the_default_limit_is_ten_and_holds_exactly_ten(self): + """Pins the documented default, at the boundary where off-by-one shows. + + Ten is not inherited from `get-backlinks --limit` (3) — an entry here + is a date, not a block of text, and the measured distribution of a + live graph breaks at ten: a cap of 3 trims 12 of 58 carried tasks, + a cap of 10 trims 4. + """ + result, _ = self._run([], self._refs(10)) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 10, todo["references"] + assert "references_withheld" not in todo, todo + + def test_the_default_limit_withholds_the_eleventh(self): + result, _ = self._run([], self._refs(11)) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 10, todo["references"] + assert todo["references_withheld"] == 1, todo + + def test_a_limit_of_one_keeps_the_newest(self): + result, _ = self._run(["--refs-limit", "1"], self._refs(3)) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["day 20260303"], todo + assert todo["references_withheld"] == 2, todo + + def test_a_negative_limit_is_refused(self): + """Silently treating -1 as 'keep all' would invert what was asked for.""" + result, _ = self._run(["--refs-limit", "-1"], self._refs(2)) + assert "--refs-limit" in result.output, result.output + assert "0 or greater" in result.output, result.output + + def test_the_marker_filter_reaches_the_reference_query(self): + """Otherwise a DOING query would collect references to TODO blocks. + + Nothing in the output would look wrong — the extra occurrences would + simply attach to tasks the filter was meant to exclude. + """ + _, api = self._run(["--status", "DOING"], []) + ref_query = next(c[0][0] for c in api.datascript_query.call_args_list + if ":block/refs" in c[0][0]) + assert "DOING" in ref_query, ref_query + assert "TODO" not in ref_query, ref_query + + def test_dated_occurrences_sort_ahead_of_undated_ones(self): + """Without a range both kinds are listed, in a fixed order. + + Datalog returns rows unordered, so an unstated order would make the + field differ between two identical calls. + """ + refs = [({"uuid": "u-carried"}, {"original-name": "Zzz Page"}), + ({"uuid": "u-carried"}, + {"original-name": "Mar 19", "journal-day": 20260319}), + ({"uuid": "u-carried"}, {"original-name": "Aaa Page"})] + result, _ = self._run([], refs) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Mar 19", "Aaa Page", "Zzz Page"], todo + + def test_a_row_the_query_could_not_fill_is_skipped(self): + """A pull answers None, not {}, for an entity with none of the pulled + attributes — observed on a live graph, on reference pages with no name. + """ + refs = [({"uuid": "u-carried"}, None), + (None, {"original-name": "Mar 19", "journal-day": 20260319}), + ({"uuid": "u-carried"}, + {"original-name": "Mar 19", "journal-day": 20260319})] + result, _ = self._run([], refs) + assert result.exit_code == 0, result.output + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Mar 19"], todo + + def test_a_page_carrying_only_name_is_still_named(self): + """original-name is absent on pages that were never given one.""" + refs = [({"uuid": "u-carried"}, + {"name": "mar 19", "journal-day": 20260319})] + result, _ = self._run([], refs) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["mar 19"], todo + + def test_plain_text_reports_a_count_when_no_date_survives(self): + """Every occurrence fell outside the range, so only the number is left. + + Printing nothing would let the task read as though it had not been + touched since the day it was written. + """ + refs = [({"uuid": "u-carried"}, + {"original-name": "Jan 1", "journal-day": 20260101})] + api = _mock_api_for_todos( + [({"content": "TODO carried", "marker": "TODO", "uuid": "u-carried"}, + {"original-name": "Mar 18th, 2026", "journal-day": 20260318})], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19"]) + assert "1 other page" in result.output, result.output + + def test_a_null_reference_result_does_not_kill_the_command(self): + """The API hands back whatever the body decoded to, `null` included. + + `LogseqAPI.call` returns `resp.json()` and only screens dicts carrying + an "error" key, so a `null` body reaches the caller as None. Without + the guard the command dies on `TypeError: 'NoneType' is not iterable` + while the todo query itself succeeded. + """ + api = MagicMock() + api.datascript_query.side_effect = lambda q: ( + None if ":block/refs" in q else [self._ORIGIN]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + assert result.exit_code == 0, result.output + todo = _json.loads(result.output)["todos"][0] + assert "references" not in todo, todo + + def test_tag_filter_and_references_coexist(self): + """--tag selects tasks by content; occurrences still count graph-wide.""" + api = _mock_api_for_todos( + [({"content": "TODO carried #urgent", "marker": "TODO", "uuid": "u-carried"}, + {"original-name": "Mar 4th, 2026", "journal-day": 20260304})], + [({"uuid": "u-carried"}, + {"original-name": "Mar 19", "journal-day": 20260319})]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--tag", "urgent", "--json"]) + todos = _json.loads(result.output)["todos"] + assert len(todos) == 1, todos + assert todos[0]["references"] == ["Mar 19"], todos[0] diff --git a/tests/test_readme_documents_options.py b/tests/test_readme_documents_options.py index e1575f2..c0d488e 100644 --- a/tests/test_readme_documents_options.py +++ b/tests/test_readme_documents_options.py @@ -107,3 +107,33 @@ def test_every_command_has_a_row(self): if name not in self.ALIASES and f"`{name}" not in text ] assert not missing, f"commands with no README row: {missing}" + + +class TestShippedExamplesAreListed: + """The README's example list is a view of the examples directory. + + A hand-maintained list of files drifts the moment someone adds one — the + same defect this file already guards for the command tables. Deriving the + expectation from the directory means a new script is either listed or the + suite says so. + """ + + def _example_names(self): + import pathlib + return {p.name for p in (pathlib.Path(__file__).parent.parent + / "examples").glob("*.sh")} + + def _listed_names(self): + import re + return set(re.findall(r"^- `([a-z0-9-]+\.sh)`", _readme_text(), re.M)) + + def test_every_example_is_listed(self): + missing = self._example_names() - self._listed_names() + assert not missing, ( + f"these scripts exist but the README never names them: {sorted(missing)}") + + def test_no_listed_example_is_missing(self): + """The other direction: a removed script must leave the list too.""" + stale = self._listed_names() - self._example_names() + assert not stale, ( + f"the README lists scripts that are not in examples/: {sorted(stale)}")