Files
chemenu/tools/chemenu/commands/review_cmd.py
T
torben fd0f60b2e7
CI / verify (push) Successful in 1m15s
Release / release (push) Successful in 37s
tools: command records, Git group - NOTES as bullets, one exit line per cause, examples and prohibitions (#142)
Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/eval_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/instructions_cmd.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/provenance_cmd.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/commands/xref.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_docs_verify.py
2026-09-26 08:25:41 +02:00

153 lines
7.3 KiB
Python

"""`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 cli_contract, 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.source is not None:
lines.append(f"Source: {report.source.kind} ({report.source.detail})")
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:
suffix = f" (id: {finding.item_id})" if finding.item_id is not None else ""
lines.append(f"[{finding.check}] {finding.project}: {finding.message}{suffix}")
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,
"item_id": finding.item_id,
}
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,
"source": (
{"kind": report.source.kind, "detail": report.source.detail}
if report.source is not None
else None
),
}
@cli_contract.record(cli_contract.CommandRecord(
path="review",
summary="The GTD weekly review.",
synopsis=(cli_contract.Variant(usage="review [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
network=cli_contract.Network.YES,
),
notes="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`). A value a provider genuinely cannot supply - a "
"`WAITING` item with no `follow_up_at` at all, a tracker project with no determinable "
"creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather "
"than a silent skip of waiting-overdue/unpaged-project for that item or project. Thresholds "
"come from `.wikitool-tasks.json`, never from the schema. Text output is one "
"`[check] project: message` line per finding, preceded by a `Source:` line naming which "
"access path answered and, for `superproductivity`'s `access: \"snapshot\"`, the snapshot's "
"age; `--json` carries the same findings plus "
"`checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` "
"(`{\"kind\": ..., \"detail\": ...}` or `null`). 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**",
failures=(cli_contract.Failure(
label="",
cause="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",
reaction="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",
),),
))
def review_command(
json_out: bool = typer.Option(False, "--json", help="Print the findings as JSON."),
):
"""Run the weekly GTD review over the task tracker and `kb/gtd/` pages.
\f
Joins 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)