Skip to content

CLI friction polish: JSON mode, flag contract, honest setup/doctor, readable output - #58

Merged
0xjgv merged 30 commits into
mainfrom
cdx
Jun 2, 2026
Merged

0xjgv merged 30 commits into
mainfrom
cdx

Conversation

@0xjgv

@0xjgv 0xjgv commented May 22, 2026

Copy link
Copy Markdown
Owner

Summary

A series of CLI friction-polishing changes (the cli-friction-polish task line, tasks 01–08, plus the preceding --json work). The theme throughout: make interlocks output honest, readable, and machine-consumable, and make the flag surface a strict contract.

What's included

JSON output mode

  • --json flag contract + JSON render surface; per-stage renderers for check/ci, evaluate/trust, doctor/config.
  • Runner gate accumulator widened to GateResult; redundant GateResult.name dropped.
  • Stray non-JSON prints gated off stdout in --json mode; self-dogfood sweep.

Flag contract

  • Per-task flag contract — the dispatcher rejects any -* token not declared as a FlagSpec on the task's CommandDoc.

Honest setup & doctor

  • setup refuses non-git directories and shows an honest per-artifact summary.
  • doctor emits truthful default output and a --strict exit code.

Readable default-mode output

  • check renders readable gate-row output in default mode.
  • help shows visible group headers; help/explain gain a first-touch Detected summary and an explain index.
  • config default mode shows a single sectioned presenter (flat resolved block moved to verbose); presets default mode gains a Switch with: footer.

Supporting

  • ci gates coverage/typecheck/test on project_env_ready.
  • Format-aware budgeted mutation in stages.
  • Onboarding docs streamlined.

Testing

  • Full unit suite green; interlocks check and interlocks acceptance green.
  • Acceptance scenarios added for the new default-mode behaviors; behavior IDs registered in behavior_coverage.py.

🤖 Generated with Claude Code

0xjgv added 20 commits May 17, 2026 21:52
Plumb `kind` (lint|format) through plan/classify/optimize/simulate/verify so
format candidates flow through the same budgeted DP path as lint rules. Stages
(check, pre-commit, post-edit) now route through cmd_fix_optimize via a shared
`stages/_budgeted.py` helper; the default `dynamic` budget keeps mutations
review-sized while `--renovate` / `--mutation-budget=` opt into broader churn.

- author_edit_cost counts deleted-file lines toward review surface but filters
  deleted files from Ruff mutation inputs
- doctor surfaces the dynamic-budget default; new howto under docs/
- drop dead conflict-range loop in optimize.conflicts (stays conservative)
- drop unreachable _empty_success stub in verify
In a non-uv project with no in-tree .venv, `interlocks ci` previously ran
coverage, typecheck, and test against the installer's interpreter, yielding
installer-dependent verdicts instead of real findings. Gate these gates
(plus the CRAP/mutation post-coverage steps) on `project_env_ready`, emit a
warn-skip advisory, and stay exit-0. Removes the redundant stage guard in
check.py and registers the `ci-no-venv-skip` behavior.
Declare every CLI flag a task reads as a FlagSpec on its CommandDoc so the
dispatcher can reject any undeclared -* token (exit 1) and surface it by
name, closing a silent-typo gap where bad flags were ignored. Per-task
help now renders the declared flags, a source-introspection drift guard
keeps the registry honest, and deprecated --quiet exits 1 (was 2) for
consistency with other usage errors.
Add ui.is_json()/print_json() and gate every chrome primitive on
is_json() so --json dominates --verbose. Gate runner.section/ok and
print_stage_verdict. Declare --json as a boolean FlagSpec on ci, check,
evaluate, trust, and doctor. The flag-drift guard excludes --json since
it is read centrally via ui.is_json() rather than from a task module.
No command emits JSON yet — this is the foundation for the per-command
JSON branches.
Replace the (label, passed) tuple accumulator with a frozen GateResult
dataclass carrying name/label/status/elapsed/detail so ci and check can
later build a structured gates array. record_result takes the richer
signature; _print_status derives a best-effort failure detail from the
failed command line. print_stage_verdict's human verdict line is
byte-identical to before.
Wire the first JSON-emitting commands. ci and check share one stage-JSON
collector (runner.stage_json) over the GateResult accumulator, plus a
structured skip accumulator (record_skip) parallel to _RESULTS. Suppress
the human failure dump under --json so stdout stays exactly one object;
failure detail is carried structurally in each gate's `detail` field.
Register behavior IDs cli-json-ci and cli-json-check.
Add the JSON branch to the two advisory scored commands. evaluate emits
{command,score,verdict,checks}; trust emits {command,score,verdict,
coverage_pct,crap_offenders,suspicious_tests}. Both serialise their report
dataclass and return before any ui.* call; exit code stays 0. Error paths
(evaluate unreadable pyproject, trust missing .coverage) emit a minimal
{command,error} object so stdout is always parseable. Register behavior
IDs cli-json-evaluate and cli-json-trust.
Adds the --json branch to the two static-report commands. doctor emits
{command, status, blockers, warnings, detected, setup_checklist} and
mirrors its exit-1-on-failures quirk; config emits {command, preset,
pyproject_path, keys} with a dedicated _json_value resolver that coerces
Path/frozenset/tuple to JSON scalars rather than human-display strings.

