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))} 

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(...
Source:https://dev.to/topclick_expert/validate-a-redirect-map-without-hiding-chains-and-loops-45ki