diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 25f5ea1a..de0df2f7 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -432,7 +432,7 @@ apm audit [PACKAGE] [OPTIONS] - `-f, --format [text|json|sarif|markdown]` - Output format: `text` (default), `json` (machine-readable), `sarif` (GitHub Code Scanning), `markdown` (step summaries). Cannot be combined with `--strip` or `--dry-run`. - `-o, --output PATH` - Write report to file. Auto-detects format from extension (`.sarif`, `.sarif.json` → SARIF; `.json` → JSON; `.md` → Markdown) when `--format` is not specified. - `--ci` - Run lockfile consistency checks for CI/CD gates. Exit 0 if clean, 1 if violations found. Auto-discovers org policy from the org `.github` repo unless `--no-policy` is set. Runs the 7 baseline checks: lockfile presence, ref consistency, deployed files present, no orphaned packages, MCP config consistency, content integrity (Unicode + hash drift on every deployed file including local content), includes consent (advisory). -- `--policy SOURCE` - *(Experimental)* Override discovery: `org` (auto-discover from org), file path, or URL. Without this flag, `--ci` auto-discovers. +- `--policy SOURCE` - *(Experimental)* Policy source. Accepts: `org` (auto-discover from your project's git remote), `owner/repo` (defaults to github.com), an `https://` URL, or a local file path. Used with `--ci` for policy checks. Without this flag, `--ci` auto-discovers. - `--no-policy` - Skip policy discovery and enforcement entirely. Equivalent to `APM_POLICY_DISABLE=1`. - `--no-cache` - Force fresh policy fetch (skip cache). Only relevant with policy discovery active. - `--no-fail-fast` - Run all checks even after a failure. By default, CI mode stops at the first failing check to save time. @@ -520,7 +520,7 @@ apm policy status [OPTIONS] ``` **Options:** -- `--policy-source SOURCE` - Override discovery: `org`, file path, or URL. Same shape as `apm install --policy`. +- `--policy-source SOURCE` - Override discovery. Accepts: `org` (auto-discover from your project's git remote), `owner/repo` (defaults to github.com), an `https://` URL, or a local file path. - `--no-cache` - Force fresh fetch (skip cache). - `--json` / `-o json` - Machine-readable output for SIEM ingestion or CI inspection. - `--check` - Exit non-zero (1) when no usable policy is found. Default is always 0; use `--check` for CI pre-checks. diff --git a/src/apm_cli/commands/audit.py b/src/apm_cli/commands/audit.py index fc02acb8..711a93fc 100644 --- a/src/apm_cli/commands/audit.py +++ b/src/apm_cli/commands/audit.py @@ -17,10 +17,11 @@ import click +from ..core.command_logger import CommandLogger from ..deps.lockfile import LockFile, get_lockfile_path +from ..policy._help_text import POLICY_SOURCE_FORMS_HELP from ..security.content_scanner import ContentScanner, ScanFinding from ..security.file_scanner import scan_lockfile_packages -from ..core.command_logger import CommandLogger from ..utils.console import ( _get_console, _rich_echo, @@ -437,7 +438,7 @@ def _render_ci_results(ci_result: "CIAuditResult") -> None: "policy_source", default=None, help=( - "Policy source: 'org' (auto-discover), file path, or URL. " + f"Policy source. {POLICY_SOURCE_FORMS_HELP} " "Used with --ci for policy checks. [experimental]" ), ) diff --git a/src/apm_cli/commands/policy.py b/src/apm_cli/commands/policy.py index cf0a0a2a..7a31f067 100644 --- a/src/apm_cli/commands/policy.py +++ b/src/apm_cli/commands/policy.py @@ -20,6 +20,7 @@ import click from ..core.command_logger import CommandLogger +from ..policy._help_text import POLICY_SOURCE_FORMS_HELP from ..policy.discovery import ( DEFAULT_CACHE_TTL, MAX_STALE_TTL, @@ -284,10 +285,7 @@ def policy(): "--policy-source", "policy_source", default=None, - help=( - "Override discovery: 'org', a local file path, owner/repo, " - "or an https URL." - ), + help=f"Override discovery. {POLICY_SOURCE_FORMS_HELP}", ) @click.option( "--no-cache", diff --git a/src/apm_cli/policy/_help_text.py b/src/apm_cli/policy/_help_text.py new file mode 100644 index 00000000..41b9292a --- /dev/null +++ b/src/apm_cli/policy/_help_text.py @@ -0,0 +1,18 @@ +"""Shared help text fragments for policy-related CLI commands. + +The canonical contract for what ``--policy`` / ``--policy-source`` accept +is enforced by ``discover_policy`` in ``discovery.py``. This module is the +single source of truth for the user-facing rendering of that contract, +shared by ``apm audit``, ``apm policy status``, and the consistency tests +that pin them together. + +Adding or removing a form here without a matching change in +``discover_policy`` (and an updated regression test in +``tests/unit/policy/test_help_consistency.py``) will fail CI. +""" + +POLICY_SOURCE_FORMS_HELP = ( + "Accepts: 'org' (auto-discover from your project's git remote), " + "'owner/repo' (defaults to github.com), an https:// URL, or a " + "local file path." +) diff --git a/src/apm_cli/policy/discovery.py b/src/apm_cli/policy/discovery.py index 37c02fa3..69894475 100644 --- a/src/apm_cli/policy/discovery.py +++ b/src/apm_cli/policy/discovery.py @@ -542,9 +542,17 @@ def discover_policy( Resolution order: 1. If policy_override is a local file path -> load from file - 2. If policy_override is a URL -> fetch from URL - 3. If policy_override is "org" -> auto-discover from org - 4. If policy_override is None -> auto-discover from org + 2. If policy_override is an https:// URL -> fetch from URL + (http:// is rejected for security) + 3. If policy_override is "org" -> auto-discover from project's git remote + 4. If policy_override is "owner/repo" (or "host/owner/repo") + -> fetch from that repo via GitHub Contents API + 5. If policy_override is None -> auto-discover from project's git remote + + The user-facing forms are documented in + ``apm_cli.policy._help_text.POLICY_SOURCE_FORMS_HELP``; that constant + is the single source of truth shared by ``apm audit --policy`` and + ``apm policy status --policy-source``. The optional ``expected_hash`` (``":"``) pins the leaf policy bytes; mismatches return ``outcome="hash_mismatch"`` and diff --git a/tests/unit/policy/test_help_consistency.py b/tests/unit/policy/test_help_consistency.py new file mode 100644 index 00000000..b91107c4 --- /dev/null +++ b/tests/unit/policy/test_help_consistency.py @@ -0,0 +1,155 @@ +"""Lockstep tests pinning the documented forms of ``--policy`` / ``--policy-source``. + +The forms accepted by ``discover_policy`` (the ground-truth parser in +``apm_cli.policy.discovery``) are mirrored in: + +- ``apm_cli.policy._help_text.POLICY_SOURCE_FORMS_HELP`` (Python constant) +- ``apm audit --policy`` Click help (uses the constant) +- ``apm policy status --policy-source`` Click help (uses the constant) +- ``docs/src/content/docs/reference/cli-commands.md`` (manual prose) + +If any of these drift, the tests below fail. See #998 for the underlying +incident that motivated this lockstep. +""" + +import re +from pathlib import Path + +from click.testing import CliRunner + +from apm_cli.policy._help_text import POLICY_SOURCE_FORMS_HELP + +# Canonical user-facing forms accepted by ``--policy`` / ``--policy-source``. +# Tokens chosen to be robust against Click's help-text reflow (no internal +# whitespace) and to uniquely identify each form. +EXPECTED_FORM_TOKENS = ("'org'", "owner/repo", "https://", "file path") + +# Same set of forms, written with the markdown backtick convention used in +# the docs (the docs render Click-style single quotes as inline code). +DOCS_FORM_TOKENS = ("`org`", "`owner/repo`", "`https://`", "file path") + +REPO_ROOT = Path(__file__).resolve().parents[3] +DOCS_PATH = ( + REPO_ROOT + / "docs" + / "src" + / "content" + / "docs" + / "reference" + / "cli-commands.md" +) + + +def _normalize_help_output(text: str) -> str: + """Collapse all whitespace runs to single spaces. + + Click reflows long help strings across terminal width; the constant + can land on word boundaries that get a newline + indent inserted. + Collapsing whitespace lets us search for canonical phrases without + having to anticipate every wrap point. + """ + return re.sub(r"\s+", " ", text) + + +def test_canonical_constant_lists_all_supported_forms(): + """The constant text mentions every form ``discover_policy`` accepts.""" + for token in EXPECTED_FORM_TOKENS: + assert token in POLICY_SOURCE_FORMS_HELP, ( + f"POLICY_SOURCE_FORMS_HELP missing canonical form: {token!r}. " + "If discover_policy stopped accepting this form, the change " + "is intentional and this test should be updated. Otherwise " + "the constant has drifted from the parser." + ) + + +def test_audit_policy_help_uses_canonical_constant(): + """``apm audit --help`` includes the canonical forms list.""" + from apm_cli.commands.audit import audit + + runner = CliRunner() + result = runner.invoke(audit, ["--help"]) + assert result.exit_code == 0, result.output + + output = _normalize_help_output(result.output) + for token in EXPECTED_FORM_TOKENS: + assert token in output, ( + f"`apm audit --help` missing canonical form: {token!r}. The " + "Click decorator may have stopped using " + "POLICY_SOURCE_FORMS_HELP." + ) + + +def test_policy_status_help_uses_canonical_constant(): + """``apm policy status --help`` includes the canonical forms list.""" + from apm_cli.commands.policy import policy + + runner = CliRunner() + result = runner.invoke(policy, ["status", "--help"]) + assert result.exit_code == 0, result.output + + output = _normalize_help_output(result.output) + for token in EXPECTED_FORM_TOKENS: + assert token in output, ( + f"`apm policy status --help` missing canonical form: {token!r}. " + "The Click decorator may have stopped using " + "POLICY_SOURCE_FORMS_HELP." + ) + + +def _bullet_starting_with(text: str, marker: str) -> str: + """Return the bullet line that begins with ``marker`` (up to next newline). + + Used to scope assertions to a specific flag's documentation bullet + instead of the whole docs file -- a form keyword may appear elsewhere + in cli-commands.md (e.g. unrelated marketplace examples), so a global + count is not strict enough to catch a removal from the bullet we + actually care about. + """ + idx = text.find(marker) + if idx < 0: + raise AssertionError( + f"Could not find bullet starting with {marker!r} in {DOCS_PATH.name}" + ) + end = text.find("\n", idx) + return text[idx:end] if end >= 0 else text[idx:] + + +def test_docs_audit_policy_bullet_lists_all_forms(): + """The ``apm audit --policy SOURCE`` doc bullet lists every canonical form.""" + text = DOCS_PATH.read_text(encoding="utf-8") + bullet = _bullet_starting_with(text, "- `--policy SOURCE`") + for token in DOCS_FORM_TOKENS: + assert token in bullet, ( + f"`apm audit --policy SOURCE` doc bullet missing form: {token!r}.\n" + f"Bullet text:\n {bullet}" + ) + + +def test_docs_policy_status_bullet_lists_all_forms(): + """The ``apm policy status --policy-source SOURCE`` bullet lists every canonical form.""" + text = DOCS_PATH.read_text(encoding="utf-8") + bullet = _bullet_starting_with(text, "- `--policy-source SOURCE`") + for token in DOCS_FORM_TOKENS: + assert token in bullet, ( + f"`apm policy status --policy-source SOURCE` doc bullet missing form: {token!r}.\n" + f"Bullet text:\n {bullet}" + ) + + +def test_no_broken_install_policy_cross_reference_anywhere_in_docs(): + """Regression guard for #994: no doc page may reference ``apm install --policy``. + + ``apm install`` has no ``--policy`` flag (only ``--no-policy``). Any + cross-reference to ``apm install --policy`` is a broken pointer. + Scoped to the entire ``docs/`` tree (not just cli-commands.md) so a + future copy-paste into another docs page is also caught. + """ + docs_root = REPO_ROOT / "docs" + offenders = [] + for md_path in docs_root.rglob("*.md"): + if "apm install --policy" in md_path.read_text(encoding="utf-8"): + offenders.append(md_path.relative_to(REPO_ROOT)) + assert not offenders, ( + "Found broken reference to `apm install --policy` (no such flag) " + f"in: {[str(p) for p in offenders]}. See #994." + )