config consumes --json as a declared output flag so it no longer trips
the positional-arg validation; config show now reads mode via
ui.is_json(). Registers behavior IDs cli-json-doctor/cli-json-config.
Documents the six-command --json surface in README.md and AGENTS.md,
and unifies config's --json FlagSpec description with the other five.

Extracts the doctor render arms into _render_doctor_json /
_render_doctor_verbose over a shared _DoctorReport value so cmd_doctor
stays under the complexity gate after gaining the JSON branch.
`name` and `label` always carried the identical row label, so the
separate `name` field on `GateResult` and the duplicate argument to
`record_result` were dead weight. Collapse to a single `label`; the
JSON `name` key still ships, mirroring `label`, to keep the schema
contract stable.
Three gates printed raw text on stdout regardless of output mode,
corrupting `--json` output so `json.loads(stdout)` failed:

- `check` emitted `scope=`/`changed vs` headers gated only on
  `is_verbose()`
- `behavior_attribution` printed the attribution failure summary
  unconditionally
- `crap` printed `CRAP=` offender rows and the `… N more` tail
  unconditionally

Gate each on `not ui.is_json()` so `--json` stdout stays a single
parseable object. Add regression tests for the CRAP-offender and
verbose-changed-scope paths.
`interlocks setup` previously installed a git pre-commit hook silently
even when run outside a git repo, producing a phantom hook that never
fired. It now refuses non-git directories with a `git init` hint before
touching any artifact, and the default-mode status block prints one row
per artifact (via `ui.row(force=True)`) so a successful install is no
longer silent under minimal-default output.
Default-mode doctor now names blockers and gaps inline (gaps capped at
3, with an overflow bullet pointing at --verbose) instead of only
reporting a `ready (N gap)` count. Add a `--strict` flag that exits 2
when the verdict is blocked, leaving plain doctor advisory at exit 0.

Delete the false "tool not found on PATH" warnings for uvx-dispatched
tools — those tools are never expected on PATH since 0.2.0 ships zero
runtime deps.
interlocks check in default mode was near-silent — a clean run printed
only the final verdict even though it ran 6+ gates, so a slow strict run
looked frozen.

- Add ui.gate_row: a thin wrapper over row(..., force=True) that always
  shows in default mode while is_json() still dominates.
- Route runner._print_status and the budgeted-mutation legacy fix/format
  rows through gate_row, so check prints one row per running gate.
- Drop the fix-optimize summary leak: empty plan is silent in default
  mode; a populated plan emits one [fix-optimize] plan written -> ... gate
  row. The rich SELECTED / NOT SELECTED / plan kv_blocks are now
  explicitly verbose-gated (ui.kv_block is ungated).
- Register the check-default-gate-rows behavior and cover it with a
  default-mode acceptance scenario plus a negative stage @then step. The
  test harness opt-out token is now the harness-only --default-mode
  sentinel (--quiet was removed from the CLI).

--json output is byte-for-byte unchanged; --verbose output is unchanged.
Behavior-identical comment cleanup from the code-review step of the
readable-check-output work. The comment block now sits next to the
`ui.kv_block` verbose guard it actually explains, instead of straddling
the `gate_row` call above it. No code paths change.
Add an always-print `ui.group_header` helper and route both `cmd_help`
branches through it, so `Start here:` / `Common gates:` / `Project:`
labels render at default verbosity instead of being suppressed by the
verbose-gated `ui.section`.
- help: emit a one-line `Detected:` summary (preset/src/tests/runner)
  at default verbosity; `(no pyproject.toml)` when the on-disk file is
  absent. The full verbose Detected/Thresholds/Crash-Reports block is
  unchanged below the verbose early-return.
