DEV Community

Cover image for Validate a Redirect Map Without Hiding Chains and Loops
Dmytro Nasyrov for TopClick

Posted on Edited on

Validate a Redirect Map Without Hiding Chains and Loops

An AI agent prepared this tutorial, code and synthetic fixtures and ran the local checks. Human manuscript review has not been performed. The author affiliation is TopClick. Results concern only the included offline fixture.

A redirect map can reach the intended destination and still conceal a route that needs review. In the local fixture below, /old reaches /new through /mid. A deliberately defective reporting wrapper calls that route direct and removes the intermediate path. Two assertions catch the change. The corrected implementation passes all twenty tests while preserving the chain as a separate result.

The practical question is which proposed entries connect an old-path inventory to declared final targets, and which entries need a decision before deployment. This tutorial supplies a complete offline validator and a regression suite. It does not request a page, follow an actual HTTP redirect or measure Google indexing. Every route is invented, and every status in the manifest is a declaration.

Decide what a passing relationship would mean

Begin with the result you are willing to accept. A direct declaration means one supplied redirect row points to a member of the supplied final-target set. A chain means multiple supplied rows reach that set. Both establish a relationship inside the manifest. They differ in the proposed route, so the report must not collapse them into one success label.

A terminal set is an assertion by whoever prepared the fixture. Membership does not prove that a destination returns HTTP 200, contains the expected content or accepts the intended query parameters. Calling the result DIRECT_DECLARED_FINAL keeps that boundary visible. A receiving reviewer can then ask for a deployed observation without mistaking the static result for an observation that already happened.

Google Search Central advises site owners: "we advise redirecting to the final destination directly." The site-move guidance provides the context for that recommendation. Here, directness is a reason to distinguish report categories. The example does not reproduce Google's crawling behavior or turn its advice into a universal numerical acceptance threshold. A useful review can therefore end with several dispositions. One entry may be ready for configuration inspection, another may need its chain shortened and a third may lack a target declaration. Keep those differences available to the person approving the map. A single green total would hide which question the program actually answered.

Freeze the input contract before traversing it

Version 1 has four top-level fields: version, old, finals and rows. The old inventory defines the entries that must receive a result. The final list declares intended terminal keys. Each row supplies a source, target and integer status. Only 301 and 308 are accepted in this particular fixture. That restriction scopes the exercise; it is not a claim that other HTTP redirect statuses are inherently invalid.

Keys are exact ASCII path-and-query strings beginning with one slash. The validator rejects scheme-relative keys, fragments, backslashes and whitespace. It rejects full absolute URLs because origin comparison is outside this contract. It is deliberately not a general URL parser. A syntactically accepted key has only passed these stated checks, with no claim that a server would interpret it as a valid route.

No normalization occurs.

Query order, case, percent-encoding spelling and trailing slashes remain part of identity. /old?a=1&b=2 differs from /old?b=2&a=1. This conservative rule prevents the example from silently assuming that two strings have identical application semantics. If a real migration uses a different identity policy, that policy needs its own specification and tests.

Duplicate sources are invalid input, even if both rows propose the same target. Choosing the first or last row would hide an ambiguity in the submitted map. Duplicate inventory and terminal keys are also rejected. A key cannot simultaneously be declared final and own a redirect rule. Resolve that contradiction before traversal instead of letting source-order precedence decide its meaning.

The function raises ValueError for invalid manifest input. It does not return a partly accepted report after skipping troublesome rows. That choice makes malformed declarations visible to the caller. A batch interface could later return structured validation errors, but it should preserve the distinction between an invalid input package and a valid graph containing unresolved routes.

Preserve the walk, including its stopping reason

For each old key, the walker retains the start, every target reached and each declared status. It also retains a set of visited keys. Reaching a visited key ends the walk as LOOP, with the repeated key still present in the path. That final repetition helps the reviewer locate the cycle without reconstructing it from a separate counter.

The stopping order matters.

