From 2b15784f89a08c0f67ac79fb1e5cf74c041979cc Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Fri, 11 Sep 2026 21:51:42 +0500 Subject: [PATCH] fix(bundler): report a newline-containing version constraint instead of crashing `_normalize_constraint` matched each comma-separated clause and used the result without checking it: match = _SPECIFIER_CLAUSE.match(raw) operator, version = match.groups() `_SPECIFIER_CLAUSE` is anchored with `^`/`$` and `.` does not cross newlines, so a clause containing an EMBEDDED newline does not match at all and `match.groups()` raised a raw AttributeError -- escaping `parse_constraint`'s contract to surface bad input as a BundlerError. A YAML block literal reaches this with no exotic input: requires: speckit_version: | >=1.0.0 <2.0.0 loads as ">=1.0.0\n<2.0.0\n", and: parse_constraint -> AttributeError: 'NoneType' object has no attribute 'groups' satisfies -> AttributeError: 'NoneType' object has no attribute 'groups' Note leading/trailing newlines were already fine (`\s*` absorbs them) -- only an embedded one fails, which is why this survived. Now raises InvalidSpecifier for an unmatched clause, which the existing handler in `parse_constraint` converts to the BundlerError callers expect. Co-Authored-By: Claude Opus 5 (1M context) --- src/specify_cli/bundler/lib/versioning.py | 13 +++++++ tests/unit/test_bundler_versioning.py | 46 +++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/specify_cli/bundler/lib/versioning.py b/src/specify_cli/bundler/lib/versioning.py index 552f21950c..577a115818 100644 --- a/src/specify_cli/bundler/lib/versioning.py +++ b/src/specify_cli/bundler/lib/versioning.py @@ -54,6 +54,19 @@ def _normalize_constraint(value: str) -> str: if not raw.strip(): continue match = _SPECIFIER_CLAUSE.match(raw) + if match is None: + # ``_SPECIFIER_CLAUSE`` is anchored with ``^``/``$`` and ``.`` does + # not cross newlines, so a clause containing an EMBEDDED newline + # does not match at all and ``match.groups()`` raised a raw + # AttributeError -- escaping ``parse_constraint``'s contract to + # report bad input as a BundlerError. A YAML block literal is an + # ordinary way to reach this: + # requires: + # speckit_version: | + # >=1.0.0 + # <2.0.0 + # which loads as ">=1.0.0\n<2.0.0\n". + raise InvalidSpecifier(f"Invalid specifier: {raw!r}") operator, version = match.groups() clauses.append(f"{operator or ''}{_normalize_semver(version)}") return ",".join(clauses) diff --git a/tests/unit/test_bundler_versioning.py b/tests/unit/test_bundler_versioning.py index 15c42ea673..eaffa51634 100644 --- a/tests/unit/test_bundler_versioning.py +++ b/tests/unit/test_bundler_versioning.py @@ -66,3 +66,49 @@ def test_parse_constraint_empty_is_permissive(): from specify_cli.bundler.lib.versioning import parse_constraint assert str(parse_constraint("")) == "" + + +@pytest.mark.parametrize( + "constraint", + [ + ">=1.0.0\n<2.0.0\n", # a YAML block literal, as loaded + ">=1.0\n.0", + "a\nb", + ], + ids=["yaml_block_literal", "split_version", "garbage"], +) +def test_constraint_with_an_embedded_newline_reports_a_bundler_error(constraint): + """A clause containing a newline must be reported, not crash. + + `_SPECIFIER_CLAUSE` is anchored with `^`/`$` and `.` does not cross + newlines, so such a clause does not match at all and `match.groups()` + raised a raw `AttributeError` — escaping `parse_constraint`'s contract to + surface bad input as a `BundlerError`. A YAML block literal reaches this + with no exotic input at all: + + requires: + speckit_version: | + >=1.0.0 + <2.0.0 + """ + from specify_cli.bundler.lib.versioning import parse_constraint + + with pytest.raises(BundlerError, match="Invalid version constraint"): + parse_constraint(constraint) + + +@pytest.mark.parametrize( + "constraint,expected", + [ + (">=1.0.0", ">=1.0.0"), + (">=v1.0.0", ">=1.0.0"), + ("~=1.2", "~=1.2"), + (">=1.0.0\n", ">=1.0.0"), # trailing newline is still stripped + ("\n>=1.0.0", ">=1.0.0"), # leading newline too + ], +) +def test_valid_constraints_are_unaffected(constraint, expected): + """Only clauses that genuinely fail to match change behaviour.""" + from specify_cli.bundler.lib.versioning import parse_constraint + + assert str(parse_constraint(constraint)) == expected