repomatic.pages_redirects module

A faithful Python replica of the engine Cloudflare Pages runs _redirects on.

Cloudflare’s documentation describes the file format; it does not describe the accounting, and the accounting is where rules die. A site once lost the last 18 rules of its file for years this way, silently: wrangler pages deploy prints nothing when the parser discards lines, and a dead redirect looks exactly like a URL nobody visits. This module replicates the reference implementation so lint-repo can audit a committed file the way production will read it, before production reads it.

Transcribed on 2026-08-10 from the engine itself, not from the documentation:

  • Parsing: packages/workers-shared/utils/configuration/parseRedirects.ts in cloudflare/workers-sdk, as bundled in wrangler 4.118 (the same code path Miniflare uses, and the same parser family the Pages asset server feeds on).

  • Matching: packages/workers-shared/asset-worker/src/utils/rules-engine.ts.

The three rules of the engine that the documentation does not state:

  1. A static rule is only free while it appears before the first dynamic rule. The parser flips canCreateStaticRule to false permanently at the first source containing * or :placeholder. Every later rule, however static it looks, is charged against the dynamic budget.

  2. The dynamic budget is 100, and blowing it aborts the file. Rule 101 of that mixed stream does not get skipped: the parser breaks out of the loop, discarding every remaining line. Order is therefore not a style choice, it decides which rules exist.

  3. Matching is anchored and literal about trailing slashes. A placeholder compiles to [^/]+ (at least one character, never a slash, never empty), a splat to .* (may be empty), and the whole source to ^...$. /a/:b does not match /x/y/ and /a/* matches /a/ with an empty splat.

class repomatic.pages_redirects.Rule(source, destination, status, line_number)[source]

Bases: object

source: str
destination: str
status: int
line_number: int
property is_dynamic: bool
class repomatic.pages_redirects.Invalid(message, line=None, line_number=None)[source]

Bases: object

message: str
line: str | None = None
line_number: int | None = None
class repomatic.pages_redirects.ParseResult(rules=<factory>, invalid=<factory>, aborted_at_line=None)[source]

Bases: object

rules: list[Rule]
invalid: list[Invalid]
aborted_at_line: int | None = None

Line number from which the parser discarded the rest of the file, if it did.

repomatic.pages_redirects.parse_redirects(text)[source]

The exact algorithm of parseRedirects, budget accounting included.

Return type:

ParseResult

repomatic.pages_redirects.misordered_statics(rules)[source]

Exact-source rules charged against the dynamic budget by their position.

The engine’s static budget (2000) only covers exact rules appearing before the first dynamic source; every exact rule after that point burns a slot of the dynamic budget (100) instead. Such a file still works while the budget holds, so this is the early warning: each rule returned here brings the file one line closer to the silent abort parse_redirects() reports as aborted_at_line. The fix is always the same reorder, all exact rules first, all pattern rules second, which is behaviour-preserving because the asset server probes exact sources first regardless of file position.

Return type:

list[Rule]

repomatic.pages_redirects.rule_pattern(source: str) Pattern[str][source]

Compile a rule source exactly the way generateRuleRegExp does.

Memoized: apply_rule() recompiles the same dynamic rules once per probed path otherwise.

Return type:

Pattern[str]

repomatic.pages_redirects.apply_rule(rule, path)[source]

Return the destination for path, or None if the rule does not match.

Return type:

str | None

repomatic.pages_redirects.sample_path(source)[source]

A concrete request path a rule source would have matched.

An exact source is already one, and is returned untouched, which is the case that matters: a dropped exact rule names the very URL that stops working. A pattern has no single answer, so each :name stands in for itself and each * for one segment, yielding an illustration rather than a promise about live traffic.

Parameters:

source (str) – Rule source, exact or patterned.

Return type:

str

Returns:

A path that rule_pattern() would match.

repomatic.pages_redirects.discarded_rules(text, parsed)[source]

The rules the engine abandoned, recovered from the tail it never read.

parse_redirects() reports that it stopped and drops everything below, because that is what production does. Naming what was lost needs the tail parsed on its own, which is what this does, with the line numbers shifted back to where they sit in the real file.

Caution

The tail is parsed with fresh budgets, so one long enough to exhaust them again reports only its first batch. Reading this as an illustration of what broke rather than an exhaustive inventory is the intent either way: the fix is the same reorder however many rules are below the line.

Parameters:
Return type:

list[Rule]

Returns:

The abandoned rules, empty when the parser read the whole file.

repomatic.pages_redirects.evaluate(rules, path)[source]

First-match evaluation over the kept rules, the way the asset server runs it.

The server splits exact sources into a hash map probed first, then walks the dynamic rules in file order. The two passes below mirror that: an exact rule wins over a pattern that also matches, wherever each sits in the file, which is also what makes the statics-first reorder the lint recommends behaviour-preserving.

Return type:

tuple[Rule, str] | None