After adding a hop, check for a repeated key first. Then check declared terminal membership, followed by a missing continuation. Only when another rule remains available does the hop limit stop the walk. A route reaching its declared final on the last allowed hop has a complete bounded trace; a route requiring one more hop remains HOP_LIMIT.

The default limit is three declared redirect edges. It is a local traversal budget, chosen to keep the example small. It does not mean that three hops are acceptable for a particular migration. A policy can require every entry to be direct while the validator still reports a complete two-edge chain. Graph classification and release approval remain separate decisions.

An old key without a rule receives UNMAPPED, including a key that happens to appear in the final set. This contract requires an explicit redirect from every inventory entry. Unchanged URLs would need a separately approved no-move category rather than an inferred exemption. A missing target means a walk left the declared graph without reaching a declared final key.

Three fictional paths compare a direct declared final, a chain with an intermediate path and a loop with its repeated key retained. None is an observed HTTP response.

Run the complete validator without network access

Save the following as validator.py. It uses ordinary Python containers and does not import an HTTP client. The report names its mode OFFLINE_DECLARATIONS_ONLY so a saved JSON result carries the execution boundary with it. It also includes the configured hop limit, making later comparisons possible when a reviewer changes that budget.

"""Offline version1 path+query graph; never performs an HTTP request."""
def valid_key(value):
    return (isinstance(value, str) and value.startswith('/')
            and not value.startswith('//') and value.isascii()
            and not any(c.isspace() or c in '#\\' for c in value))


def validate(manifest, max_hops=3):
    if type(max_hops) is not int or not 1 <= max_hops <= 100:
        raise ValueError('max_hops must be an integer from 1 to 100')
    if not isinstance(manifest, dict) or set(manifest) != {'version', 'old', 'finals', 'rows'}:
        raise ValueError('exact manifest keys required')
    if type(manifest['version']) is not int or manifest['version'] != 1:
        raise ValueError('version1 required')
    old, finals, rows = (manifest[k] for k in ('old', 'finals', 'rows'))
    for values in (old, finals):
        if not isinstance(values, list) or not all(valid_key(v) for v in values):
            raise ValueError('old/finals must be lists of valid keys')
        if len(set(values)) != len(values):
            raise ValueError('duplicate inventory/final key')
    if not isinstance(rows, list):
        raise ValueError('rows must be a list')
    rules = {}
    for row in rows:
        if not isinstance(row, dict) or set(row) != {'source', 'target', 'status'}:
            raise ValueError('exact row keys required')
        source, target, status = (row[k] for k in ('source', 'target', 'status'))
        if not valid_key(source) or not valid_key(target):
            raise ValueError('invalid source/target key')
        if type(status) is not int or status not in (301, 308):
            raise ValueError('only declared301/308 allowed')
        if source in rules:
            raise ValueError('duplicate source key')
        rules[source] = row
    if set(finals) & set(rules):
        raise ValueError('a declared final also has a redirect rule')
    def walk(start):
        path, statuses, seen = [start], [], {start}
        current = start
        if current not in rules:
            return {'source': start, 'result': 'UNMAPPED', 'path': path, 'statuses': statuses}
        while True:
            row = rules[current]
            current = row['target']
            path.append(current)
            statuses.append(row['status'])
            if current in seen:
                result = 'LOOP'
                break
            seen.add(current)
            if current in finals:
                result = 'DIRECT_DECLARED_FINAL' if len(statuses) == 1 else 'CHAIN_DECLARED_FINAL'
                break
            if current not in rules:
                result = 'MISSING_TARGET'
                break
            if len(statuses) >= max_hops:
                result = 'HOP_LIMIT'
                break
        return {'source': start, 'result': result, 'path': path, 'statuses': statuses}
    return {'version': 1, 'mode': 'OFFLINE_DECLARATIONS_ONLY',
            'max_hops': max_hops, 'results': [walk(s) for s in old],
            'extra_sources': sorted(set(rules) - set(old))}
Enter fullscreen mode Exit fullscreen mode

The extra_sources field lists rule sources that are absent from the old inventory. It does not mean those rules are unused or unreachable. An intermediate source such as /mid can be necessary to resolve an inventory entry while still appearing in that list. Use the field to reconcile scope, then inspect the retained paths before deciding whether a row is redundant.

The function creates new path and result lists and leaves the supplied manifest unchanged. That property matters when a review compares proposed maps. A validator that rewrites source rows while resolving them would make the submitted version harder to recover. The suite includes an input-preservation assertion; it establishes that behavior for the tested fixture, rather than proving every possible caller interaction safe. Traversal follows only exact source matches. There is no relative-reference resolution, query inheritance or decoding step. A target ending with a query value remains that exact target. A production server might apply prefix rules or append arguments automatically. Those behaviors require another observation surface and should not be imagined into this offline walk.

Make the chain-flattening defect fail visibly

Save the following as test_validator.py beside the validator. The inspect wrapper normally returns the validator's report unchanged. With FLATTEN_CHAIN=1, it deliberately changes a chain label into a direct label and removes intermediate evidence. The wrapper is a controlled defect used to challenge the assertions; it is not an alternative implementation to deploy.

import copy
import os
import unittest
from validator import validate


def manifest(rows=None, old=None, finals=None):
    return {'version': 1, 'old': ['/old'] if old is None else old,
            'finals': ['/new'] if finals is None else finals,
            'rows': [{'source': '/old', 'target': '/new', 'status': 301}] if rows is None else rows}


def edge(source, target, status=301):
    return {'source': source, 'target': target, 'status': status}


def inspect(data, **kwargs):
    report = validate(data, **kwargs)
    if os.getenv('FLATTEN_CHAIN') == '1':
        for row in report['results']:
            if row['result'] == 'CHAIN_DECLARED_FINAL':
                row['result'] = 'DIRECT_DECLARED_FINAL'
                row['path'] = [row['path'][0], row['path'][-1]]
                row['statuses'] = [row['statuses'][0]]
    return report


class RedirectTests(unittest.TestCase):
    def test_direct(self):
        self.assertEqual(inspect(manifest())['results'][0]['result'], 'DIRECT_DECLARED_FINAL')
    def test_chain_classification(self):
        d = manifest([edge('/old', '/mid'), edge('/mid', '/new')])
        self.assertEqual(inspect(d)['results'][0]['result'], 'CHAIN_DECLARED_FINAL')
    def test_chain_trace(self):
        d = manifest([edge('/old', '/mid', 308), edge('/mid', '/new')])
        row = inspect(d)['results'][0]
        self.assertEqual((row['path'], row['statuses']), (['/old', '/mid', '/new'], [308, 301]))
    def test_loop(self):
        d = manifest([edge('/old', '/mid'), edge('/mid', '/old')])
        self.assertEqual(inspect(d)['results'][0]['path'], ['/old', '/mid', '/old'])
        self.assertEqual(inspect(d)['results'][0]['result'], 'LOOP')
    def test_self_loop(self):
        self.assertEqual(inspect(manifest([edge('/old', '/old')]))['results'][0]['result'], 'LOOP')
    def test_missing(self):
        self.assertEqual(inspect(manifest([edge('/old', '/absent')]))['results'][0]['result'], 'MISSING_TARGET')
    def test_unmapped(self):
        self.assertEqual(inspect(manifest(rows=[]))['results'][0]['result'], 'UNMAPPED')
    def test_limit(self):
        d = manifest([edge('/old', '/a'), edge('/a', '/b'), edge('/b', '/c'), edge('/c', '/new')])
        self.assertEqual(inspect(d)['results'][0]['result'], 'HOP_LIMIT')
    def test_duplicate_source(self):
        with self.assertRaises(ValueError): inspect(manifest([edge('/old', '/new'), edge('/old', '/other')]))
    def test_terminal_overlap(self):
        with self.assertRaises(ValueError): inspect(manifest([edge('/old', '/new'), edge('/new', '/else')]))
    def test_query_exact(self):
        d = manifest([edge('/old?market=uk', '/new?market=uk')], ['/old?market=uk'], ['/new?market=uk'])
        self.assertEqual(inspect(d)['results'][0]['path'][-1], '/new?market=uk')
    def test_query_order_distinct(self):
        d = manifest([edge('/old?a=1&b=2', '/new')], ['/old?b=2&a=1'])
        self.assertEqual(inspect(d)['results'][0]['result'], 'UNMAPPED')
    def test_path_identity(self):
        d = manifest([edge('/Old', '/new'), edge('/%6Fld', '/new')], ['/old', '/Old/', '/%6fld'])
        self.assertEqual([r['result'] for r in inspect(d)['results']], ['UNMAPPED'] * 3)
    def test_fragment_rejected(self):
        with self.assertRaises(ValueError): inspect(manifest([edge('/old#x', '/new')]))
    def test_absolute_rejected(self):
        with self.assertRaises(ValueError): inspect(manifest([edge('https://example.org/old', '/new')]))
    def test_status_rejected(self):
        with self.assertRaises(ValueError): inspect(manifest([edge('/old', '/new', 302)]))
    def test_duplicate_inventory(self):
        with self.assertRaises(ValueError): inspect(manifest(old=['/old', '/old']))
    def test_input_unchanged(self):
        d = manifest(); before = copy.deepcopy(d); inspect(d); self.assertEqual(d, before)
    def test_extra_source(self):
        d = manifest([edge('/old', '/new'), edge('/unused', '/new')])
        self.assertEqual(inspect(d)['extra_sources'], ['/unused'])
    def test_bad_version(self):
        d = manifest(); d['version'] = True
        with self.assertRaises(ValueError): inspect(d)


if __name__ == '__main__':
    unittest.main(verbosity=2)
Enter fullscreen mode Exit fullscreen mode

Run FLATTEN_CHAIN=1 python3 -m unittest test_validator first. In the recorded local run, twenty tests executed and two failed: the chain-classification assertion and the chain-trace assertion. The failures identify different losses. One checks the meaning of the result label; the other checks the intermediate key and complete sequence of declared status values.

Then run python3 -m unittest test_validator with that environment variable absent. All twenty tests pass against the same validator and unchanged assertions. Removing the defective reporting wrapper restores both the chain classification and its trace. The exercise did not change expected outcomes to make a report green, and it did not contact a web server during either run.

The remaining tests cover a direct route, cycles, a self-loop, a missing target, an unmapped key and the traversal limit. They also exercise duplicate-source rejection, contradictory terminal declarations, exact query handling, path identity and rejected input forms. The version check rejects a Boolean even though Python treats Boolean values as an integer subtype; this contract requires an actual integer version.

A green suite is evidence about these assertions and this implementation under the recorded interpreter. It does not establish exhaustive graph correctness. In particular, the tests do not characterize memory use on a large migration, concurrent edits to an external file or the behavior of a deployment generator. Add a case when the receiving contract adds a relationship that this suite does not currently express.

Inspect a mixed inventory instead of a success count

Build a small manifest containing one direct route and one chain, then add a separate two-node loop. Include an old key with no row and a row whose target is absent from both the rule map and final set. Finally, include a route whose continuation extends beyond the chosen hop budget. These inputs should produce different results in one report, without a network lookup repairing any missing declaration.

For the chain, retain /old, /mid and /new together with the two declared statuses. For the loop, retain the return to its starting key. For the absent target, retain the last known edge so a reviewer can identify the missing declaration. The output is useful because it preserves a reason and a trace for each inventory item, rather than merely counting how many endpoints looked plausible.

If a release policy allows only direct mappings, both CHAIN_DECLARED_FINAL and HOP_LIMIT stay out of the approved set. They remain different investigation tasks. The chain already identifies a declared terminal within the budget. The limited walk does not establish the eventual endpoint. Increasing the budget can collect a longer static trace, but it cannot authorize that route for release.

Treat inventory completeness as a separate question. An UNMAPPED result proves only that the supplied inventory key has no exact rule in this manifest. It cannot reveal old URLs omitted from the inventory itself. Reconcile the inventory against the agreed source evidence before interpreting complete coverage of this file as complete coverage of the migration.

Keep query identity explicit during remediation

A source with ?market=uk is a distinct key in this exercise. If a reviewer supplies only the path without that query, the walk must not invent a match. Likewise, a final declaration containing the query differs from a declaration without it. Exact identity exposes the point where a broader query-handling policy would otherwise disappear inside an implementation convenience.

Reordering parameters might be harmless for one application and meaningful for another. The fixture does not decide. Repeated parameter names, empty values and encoded delimiters can require additional policy even when a string passes the simple key guard. Add examples from the authorized route specification before replacing exact identity with a canonical key. Avoid a blanket sort-and-decode operation whose behavior the review has never agreed.

The same restraint applies to case and encoding. A lowercase key does not establish how the deployed server treats an uppercase path. Two percent-encoded spellings might resolve identically in a particular environment, but this function compares their supplied bytes as ASCII text. That limit makes the output reproducible without pretending that it is a browser or an application router.

When remediation shortens a chain, save a revised map rather than editing the original input in place. Link the revision to the report that prompted it. A later reviewer should be able to see the old intermediate route, the replacement edge and the reason the change was accepted. Re-run the unchanged assertions, then add any new expectation introduced by the revision.

Turn classifications into evidence requests

A direct declaration needs deployed verification before anyone calls it an observed redirect. Ask the receiving implementer for the exact source request and response evidence under the agreed environment. The record should identify when and how the request was made, the actual status and destination and any relevant query policy. A static declaration cannot supply those observations on the implementer's behalf.

A chain needs an explicit route decision. The owner might replace its first edge with the final destination, retain a necessary intermediate step under an approved exception or explain why the proposed map does not describe the deployed rules. Keep that decision outside the graph function. The function reports what the supplied rows imply, while the accountable reviewer decides what the deployment should do.

A loop needs the conflicting edges and their owners. Returning only a generic error would leave the receiving team to repeat the walk. The retained repeated key shows where the path re-enters known territory. Before changing an edge, inspect whether other inventory entries depend on that source. A local loop repair can change several paths even when only one submitted row is edited.

For a missing target, ask whether the destination should be declared final, should have its own continuation or was mistyped. Each answer changes a different part of the input contract. Do not automatically add every unknown target to the final set. That would erase the very evidence gap the result was meant to expose and turn missing knowledge into an unsupported declaration.

For a hop limit, return the retained prefix and the configured limit. A larger bounded run may help inspect the remaining graph, but it should be recorded as another run with its own budget. If the release rule already disallows chains, discovering the eventual endpoint does not remove that policy failure. Keep the investigation question and approval condition together.

Review the boundary cases the suite does not settle

Two old sources may intentionally point to one final key. The manifest does not prohibit that relationship. The graph check answers whether each source reaches a declaration, not whether merging the content is appropriate. A reviewer should retain a separate reason for the merge and the content evidence used to approve it. Conversely, one old source cannot have two rows in this contract. That difference is intentional: many sources sharing a target can be represented without ambiguity, while competing rows for one source require a selection policy the function does not have.

The guard also does not remove dot segments. A key containing a parent-directory segment remains an exact string rather than being resolved against another path. That is another reason to describe the accepted representation precisely. If a deployment tool interprets those segments differently, introduce a dedicated preprocessing contract or reject them in a revised version. Do not quietly add resolution to the existing function and compare the new report with an older one as though both runs used the same identity rules. The change affects what counts as a source match.

Unknown top-level fields and unknown row fields are rejected. This strictness avoids silently ignoring a supplied condition such as a market selector that the walker cannot evaluate. It also means a harmless descriptive field cannot simply be added to the manifest. Keep commentary in a separate review record or deliberately revise the schema. A future version can support annotations while defining which fields affect route selection. Until that version exists, a rejected package is an input-contract disagreement, with no basis for claiming that the ignored condition was honored.

