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
153 lines
7.3 KiB
Python
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)
|