DEV Community

Avery Lin
Avery Lin

Posted on

Make README Quickstarts Fail CI When Flags Drift From argparse

Generated quickstart copy goes stale when CLI flags move and the README still shows last week's names. A small continuous-integration gate can import the real parser, extract option strings, and reject markdown that cites unknown flags. Models may draft the session narrative, but they should not be the source of flag names, default ports, or support hours. The sections below walk through a reproducible extractor, a session checker, and a decision table for ownership.

Why tone review misses the real defect

Most broken READMEs do not fail because the prose sounds awkward or too brief for newcomers. They fail because a documented command line no longer matches the parser that shipped users will actually import. A fluent model can still invent --env-file, swap --port for --listen, or promise round-the-clock support nobody signed. Treating those errors as editorial nits hides a cheaper check: every flag-like token must exist on the live argparse object.

That check stays mechanical and fast, and it remains independent of whoever drafted the surrounding paragraph. It also splits inventory text from promise text, which later sections encode as a linter rather than a style guide. Inventory is anything a parser, a compose file, or a package metadata file can prove in CI. Promises are support hours, production defaults, license claims, and security contacts.

Teams that debate comment density will still ship the wrong --output flag if nobody imports the parser. The remainder of the workflow therefore treats documentation as two pipelines that share a repository but not a writer.

Ownership table before any drafting

Use this table as the contract for the pipeline, not as optional writing advice after the fact. Copy the table into the pull-request template if reviewers otherwise treat generated prose as already verified.

Doc fragment Allowed drafter Ground truth Merge rule
Flag names, short options, metavars None parser actions after import Must match extractor JSON
Example command sequence Model or human Each flag cited in extractor JSON Checker must pass
Published host:port bindings None compose.yaml ports Must match compose inventory
Support hours, paging, security mailbox Human only Signed HTML comment block Unsigned hits fail CI
License, trademark, warranty Human only LICENSE plus signed block Never model-authored
Why-this-flag one-liners Model draft OK Adjacent help= string Reviewer may edit tone

The table is the first artifact to copy, because it prevents a chat transcript from becoming the README source. Everything after this heading is one implementation of that table for a small Python CLI.

Procedure

  1. Move argparse construction into an importable build_parser() function with no network side effects.
  2. Extract option strings and compose host ports into docs/inventories/cli.json on every CI run.
  3. Restrict model output to docs/quickstart.md fenced commands plus connective prose that cites that inventory.
  4. Run the citation checker and fail the job on unknown flags, unknown ports, or unsigned promises.
  5. Keep support, license, and warranty text in signed blocks owned by a named human, not by the model.

1. Keep parser construction importable

Hide argparse behind a function so tests and extractors do not execute side effects at import time. The sample below is small on purpose, which keeps the later checker easy to read and easy to test.

# demo_cli/parser.py
from __future__ import annotations