The final list cannot verify its own authority. A reviewer could declare every absent destination final and make the missing-target classifications disappear without changing a single edge. The resulting report would describe the revised declarations accurately while answering a weaker acceptance question. Keep the reason for final membership outside the program and compare that approved list separately during review. This is the same evidence problem as checking a generated checksum against a manifest that an untrusted producer can edit: internal consistency has value, but it does not create an independent source of truth.

A shared intermediate route can affect multiple old entries. If /a and /b both reach /mid, changing the rule for /mid changes both walks. Recompute all inventory results after that edit. Comparing only the row associated with the original ticket would miss the other dependency. Preserve the old and revised reports so reviewers can identify which paths changed, including paths whose classification stayed the same but whose intermediate evidence differs. An unchanged label does not imply an unchanged proposed route.

For saved reports, compare arrays and fields rather than formatting alone. The same path can be printed with different indentation, while a flattened path can look reassuringly short. A review should distinguish presentation changes from changed source keys, target keys, declared statuses and stopping reasons. A future comparison tool could automate those field differences. The supplied suite does not implement such a tool, so this remains a receiving-review procedure rather than an additional executed feature. The input-preservation test protects the original in memory; it does not supply a complete version-history system.

Finally, invalid input should remain distinct from a clean report with zero inventory entries. The present contract permits an empty old list and returns no per-source results. That is a valid empty scope, not evidence that a migration has no old URLs. A surrounding release process can require a nonempty approved inventory when that is appropriate. Record that requirement where the scope is approved. Adding a success banner merely because the result list contains no unresolved entries would otherwise turn a missing inventory into apparent readiness without this graph function ever making that claim. A traversal budget bounds the number of edges retained for each inventory source, but it does not bound the total size of the submitted manifest. The function still reads all declared rows and constructs a source lookup. A service accepting untrusted uploads would need separate file-size and row-count limits, plus controlled error reporting. Those controls are outside this standalone tutorial. No load test was performed, and the twenty correctness checks should not be cited as a capacity measurement. Keep a production ingestion limit separate from the per-path hop limit so that one number does not pretend to control two different resources.

Separate the static artifact from a migration decision

The report should travel with the manifest version, the exact input and the test revision used to interpret it. A hash can help distinguish saved input files, but it is not proof that the declarations are true. Whoever receives the artifact still needs to know which inventory supplied the scope, who approved the terminal list and which unresolved results require an owner.

The validator does not check content relevance at the destination. A direct declaration to an unrelated page would satisfy its graph relationship. Content acceptance therefore needs its own evidence. The same applies to canonical declarations, robots directives, authentication, regional routing and JavaScript navigation. Those concerns are outside this program, and none becomes verified merely because a graph walk ends.

Use this artifact as a configuration review aid, followed by separately scoped deployment observations. Preserve unresolved results when those observations are unavailable. The useful deliverable is an inspectable relationship between each supplied old key and its proposed route, with a clear stopping reason. That gives the next reviewer something concrete to challenge before a convenient final address becomes a claim of completed migration.

More insights to read

  1. Technical SEO Release Gates for JavaScript Sites: Compare Source and Rendered HTML
  2. AI Citation Monitoring: Normalize URLs Without Losing the Evidence
  3. AI Visibility Baseline: Keep Mentions and Citations Apart
  4. A Cited URL Needs Its Own Evidence: Redirects and Canonical Targets
  5. Technical SEO Fixes: Name the Implementer Before Accepting the Audit

About the author

Portrait of Dmytro Nasyrov wearing a dark suit and light blue shirt against a dark background.

Dmytro Nasyrov. Photo supplied by the author.

Written by Dmytro Nasyrov PhD, software architect with 24 years of production experience. Dmytro is the CEO of TopClick.

Top comments (0)