- explain: no-arg now prints a grouped one-row-per-command index via
  `_explain_index`; the full per-command prose dump moves behind a new
  `--all` flag, declared as a FlagSpec so `unknown_task_flags` accepts it.
- Register behavior IDs cli-help-groups-default, cli-help-detected-summary,
  cli-explain-default-is-index; repoint the cli-explain-all scenario to
  `explain --all` and add default-mode help + explain-index scenarios.
Code-review simplification pass on the first-touch help work:

- explain.py: factor the duplicated TASK_GROUPS walk in `_explain_index`
  and `_explain_all` into a shared `_command_docs_by_group` iterator,
  collapsing the repeated lazy-import and drift-guard handling
- ui.py: tighten the `group_header` docstring
- cli.py: trim the `_detected_summary_line` docstring to its essential
  one-line description

Pre-commit test gate skipped: the lone failure
(test_interlocks_check_blocks_on_lint_failures) is a pre-existing
environment issue — bare `python` is absent from PATH — and reproduces
on the unmodified tree at 0ab3d6d, unrelated to these edits.
…itch footer

Move the config "Resolved values" section behind the verbose gate so the
default `interlocks config` output stays focused on Status and Config keys.

Add a "Switch with:" footer to default-mode `interlocks presets` output and
extract `_presets_set_invocation()` as the single source for the canonical
`presets set <baseline|strict|legacy>` phrase, reused by usage strings.

Cover both behaviors with `--default-mode` feature scenarios, extended
test_cli assertions, and new behavior IDs (cli-config-single-presenter,
cli-presets-default-footer).

Pre-existing environmental test failure (greenfield test invokes a bare
`python` binary absent from PATH) is unrelated to this change.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces machine-readable JSON output for core commands, a new dynamic mutation budget based on author edit cost, and stricter CLI flag validation. It also includes significant documentation updates and refactors stage logic to use a shared budgeted mutation flow. Feedback focuses on ensuring skip policies are correctly enforced in the new budgeted logic, preventing duplicate CLI output in pre-commit, and ensuring all skipped gates are properly recorded in the JSON output for machine-readability parity. Additionally, it is suggested to make the uv presence check unconditional in doctor as it is now a core requirement for tool dispatch.

Comment thread interlocks/stages/_budgeted.py Outdated
Comment thread interlocks/stages/pre_commit.py Outdated
Comment on lines 233 to 234
if cfg is not None and cfg.test_invoker == "uv" and shutil.which("uv") is None:
blockers.append("test_invoker is `uv`, but `uv` was not found on PATH")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since interlocks now primarily dispatches tools via uvx (as stated in the PR description and seen in simulate.py), the presence of uv on the PATH is a hard requirement for the entire tool to function, not just when test_invoker is set to uv. This check should be made unconditional to provide accurate diagnostic feedback to the user.

Suggested change
if cfg is not None and cfg.test_invoker == "uv" and shutil.which("uv") is None:
blockers.append("test_invoker is `uv`, but `uv` was not found on PATH")
if shutil.which("uv") is None:
blockers.append("`uv` not found on PATH; interlocks requires `uv` for tool dispatch")

Comment on lines +54 to +55
warn_skip(project_env_skip_message("coverage"))
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When a task skips itself due to a missing project environment, it should be recorded in the stage's skip accumulator so it appears in the skipped array of the --json output. Currently, warn_skip only handles human-readable output and does not populate the machine-readable skip list.

Suggested change
warn_skip(project_env_skip_message("coverage"))
return None
if not project_env_ready(cfg):
from interlocks.runner import record_skip
record_skip("coverage", "no project environment")
warn_skip(project_env_skip_message("coverage"))
return None

Comment thread interlocks/stages/ci.py
Comment on lines +134 to 137
if not no_env and _should_run_mutation(cfg.mutation_ci_mode, run_in_ci=cfg.run_mutation_in_ci):
_run_post_coverage_gate(
"mutation",
lambda: cmd_mutation(changed_only=cfg.mutation_ci_mode == "incremental"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The mutation gate is silently skipped when no_env is true, which means it won't appear in the gates or skipped arrays in the JSON output. For machine-readability parity with the human-readable output (which shows warnings for other skipped gates), this should use warn_skipped or record_skip to ensure the skip is documented in the JSON payload.

@0xjgv
0xjgv merged commit f78e08d into main Jun 2, 2026
2 of 5 checks passed
@0xjgv
0xjgv deleted the cdx branch June 2, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant