From 2777d2fd4415db28764387cfeac0d912db369e24 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 09:09:18 -0400 Subject: [PATCH 1/3] fix: recognize structured rate claim windows --- scripts/validate_storefront_claims.py | 45 ++++++++++++++++++++------- tests/test_storefront_claims.py | 18 +++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/scripts/validate_storefront_claims.py b/scripts/validate_storefront_claims.py index 969ae38..46eccec 100644 --- a/scripts/validate_storefront_claims.py +++ b/scripts/validate_storefront_claims.py @@ -21,17 +21,41 @@ ".so", } _RATE_COUNT = r"\d[\d,]*" -_RATE_ACTION = r"(?:(?:api[- ]+)?(?:requests?|calls?)|reqs?\.?)" -_RATE_PERIOD = r"(?:minutes?|mins?\.?|hours?|hrs?\.?|days?)" -_RATE_FREQUENCY = r"(?:minutely|hourly|daily|per[- ]+(?:minute|hour|day))" +_RATE_ACTION = r"(?:(?:api[- ]+)?(?:requests?|calls?|queries?|hits?|credits?)|reqs?\.?)" +_RATE_UNIT_SINGULAR = r"(?:second|sec|minute|min|hour|hr|day|week|month|year)" +_RATE_UNIT = rf"{_RATE_UNIT_SINGULAR}s?\.?" +_RATE_ADVERB = r"(?:secondly|minutely|hourly|daily|weekly|monthly|yearly)" +_RATE_DURATION = rf"(?:(?:a|an|one|any|rolling|{_RATE_COUNT})[- ]+){{0,3}}{_RATE_UNIT}" +_RATE_WINDOW = ( + rf"(?:(?:/[- ]*|(?:per|each|every|in|within|over|during|for)[- ]+)" + rf"{_RATE_DURATION}|" + rf"(?:a|an)[- ]+{_RATE_UNIT})" +) +_RATE_FREQUENCY = ( + rf"(?:{_RATE_ADVERB}|per[- ]+{_RATE_UNIT_SINGULAR}|" + rf"(?:one|{_RATE_COUNT})[- ]+{_RATE_UNIT_SINGULAR})" +) +_RATE_SCOPE = r"(?:(?:api[- ]+)?(?:requests?|calls?|queries?|hits?|credits?)[- ]+|api[- ]+)?" +_RATE_LIMIT = r"(?:rate[- ]+limit|limit|allowance|quota|cap)" +_RATE_ASSIGNMENT = r"(?:of|is|at|to|:|=)?" FIXED_RATE = re.compile( rf"\b{_RATE_COUNT}[- ]+{_RATE_ACTION}" - rf"(?:(?:[- ]*(?:per|an?|each|every)[- ]+|[- ]*/[- ]*){_RATE_PERIOD}\b|" - rf"[- ]+{_RATE_FREQUENCY}\b)|" - rf"\b{_RATE_FREQUENCY}[- ]+" - rf"(?:(?:api[- ]+)?(?:requests?|calls?)[- ]+)?" - rf"(?:limit|allowance|quota|cap)\s*(?:of|is|:|=)?\s*" - rf"{_RATE_COUNT}[- ]+{_RATE_ACTION}\b", + rf"(?:[- ]*{_RATE_WINDOW}\b|[- ]+{_RATE_ADVERB}\b)|" + rf"\b{_RATE_COUNT}[- ]*{_RATE_WINDOW}[- ]+{_RATE_ACTION}\b|" + rf"\b{_RATE_COUNT}[- ]+{_RATE_ADVERB}[- ]+{_RATE_ACTION}\b|" + rf"\b{_RATE_FREQUENCY}[- ]+{_RATE_COUNT}[- ]+{_RATE_ACTION}\b|" + rf"\b{_RATE_ACTION}\s*(?::|=|is|of)?\s*{_RATE_COUNT}[- ]*" + rf"{_RATE_WINDOW}\b|" + rf"\b{_RATE_ACTION}[- ]+{_RATE_ADVERB}\s*(?::|=|is)?\s*" + rf"{_RATE_COUNT}\b|" + rf"\b{_RATE_FREQUENCY}[- ]+{_RATE_ACTION}\s*(?::|=|is)?\s*" + rf"{_RATE_COUNT}\b|" + rf"\b{_RATE_ACTION}[- ]*{_RATE_WINDOW}\s*(?::|=|is)?\s*" + rf"{_RATE_COUNT}\b|" + rf"\b{_RATE_FREQUENCY}[- ]+{_RATE_SCOPE}{_RATE_LIMIT}\s*" + rf"{_RATE_ASSIGNMENT}\s*{_RATE_COUNT}(?:[- ]+{_RATE_ACTION})?\b|" + rf"\b{_RATE_SCOPE}{_RATE_LIMIT}\s*{_RATE_ASSIGNMENT}\s*{_RATE_COUNT}" + rf"(?:[- ]+{_RATE_ACTION})?(?:[- ]*{_RATE_WINDOW}\b|[- ]+{_RATE_ADVERB}\b)", re.IGNORECASE, ) BLOCKED: Sequence[Tuple[str, Pattern[str]]] = ( @@ -151,8 +175,7 @@ def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]: for path in surfaces: text = path.read_text(encoding="utf-8") for label, pattern in BLOCKED: - match = pattern.search(text) - if match: + for match in pattern.finditer(text): failures.append( f"{path.relative_to(root)}: {label} matched {match.group(0)!r}" ) diff --git a/tests/test_storefront_claims.py b/tests/test_storefront_claims.py index d730017..07715d4 100644 --- a/tests/test_storefront_claims.py +++ b/tests/test_storefront_claims.py @@ -157,6 +157,21 @@ def test_rejects_claim_in_future_installed_package_data(tmp_path: Path) -> None: "100 API requests every hour", "daily cap is 50 calls", "50-call-per-day allowance", + "50 requests per 24 hours", + "50 requests every 24 hours", + "50 API calls in a day", + "daily 50-request limit", + "24-hour quota of 50 calls", + "API rate limit is 200 every hour", + "weekly 5,000-credit allowance", + "2,000 queries per 30 days", + "50/day API calls", + "50 daily API calls", + "API calls: 50 per day", + "API calls daily: 50", + "daily API calls: 50", + "50 requests over a rolling 24-hour window", + "50 API calls during any one-hour period", ], ) def test_rejects_fixed_rate_aliases_in_installed_text(tmp_path: Path, claim: str) -> None: @@ -172,6 +187,9 @@ def test_rejects_fixed_rate_aliases_in_installed_text(tmp_path: Path, claim: str "Run 50 tests daily.", "The response contains 50 records per page.", "Retry attempt 50 failed.", + "Daily 50-test limit.", + "A 24-hour test window contains 50 assertions.", + "The monthly report contains 50 records.", ], ) def test_fixed_rate_aliases_do_not_match_versions_or_test_counts( From cfa61d0b096147734fd02d00bb47c34d81aef2b3 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 09:19:52 -0400 Subject: [PATCH 2/3] fix: detect bounded rate claims order independently --- scripts/validate_storefront_claims.py | 136 +++++++++++++++++++------- tests/test_storefront_claims.py | 9 +- 2 files changed, 109 insertions(+), 36 deletions(-) diff --git a/scripts/validate_storefront_claims.py b/scripts/validate_storefront_claims.py index 46eccec..3971828 100644 --- a/scripts/validate_storefront_claims.py +++ b/scripts/validate_storefront_claims.py @@ -5,7 +5,7 @@ import csv import re from pathlib import Path -from typing import Iterable, List, Pattern, Sequence, Tuple +from typing import Iterable, Iterator, List, Match, Pattern, Sequence, Set, Tuple ROOT = Path(__file__).resolve().parents[1] CONTRACT = "https://api.oilpriceapi.com/product-facts.json" @@ -26,38 +26,21 @@ _RATE_UNIT = rf"{_RATE_UNIT_SINGULAR}s?\.?" _RATE_ADVERB = r"(?:secondly|minutely|hourly|daily|weekly|monthly|yearly)" _RATE_DURATION = rf"(?:(?:a|an|one|any|rolling|{_RATE_COUNT})[- ]+){{0,3}}{_RATE_UNIT}" -_RATE_WINDOW = ( - rf"(?:(?:/[- ]*|(?:per|each|every|in|within|over|during|for)[- ]+)" - rf"{_RATE_DURATION}|" - rf"(?:a|an)[- ]+{_RATE_UNIT})" -) -_RATE_FREQUENCY = ( - rf"(?:{_RATE_ADVERB}|per[- ]+{_RATE_UNIT_SINGULAR}|" - rf"(?:one|{_RATE_COUNT})[- ]+{_RATE_UNIT_SINGULAR})" -) -_RATE_SCOPE = r"(?:(?:api[- ]+)?(?:requests?|calls?|queries?|hits?|credits?)[- ]+|api[- ]+)?" -_RATE_LIMIT = r"(?:rate[- ]+limit|limit|allowance|quota|cap)" -_RATE_ASSIGNMENT = r"(?:of|is|at|to|:|=)?" -FIXED_RATE = re.compile( - rf"\b{_RATE_COUNT}[- ]+{_RATE_ACTION}" - rf"(?:[- ]*{_RATE_WINDOW}\b|[- ]+{_RATE_ADVERB}\b)|" - rf"\b{_RATE_COUNT}[- ]*{_RATE_WINDOW}[- ]+{_RATE_ACTION}\b|" - rf"\b{_RATE_COUNT}[- ]+{_RATE_ADVERB}[- ]+{_RATE_ACTION}\b|" - rf"\b{_RATE_FREQUENCY}[- ]+{_RATE_COUNT}[- ]+{_RATE_ACTION}\b|" - rf"\b{_RATE_ACTION}\s*(?::|=|is|of)?\s*{_RATE_COUNT}[- ]*" - rf"{_RATE_WINDOW}\b|" - rf"\b{_RATE_ACTION}[- ]+{_RATE_ADVERB}\s*(?::|=|is)?\s*" - rf"{_RATE_COUNT}\b|" - rf"\b{_RATE_FREQUENCY}[- ]+{_RATE_ACTION}\s*(?::|=|is)?\s*" - rf"{_RATE_COUNT}\b|" - rf"\b{_RATE_ACTION}[- ]*{_RATE_WINDOW}\s*(?::|=|is)?\s*" - rf"{_RATE_COUNT}\b|" - rf"\b{_RATE_FREQUENCY}[- ]+{_RATE_SCOPE}{_RATE_LIMIT}\s*" - rf"{_RATE_ASSIGNMENT}\s*{_RATE_COUNT}(?:[- ]+{_RATE_ACTION})?\b|" - rf"\b{_RATE_SCOPE}{_RATE_LIMIT}\s*{_RATE_ASSIGNMENT}\s*{_RATE_COUNT}" - rf"(?:[- ]+{_RATE_ACTION})?(?:[- ]*{_RATE_WINDOW}\b|[- ]+{_RATE_ADVERB}\b)", +_RATE_NUMBER_PATTERN = re.compile(rf"(?]{1,500}>") +_MAX_ACTION_COUNT_GAP = 64 +_MAX_RATE_SPAN = 200 BLOCKED: Sequence[Tuple[str, Pattern[str]]] = ( ("real-time claim", re.compile(r"\breal[ -]?time\b", re.IGNORECASE)), ( @@ -123,10 +106,6 @@ re.IGNORECASE, ), ), - ( - "fixed demo rate", - FIXED_RATE, - ), ) @@ -170,6 +149,89 @@ def discover_installed_surfaces(package_root: Path) -> List[Path]: return sorted(set(surfaces)) +def _bounded_rate_segments(text: str) -> Iterator[Tuple[int, str]]: + start = 0 + for boundary in _RATE_BOUNDARY_PATTERN.finditer(text): + segment = text[start : boundary.start()] + if segment.strip(): + yield start, segment + start = boundary.end() + if text[start:].strip(): + yield start, text[start:] + + +def _token_gap(left: Match[str], right: Match[str]) -> int: + if left.end() <= right.start(): + return right.start() - left.end() + if right.end() <= left.start(): + return left.start() - right.end() + return 0 + + +def _claim_span( + action: Match[str], count: Match[str], cadence: Match[str] +) -> Tuple[int, int]: + return ( + min(action.start(), count.start(), cadence.start()), + max(action.end(), count.end(), cadence.end()), + ) + + +def _fixed_rate_claims(text: str) -> List[str]: + """Find count + API action + cadence triples in a bounded sentence window.""" + claims: List[str] = [] + seen: Set[Tuple[int, str]] = set() + + for segment_offset, segment in _bounded_rate_segments(text): + searchable = _HTML_TAG_PATTERN.sub(" ", segment) + counts = list(_RATE_NUMBER_PATTERN.finditer(searchable)) + cadences = list(_RATE_CADENCE_PATTERN.finditer(searchable)) + if not counts or not cadences: + continue + allowance_counts = [ + count + for count in counts + if not any( + cadence.start() <= count.start() and count.end() <= cadence.end() + for cadence in cadences + ) + ] + if not allowance_counts: + continue + + for action in _RATE_ACTION_PATTERN.finditer(searchable): + nearby_counts = [ + count + for count in allowance_counts + if _token_gap(action, count) <= _MAX_ACTION_COUNT_GAP + ] + if not nearby_counts: + continue + count = min(nearby_counts, key=lambda token: _token_gap(action, token)) + + bounded_cadences = [ + cadence + for cadence in cadences + if _claim_span(action, count, cadence)[1] + - _claim_span(action, count, cadence)[0] + <= _MAX_RATE_SPAN + ] + if not bounded_cadences: + continue + cadence = min( + bounded_cadences, + key=lambda token: _claim_span(action, count, token)[1] + - _claim_span(action, count, token)[0], + ) + claim_start, claim_end = _claim_span(action, count, cadence) + claim = re.sub(r"\s+", " ", searchable[claim_start:claim_end]).strip() + key = (segment_offset + claim_start, claim) + if key not in seen: + seen.add(key) + claims.append(claim) + return claims + + def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]: failures: List[str] = [] for path in surfaces: @@ -179,6 +241,10 @@ def _claim_failures(root: Path, surfaces: Iterable[Path]) -> List[str]: failures.append( f"{path.relative_to(root)}: {label} matched {match.group(0)!r}" ) + for claim in _fixed_rate_claims(text): + failures.append( + f"{path.relative_to(root)}: fixed demo rate matched {claim!r}" + ) return failures diff --git a/tests/test_storefront_claims.py b/tests/test_storefront_claims.py index 07715d4..1d339fd 100644 --- a/tests/test_storefront_claims.py +++ b/tests/test_storefront_claims.py @@ -162,7 +162,7 @@ def test_rejects_claim_in_future_installed_package_data(tmp_path: Path) -> None: "50 API calls in a day", "daily 50-request limit", "24-hour quota of 50 calls", - "API rate limit is 200 every hour", + "API call rate limit is 200 every hour", "weekly 5,000-credit allowance", "2,000 queries per 30 days", "50/day API calls", @@ -172,6 +172,10 @@ def test_rejects_claim_in_future_installed_package_data(tmp_path: Path) -> None: "daily API calls: 50", "50 requests over a rolling 24-hour window", "50 API calls during any one-hour period", + "50 request limit per day", + "50-call limit per day", + "50 requests allowed daily", + "

