diff --git a/.claude/rules/development.md b/.claude/rules/development.md index 2994149..b78110a 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -92,6 +92,23 @@ status set; `TestIdentifyRealSamples::test_no_committed_fixture_reports_a_truste is that guard. Read a capability the stack does not have as a missing input, never as a negative finding. +## The docs are a seam, and they drift like one + +`tests/test_docs_cover_the_public_surface.py` reads the live Click tree and the +live `InvisibleOptions` dataclass and requires every knob to be NAMED in the +user-facing pages. The code-to-code seams were already guarded and the +code-to-docs seam was not, so it drifted exactly the way an unguarded seam does: +`docs/cli.md` named none of `--adaptive-polish`, `--controlnet-scale`, +`--detect/--no-detect`, `--humanize`, `--strength`, `--tile-size`, +`--tile-overlap` or `--unsharp`, and `docs/python-api.md` named 9 of the 16 +option fields. + +Group option spellings by the Click PARAMETER, never by string surgery on the +names. A boolean pair can be spelled anything -- `--strip-metadata/--keep-metadata`, +`--keep-standard/--remove-all` -- so deriving the partner from a `--no-` prefix +silently splits those pairs and demands both halves. Either spelling documents +the pair. + ## One measurement, one gate seam A detector is split into a trust-level-blind scan and a verdict that applies the diff --git a/CLAUDE.md b/CLAUDE.md index edf46ec..1460bc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ Optional features and installation groups are documented in [`docs/installation. `maintain.sh` runs dependency freshness and security checks, Ruff, Pyright scoped to `src/`, and the parallel test suite. Full-project Pyright is not the project gate because the ML dependency graph can exhaust Node memory. +This gate does NOT pass `--ignore-unfixed` to uv-secure, so a transitive CVE with no released fix stops it before Ruff, Pyright and the tests ever run. That is deliberate and it is not a blocker: run those three separately and report them, per the triage in [`docs/development.md`](docs/development.md). A red gate here is not the same signal as a red gate in a sibling repository that does ignore unfixed findings. + Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md). Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist. @@ -51,4 +53,4 @@ Topic-specific rules live in `.claude/rules/*.md` and are auto-loaded when match | File | Covers | |---|---| -| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, and the detection-path measurement rule | +| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, the docs-coverage seam, and the detection-path measurement rule | diff --git a/docs/python-api.md b/docs/python-api.md index 6ccbc29..d9241c5 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -146,6 +146,31 @@ engine's own parameter names and defaults. `force`, which decides whether the engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside `backend` and `sensitivity`. +The complete field set, with the shipped defaults: + +| Field | Default | Effect | +| --- | --- | --- | +| `strength` | `None` | Denoising strength. `None` resolves per profile and vendor cohort. | +| `pipeline` | `"qwen-zimage"` | Profile: `qwen-zimage`, `sdxl-zimage`, `chroma-zimage`, or `auto`. | +| `vendor` | `None` | Strength cohort, and an assertion that the watermark is present. | +| `seed` | `None` | `None` uses the certified seed 0. Removal near the floor is seed-dependent. | +| `hf_token` | `None` | Hugging Face token for the model download; the environment and a local `.env` are read when unset. | +| `humanize` | `0.0` | Analog film grain. 0 is off; 2.0-6.0 is the useful band. | +| `unsharp` | `0.0` | Unsharp-mask sharpening. 0 is off. | +| `adaptive_polish` | `None` | `None` lets the profile decide (off for `qwen-zimage`, on for `sdxl-zimage`); `True`/`False` override it. | +| `max_resolution` | `0` | Cap the long side before diffusion. 0 keeps native geometry and the most detail. | +| `controlnet_conditioning_scale` | `1.0` | Canny ControlNet conditioning on the global stage. Higher stays closer to the original structure and text. | +| `cpu_offload` | `False` | Stream model components between CPU and GPU. Lower CUDA memory, slower. | +| `tile` | `False` | Regenerate large inputs in overlapping tiles instead of downscaling. | +| `tile_size` | `1024` | Tile side in pixels when `tile` is set. | +| `tile_overlap` | `128` | Tile overlap in pixels. More overlap hides seams and costs time. | +| `text_manifest` | `None` | Path to an operator-verified text manifest; see the text-restoration section. | +| `fidelity_anchor` | `False` | Opt into the fidelity-anchor pass. | + +`tests/test_docs_cover_the_public_surface.py` fails when a field is added here +and not to this table, because this seam had already drifted by eight fields +before anything checked it. + If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete` **before** writing anything: an AI-readable output is worse than no output. diff --git a/tests/test_docs_cover_the_public_surface.py b/tests/test_docs_cover_the_public_surface.py new file mode 100644 index 0000000..eb652e7 --- /dev/null +++ b/tests/test_docs_cover_the_public_surface.py @@ -0,0 +1,88 @@ +"""The user-facing docs must name every knob the public surface actually has. + +The code-to-code seams are already guarded: `test_api.py` compares +`InvisibleOptions` against the engine signature field by field, and +`test_cli.py` drives the commands. Nothing guarded the code-to-DOCS seam, and it +drifted exactly the way an unguarded seam does -- silently, one addition at a +time. When this was first measured, `docs/cli.md` named none of +`--adaptive-polish`, `--controlnet-scale`, `--detect/--no-detect`, `--humanize`, +`--strength`, `--tile-size`, `--tile-overlap` or `--unsharp`, and +`docs/python-api.md` named 9 of the 16 `InvisibleOptions` fields. + +These tests read the real Click tree and the real dataclass rather than a +hand-kept list, so a knob added tomorrow fails here until someone documents it. +Both accept EITHER spelling of a boolean pair: `--keep-metadata` documents +`--strip-metadata/--keep-metadata` perfectly well, and requiring the on-form +would force noise into the guides. +""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path + +import click + +from remove_ai_watermarks.api import InvisibleOptions +from remove_ai_watermarks.cli import main + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# The pages a user is sent to for command and API reference. A knob mentioned +# only in a research-archive page is not documented for the purpose of this gate. +CLI_PAGES = ("docs/cli.md", "README.md", "docs/installation.md") +API_PAGES = ("docs/python-api.md", "README.md") + + +def _text(pages: tuple[str, ...]) -> str: + return "".join((REPO_ROOT / page).read_text(encoding="utf-8") for page in pages) + + +def _cli_options() -> list[tuple[str, list[str]]]: + """Every ``(command, spellings)`` pair in the tree. + + Group by the Click parameter itself, never by string surgery on the names: a + boolean pair can be spelled anything (``--strip-metadata/--keep-metadata``, + ``--keep-standard/--remove-all``), so deriving the partner from a ``--no-`` + prefix silently splits those pairs into two groups and demands both halves. + """ + found: list[tuple[str, list[str]]] = [] + + def walk(command: click.Command, prefix: str = "") -> None: + if isinstance(command, click.Group): + for name, sub in sorted(command.commands.items()): + walk(sub, f"{prefix}{name} ") + return + for param in command.params: + if not isinstance(param, click.Option): + continue + names = [o for o in param.opts + param.secondary_opts if o.startswith("--")] + if names and names != ["--help"]: + found.append((prefix.strip() or "(root)", names)) + + walk(main) + return found + + +def test_every_cli_option_is_named_in_the_command_docs() -> None: + docs = _text(CLI_PAGES) + undocumented = [ + f"{command}: {' / '.join(names)}" + for command, names in _cli_options() + if not any(name in docs for name in names) + ] + assert not undocumented, ( + "CLI options absent from " + ", ".join(CLI_PAGES) + ":\n " + "\n ".join(sorted(set(undocumented))) + ) + + +def test_every_invisible_option_field_is_named_in_the_api_docs() -> None: + docs = _text(API_PAGES) + fields = [f.name for f in dataclasses.fields(InvisibleOptions)] + assert fields, "InvisibleOptions has no fields; the guard would pass vacuously" + undocumented = [name for name in fields if name not in docs] + assert not undocumented, ( + "InvisibleOptions fields absent from " + + ", ".join(API_PAGES) + + f": {undocumented}. Document the field or explain in python-api.md why it is internal." + )