fix(policy): classify '=1.2.3' explicit-equality as pinned constraint#1506
Conversation
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>
There was a problem hiding this comment.
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_OPERATORSindeps/registry/semver.pyso=1.2.3passes the syntax gate (and==1.2.3still fails becauseparse_semver("=1.2.3")isNone). - Add an
=branch to_satisfies_singleinmarketplace/semver.pyguarded bynot 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
APM Review Panel:
|
| 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
- [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.
- [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.
- [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.
- [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.
- [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>
1c200bf to
92c974a
Compare
|
Panel follow-ups folded in. Commit
Deferred to follow-up (out of scope for a correctness fix, per the panel's own framing): the Validation evidence (latest push, commit
Ready to merge. |
…fix-pinned # Conflicts: # CHANGELOG.md
TL;DR
policy.dependencies.require_pinned_constraint: true(shipped in #1494) treated the npm- and cargo-style explicit-equality form=1.2.3asBARE_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.Zas an exact pin, so users get the behaviour the #1494 commit message promised ("exact versions" among the pinned forms). Pip-style==1.2.3stays rejected so users do not silently get the wrong dialect.Problem (WHY)
_constraint_pinning.pydefers tois_semver_rangefromapm_cli.deps.registry.semverto decide whether a ref is a valid semver range. That helper's_RANGE_OPERATORStuple omitted the=prefix, so=1.2.3failed the parse-time gate and fell through to theBARE_BRANCHbucket.tests/integration/policy/test_require_pinned_constraint_e2e.py:pkg@=1.2.3andpkg@1.2.3as equivalent (node-semver). Cargo treats=1.2.3as the stricter explicit pin (bare1.2.3is implicitly caret-equivalent). Pip alone uses==1.2.3and rejects=1.2.3. APM follows the node-semver grammar.dep: =1.2.3inapm.ymlwithrequire_pinned_constraint: truegot an install block citing a "bare branch" their ref clearly is not.Approach (WHAT)
=1.2.3as a pin?==1.2.3as a pin?deps/registry/semver.py) so the classifier sees it as a range; runtime matcher (marketplace/semver.py) so resolution can match versionsImplementation (HOW)
src/apm_cli/deps/registry/semver.py"="to_RANGE_OPERATORS. The existing_is_range_componentloop strips the operator and validates the suffix viaparse_semver, so=1.2.3becomes a valid range,=garbagestays invalid, and==1.2.3is rejected becauseparse_semver("=1.2.3")returnsNone.src/apm_cli/marketplace/semver.py=branch in_satisfies_singlethat parses the suffix and matches the exact(major, minor, patch, prerelease)tuple, mirroring the existing "Exact match" fallback semantics. Guarded withnot spec.startswith("==")so the pip-style form falls through to the invalid path.src/apm_cli/policy/_constraint_pinning.pyis_semver_range("=1.2.3")returnsTrue, the existing_classify_rangesingle-component branch returnsNone(pinned) because=1.2.3does not start with>=or>.Diagram
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"]Trade-offs
=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.!=). Cargo supports it; npm does not. APM follows npm here, deferring the question until a real user asks.=only to the classifier would have made the policy gate accept=1.2.3while resolution silently failed to find a matching version. Both layers must agree on the grammar.Benefits
=1.2.3) no longer get blocked by a policy whose own commit message advertised exact versions as pinned._constraint_pinning.pycontract advertised in the module docstring ("Exact semver versions (1.2.3)") is now honoured for both spellings of an exact version.Validation
All four lint guards pass silently and the targeted test set is green.
Lint chain (CI-mirror, all silent / exit 0)
Targeted test run
Scenario evidence
require_pinned_constraint: trueacceptsdep: =1.2.3and does not block installtests/integration/policy/test_require_pinned_constraint_e2e.py::TestPromiseBPinnedDepPassesPolicyGate::test_bare_exact_version_does_not_trigger_block(extended to include=1.2.3alongside1.2.3)=X.Y.Z,=X.Y.Z-prerelease,=X.Y.Z+buildas pinned for both git-source and registry-source depstests/unit/policy/test_pinned_constraint.py::test_equals_prefix_exact_version_classified_as_pinned+::test_equals_prefix_exact_version_pinned_on_registry_source==1.2.3still falls through toBARE_BRANCHso users see a violation pointing at the supported formtests/unit/policy/test_pinned_constraint.py::test_double_equals_prefix_rejected_as_bare_branchsatisfies_rangematches=1.2.3against1.2.3(and not1.2.4, not1.2.3-beta.1)tests/unit/marketplace/test_semver.py::TestSatisfiesRange::test_eq_exact+::test_eq_prerelease=1.2.3and rejects==1.2.3/=garbage/=1.2tests/unit/registry/test_semver.py::TestIsSemverRange::test_accepts_valid_ranges+::test_rejects_invalid_refsMutation-break evidence
"="from_RANGE_OPERATORSindeps/registry/semver.pytest_equals_prefix_exact_version_classified_as_pinned, 3xtest_accepts_valid_ranges[=...], 1xtest_bare_exact_version_does_not_trigger_block=branch inmarketplace/semver.py::_satisfies_singletest_eq_exact,test_eq_prereleaseHow to test
git fetch && git checkout fix/policy-equals-prefix-pinneduv 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 greendependencies.apm: ["owner/repo#=1.2.3"]and an org policy withenforcement: block+dependencies.require_pinned_constraint: true; runapm installand confirm the policy gate does NOT abort with "bare branch '=1.2.3'"==1.2.3-- confirm the install IS blocked and the diagnostic still points at the offending depCo-authored-by: Copilot 223556219+Copilot@users.noreply.github.com