From 80b57e0d01b84a707da2b038d20d27c0af855fb3 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Sat, 19 Sep 2026 22:09:18 +0200 Subject: [PATCH] stack: wikitool review - der Wochenrueckblick als Join zur Lesezeit (#125) Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/cli.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/review.py - tools/chemenu/tests/test_review.py --- CHANGES.md | 35 ++- VERSION | 2 +- tools/CONTRACT.md | 2 + tools/chemenu/cli.py | 2 + tools/chemenu/commands/review_cmd.py | 88 +++++++ tools/chemenu/commands/run_budget.py | 8 +- tools/chemenu/review.py | 262 ++++++++++++++++++++ tools/chemenu/tests/test_review.py | 357 +++++++++++++++++++++++++++ 8 files changed, 751 insertions(+), 5 deletions(-) create mode 100644 tools/chemenu/commands/review_cmd.py create mode 100644 tools/chemenu/review.py create mode 100644 tools/chemenu/tests/test_review.py diff --git a/CHANGES.md b/CHANGES.md index 82b0cb9..51bba30 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 7.0.0-beta.2 - 2026-09-19 - Task-tracker provider layer, with a Super Productivity adapter +## 7.0.0-beta.3 - 2026-09-19 - wikitool review: the weekly GTD review as a read-time join **Author:** Torben Nehmer @@ -70,6 +70,7 @@ concern - readable here, never shipped as something to parse. - Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart - Task-tracker provider layer, with a Super Productivity adapter +- wikitool review: the weekly GTD review as a read-time join ### Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart @@ -129,6 +130,38 @@ Zwei Zwischenbefunde aus der Umsetzung, gegen den tatsaechlichen Quellcode von Ausserdem verifiziert, ohne Designfolgen: Super Productivitys Someday/Maybe-Aequivalent ist der bestehende `backlogTaskIds`-Puffer je Projekt, keine eigene Tag-Konvention. +### wikitool review: the weekly GTD review as a read-time join + +Gitea #119 (Paket #125): das tragende Bauteil - `wikitool review` joint die Tracker-Seite +(`chemenu.tasks`, #124) und die `kb/gtd/`-Projektseiten ueber den case-normalisierten Namen und +gibt einen Bericht aus. Es speichert nichts, nicht einmal eine `reports/`-Datei (D3) - `search` +ist das naechste Vorbild dafuer, und `review` ist deshalb genauso vom Iterationsbudget +ausgenommen. + +Fuenf Pruefungen (#119 D10/D26), alle in `chemenu.review.run_review`: **stalled** (Tracker- +Projekt ohne offene Posten, `kb/`-Seite `state: active` - `dormant`/`completed`/`abandoned` +melden nie, D27), **waiting_overdue** (`follow_up_at` aelter als `stalled_waiting_days`), +**unpaged_project** (Tracker-Projekt ohne `kb/`-Seite, aelter als `unpaged_project_weeks`), +**no_open_loop** (`kb/`-Seite `active`, aber kein Tracker-Projekt dieses Namens oder keine +offenen Posten - die Gegenrichtung des vorigen Abgleichs, D8s beidseitiger unmatched-Bericht), +**someday_stale** (Someday-Posten seit `someday_stale_months` unveraendert, ueber Kalendermonate +gerechnet statt ueber `Tage / 30`). Ein Tracker-Projekt ohne offene Posten mit aktiver `kb/`-Seite +erfuellt zugleich stalled und no_open_loop - beide melden, das ist keine Dopplung, sondern zwei +verschiedene Aussagen ueber denselben Zustand. + +Jeder Providerzugriff ist einzeln abgesichert: scheitert `projects()`, entfallen die vier darauf +aufbauenden Pruefungen; scheitert `someday_items()`, entfaellt nur die fuenfte; scheitert +`open_items()` fuer ein einzelnes Tracker-Projekt, faellt nur dieses eine aus den betroffenen +Pruefungen heraus, der Rest laeuft weiter. Ein so unvollstaendiger Bericht setzt `complete` auf +`false`, druckt trotzdem alles, was noch entschieden werden konnte, und die CLI beendet sich mit +Exit 1 - nie mit einem leisen Teilbericht, der wie eine ruhige Woche aussieht. Fehlt +`.wikitool-tasks.json` ganz, oder ist es kaputt, scheitert der Aufruf sofort und sagt das - das +ist ein Konfigurationsfehler, kein Erreichbarkeitsproblem, und braucht deshalb keinen Teilbericht. + +`--json` traegt dieselben Befunde maschinenlesbar (`findings`/`checks_run`/`checks_skipped`/ +`kb_project_count`/`complete`); ein Test haelt beide Formen gegeneinander, wie es +`test_mcp_server.py` fuer den MCP-Lesepfad gegen die CLI tut. + --- ## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt diff --git a/VERSION b/VERSION index 53f71e5..6cb44aa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.0.0-beta.2 +7.0.0-beta.3 diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 6141d02..3e091f7 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -125,6 +125,7 @@ tools/wikitool --help |---------|---------| | `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report .md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing | | `search [""] [--field ...] [--kind/--subtype/--collection/--tag ] [--regex] [--limit N] [--sort [-]] [--backend ] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** | +| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`. No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** | ### Provenance @@ -339,6 +340,7 @@ is atomic, and whether a retry is safe. |---------|--------------|---------|--------------| | `lint` | Only with `--fail-on-error`: hard findings exist | Writes one report file (single atomic write) unless `--json` | Safe to retry freely, but re-run it to re-*measure*, never to re-read: the printed path holds the full report. Exit 1 means "act on the findings", not "the tool is broken" | | `search` | `rg` is not installed or did not finish within 30 s, a malformed `--field` predicate, an unknown field name, or an unknown `--backend` | Read-only | Fix the argument and retry. A timeout is a pathological pattern or an unresponsive corpus directory, not a slow answer - narrow the query or drop `--regex` rather than retrying it unchanged. An unknown field name is reported with the list of fields that do exist - it is never answered with an empty result, because that would read as "no such pages" | +| `review` | Either no `.wikitool-tasks.json` (or a malformed one) - not yours to fix by retrying unchanged, configure or repair it first - **or** the provider was reachable at config-parse time but a read call failed mid-run, in which case the full report (findings plus which checks ran) is printed first and exit 1 follows, never a silent partial success | Read-only | The two exit-1 causes above need different responses: a config problem needs editing `.wikitool-tasks.json`; an unreachable provider (e.g. the tracker app not running) needs starting it, then a plain retry - the command re-reads everything fresh each time, so nothing here is ever stale to re-fetch | ### Provenance diff --git a/tools/chemenu/cli.py b/tools/chemenu/cli.py index d1610d7..f565448 100644 --- a/tools/chemenu/cli.py +++ b/tools/chemenu/cli.py @@ -29,6 +29,7 @@ try: page_ops, provenance_cmd, raw_cmd, + review_cmd, run_budget, search as search_module, touch as touch_module, @@ -158,6 +159,7 @@ app.command("rm")(page_ops.rm_command) app.command("move")(page_ops.move_command) app.command("lint")(lint_module.lint_command) app.command("search")(search_module.search_command) +app.command("review")(review_cmd.review_command) app.command("publish")(git_publish.publish_command) app.command("sync")(git_publish.sync_command) app.command("doctor")(doctor.doctor_command) diff --git a/tools/chemenu/commands/review_cmd.py b/tools/chemenu/commands/review_cmd.py new file mode 100644 index 0000000..78aa91f --- /dev/null +++ b/tools/chemenu/commands/review_cmd.py @@ -0,0 +1,88 @@ +"""`wikitool review` - the terminal adapter over `chemenu.review` (Gitea #125). + +The checks, the join and the partial-report rule live in `chemenu.review`, +which imports no CLI machinery. This module owns only what a terminal needs: +the `--json` flag, the two render forms, and the exit code. +""" +from __future__ import annotations + +import json + +import typer + +from chemenu import config +from chemenu.commands._util import fail +from chemenu.errors import ValidationError +from chemenu.review import ALL_CHECKS, ReviewReport, run_review + +__all__ = ["render_report", "report_to_dict", "review_command"] + + +def render_report(report: ReviewReport) -> str: + """The `--json`-free rendering. One line per finding, `[check] project: + message`, so a hit can be told apart from the summary line without a + schema - the same shape `search`'s table takes for the same reason.""" + lines: list[str] = [] + if report.checks_skipped: + lines.append("INCOMPLETE - the following check(s) did not run:") + for check, reason in report.checks_skipped: + lines.append(f" - {check}: {reason}") + lines.append( + f"Partial result: {report.kb_project_count} kb/ project page(s) found; no " + "tracker cross-check for the check(s) above." + ) + lines.append("") + + if not report.findings: + lines.append("No findings.") + else: + for finding in report.findings: + lines.append(f"[{finding.check}] {finding.project}: {finding.message}") + + lines.append("") + lines.append(f"{len(report.findings)} finding(s), {len(report.checks_run)}/{len(ALL_CHECKS)} check(s) ran.") + return "\n".join(lines) + + +def report_to_dict(report: ReviewReport) -> dict: + """The `--json` form. Carries the same three things the text form does - + findings, which checks ran, which were skipped and why - so a caller never + has to parse prose to tell a partial report from a complete one.""" + return { + "findings": [ + {"check": finding.check, "project": finding.project, "message": finding.message} + for finding in report.findings + ], + "checks_run": list(report.checks_run), + "checks_skipped": [ + {"check": check, "reason": reason} for check, reason in report.checks_skipped + ], + "kb_project_count": report.kb_project_count, + "complete": report.complete, + } + + +def review_command( + json_out: bool = typer.Option(False, "--json", help="Print the findings as JSON."), +): + """Run the weekly GTD review: join the task tracker against kb/gtd/ pages + over the project name and report the five staleness/mismatch checks + (#119 D10/D26). Read-only - stores nothing, not even a reports/ file + (#119 D3), and is exempt from the Iteration Budget Gate like `search`.""" + try: + report = run_review(config.ROOT) + except ValidationError as exc: + fail(str(exc)) + return + + if json_out: + typer.echo(json.dumps(report_to_dict(report), indent=2)) + else: + typer.echo(render_report(report)) + + if not report.complete: + # Printed above already - this is deliberately not fail(), which + # would swallow the report just rendered behind a single ERROR line. + # See chemenu.review.ReviewReport.complete: an incomplete report must + # never exit 0 the way a quiet week does. + raise typer.Exit(code=1) diff --git a/tools/chemenu/commands/run_budget.py b/tools/chemenu/commands/run_budget.py index 5785b60..cf61028 100644 --- a/tools/chemenu/commands/run_budget.py +++ b/tools/chemenu/commands/run_budget.py @@ -115,9 +115,11 @@ SKIP_COMMAND_PATHS = { # reading, not iterating: the budget exists to stop an agent looping over the # wiki's *state*, and charging for a search would penalise the one habit that # lowers cost - looking before reading. `doctor` is here for the same reason: -# it only reads and reports, never mutates anything. Every command that -# mutates anything stays counted. -SKIP_COMMANDS = {"search", "doctor"} +# it only reads and reports, never mutates anything. `review` (#125) joins the +# task tracker against kb/gtd/ pages and stores nothing either (#119 D3) - the +# same read-only argument as `search`, just over a different pair of sources. +# Every command that mutates anything stays counted. +SKIP_COMMANDS = {"search", "doctor", "review"} def is_exempt(command: str, args: list[str]) -> bool: diff --git a/tools/chemenu/review.py b/tools/chemenu/review.py new file mode 100644 index 0000000..d748837 --- /dev/null +++ b/tools/chemenu/review.py @@ -0,0 +1,262 @@ +"""`wikitool review` core (Gitea #125; design in #119 D3/D8/D10/D26). + +The weekly review joins the task tracker (`chemenu.tasks`, #124) against the +`kb/gtd/` project pages over the case-normalized project name +(`chemenu.tasks.protocol.normalize_project_name`, #119 D8) - a join done at +*read time* and never stored (#119 D3, the `reports/` posture: never +re-derive, always compile, but nothing here is a compiled artifact). This +module owns the five checks and their data flow; `chemenu.commands.review_cmd` +owns the CLI adapter, flags and rendering. + +Every provider read is wrapped individually so a single unreachable call +degrades the affected checks rather than the whole report: `projects()` +failing skips checks 1/2/3/4 (they all need the project list), `someday_items()` +failing skips only check 5, and a single project's `open_items()` failing +skips that one project everywhere without aborting the others. A `ReviewReport` +with anything in `checks_skipped` is, by construction, never mistaken for a +quiet week - see `ReviewReport.complete` and `commands.review_cmd`'s exit code. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Optional + +from chemenu import config, kb_scan +from chemenu.errors import ValidationError +from chemenu.tasks import config as tasks_config +from chemenu.tasks.protocol import OpenItems, TaskReader, normalize_project_name + +CHECK_STALLED = "stalled" +CHECK_WAITING_OVERDUE = "waiting_overdue" +CHECK_UNPAGED_PROJECT = "unpaged_project" +CHECK_NO_OPEN_LOOP = "no_open_loop" +CHECK_SOMEDAY_STALE = "someday_stale" + +# The checks that need the full tracker project list (#119 D26 checks 1/2/3/4) +# - `projects()` failing skips all four together, since none of them can be +# answered from `someday_items()` alone. +_PROJECT_LIST_CHECKS = (CHECK_STALLED, CHECK_WAITING_OVERDUE, CHECK_UNPAGED_PROJECT, CHECK_NO_OPEN_LOOP) + +ALL_CHECKS = (*_PROJECT_LIST_CHECKS, CHECK_SOMEDAY_STALE) + + +@dataclass(frozen=True) +class Finding: + """One reported mismatch. `project` is the display name - the kb/ page's + title when a check is anchored on the kb/ side (checks 1 and 4, where the + kb/ page is what the finding is about), the tracker's own project name + otherwise (checks 2 and 3, where no kb/ page need exist).""" + + check: str + project: str + message: str + + +@dataclass(frozen=True) +class ReviewReport: + findings: tuple[Finding, ...] + checks_run: tuple[str, ...] + checks_skipped: tuple[tuple[str, str], ...] # (check, reason) + kb_project_count: int + + @property + def complete(self) -> bool: + """Whether every one of the five checks actually ran. `False` is the + signal `commands.review_cmd` exits 1 on - a partial report must never + read like a quiet week (see this module's docstring).""" + return not self.checks_skipped + + +@dataclass(frozen=True) +class _KbProject: + title: str + state: Optional[str] + + +def _load_kb_projects(kb_dir: Path) -> dict[str, _KbProject]: + """Every `kb/gtd/` project page, keyed by case-normalized title (#119 D8). + `page.kind` resolves through the type-spec's own `name:` field + (`Page.kind`), so this finds a `project` page regardless of which + `responsibility:` area it lives under.""" + pages = kb_scan.load_kb_pages(kb_dir) + result: dict[str, _KbProject] = {} + for page in pages.values(): + if page.kind != "project": + continue + result[normalize_project_name(page.title)] = _KbProject( + title=page.title, state=page.frontmatter.get("state") + ) + return result + + +def _build_reader(cfg: tasks_config.TasksConfig) -> TaskReader: + """Dispatch on `cfg.provider` - the same inline shape + `doctor.check_tasks_provider` uses, kept in sync with + `tasks_config.KNOWN_PROVIDERS`.""" + if cfg.provider == "superproductivity": + from chemenu.tasks import superproductivity as sp + + sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config) + return sp.SuperProductivityReader(sp_cfg) + raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.") + + +def _weeks_between(start: date, end: date) -> float: + return (end - start).days / 7 + + +def _months_between(start: date, end: date) -> int: + """Whole calendar months between two dates, floored - `age_months` for + check 5. Deliberately calendar-based rather than `days / 30`: an + approximation would drift a fixed-days threshold away from what "N months" + actually means at the boundary.""" + months = (end.year - start.year) * 12 + (end.month - start.month) + if end.day < start.day: + months -= 1 + return months + + +def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport: + """Run the five weekly-review checks (#119 D10/D26) against the instance + rooted at `root` and return their findings. Stores nothing (#119 D3): every + provider read is a fresh call, and no file under `root` is touched. + + Raises `ValidationError` for anything that is not "the provider is + unreachable right now" - no `.wikitool-tasks.json` at all, or a + malformed one. Those are configuration problems a retry cannot fix, so + `commands.review_cmd` reports them as an ordinary exit-1 error rather than + a partial report. + """ + today = today if today is not None else date.today() + cfg = tasks_config.read_config(root) + if cfg is None: + raise ValidationError( + f"No {config.TASKS_CONFIG_FILENAME} - no task tracker is configured, so there is " + "nothing to join the weekly review against. Configure one first." + ) + reader = _build_reader(cfg) + kb_projects = _load_kb_projects(Path(root) / "kb") + + findings: list[Finding] = [] + checks_run: list[str] = [] + checks_skipped: list[tuple[str, str]] = [] + + try: + tracker_projects = reader.projects() + except ValidationError as exc: + reason = str(exc) + checks_skipped.extend((check, reason) for check in _PROJECT_LIST_CHECKS) + tracker_projects = None + + if tracker_projects is not None: + open_items_by_key: dict[str, tuple[str, OpenItems]] = {} + broken_keys: set[str] = set() + for project in tracker_projects: + key = normalize_project_name(project.name) + try: + open_items_by_key[key] = (project.name, reader.open_items(project.name)) + except ValidationError: + broken_keys.add(key) + + # Check 1 - stalled: a tracker project with zero open items whose kb/ + # page is `state: active` (#119 D27's anti-noise core: dormant, + # completed and abandoned never reach here). + for project in tracker_projects: + key = normalize_project_name(project.name) + if key in broken_keys: + continue + _, items = open_items_by_key[key] + if items.count != 0: + continue + kb = kb_projects.get(key) + if kb is not None and kb.state == "active": + findings.append(Finding( + CHECK_STALLED, kb.title, + f"Tracker project '{project.name}' has zero open items and the kb/ page " + "is state: active.", + )) + checks_run.append(CHECK_STALLED) + + # Check 2 - waiting-for overdue: any WAITING item whose follow_up_at + # is older than the threshold (#119 D9/D30 - never the due date). + for _key, (name, items) in open_items_by_key.items(): + for item in items.waiting: + if item.follow_up_at is None: + continue + age_days = (today - item.follow_up_at).days + if age_days > cfg.thresholds.stalled_waiting_days: + findings.append(Finding( + CHECK_WAITING_OVERDUE, name, + f"'{item.title}' is {age_days} day(s) past its follow_up_at " + f"({item.follow_up_at.isoformat()}).", + )) + checks_run.append(CHECK_WAITING_OVERDUE) + + # Check 3 - tracker project with no kb/ page, older than the + # threshold (#119 D26's noise brake: an age threshold, not a marker). + for project in tracker_projects: + key = normalize_project_name(project.name) + if key in kb_projects or project.created is None: + continue + age_weeks = _weeks_between(project.created, today) + if age_weeks > cfg.thresholds.unpaged_project_weeks: + findings.append(Finding( + CHECK_UNPAGED_PROJECT, project.name, + f"No kb/ page for this tracker project after {age_weeks:.1f} week(s) " + f"(created {project.created.isoformat()}).", + )) + checks_run.append(CHECK_UNPAGED_PROJECT) + + # Check 4 - kb/ page with no open loop: `state: active` but either no + # tracker project of this name exists, or it has zero open items. This + # is the reverse direction of check 3's join (#119 D8's beidseitig + # unmatched report - a rename on either side must surface somewhere). + tracker_by_key = {normalize_project_name(p.name): p for p in tracker_projects} + for key, kb in kb_projects.items(): + if kb.state != "active": + continue + project = tracker_by_key.get(key) + if project is None: + findings.append(Finding( + CHECK_NO_OPEN_LOOP, kb.title, + "state: active, but no tracker project of this name exists.", + )) + continue + if key in broken_keys: + continue + _, items = open_items_by_key[key] + if items.count == 0: + findings.append(Finding( + CHECK_NO_OPEN_LOOP, kb.title, + f"state: active, but tracker project '{project.name}' has zero open items.", + )) + checks_run.append(CHECK_NO_OPEN_LOOP) + + # Check 5 - someday/maybe items untouched for longer than the threshold. + # Independent of the tracker project list, so it still runs when + # `projects()` above failed but `someday_items()` does not. + try: + someday = reader.someday_items() + except ValidationError as exc: + checks_skipped.append((CHECK_SOMEDAY_STALE, str(exc))) + else: + for item in someday: + if item.modified is None: + continue + age_months = _months_between(item.modified, today) + if age_months > cfg.thresholds.someday_stale_months: + findings.append(Finding( + CHECK_SOMEDAY_STALE, item.title, + f"Untouched for {age_months} month(s) (last modified " + f"{item.modified.isoformat()}).", + )) + checks_run.append(CHECK_SOMEDAY_STALE) + + return ReviewReport( + findings=tuple(findings), + checks_run=tuple(checks_run), + checks_skipped=tuple(checks_skipped), + kb_project_count=len(kb_projects), + ) diff --git a/tools/chemenu/tests/test_review.py b/tools/chemenu/tests/test_review.py new file mode 100644 index 0000000..4ee3073 --- /dev/null +++ b/tools/chemenu/tests/test_review.py @@ -0,0 +1,357 @@ +"""Tests for `chemenu.review` and `wikitool review` (Gitea #125). + +`run_review` takes its root as an explicit argument and never touches +`config.ROOT`, so these tests build a tree under `tmp_path` without needing +`kb_dir`/`use_shipped_type_specs` - the real shipped `types/project.md` is +what `config.ROOT` already falls back to under the hermetic fixture (see +`instructions/dev/testing-conventions.md`), and that is the schema this +feature is meant to be exercised against, not a synthetic stand-in. +""" +from __future__ import annotations + +import json +import subprocess +from datetime import date, datetime, timezone +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from chemenu.cli import app +from chemenu.commands.run_budget import is_exempt +from chemenu.errors import ValidationError +from chemenu.frontmatter_io import write_page +from chemenu.tests.conftest import use_shipped_type_specs +from chemenu.review import ( + CHECK_NO_OPEN_LOOP, + CHECK_SOMEDAY_STALE, + CHECK_STALLED, + CHECK_UNPAGED_PROJECT, + CHECK_WAITING_OVERDUE, + run_review, +) + +runner = CliRunner() + +TODAY = date(2026, 9, 19) + +THRESHOLDS = { + "stalled_waiting_days": 14, + "unpaged_project_weeks": 3, + "someday_stale_months": 5, +} + + +def _ms(year: int, month: int, day: int) -> int: + return int(datetime(year, month, day, tzinfo=timezone.utc).timestamp() * 1000) + + +def _entity_state(records: dict[str, dict]) -> dict: + return {"ids": list(records.keys()), "entities": records} + + +def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path: + db_path = root / "db.json" + db_path.write_text( + json.dumps({ + "project": _entity_state(projects), + "task": _entity_state(tasks), + "tag": _entity_state(tags), + }), + encoding="utf-8", + ) + return db_path + + +def _write_tasks_config(root: Path, db_path: Path, thresholds: dict | None = None) -> None: + (root / ".wikitool-tasks.json").write_text( + json.dumps({ + "schema": 1, + "provider": "superproductivity", + "thresholds": thresholds or THRESHOLDS, + "superproductivity": {"db_path": str(db_path)}, + }), + encoding="utf-8", + ) + + +def _project_page(root: Path, name: str, state: str, *, area: str = "haus") -> None: + write_page( + root / "kb" / "gtd" / area / f"{name}.md", + { + "type": "types/project.md", + "state": state, + "responsibility": area, + "created": "2026-01-01", + "modified": "2026-01-01", + "provenance": "general", + "summary": f"Fixture project {name}.", + }, + f"\n# {name}\n\n## Ziel\n\nFixture.\n", + ) + + +def _tree(root: Path) -> dict[str, bytes]: + return { + str(p.relative_to(root)): p.read_bytes() + for p in root.rglob("*") if p.is_file() + } + + +# --- check 1: stalled -------------------------------------------------------- + + +@pytest.mark.parametrize("state,should_fire", [ + ("active", True), + ("dormant", False), + ("completed", False), + ("abandoned", False), +]) +def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire): + projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), + "taskIds": [], "backlogTaskIds": []}} + db_path = _write_snapshot(tmp_path, projects, {}, {}) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Ship Chemenu 7.0", state) + + report = run_review(tmp_path, today=TODAY) + fired = any(f.check == CHECK_STALLED for f in report.findings) + assert fired == should_fire + + +# --- check 2: waiting overdue ------------------------------------------------ + + +@pytest.mark.parametrize("remind_day,should_fire", [ + (1, True), # 2026-01-01 -> far more than 14 days before TODAY + (10, False), # 2026-09-10 -> 9 days before TODAY, under the threshold +]) +def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire): + month = 1 if remind_day == 1 else 9 + projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1), + "taskIds": ["t1"], "backlogTaskIds": []}} + tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False, + "tagIds": ["tag-wait"], "remindAt": _ms(2026, month, remind_day)}} + tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}} + db_path = _write_snapshot(tmp_path, projects, tasks, tags) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Kueche renovieren", "active") + + report = run_review(tmp_path, today=TODAY) + fired = any(f.check == CHECK_WAITING_OVERDUE for f in report.findings) + assert fired == should_fire + + +# --- check 3: unpaged tracker project ---------------------------------------- + + +@pytest.mark.parametrize("created_year_month_day,should_fire", [ + ((2026, 9, 15), False), # 4 days old, well under 3 weeks + ((2026, 8, 1), True), # ~7 weeks old +]) +def test_check3_unpaged_project_age_threshold(tmp_path, created_year_month_day, should_fire): + y, m, d = created_year_month_day + projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d), + "taskIds": [], "backlogTaskIds": []}} + db_path = _write_snapshot(tmp_path, projects, {}, {}) + _write_tasks_config(tmp_path, db_path) + (tmp_path / "kb" / "gtd").mkdir(parents=True) + + report = run_review(tmp_path, today=TODAY) + fired = any(f.check == CHECK_UNPAGED_PROJECT for f in report.findings) + assert fired == should_fire + + +def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path): + """'Kueche renovieren' and 'kueche renovieren' are the same project (#119 + D8) - no unpaged/no-open-loop finding from the case difference alone.""" + projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1), + "taskIds": ["t1"], "backlogTaskIds": []}} + tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}} + db_path = _write_snapshot(tmp_path, projects, tasks, {}) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Kueche renovieren", "active") + + report = run_review(tmp_path, today=TODAY) + assert not any(f.check == CHECK_UNPAGED_PROJECT for f in report.findings) + assert not any(f.check == CHECK_NO_OPEN_LOOP for f in report.findings) + + +# --- check 4: kb/ page with no open loop ------------------------------------- + + +def test_check4_fires_when_no_tracker_project_exists(tmp_path): + db_path = _write_snapshot(tmp_path, {}, {}, {}) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Ghost Project", "active") + + report = run_review(tmp_path, today=TODAY) + findings = [f for f in report.findings if f.check == CHECK_NO_OPEN_LOOP] + assert len(findings) == 1 + assert findings[0].project == "Ghost Project" + + +# --- run isolation and file mutation ----------------------------------------- + + +def test_a_run_touches_no_file(tmp_path): + projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), + "taskIds": [], "backlogTaskIds": ["t3"]}} + tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [], + "updated": _ms(2020, 1, 1)}} + db_path = _write_snapshot(tmp_path, projects, tasks, {}) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Ship Chemenu 7.0", "active") + + before = _tree(tmp_path) + run_review(tmp_path, today=TODAY) + assert _tree(tmp_path) == before + + +# --- check 5: someday stale --------------------------------------------------- + + +@pytest.mark.parametrize("updated_ymd,should_fire", [ + ((2026, 8, 1), False), # ~1.5 months old + ((2025, 1, 1), True), # well over 5 months old +]) +def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire): + y, m, _d = updated_ymd + projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), + "taskIds": [], "backlogTaskIds": ["t3"]}} + tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, + "tagIds": [], "updated": _ms(y, m, 1)}} + db_path = _write_snapshot(tmp_path, projects, tasks, {}) + _write_tasks_config(tmp_path, db_path) + + report = run_review(tmp_path, today=TODAY) + fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings) + assert fired == should_fire + + +# --- provider/configuration errors ------------------------------------------- + + +def test_no_tasks_config_is_a_clear_validation_error(tmp_path): + (tmp_path / "kb" / "gtd").mkdir(parents=True) + with pytest.raises(ValidationError, match="no task tracker is configured"): + run_review(tmp_path, today=TODAY) + + +def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path): + """A db_path that does not exist is the unreachable-provider case: checks + 1/2/3/4 cannot run at all, but check 5 uses the same failing read and is + skipped too - the report must say so, never look like a quiet week.""" + _write_tasks_config(tmp_path, tmp_path / "does-not-exist.json") + _project_page(tmp_path, "Ship Chemenu 7.0", "active") + + report = run_review(tmp_path, today=TODAY) + assert not report.complete + skipped_checks = {check for check, _reason in report.checks_skipped} + assert skipped_checks == { + CHECK_STALLED, CHECK_WAITING_OVERDUE, CHECK_UNPAGED_PROJECT, + CHECK_NO_OPEN_LOOP, CHECK_SOMEDAY_STALE, + } + assert report.kb_project_count == 1 + assert report.findings == () + + +# --- CLI: exit codes, --json, budget exemption ------------------------------- + + +def test_cli_no_provider_exits_1_with_a_clear_message(tmp_path, monkeypatch): + from chemenu import config + + monkeypatch.setattr(config, "ROOT", tmp_path) + use_shipped_type_specs(monkeypatch) + (tmp_path / "kb" / "gtd").mkdir(parents=True) + result = runner.invoke(app, ["review"]) + assert result.exit_code == 1 + assert "no task tracker is configured" in result.output + + +def test_cli_incomplete_report_exits_1_and_still_prints(tmp_path, monkeypatch): + from chemenu import config + + monkeypatch.setattr(config, "ROOT", tmp_path) + use_shipped_type_specs(monkeypatch) + _write_tasks_config(tmp_path, tmp_path / "does-not-exist.json") + (tmp_path / "kb" / "gtd").mkdir(parents=True) + + result = runner.invoke(app, ["review"]) + assert result.exit_code == 1 + assert "INCOMPLETE" in result.output + + +def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch): + """The golden check (#125's own AC): the `--json` findings and the + text-rendered findings must name exactly the same (check, project) pairs - + the same posture `test_mcp_server.py` holds the MCP wire format to + against the CLI's own `--json`.""" + from chemenu import config + + monkeypatch.setattr(config, "ROOT", tmp_path) + use_shipped_type_specs(monkeypatch) + projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), + "taskIds": [], "backlogTaskIds": []}} + db_path = _write_snapshot(tmp_path, projects, {}, {}) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Ship Chemenu 7.0", "active") + + text_result = runner.invoke(app, ["review"]) + json_result = runner.invoke(app, ["review", "--json"]) + assert text_result.exit_code == 0 + assert json_result.exit_code == 0 + + payload = json.loads(json_result.output) + json_pairs = {(f["check"], f["project"]) for f in payload["findings"]} + + import re + + text_pairs = { + (m.group(1), m.group(2)) + for m in re.finditer(r"^\[(\S+)\] ([^:]+):", text_result.output, re.MULTILINE) + } + # A tracker project with zero open items and an active kb/ page satisfies + # both check 1's and check 4's condition (#125's table: check 4's "no + # open items" branch is not exclusive of check 1) - both fire. + assert json_pairs == text_pairs == { + (CHECK_STALLED, "Ship Chemenu 7.0"), + (CHECK_NO_OPEN_LOOP, "Ship Chemenu 7.0"), + } + + +def test_review_is_exempt_from_the_iteration_budget(): + assert is_exempt("review", []) is True + assert is_exempt("review", ["--json"]) is True + + +@pytest.mark.skipif( + subprocess.run(["git", "--version"], capture_output=True).returncode != 0, + reason="git not available", +) +def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch): + from chemenu import config + + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Fixture Author"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "fixture@example.com"], cwd=tmp_path, check=True) + + projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), + "taskIds": [], "backlogTaskIds": []}} + db_path = _write_snapshot(tmp_path, projects, {}, {}) + _write_tasks_config(tmp_path, db_path) + _project_page(tmp_path, "Ship Chemenu 7.0", "active") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True) + + monkeypatch.setattr(config, "ROOT", tmp_path) + use_shipped_type_specs(monkeypatch) + before = subprocess.run( + ["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True + ).stdout + runner.invoke(app, ["review", "--json"]) + after = subprocess.run( + ["git", "status", "--porcelain"], cwd=tmp_path, capture_output=True, text=True, check=True + ).stdout + assert before == after == ""