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
This commit is contained in:
1 parent
1875449b31
commit
80b57e0d01
8 files changed
+751
-5
No files matched your search
+34
-1
@@ -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
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
<!-- wikitool:bumps -->
|
<!-- wikitool:bumps -->
|
||||||
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
||||||
- Task-tracker provider layer, with a Super Productivity adapter
|
- Task-tracker provider layer, with a Super Productivity adapter
|
||||||
|
- wikitool review: the weekly GTD review as a read-time join
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
### Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
### 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
|
Ausserdem verifiziert, ohne Designfolgen: Super Productivitys Someday/Maybe-Aequivalent ist der
|
||||||
bestehende `backlogTaskIds`-Puffer je Projekt, keine eigene Tag-Konvention.
|
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
|
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ tools/wikitool <command> --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 <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
| `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 <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--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** |
|
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--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
|
### 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" |
|
| `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" |
|
| `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
|
### Provenance
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ try:
|
|||||||
page_ops,
|
page_ops,
|
||||||
provenance_cmd,
|
provenance_cmd,
|
||||||
raw_cmd,
|
raw_cmd,
|
||||||
|
review_cmd,
|
||||||
run_budget,
|
run_budget,
|
||||||
search as search_module,
|
search as search_module,
|
||||||
touch as touch_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("move")(page_ops.move_command)
|
||||||
app.command("lint")(lint_module.lint_command)
|
app.command("lint")(lint_module.lint_command)
|
||||||
app.command("search")(search_module.search_command)
|
app.command("search")(search_module.search_command)
|
||||||
|
app.command("review")(review_cmd.review_command)
|
||||||
app.command("publish")(git_publish.publish_command)
|
app.command("publish")(git_publish.publish_command)
|
||||||
app.command("sync")(git_publish.sync_command)
|
app.command("sync")(git_publish.sync_command)
|
||||||
app.command("doctor")(doctor.doctor_command)
|
app.command("doctor")(doctor.doctor_command)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -115,9 +115,11 @@ SKIP_COMMAND_PATHS = {
|
|||||||
# reading, not iterating: the budget exists to stop an agent looping over the
|
# 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
|
# 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:
|
# lowers cost - looking before reading. `doctor` is here for the same reason:
|
||||||
# it only reads and reports, never mutates anything. Every command that
|
# it only reads and reports, never mutates anything. `review` (#125) joins the
|
||||||
# mutates anything stays counted.
|
# task tracker against kb/gtd/ pages and stores nothing either (#119 D3) - the
|
||||||
SKIP_COMMANDS = {"search", "doctor"}
|
# 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:
|
def is_exempt(command: str, args: list[str]) -> bool:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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 == ""
|
||||||
Reference in new issue
Block a user