Skip to content

Commit 95c2dec

Browse files
kevinbeier-enbwcursoragent
authored andcommitted
fix(outdated): scope {name} tag patterns to package
When a tag_pattern contains {name}, specialize matching to the target package name so monorepo tags for other packages don't skew outdated results. Also harden parse_tag_version for patterns without {version}. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 47152eb commit 95c2dec

6 files changed

Lines changed: 88 additions & 19 deletions

File tree

src/apm_cli/commands/marketplace/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1031,7 +1031,11 @@ def _extract_tag_versions(refs, entry, yml, include_prerelease):
10311031
)
10321032

10331033
def _collect(pattern: str) -> list:
1034-
tag_rx = build_tag_regex(pattern)
1034+
tag_rx = (
1035+
build_tag_regex(pattern, name=entry.name)
1036+
if "{name}" in pattern
1037+
else build_tag_regex(pattern)
1038+
)
10351039
collected = []
10361040
for sv, tag_name, _ in iter_semver_tags(refs, tag_rx):
10371041
if sv.is_prerelease and not (include_prerelease or entry.include_prerelease):

src/apm_cli/commands/outdated.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,20 @@ def _resolve_tag_pattern(current_ref: str, package_name: str) -> str | None:
4949
if inferred:
5050
return inferred
5151
if TAG_RE.match(current_ref or ""):
52-
return "v{version}" if (current_ref or "").startswith(("v", "V")) else "{version}"
52+
return "v{version}" if (current_ref or "").startswith("v") else "{version}"
5353
return None
5454

5555

56-
def _semver_tag_candidates(tag_refs, pattern: str):
56+
def _semver_tag_candidates(tag_refs, pattern: str, package_name: str = ""):
5757
"""Return ``(SemVer, tag_name)`` pairs matching *pattern*, highest first."""
5858
from ..marketplace.semver import SemVer, parse_semver
59-
from ..marketplace.tag_pattern import build_tag_regex, parse_tag_version
59+
from ..marketplace.tag_pattern import build_tag_regex
6060

61-
tag_rx = build_tag_regex(pattern)
61+
tag_rx = (
62+
build_tag_regex(pattern, name=package_name)
63+
if "{name}" in pattern and package_name
64+
else build_tag_regex(pattern)
65+
)
6266
candidates: list[tuple[SemVer, str]] = []
6367
for remote_ref in tag_refs:
6468
match = tag_rx.match(remote_ref.name)
@@ -249,7 +253,7 @@ def _check_one_dep(dep, downloader, verbose, registry_ctx=None):
249253

250254
from ..marketplace.tag_pattern import parse_tag_version
251255

252-
candidates = _semver_tag_candidates(tag_refs, tag_pattern)
256+
candidates = _semver_tag_candidates(tag_refs, tag_pattern, package_basename)
253257
if not candidates:
254258
return OutdatedRow(
255259
package=package_name,
@@ -260,8 +264,12 @@ def _check_one_dep(dep, downloader, verbose, registry_ctx=None):
260264
)
261265

262266
_, latest_tag = candidates[0]
263-
current_ver = parse_tag_version(current_ref, tag_pattern) or _strip_v(current_ref)
264-
latest_ver = parse_tag_version(latest_tag, tag_pattern) or _strip_v(latest_tag)
267+
current_ver = (
268+
parse_tag_version(current_ref, tag_pattern, name=package_basename) or _strip_v(current_ref)
269+
)
270+
latest_ver = (
271+
parse_tag_version(latest_tag, tag_pattern, name=package_basename) or _strip_v(latest_tag)
272+
)
265273

266274
if is_newer_version(current_ver, latest_ver):
267275
extra = [name for _, name in candidates[:10]] if verbose else []

src/apm_cli/marketplace/tag_pattern.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,22 @@ def render_tag(pattern: str, *, name: str, version: str) -> str:
6767
return result
6868

6969