daily 50 API calls

", ], ) def test_rejects_fixed_rate_aliases_in_installed_text(tmp_path: Path, claim: str) -> None: @@ -190,6 +194,9 @@ def test_rejects_fixed_rate_aliases_in_installed_text(tmp_path: Path, claim: str "Daily 50-test limit.", "A 24-hour test window contains 50 assertions.", "The monthly report contains 50 records.", + "1 week queries", + "return 120 # 2 minutes for year queries", + "50 records are returned. Requests include timestamps updated daily.", ], ) def test_fixed_rate_aliases_do_not_match_versions_or_test_counts( From c6fdd4a549ece41c5e5832dbd3ad8569b5004bd4 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 09:24:03 -0400 Subject: [PATCH 3/3] test: keep storefront claim classifications distinct --- scripts/validate_storefront_claims.py | 2 -- tests/test_storefront_claims.py | 7 +++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/validate_storefront_claims.py b/scripts/validate_storefront_claims.py index 3971828..c3534ad 100644 --- a/scripts/validate_storefront_claims.py +++ b/scripts/validate_storefront_claims.py @@ -76,8 +76,6 @@ ( "fixed allowance", re.compile( - r"\b\d[\d,]*\s+(?:free\s+)?(?:api\s+requests?|station\s+queries?)" - r"\s*(?:/|per\s+)month\b|" r"\bmonthly\s+station\s+(?:query|request)\s+limit\b", re.IGNORECASE, ), diff --git a/tests/test_storefront_claims.py b/tests/test_storefront_claims.py index 1d339fd..bdaf0fb 100644 --- a/tests/test_storefront_claims.py +++ b/tests/test_storefront_claims.py @@ -184,6 +184,13 @@ def test_rejects_fixed_rate_aliases_in_installed_text(tmp_path: Path, claim: str assert any("fixed demo rate" in failure for failure in failures), failures +def test_reports_one_failure_for_overlapping_monthly_rate(tmp_path: Path) -> None: + failures = _installed_text_failures(tmp_path, "50 API requests per month") + + matching = [failure for failure in failures if "50 API requests per month" in failure] + assert len(matching) == 1, failures + + @pytest.mark.parametrize( "text", [