Repository: speckit (difficulty: medium)

Issue:
parse_roman happily returns numbers for strings that are not Roman numerals at all. It only checks that every character is one of IVXLCDM and then runs a subtractive sum, so every malformed arrangement of valid symbols parses to some number instead of raising.

Observed vs expected:
- parse_roman("IIII") returns 4; expected ValueError (I repeats at most three times in a row, canonical 4 is "IV")
- parse_roman("VV") returns 10; expected ValueError (V never repeats)
- parse_roman("IL") returns 49; expected ValueError (the only legal subtractive pairs are IV, IX, XL, XC, CD, CM; canonical 49 is "XLIX")
- parse_roman("VX") returns 5; expected ValueError
- parse_roman("MMMM") returns 4000; expected ValueError (the representable range is 1..3999)
- parse_roman("MCMXCIVX") returns 1994; expected ValueError (trailing junk after a complete numeral)
- parse_roman("IVIV") returns 8; expected ValueError

Valid canonical numerals must keep parsing to the right value across the whole range: parse_roman("I") == 1, parse_roman("MCMXCIV") == 1994, parse_roman("MMMCMXCIX") == 3999, and likewise for every value in 1..3999 rendered in its canonical form. The empty string, lowercase input like "iv", any character outside IVXLCDM, and any non-canonical arrangement must raise ValueError, exactly as the module docstring specifies.

Source files:
core.py: """Strict parsing of canonical Roman numerals.

parse_roman(s) converts a canonical uppercase Roman numeral to an int.

Contract:

- Symbol values: I=1, V=5, X=10, L=50, C=100, D=500, M=1000.
- The input must be exactly one canonical numeral for a value in
  1..3999. Canonical means the standard modern form: I, X, C, and M
  appear at most three times in a row; V, L, and D never repeat; the
  only legal subtractive pairs are IV, IX, XL, XC, CD, and CM.
  Equivalently, the numeral is the concatenation of the canonical
  thousands, hundreds, tens, and units forms, i.e. it matches
  M{0,3} (CM|CD|D?C{0,3}) (XC|XL|L?X{0,3}) (IX|IV|V?I{0,3})
  and is non-empty.
- Anything else raises ValueError: the empty string, lowercase or
  mixed-case input, characters outside IVXLCDM, and any non-canonical
  arrangement such as IIII, VV, IL, IC, XM, VX, MMMM, or IVIV.
"""

_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}


def parse_roman(s):
    """Parse a canonical Roman numeral; raise ValueError otherwise."""
    if not s or any(ch not in _VALUES for ch in s):
        raise ValueError("not a canonical Roman numeral: %r" % (s,))
    total = 0
    for i, ch in enumerate(s):
        value = _VALUES[ch]
        if i + 1 < len(s) and _VALUES[s[i + 1]] > value:
            total -= value
        else:
            total += value
    return total


Test files:
test_core.py: """Tests for strict canonical Roman numeral parsing."""

import pytest

from core import parse_roman

# Independent canonical renderer: the standard greedy table expansion
# produces exactly the canonical form for every value in 1..3999, so the
# round trip below covers the full domain without pinning any literal.
_TABLE = (
    (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
    (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
    (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
)


def to_roman(n):
    parts = []
    for value, symbol in _TABLE:
        while n >= value:
            parts.append(symbol)
            n -= value
    return "".join(parts)


def test_full_range_round_trip():
    for n in range(1, 4000):
        assert parse_roman(to_roman(n)) == n


def test_non_canonical_strings_raise():
    invalid = [
        "", "iv", "mcmxciv", "IIII", "VV", "XXXX", "LL", "DD",
        "IL", "IC", "ID", "IM", "XD", "XM", "VX", "LC", "DM",
        "MMMM", "MCMXCIVX", "IVIV", "IXI", "XCX", "IIV",
        " IV", "IV ", "I V", "IV.", "MCMXCIVIL",
    ]
    for s in invalid:
        with pytest.raises(ValueError):
            parse_roman(s)


Patch:
--- a/core.py
+++ b/core.py
@@ -18,12 +18,16 @@
   arrangement such as IIII, VV, IL, IC, XM, VX, MMMM, or IVIV.
 """
 
+import re
+
 _VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
+
+_CANONICAL = re.compile(r"M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})")
 
 
 def parse_roman(s):
     """Parse a canonical Roman numeral; raise ValueError otherwise."""
-    if not s or any(ch not in _VALUES for ch in s):
+    if not s or _CANONICAL.fullmatch(s) is None:
         raise ValueError("not a canonical Roman numeral: %r" % (s,))
     total = 0
     for i, ch in enumerate(s):