Skip to content

fix(policy): classify '=1.2.3' explicit-equality as pinned constraint#1506

Merged
danielmeppiel merged 3 commits into
mainfrom
fix/policy-equals-prefix-pinned
May 27, 2026
Merged

fix(policy): classify '=1.2.3' explicit-equality as pinned constraint#1506
danielmeppiel merged 3 commits into
mainfrom
fix/policy-equals-prefix-pinned

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

TL;DR

policy.dependencies.require_pinned_constraint: true (shipped in #1494) treated the npm- and cargo-style explicit-equality form =1.2.3 as BARE_BRANCH, blocking the install with a confusing "bare branch '=1.2.3' tracks a moving tip" diagnostic. This PR teaches the semver parser and the runtime matcher to accept =X.Y.Z as an exact pin, so users get the behaviour the #1494 commit message promised ("exact versions" among the pinned forms). Pip-style ==1.2.3 stays rejected so users do not silently get the wrong dialect.

Problem (WHY)

  • The constraint classifier in _constraint_pinning.py defers to is_semver_range from apm_cli.deps.registry.semver to decide whether a ref is a valid semver range. That helper's _RANGE_OPERATORS tuple omitted the = prefix, so =1.2.3 failed the parse-time gate and fell through to the BARE_BRANCH bucket.
  • The e2e agent that opened test(policy): e2e integration tests for require_pinned_constraint (follow-up to #1494) #1505 observed this directly and documented it as a known gap in tests/integration/policy/test_require_pinned_constraint_e2e.py:

    "The =1.2.3 alternate form is NOT currently recognized as pinned -- the classifier in _constraint_pinning.py treats it as a BARE_BRANCH."

  • Reference precedent in popular package managers: npm treats pkg@=1.2.3 and pkg@1.2.3 as equivalent (node-semver). Cargo treats =1.2.3 as the stricter explicit pin (bare 1.2.3 is implicitly caret-equivalent). Pip alone uses ==1.2.3 and rejects =1.2.3. APM follows the node-semver grammar.
  • Net effect before this fix: a user who wrote dep: =1.2.3 in apm.yml with require_pinned_constraint: true got an install block citing a "bare branch" their ref clearly is not.

Approach (WHAT)

Decision Choice Why
Accept =1.2.3 as a pin? Yes Matches npm + cargo precedent; user-friendly
Accept ==1.2.3 as a pin? No Pip-style; not in node-semver grammar APM follows. Reject loudly so users see a violation pointing at the supported form rather than silent acceptance of the wrong dialect
Where to add the operator? Both layers Parse-time gate (deps/registry/semver.py) so the classifier sees it as a range; runtime matcher (marketplace/semver.py) so resolution can match versions

Implementation (HOW)

File Change
src/apm_cli/deps/registry/semver.py Add "=" to _RANGE_OPERATORS. The existing _is_range_component loop strips the operator and validates the suffix via parse_semver, so =1.2.3 becomes a valid range, =garbage stays invalid, and ==1.2.3 is rejected because parse_semver("=1.2.3") returns None.
src/apm_cli/marketplace/semver.py Add an = branch in _satisfies_single that parses the suffix and matches the exact (major, minor, patch, prerelease) tuple, mirroring the existing "Exact match" fallback semantics. Guarded with not spec.startswith("==") so the pip-style form falls through to the invalid path.
src/apm_cli/policy/_constraint_pinning.py No change. Once is_semver_range("=1.2.3") returns True, the existing _classify_range single-component branch returns None (pinned) because =1.2.3 does not start with >= or >.

Diagram

Legend: classification flow for a constraint string. Bold node is the path that was previously broken for =1.2.3 and is now reachable.

flowchart LR
    A["dep ref =1.2.3"] --> B{"is_semver_range(spec)"}
    B -->|"True (after fix)"| C["_classify_range(spec)"]
    B -->|"False (before fix)"| D["BARE_BRANCH (incorrect)"]
    C --> E{"starts with '>=' or '>'?"}
    E -->|"No"| F["return None - pinned"]
    E -->|"Yes"| G["OPEN_UPPER / GREATER_THAN_ONLY"]
Loading

Trade-offs

  • Accepting = but rejecting == could surprise users coming from pip. The error message will say "bare branch" until the diagnostic gets its own follow-up; opted not to bundle that with the correctness fix.
  • No new operator for inverse-equality (!=). Cargo supports it; npm does not. APM follows npm here, deferring the question until a real user asks.
  • Two-file fix instead of one. Adding = only to the classifier would have made the policy gate accept =1.2.3 while resolution silently failed to find a matching version. Both layers must agree on the grammar.

Benefits

  1. Users who follow npm/cargo muscle memory (=1.2.3) no longer get blocked by a policy whose own commit message advertised exact versions as pinned.
  2. The _constraint_pinning.py contract advertised in the module docstring ("Exact semver versions (1.2.3)") is now honoured for both spellings of an exact version.
  3. Removes a documented test caveat in the test(policy): e2e integration tests for require_pinned_constraint (follow-up to #1494) #1505 e2e file -- the "known gap" comment is gone, replaced by the regression trap.
  4. Mutation-break verified: deleting either half of the fix fails a test that points at the deletion.

Validation

All four lint guards pass silently and the targeted test set is green.

Lint chain (CI-mirror, all silent / exit 0)
$ uv run --extra dev ruff check src/ tests/
All checks passed!

$ uv run --extra dev ruff format --check src/ tests/
1107 files already formatted

$ uv run --extra dev python -m pylint --disable=all --enable=R0801 \
    --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

$ bash scripts/lint-auth-signals.sh
[+] auth-signal lint clean
Targeted test run
$ uv run --extra dev pytest tests/unit/policy tests/unit/registry \
    tests/unit/marketplace/test_semver.py tests/integration/policy \
    -q --tb=line -k "pin or constraint or semver or registry"
421 passed, 658 deselected, 3 xfailed in 10.74s

Scenario evidence

User-promise scenario Test proving it APM principle
require_pinned_constraint: true accepts dep: =1.2.3 and does not block install tests/integration/policy/test_require_pinned_constraint_e2e.py::TestPromiseBPinnedDepPassesPolicyGate::test_bare_exact_version_does_not_trigger_block (extended to include =1.2.3 alongside 1.2.3) Deterministic policy gates
Classifier recognises =X.Y.Z, =X.Y.Z-prerelease, =X.Y.Z+build as pinned for both git-source and registry-source deps tests/unit/policy/test_pinned_constraint.py::test_equals_prefix_exact_version_classified_as_pinned + ::test_equals_prefix_exact_version_pinned_on_registry_source Source-agnostic shape decides
Pip-style ==1.2.3 still falls through to BARE_BRANCH so users see a violation pointing at the supported form tests/unit/policy/test_pinned_constraint.py::test_double_equals_prefix_rejected_as_bare_branch Cite-or-omit: do not silently accept a foreign dialect
satisfies_range matches =1.2.3 against 1.2.3 (and not 1.2.4, not 1.2.3-beta.1) tests/unit/marketplace/test_semver.py::TestSatisfiesRange::test_eq_exact + ::test_eq_prerelease Runtime matcher honours parse-time grammar
Parse-time gate accepts =1.2.3 and rejects ==1.2.3 / =garbage / =1.2 tests/unit/registry/test_semver.py::TestIsSemverRange::test_accepts_valid_ranges + ::test_rejects_invalid_refs One canonical semver grammar across the codebase

Mutation-break evidence

Deletion Test that fails
Remove "=" from _RANGE_OPERATORS in deps/registry/semver.py 8 tests fail: 4x test_equals_prefix_exact_version_classified_as_pinned, 3x test_accepts_valid_ranges[=...], 1x test_bare_exact_version_does_not_trigger_block
Remove the = branch in marketplace/semver.py::_satisfies_single 2 tests fail: test_eq_exact, test_eq_prerelease

How to test

  1. git fetch && git checkout fix/policy-equals-prefix-pinned
  2. uv run --extra dev pytest tests/unit/policy/test_pinned_constraint.py tests/unit/registry/test_semver.py tests/unit/marketplace/test_semver.py tests/integration/policy/test_require_pinned_constraint_e2e.py -q -- expect all green
  3. Create a sample project with dependencies.apm: ["owner/repo#=1.2.3"] and an org policy with enforcement: block + dependencies.require_pinned_constraint: true; run apm install and confirm the policy gate does NOT abort with "bare branch '=1.2.3'"
  4. Same as (3) but with ==1.2.3 -- confirm the install IS blocked and the diagnostic still points at the offending dep

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

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>
Copilot AI review requested due to automatic review settings May 27, 2026 12:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a UX gap in the require_pinned_constraint policy (shipped in #1494): the npm/cargo-style explicit-equality form =1.2.3 was misclassified as BARE_BRANCH because is_semver_range did not recognise the = operator. This PR teaches both the parse-time gate and the runtime matcher to accept =X.Y.Z as an exact pin while still rejecting the pip-style ==1.2.3 (which is not part of the node-semver grammar APM follows).

Changes:

  • Add "=" to _RANGE_OPERATORS in deps/registry/semver.py so =1.2.3 passes the syntax gate (and ==1.2.3 still fails because parse_semver("=1.2.3") is None).
  • Add an = branch to _satisfies_single in marketplace/semver.py guarded by not spec.startswith("=="), mirroring exact-match semantics.
  • Regression coverage in registry, marketplace, policy, and policy e2e tests; CHANGELOG entry.
Show a summary per file
File Description
src/apm_cli/deps/registry/semver.py Adds = to the recognised range operators so is_semver_range("=1.2.3") returns True.
src/apm_cli/marketplace/semver.py Adds explicit-equality branch in _satisfies_single; rejects == prefix.
tests/unit/registry/test_semver.py Accepts =1.2.3 variants; rejects ==1.2.3, =garbage, =1.2.
tests/unit/marketplace/test_semver.py Adds test_eq_exact, test_eq_prerelease, test_eq_invalid_spec.
tests/unit/policy/test_pinned_constraint.py Classifier tests for =X.Y.Z, registry-source, and ==1.2.3 rejection.
tests/integration/policy/test_require_pinned_constraint_e2e.py Extends bare-exact e2e to also assert =1.2.3 passes the gate.
CHANGELOG.md Unreleased Fixed entry documenting the behaviour change.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 0

@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

APM Review Panel: ship_now

Fixes policy.dependencies.require_pinned_constraint mis-classifying npm/cargo-style =1.2.3 as BARE_BRANCH, unblocking installs that already satisfy the pinning rule -- backed by 4-tier test coverage and mutation-break verification.

cc @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

All eight panelists converge on the same signal: this is a surgical, well-tested correctness fix with zero security surface change. The python-architect confirms the two-layer operator-table approach is architecturally consistent; supply-chain-security confirms no new attack surface; test-coverage-expert confirms the 4-tier suite (parse gate, runtime matcher, classifier unit, e2e integration) leaves no gap. No panelist raised a blocking or required finding.

The only substantive follow-up theme is doc-drift: three independent call sites (policy-schema.md, manage-dependencies.md, governance.md) now enumerate pinned forms without mentioning the newly-accepted =1.2.3 syntax. The devx-ux-expert and oss-growth-hacker converge on the same manage-dependencies.md gap; the doc-writer names the policy-schema.md and governance.md mirrors. These are real but do not gate a correctness fix -- they are post-merge documentation work.

The cli-logging and devx-ux experts both note that pip-style ==1.2.3 yields a misleading 'bare branch' diagnostic. This is pre-existing behaviour cemented (not introduced) by this PR; a follow-up issue with the suggested UNSUPPORTED_OPERATOR variant is the right vehicle.

Aligned with: governed by policy (require_pinned_constraint now correctly honours its own contract: =1.2.3 is an explicit equality pin and the classifier recognises it as such); pragmatic as npm (matches npm/cargo operator grammar precedent; users who think in node-semver get the expected result without learning APM-specific syntax); oss community driven (bug was discovered by the e2e test-coverage agent during PR #1505 review and fixed in the same session -- the review-panel toolchain eating its own dogfood).

Growth signal. This PR is concrete evidence the review-panel pays for itself: the test-coverage agent in PR #1505 surfaced the =1.2.3 gap, and the fix landed in one session. Story angle for the next release digest: 'our own CI agents find and fix ecosystem-familiarity gaps before users hit them' -- reinforces the AI-native positioning without requiring any external proof point.

Panel summary

Persona B R N Takeaway
Python Architect 0 0 1 Surgical 2-layer fix is architecturally sound; operator tables are correctly layered across parse-time gate and runtime matcher.
CLI Logging Expert 0 0 1 No CLI output surfaces touched; fix operates at semver-parse layer. Deferral of ==X.Y.Z diagnostic wording is acceptable.
DevX UX Expert 0 2 1 Fix is sound and matches npm/cargo mental model; recommend documenting =X.Y.Z in the pin-forms section and improving the ==X.Y.Z diagnostic.
Supply Chain Security Expert 0 0 0 Pure string-grammar change with no security implications; no findings.
OSS Growth Hacker 0 0 1 Clean bug fix reinforcing ecosystem-familiarity positioning; CHANGELOG entry is polished and release-ready. No adoption blockers.
Doc Writer 0 1 1 CHANGELOG entry is well-formed; one doc-drift finding: policy-schema.md enumerates pinned forms without =1.2.3.
Test Coverage Expert 0 0 0 4-tier coverage (parse gate, runtime matcher, classifier, e2e) with mutation-break reasoning confirmed; no gaps.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Doc Writer] Add =1.2.3 to the pinned-forms table in policy-schema.md (line 91) and mirror in governance.md (line 387); note pip-style == stays rejected. -- Post-merge a reader consulting the reference cannot tell that =1.2.3 is accepted. Two call sites drift from runtime behaviour.
  2. [DevX UX Expert] Add a constraint-grammar row/note in manage-dependencies.md listing bare, =, ^, ~ forms with an explicit note that pip-style == is unsupported. -- Consumer discoverability: the page is the first place a user looks to learn how to pin, and it only shows tag/SHA examples today. Growth-hacker converges on same gap.
  3. [DevX UX Expert] Improve ==X.Y.Z diagnostic: replace misleading 'bare branch' wording with an UNSUPPORTED_OPERATOR hint suggesting =1.2.3 or bare 1.2.3. -- Pip users writing ==1.2.3 get told their version spec is a 'branch that tracks a moving tip' -- wrong mental model, no recovery path. Pre-existing but cemented by this PR.
  4. [Python Architect] Add a one-line comment documenting the longest-prefix-first ordering invariant on _RANGE_OPERATORS. -- Future maintainer clarity; implicit contract that could be broken by a naive alphabetical sort. Marginal value but low cost.
  5. [Doc Writer] Normalise CHANGELOG spelling: 'recognised' -> 'recognized' for US-English consistency with surrounding entries. -- One-character consistency edit; trivial but noted for completeness.

Recommendation

Merge as-is. The fix is surgical, comprehensive tests with mutation-break verification confirm correctness, all CI is green, and no panelist raised a blocking finding. Open a single follow-up issue to batch the three doc-drift sites (policy-schema.md, manage-dependencies.md, governance.md) and the ==X.Y.Z diagnostic wording improvement; these are post-merge polish that should not delay a correctness fix reaching users.


Full per-persona findings

Python Architect

  • [nit] _RANGE_OPERATORS ordering relies on implicit longest-prefix-first contract at src/apm_cli/deps/registry/semver.py:15
    Ordering invariant (multi-char ops before single-char prefixes) is implicit; established pattern in the file, but a one-line comment would be marginal value.
    Suggested: Add a comment documenting the ordering invariant on _RANGE_OPERATORS.

CLI Logging Expert

  • [nit] Follow-up: improve diagnostic wording when ==X.Y.Z falls through to BARE_BRANCH at src/apm_cli/policy/_constraint_pinning.py
    User who writes ==1.2.3 gets 'bare branch ==1.2.3 tracks a moving tip' -- misleading wording but install is correctly blocked. Improving requires touching humanize_reason; reasonable follow-up.

DevX UX Expert

  • [recommended] manage-dependencies.md 'Pin a version' section does not mention =X.Y.Z as a valid exact-pin form at docs/src/content/docs/consumer/manage-dependencies.md:128
    Discoverability: a user reading docs to learn how to pin will only find tag/SHA examples; registry constraint grammar (=, ^, ~, bare) is undocumented in any consumer-facing page.
    Suggested: Add a row/note listing bare 1.2.3, =1.2.3, ^1.2.3, ~1.2.3 with the explicit note that pip-style == is not supported.
  • [recommended] A pip user writing ==1.2.3 gets misleading 'bare branch' diagnostic with no recovery hint at src/apm_cli/policy/_constraint_pinning.py:197
    Recovery: the error names the wrong mental model (branch) for something that is clearly a version-spec attempt. Pre-existing but cemented by this PR's intentional == rejection.
    Suggested: Add an UNSUPPORTED_OPERATOR variant or special-case format_unbounded_hint for /^==\d/: 'unsupported operator ==; use =1.2.3 or bare 1.2.3 for exact pinning'.
  • [nit] CHANGELOG entry is well-written and user-readable; no action needed
    Names the symptom (BARE_BRANCH mis-classification), the fix (both forms accepted), and the boundary (pip == still rejected).

Supply Chain Security Expert

No findings.

OSS Growth Hacker

  • [nit] Docs 'Reference formats' table and 'Pin a version' section omit the =X.Y.Z form now accepted by the classifier. at docs/src/content/docs/consumer/manage-dependencies.md:56
    manage-dependencies.md line 56 lists tag/SHA examples but never =1.2.3. Small friction; the whole point of this PR is to reduce that friction, so docs should match.
    Suggested: Add a Reference-formats row: '| Explicit equality | owner/repo#=1.2.3 | npm/cargo-style exact pin (equivalent to bare 1.2.3). |'.

Auth Expert -- inactive

PR touches only semver parsing, policy tests, and CHANGELOG -- no auth, token, credential, or host-classification surface.

Doc Writer

  • [recommended] policy-schema.md reference table enumerates pinned forms but omits the newly-accepted '=1.2.3' explicit-equality form. at docs/src/content/docs/reference/policy-schema.md:91
    docs/src/content/docs/reference/policy-schema.md lines 79-95 shows exact/caret/tilde/tag/SHA as OK but not =1.2.3. Same omission at packages/apm-guide/.apm/skills/apm-usage/governance.md line 387. Post-merge a reader cannot tell that =1.2.3 is accepted.
    Suggested: Add an OK-group line for =1.2.3 (npm/cargo explicit-equality); mirror in apm-usage/governance.md; explicitly note pip-style ==1.2.3 stays rejected.
  • [nit] CHANGELOG entry uses 'recognised' (British); surrounding entries lean US 'recognized'. at CHANGELOG.md:11
    One-character consistency edit.

Test Coverage Expert

No findings.

This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.

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>
@danielmeppiel danielmeppiel force-pushed the fix/policy-equals-prefix-pinned branch from 1c200bf to 92c974a Compare May 27, 2026 12:40
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Panel follow-ups folded in. Commit 92c974aa addresses the curated doc-drift items from the advisory pass:

  • docs/src/content/docs/reference/policy-schema.md: added =1.5.3 OK example alongside bare 1.5.3, and an explicit ==1.5.3 FAIL row so readers see the boundary.
  • docs/src/content/docs/consumer/manage-dependencies.md: added a registry-constraint grammar table (bare, =, ^, ~, bounded range) under "Pin a version", with an explicit note that pip-style ==1.2.3 is unsupported.
  • packages/apm-guide/.apm/skills/apm-usage/governance.md: named =1.2.3 alongside bare 1.2.3 in the pinned-constraint remediation column.
  • CHANGELOG.md: normalized spelling (recognised -> recognized) for consistency with surrounding entries.

Deferred to follow-up (out of scope for a correctness fix, per the panel's own framing): the ==X.Y.Z diagnostic-wording improvement and the _RANGE_OPERATORS ordering-invariant comment.

Validation evidence (latest push, commit 92c974aa):

  • Lint chain locally clean: ruff check, ruff format --check both silent.
  • All required CI checks green: CI (Build & Test) success, CodeQL success, Merge Gate success, NOTICE Drift Check success, license/cla pass.
  • Test suite unchanged (no src/ deltas in this commit); the original 4-tier fix coverage from commit 3f2e4ee8 is untouched.

Ready to merge.

@danielmeppiel danielmeppiel merged commit 8f76d69 into main May 27, 2026
14 checks passed
@danielmeppiel danielmeppiel deleted the fix/policy-equals-prefix-pinned branch May 27, 2026 13:50
@danielmeppiel danielmeppiel mentioned this pull request May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants