Skip to content

Commit 8f76d69

Browse files
fix(policy): classify '=1.2.3' explicit-equality as pinned constraint (#1506)
* fix(policy): classify '=1.2.3' explicit-equality as pinned constraint The constraint classifier in '_constraint_pinning.py' relied on 'is_semver_range' from 'apm_cli.deps.registry.semver' to recognise valid semver ranges. That helper's '_RANGE_OPERATORS' tuple omitted the '=' prefix, so any user who wrote the npm- and cargo-style explicit-equality form ('=1.2.3') in 'apm.yml' got the constraint mis-classified as BARE_BRANCH. Under 'policy.dependencies.require_ pinned_constraint: true', the install was blocked with a confusing "bare branch '=1.2.3' tracks a moving tip" diagnostic. Fix: teach both 'deps/registry/semver.py' (parse-time gate) and 'marketplace/semver.py' (runtime range matcher) to accept '=X.Y.Z' as an exact pin. The classifier then flows through the existing semver-range probe and returns None (pinned) for '=1.2.3', '=1.2.3-beta.1', '=0.0.1', etc. Scope decision: - Accept: bare '1.2.3' and '=1.2.3' (npm / cargo precedent; cargo treats '=1.2.3' as the stricter explicit pin). - Reject: '==1.2.3' (pip-style is not part of node-semver; users who write it get a clear violation pointing at the supported form rather than silent acceptance of the wrong dialect). Regression traps: - tests/unit/policy: 5 parametrised cases plus a registry-source case and a '==' rejection case. - tests/unit/registry: '=1.2.3' / '=0.0.1' / '=1.2.3-beta.1' added to the accepted-ranges parametrize; '==1.2.3' / '=garbage' / '=1.2' added to the rejection set. - tests/unit/marketplace: 'satisfies_range' positive + prerelease + invalid-spec cases for the '=' operator. - tests/integration/policy: existing 'test_bare_exact_version_does _not_trigger_block' extended to include '=1.2.3' alongside '1.2.3'; the documented '=1.2.3 is a known gap' caveat is removed. Mutation-break verified: deleting '=' from '_RANGE_OPERATORS' fails the unit + e2e regression traps; deleting the '=' branch in 'marketplace/semver.py' fails the satisfies_range trap. Follow-up to #1505 (cannot fold; #1505 already merged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document =1.2.3 explicit-equality pin form Fold panel-recommended follow-ups into the same PR: - reference/policy-schema.md: add =1.5.3 OK example and ==1.5.3 FAIL example - consumer/manage-dependencies.md: add registry semver constraint table with explicit note that pip-style == is unsupported - apm-usage/governance.md: name =1.2.3 alongside bare 1.2.3 in the pinned-constraint remediation column - CHANGELOG.md: normalise spelling (recognised -> recognized) for consistency with surrounding entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 61fe506 commit 8f76d69

11 files changed

Lines changed: 111 additions & 12 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,4 @@ scout-pipeline-result.png
8080
server.pid
8181
.docs-rewrite-plan/
8282
build/apm-*/
83+
copilot-scratch/

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- `apm deps why <package>` explains why a transitive dependency is installed by walking the lockfile's `resolved_by` chain back to the user's direct declaration in `apm.yml`. Supports `--global` for user-scope lockfiles and `--json` for scriptable output (JSON to stdout, all logs to stderr; analogue of `npm why` / `yarn why`). Exits `0` on success, `1` when the package isn't installed or the query is ambiguous, `2` when no lockfile exists. (#1490)
1313

14+
### Fixed
15+
16+
- `policy.dependencies.require_pinned_constraint: true` no longer misclassifies the npm- and cargo-style explicit-equality form `=1.2.3` as `BARE_BRANCH`. Both `1.2.3` and `=1.2.3` are now recognized as pinned constraints; the pip-style `==1.2.3` form is still rejected (not part of node-semver). Follow-up to #1494 / #1505.
17+
1418
## [0.15.0] - 2026-05-27
1519

1620
### Security

docs/src/content/docs/consumer/manage-dependencies.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ dependencies:
138138
- acme/playbooks#a1b2c3d4e5f6... # SHA (immutable)
139139
```
140140

141+
For registry-sourced deps or when `policy.dependencies.require_pinned_constraint: true` is on, the ref slot also accepts semver constraints:
142+
143+
| Form | Example | Meaning |
144+
|---|---|---|
145+
| Bare exact | `owner/repo#1.2.3` | Pinned to exactly 1.2.3. |
146+
| Explicit equality | `owner/repo#=1.2.3` | Same as bare exact (npm- and cargo-style). |
147+
| Caret range | `owner/repo#^1.2.3` | `>=1.2.3, <2.0.0`. |
148+
| Tilde range | `owner/repo#~1.2.3` | `>=1.2.3, <1.3.0`. |
149+
| Bounded range | `owner/repo#>=1.2.0 <2.0.0` | Explicit lower and upper bound. |
150+
151+
Pip-style `==1.2.3` is not part of the node-semver grammar APM follows and is rejected as an unbounded ref under `require_pinned_constraint`. Use `=1.2.3` or the bare form instead.
152+
141153
Branches move; tags and SHAs do not. For reproducibility, prefer tags or
142154
SHAs. The lockfile pins the resolved commit either way, so two clones
143155
running `apm install` get the same bytes -- but a branch ref will resolve

docs/src/content/docs/reference/policy-schema.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ dependencies:
8888
- acme/lib#main # FAIL: bare branch (BARE_BRANCH)
8989
- fourth/lib#^1.2.0 # OK: caret range
9090
- fifth/lib#~1.2.3 # OK: tilde range
91-
- sixth/lib#1.5.3 # OK: exact version
91+
- sixth/lib#1.5.3 # OK: exact version (bare)
92+
- sixth_eq/lib#=1.5.3 # OK: exact version (npm/cargo explicit equality)
93+
- sixth_pip/lib#==1.5.3 # FAIL: pip-style operator not supported (BARE_BRANCH)
9294
- seventh/lib#v1.5.3 # OK: literal tag
9395
- eighth/lib#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # OK: SHA
9496
- ./packages/local # OK: local-path dep (no version surface)

packages/apm-guide/.apm/skills/apm-usage/governance.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ Violation classes:
384384
| `denylist` | `dependencies.deny` match | Remove dep from `apm.yml`, request org-policy update, or `--no-policy` for one-off bypass |
385385
| `allowlist` | Dep not in non-empty `dependencies.allow` | Add to org allowlist or switch to an approved package |
386386
| `required` | Missing `dependencies.require` entry, or version-pin mismatch | Add the dep (and pin) to `apm.yml`. Pin mismatches downgrade to warn under `require_resolution: project-wins`; missing required deps still block |
387-
| `pinned-constraint` | `dependencies.require_pinned_constraint: true` + a direct dep with no ref, a wildcard, a bare branch, or a bare `>=X.Y` | Pin the dep to an exact version, caret/tilde/bounded semver range, literal `vX.Y.Z` tag, or a full SHA. Roll out enforcement with `warn` before `block`. |
387+
| `pinned-constraint` | `dependencies.require_pinned_constraint: true` + a direct dep with no ref, a wildcard, a bare branch, or a bare `>=X.Y` | Pin the dep to an exact version (`1.2.3` or npm/cargo-style `=1.2.3`; pip-style `==1.2.3` is not supported), caret/tilde/bounded semver range, literal `vX.Y.Z` tag, or a full SHA. Roll out enforcement with `warn` before `block`. |
388388
| `transport` | MCP transport not in `mcp.transport.allow` | Switch transport, or request `mcp.transport.allow` update |
389389
| `target` | Resolved target not in `compilation.target.allow` (or violates `target.enforce`) | Re-run with `--target <allowed>`, or adjust `compilation.target` in `apm.yml` |
390390
| `transitive_mcp` | MCP server pulled in by a transitive dep, blocked by `mcp.deny` / `transport` / `self_defined` | Remove offending dep, request policy update, or set `mcp.trust_transitive: true` |

src/apm_cli/deps/registry/semver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from apm_cli.marketplace.semver import SemVer, parse_semver, satisfies_range
1414

15-
_RANGE_OPERATORS = (">=", "<=", ">", "<", "^", "~")
15+
_RANGE_OPERATORS = (">=", "<=", ">", "<", "^", "~", "=")
1616
_WILDCARD_RE = re.compile(r"^\d+\.\d+\.[xX*]$")
1717

1818

src/apm_cli/marketplace/semver.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,21 @@ def _satisfies_single(version: SemVer, spec: str) -> bool:
215215
base = parse_semver(spec[1:])
216216
return base is not None and version < base
217217

218+
# Explicit-equality operator (npm/cargo style): =1.2.3 := exact 1.2.3.
219+
# APM follows the node-semver grammar, so pip-style ``==X.Y.Z`` is
220+
# NOT recognised; users who write ``==1.2.3`` get a parse-time
221+
# rejection via ``is_semver_range`` (see deps/registry/semver.py).
222+
if spec.startswith("=") and not spec.startswith("=="):
223+
base = parse_semver(spec[1:])
224+
if base is None:
225+
return False
226+
return (
227+
version.major == base.major
228+
and version.minor == base.minor
229+
and version.patch == base.patch
230+
and version.prerelease == base.prerelease
231+
)
232+
218233
# Wildcard: 1.2.x or 1.2.*
219234
wildcard_match = re.match(r"^(\d+)\.(\d+)\.[xX*]$", spec)
220235
if wildcard_match:

tests/integration/policy/test_require_pinned_constraint_e2e.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,18 +158,18 @@ def test_caret_range_does_not_trigger_block(
158158
def test_bare_exact_version_does_not_trigger_block(
159159
self, mock_gate, mock_preflight, mock_dl, mock_updates, project
160160
):
161-
# NOTE: The bare-version form ``1.2.3`` is the documented
162-
# "exact version" pinning form (see #1494 commit message and
163-
# tests/unit/policy/test_pinned_constraint.py). The ``=1.2.3``
164-
# alternate form is NOT currently recognized as pinned -- the
165-
# classifier in ``_constraint_pinning.py`` treats it as a
166-
# BARE_BRANCH. Documenting that in this test by using only
167-
# the documented form; flagging the ``=1.2.3`` gap in the PR
168-
# body for a separate UX issue.
161+
# Covers both pin shapes the classifier accepts:
162+
# * ``1.2.3`` -- bare exact version
163+
# * ``=1.2.3`` -- npm/cargo-style explicit-equality
164+
# Both must pass the gate under enforcement=block +
165+
# require_pinned_constraint=true. The ``=1.2.3`` half is the
166+
# regression trap for the bug observed in PR #1505: before the
167+
# fix, ``_constraint_pinning.py`` mis-classified ``=1.2.3`` as
168+
# ``BARE_BRANCH`` and blocked the install.
169169
project_dir, runner = project
170170
_write_apm_yml(
171171
project_dir / "apm.yml",
172-
deps=["test-org/skills#1.2.3"],
172+
deps=["test-org/skills#1.2.3", "test-org/other#=1.2.3"],
173173
)
174174
fetch = _fetch(_load_fixture("apm-policy-block.yml"))
175175
mock_gate.return_value = fetch
@@ -180,6 +180,7 @@ def test_bare_exact_version_does_not_trigger_block(
180180
out = result.output.lower()
181181
assert "blocked by org policy" not in out, result.output
182182
assert "unbounded constraint" not in out, result.output
183+
assert "bare branch" not in out, result.output
183184

184185

185186
# ---------------------------------------------------------------------------

tests/unit/marketplace/test_semver.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,22 @@ def test_lt(self) -> None:
226226
assert satisfies_range(parse_semver("0.9.0"), "<1.0.0") # type: ignore[arg-type]
227227
assert not satisfies_range(parse_semver("1.0.0"), "<1.0.0") # type: ignore[arg-type]
228228

229+
# -- Explicit-equality operator (=X.Y.Z): npm / cargo style --
230+
231+
def test_eq_exact(self) -> None:
232+
assert satisfies_range(parse_semver("1.2.3"), "=1.2.3") # type: ignore[arg-type]
233+
assert not satisfies_range(parse_semver("1.2.4"), "=1.2.3") # type: ignore[arg-type]
234+
assert not satisfies_range(parse_semver("1.2.2"), "=1.2.3") # type: ignore[arg-type]
235+
236+
def test_eq_prerelease(self) -> None:
237+
assert satisfies_range(parse_semver("1.2.3-beta.1"), "=1.2.3-beta.1") # type: ignore[arg-type]
238+
assert not satisfies_range(parse_semver("1.2.3"), "=1.2.3-beta.1") # type: ignore[arg-type]
239+
240+
def test_eq_invalid_spec(self) -> None:
241+
sv = parse_semver("1.0.0")
242+
assert sv is not None
243+
assert not satisfies_range(sv, "=garbage")
244+
229245
def test_gt_invalid_spec(self) -> None:
230246
sv = parse_semver("1.0.0")
231247
assert sv is not None

tests/unit/policy/test_pinned_constraint.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,42 @@ def test_classify_pinned_specs_return_none(spec):
129129
assert classify_unbounded_reason(_dep(spec)) is None
130130

131131

132+
# Regression trap for the ``=1.2.3`` alternate exact-version form
133+
# (npm- and cargo-style "explicit equality"). Before the fix shipped
134+
# alongside this test, ``_constraint_pinning.py`` mis-classified these
135+
# as ``BARE_BRANCH`` because ``is_semver_range`` rejected the leading
136+
# ``=`` operator, and ``require_pinned_constraint: true`` would block
137+
# the install with a confusing branch-name diagnostic.
138+
@pytest.mark.parametrize(
139+
"spec",
140+
[
141+
"=1.2.3",
142+
"=0.0.1",
143+
"=1.2.3-beta.1",
144+
"=1.2.3+build.42",
145+
],
146+
)
147+
def test_equals_prefix_exact_version_classified_as_pinned(spec):
148+
assert classify_unbounded_reason(_dep(spec)) is None
149+
assert is_pinned_constraint(_dep(spec)) is True
150+
151+
152+
def test_equals_prefix_exact_version_pinned_on_registry_source():
153+
# Same contract for registry-routed deps: ``=1.2.3`` is a pin, not
154+
# an unbounded constraint.
155+
assert classify_unbounded_reason(_dep("=1.2.3", source="registry")) is None
156+
157+
158+
def test_double_equals_prefix_rejected_as_bare_branch():
159+
# APM follows the npm/cargo semver grammar where ``=`` is the
160+
# explicit-equality operator. The pip-style ``==`` form is NOT
161+
# part of node-semver and is intentionally not recognised; it
162+
# falls through to ``BARE_BRANCH`` so that a user who wrote
163+
# ``==1.2.3`` gets a violation that points them at the supported
164+
# syntax instead of silently accepting the wrong dialect.
165+
assert classify_unbounded_reason(_dep("==1.2.3")) is UnboundedReason.BARE_BRANCH
166+
167+
132168
def test_classify_caret_range_returns_pinned_none():
133169
assert classify_unbounded_reason(_dep("^1.2.3")) is None
134170

0 commit comments

Comments
 (0)