70-
def build_tag_regex(pattern: str) -> re.Pattern[str]:
70+
def build_tag_regex(pattern: str, *, name: str | None = None) -> re.Pattern[str]:
7171
"""Return a compiled regex that captures ``{version}`` from a tag.
7272
7373
Literal text in *pattern* is escaped so that special regex characters
7474
(e.g. dots, parens) are matched verbatim. ``{version}`` becomes a
7575
named capture group ``(?P<version>...)`` matching a semver-like
76-
string. ``{name}`` becomes a non-capturing wildcard ``[^/]+``.
76+
string. ``{name}`` becomes a non-capturing wildcard ``[^/]+``, or
77+
the literal *name* when provided (for monorepo per-package tags).
7778
7879
Parameters
7980
----------
8081
pattern:
8182
Tag pattern string, e.g. ``"v{version}"``.
83+
name:
84+
When set and the pattern contains ``{name}``, match only this
85+
package name instead of any ``[^/]+`` segment.
8286
8387
Returns
8488
-------
@@ -116,20 +120,28 @@ def build_tag_regex(pattern: str) -> re.Pattern[str]:
116120
)
117121

118122
escaped = escaped.replace(re.escape(_sentinel_version), _VERSION_RX)
119-
escaped = escaped.replace(re.escape(_sentinel_name), r"[^/]+")
123+
if _PLACEHOLDER_NAME in pattern and name:
124+
name_rx = re.escape(name)
125+
else:
126+
name_rx = r"[^/]+"
127+
escaped = escaped.replace(re.escape(_sentinel_name), name_rx)
120128

121129
return re.compile(r"^" + escaped + r"$")
122130

123131

124132
def infer_tag_pattern(tag: str, package_name: str = "") -> str | None:
125133
"""Return the first default pattern that matches *tag*, or ``None``.
126134
127-
*package_name* is accepted for API symmetry with :func:`render_tag` but
128-
is not required for matching because ``{name}`` is a wildcard.
135+
When *package_name* is set, patterns containing ``{name}`` only match
136+
tags for that package (monorepo-safe).
129137
"""
130-
del package_name # reserved for callers that pass the package display name
131138
for pattern in DEFAULT_TAG_PATTERNS:
132-
if build_tag_regex(pattern).match(tag):
139+
rx = (
140+
build_tag_regex(pattern, name=package_name)
141+
if _PLACEHOLDER_NAME in pattern and package_name
142+
else build_tag_regex(pattern)
143+
)
144+
if rx.match(tag):
133145
return pattern
134146
return None
135147

@@ -146,12 +158,19 @@ def infer_tag_pattern_from_refs(refs: list, package_name: str = "") -> str | Non
146158
return None
147159

148160

149-
def parse_tag_version(tag: str, pattern: str) -> str | None:
161+
def parse_tag_version(tag: str, pattern: str, *, name: str | None = None) -> str | None:
150162
"""Extract the semver substring from *tag* using *pattern*."""
151-
match = build_tag_regex(pattern).match(tag)
163+
if _PLACEHOLDER_VERSION not in pattern:
164+
return None
165+
rx = (
166+
build_tag_regex(pattern, name=name)
167+
if _PLACEHOLDER_NAME in pattern and name
168+
else build_tag_regex(pattern)
169+
)
170+
match = rx.match(tag)
152171
if match is None:
153172
return None
154-
return match.group("version")
173+
return match.groupdict().get("version")
155174

156175

157176
def is_version_tag_ref(ref: str, package_name: str | None = None) -> bool:

tests/unit/commands/test_marketplace_outdated.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,32 @@ def test_infers_name_underscore_v_version_from_remote_tags(self):
116116
refs = [
117117
RemoteRef(name="refs/tags/api-governance_v1.0.1", sha=_SHA_A),
118118
RemoteRef(name="refs/tags/api-governance_v1.0.2", sha=_SHA_B),
119+
RemoteRef(name="refs/tags/other-pkg_v9.9.9", sha=_SHA_C),
119120
]
120121
results = _extract_tag_versions(refs, entry, yml, include_prerelease=False)
121122
tag_names = [tag for _sv, tag in results]
122123
assert "api-governance_v1.0.1" in tag_names
123124
assert "api-governance_v1.0.2" in tag_names
125+
assert "other-pkg_v9.9.9" not in tag_names
126+
127+
def test_collect_ignores_other_package_tags_in_monorepo(self):
128+
from types import SimpleNamespace
129+
130+
from apm_cli.commands.marketplace import _extract_tag_versions
131+
132+
entry = SimpleNamespace(
133+
name="apm1",
134+
tag_pattern="{name}_v{version}",
135+
include_prerelease=False,
136+
)
137+
yml = SimpleNamespace(build=SimpleNamespace(tag_pattern="v{version}"))
138+
refs = [
139+
RemoteRef(name="refs/tags/apm1_v1.0.0", sha=_SHA_A),
140+
RemoteRef(name="refs/tags/apm2_v1.0.0", sha=_SHA_B),
141+
]
142+
results = _extract_tag_versions(refs, entry, yml, include_prerelease=False)
143+
tag_names = [tag for _sv, tag in results]
144+
assert tag_names == ["apm1_v1.0.0"]
124145

125146

126147
# ---------------------------------------------------------------------------

tests/unit/marketplace/test_tag_pattern.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ def test_name_wildcard_non_greedy(self) -> None:
159159
assert m is not None
160160
assert m.group("version") == "2.0.0"
161161

162+
def test_name_specialized_to_package(self) -> None:
163+
rx = build_tag_regex("{name}_v{version}", name="apm1")
164+
assert rx.match("apm1_v1.0.0") is not None
165+
assert rx.match("apm2_v1.0.0") is None
166+
162167
def test_complex_pattern(self) -> None:
163168
rx = build_tag_regex("{name}@{version}")
164169
m = rx.match("tool@3.1.4")
@@ -232,6 +237,10 @@ class TestInferTagPattern:
232237
def test_name_underscore_v_version(self) -> None:
233238
assert infer_tag_pattern("api-governance_v1.0.1") == "{name}_v{version}"
234239

240+
def test_name_underscore_v_scoped_to_package(self) -> None:
241+
assert infer_tag_pattern("apm1_v1.0.1", "apm1") == "{name}_v{version}"
242+
assert infer_tag_pattern("apm2_v1.0.1", "apm1") is None
243+
235244
def test_name_at_version_not_inferred(self) -> None:
236245
assert infer_tag_pattern("api-governance@1.0.1") is None
237246

@@ -258,7 +267,13 @@ def test_main_is_not_tag(self) -> None:
258267

259268
class TestParseTagVersion:
260269
def test_name_underscore_v_version(self) -> None:
261-
assert parse_tag_version("api-governance_v1.0.1", "{name}_v{version}") == "1.0.1"
270+
assert (
271+
parse_tag_version("api-governance_v1.0.1", "{name}_v{version}", name="api-governance")
272+
== "1.0.1"
273+
)
274+
275+
def test_pattern_without_version_returns_none(self) -> None:
276+
assert parse_tag_version("tool-latest", "{name}-latest") is None
262277

263278

264279
class TestInferTagPatternFromRefs:

tests/unit/test_outdated_phase3w5.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ def test_name_underscore_v_version_tag_outdated(self):
372372
ref_tags = [
373373
_make_remote_ref("api-governance_v1.0.2", "tag", "sha2"),
374374
_make_remote_ref("api-governance_v1.0.1", "tag", "sha1"),
375+
_make_remote_ref("other-pkg_v9.9.9", "tag", "sha9"),
375376
]
376377
downloader = MagicMock()
377378
downloader.list_remote_refs.return_value = ref_tags
@@ -388,6 +389,7 @@ def test_name_underscore_v_version_tag_outdated(self):
388389
assert result.status == "outdated"
389390
assert result.latest == "api-governance_v1.0.2"
390391
assert result.source == "git tags"
392+
assert "other-pkg" not in (result.latest or "")
391393

392394
def test_tag_no_tags_returns_unknown(self):
393395
from apm_cli.commands.outdated import _check_one_dep

0 commit comments

Comments
 (0)