Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/commands/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]"
),
)
Expand Down
6 changes: 2 additions & 4 deletions src/apm_cli/commands/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/apm_cli/policy/_help_text.py
Original file line number Diff line number Diff line change
@@ -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."
)
14 changes: 11 additions & 3 deletions src/apm_cli/policy/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`` (``"<algo>:<hex>"``) pins the leaf
policy bytes; mismatches return ``outcome="hash_mismatch"`` and
Expand Down
155 changes: 155 additions & 0 deletions tests/unit/policy/test_help_consistency.py
Original file line number Diff line number Diff line change
@@ -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."
)
Loading