import argparse


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog='demo-cli',
        description='Batch resize images for a local preview pipeline.',
    )
    parser.add_argument('--input-dir', required=True, help='Directory of source images.')
    parser.add_argument('--output-dir', required=True, help='Directory for resized files.')
    parser.add_argument('--width', type=int, default=640, help='Target width in pixels.')
    parser.add_argument('--height', type=int, default=480, help='Target height in pixels.')
    parser.add_argument(
        '--dry-run',
        action='store_true',
        help='Print planned writes without creating files.',
    )
    parser.add_argument(
        '--log-json',
        action='store_true',
        help='Emit one JSON object per image to stdout.',
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.dry_run:
        print(f'would process {args.input_dir} -> {args.output_dir}')
        return 0
    print(f'process {args.input_dir} -> {args.output_dir}')
    return 0
Enter fullscreen mode Exit fullscreen mode

A matching compose file supplies ports that the README must not invent. Keep published ports in simple host:container form so the extractor can take the leftmost field without guessing bind addresses.

# compose.yaml
services:
  preview:
    image: demo-cli:local
    ports:
      - '8088:8088'
Enter fullscreen mode Exit fullscreen mode

2. Extract option strings in CI, not in chat

The extractor imports build_parser, walks actions, and writes JSON the checker can diff. It does not ask a model which flags exist, because that question is already answered by the parser object. Help strings travel with the flags so a later draft can paraphrase purpose without inventing names.

# tools/extract_cli_options.py
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml

from demo_cli.parser import build_parser


def option_rows() -> list[dict]:
    rows: list[dict] = []
    for action in build_parser()._actions:
        if not action.option_strings:
            continue
        rows.append({
            'options': list(action.option_strings),
            'help': action.help or '',
            'required': bool(getattr(action, 'required', False)),
        })
    return rows


def compose_host_ports(path: Path) -> list[str]:
    data = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
    ports: list[str] = []
    for service in data.get('services', {}).values():
        for item in service.get('ports', []):
            ports.append(str(item).split(':')[0].strip().strip("'\""))
    return sorted(set(ports))


def main() -> int:
    rows = option_rows()
    payload = {
        'prog': 'demo-cli',
        'options': sorted({flag for row in rows for flag in row['options']}),
        'flags': rows,
        'host_ports': compose_host_ports(Path('compose.yaml')),
    }
    out = Path('docs/inventories/cli.json')
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
    print(json.dumps(payload, indent=2))
    return 0


if __name__ == '__main__':
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Run the extractor with the same interpreter the package tests already use. Regeneration in CI should remain mandatory even if the JSON is committed for review diffs.

python -m pip install pyyaml
python tools/extract_cli_options.py
Enter fullscreen mode Exit fullscreen mode

Walking parser._actions is convenient and brittle across Python versions, so pin the checker to the interpreter you already test. The committed JSON is a cache of the parser, not an independent specification. If the cache and a fresh extract disagree, the extract wins and the README must follow it.

3. Constrain the markdown the model may draft

The model may draft a quickstart session only as fenced commands plus connective prose. It may not introduce flag names, ports, or support sentences that the inventories do not already contain. A skeleton like the one below makes that constraint visible during review.

# Quickstart

Connective prose may be drafted, but every command must cite inventory flags.

```bash
demo-cli --input-dir ./in --output-dir ./out --width 640 --dry-run
```

Preview binds to the compose host port:

```bash
curl -sS http://127.0.0.1:8088/health
```
Enter fullscreen mode Exit fullscreen mode

Unsigned promise language belongs in a separate region that CI rejects until a human wraps it. The date and owner attributes are review metadata, not proof that the mailbox is monitored.

<!-- signed:support owner='docs-oncall' date='2026-09-23' -->
Security reports go to security@example.com. Support hours are business days, 15:00-23:00 UTC.
<!-- /signed:support -->
Enter fullscreen mode Exit fullscreen mode

4. Check citations and block unsigned promises

The checker is the original artifact for this workflow. It tokenizes fenced bash blocks, collects tokens that start with -, and compares them to the inventory. It also scans for promise patterns outside signed regions, which keeps support copy from riding in on a generated session.

# tools/check_quickstart.py
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

FENCE = re.compile(r'```

(?:bash|sh|shell)\n(.*?)

```', re.DOTALL)
FLAG = re.compile(r'(?<![\w-])(--[A-Za-z0-9][\w-]*|-[A-Za-z])\b')
SIGNED = re.compile(
    r'<!-- signed:support\b.*?-->(.*?)<!-- /signed:support -->',
    re.DOTALL,
)
PROMISE = re.compile(
    r'\b(sla|24/7|twenty-four-hour|guaranteed|warranty|on-call|security@|'
    r'production default|we promise|always available)\b',
    re.I,
)
HOSTPORT = re.compile(r'127\.0\.0\.1:(\d+)|localhost:(\d+)')


def strip_signed(text: str) -> str:
    return SIGNED.sub('', text)


def flags_in(command: str) -> list[str]:
    return FLAG.findall(command)


def main(readme: Path, inventory: Path) -> int:
    inv = json.loads(inventory.read_text(encoding='utf-8'))
    allowed = set(inv['options'])
    ports = set(inv['host_ports'])
    text = readme.read_text(encoding='utf-8')
    errors: list[str] = []

    for block in FENCE.findall(text):
        for token in flags_in(block):
            if token not in allowed:
                errors.append(f'unknown flag {token}')
        for match in HOSTPORT.finditer(block):
            port = match.group(1) or match.group(2)
            if port not in ports:
                errors.append(f'unknown host port {port}')

    unsigned = strip_signed(text)
    for match in PROMISE.finditer(unsigned):
        errors.append(f'unsigned promise {match.group(0)!r}')

    if errors:
        print('\n'.join(errors))
        return 1
    print('quickstart citations ok')
    return 0


if __name__ == '__main__':
    raise SystemExit(
        main(Path('docs/quickstart.md'), Path('docs/inventories/cli.json'))
    )
Enter fullscreen mode Exit fullscreen mode

5. Lock the behavior with tests

Label these tests as executable examples for the sample CLI, not as fleet metrics from a production incident. They document the gate, which is the point of the article. Put the repository root on PYTHONPATH so tools.check_quickstart imports cleanly.

# tests/test_check_quickstart.py
from pathlib import Path

from tools.check_quickstart import main


def write(tmp: Path, name: str, body: str) -> Path:
    path = tmp / name
    path.write_text(body, encoding='utf-8')
    return path


def test_unknown_flag_fails(tmp_path: Path) -> None:
    inventory = write(
        tmp_path,
        'cli.json',
        '{"options": ["--input-dir"], "host_ports": ["8088"]}\n',
    )
    readme = write(
        tmp_path,
        'q.md',
        '```

bash\ndemo-cli --input-dir ./in --recursive\n

```\n',
    )
    assert main(readme, inventory) == 1


def test_unknown_port_fails(tmp_path: Path) -> None:
    inventory = write(
        tmp_path,
        'cli.json',
        '{"options": ["--dry-run"], "host_ports": ["8088"]}\n',
    )
    readme = write(
        tmp_path,
        'q.md',
        '```

bash\ncurl -sS http://127.0.0.1:3000/health\n

```\n',
    )
    assert main(readme, inventory) == 1


def test_signed_support_may_name_a_mailbox(tmp_path: Path) -> None:
    inventory = write(
        tmp_path,
        'cli.json',
        '{"options": ["--dry-run"], "host_ports": ["8088"]}\n',
    )
    readme = write(
        tmp_path,
        'q.md',
        '```

bash\ndemo-cli --dry-run\n

```\n\n'
        '<!-- signed:support owner=\'docs-oncall\' date=\'2026-09-23\' -->\n'
        'Security reports go to security@example.com.\n'
        '<!-- /signed:support -->\n',
    )
    assert main(readme, inventory) == 0
Enter fullscreen mode Exit fullscreen mode
export PYTHONPATH=.
python -m pip install pytest pyyaml
python tools/extract_cli_options.py
python tools/check_quickstart.py
python -m pytest tests/test_check_quickstart.py -q
Enter fullscreen mode Exit fullscreen mode

Wire those three commands as one CI step in whatever system already runs unit tests. Do not treat a green chat transcript as a substitute for that step, because the transcript cannot import build_parser.

What to do when the checker fails

When the checker prints unknown flag --recursive, the fix is not a warmer prompt to the model. Either add the flag to build_parser and regenerate the inventory, or remove the token from the quickstart fence. When it prints an unsigned promise, move that sentence into a signed block after a human confirms the hours. Regenerating the README from chat without re-running the extractor repeats the original failure, so CI should call extract and check in that order.

Unknown ports follow the same rule as unknown flags. If curl cites 3000 and compose publishes 8088, change the markdown or change compose, then extract again. Do not add a second default in prose, because the checker only sees fenced commands and signed blocks.

Where a free model may participate

After the extractor exists, narrative prose still needs a first draft, and that draft is the only place a model should write. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can propose the connective sentences around the fenced commands, and its free server option can run the import-based extractor on the same tree. The checker still rejects invented flags, unknown ports, and unsigned support copy, so the model never becomes the inventory. If the draft cites --recursive and the parser has no such action, the run fails before review.

That arrangement keeps the merge rule in the repository. The useful output is the failing check, not a longer prompt log.

Limitations, and who should skip this

The gate assumes an importable Python argparse object and a compose file whose published ports are static strings. Click lazy loading, binary CLIs, flags generated from a remote schema, and ports allocated at runtime will produce false confidence or false failures. Promise detection is a regular-expression denylist, so legal language that avoids the listed words can still slip through unsigned.

Do not use this workflow as a substitute for legal review of licenses, warranties, or security policy. Do not use it on libraries that have no CLI and no compose surface, because the inventory will be empty and the checker will only scan for words. Teams that already maintain human-written READMEs with review on every sentence gain little, unless flag drift is already a recurring defect.

The approach also refuses to auto-sign anything. A model that pastes a signed:support block around its own mailbox should be treated as a process bug, not as a clever default. Keep signature rights on named humans, and rotate the owner attribute when on-call coverage changes. Positionals, mutually exclusive groups, and environment-variable fallbacks are outside this checker, so document those paths in signed prose or extend the inventory before claiming coverage.

If you run the extractor in a hosted workspace that already offers free model access, keep the checker in the same session as the draft so invented flags never reach the README.

Top comments (0)