Skip to content

DOC-6951 Add a scanner for pages that moved without an alias - #3767

Open
andy-stark-redis wants to merge 7 commits into
mainfrom
DOC-6951-redirect-map
Open

DOC-6951 Add a scanner for pages that moved without an alias#3767
andy-stark-redis wants to merge 7 commits into
mainfrom
DOC-6951-redirect-map

Conversation

@andy-stark-redis

@andy-stark-redis andy-stark-redis commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Adds a scanner that finds content pages which moved without gaining an alias for their old URL — item A of DOC-6951. Tooling and tests only: no content changes, and nothing published yet.

Why

Renaming a content file changes its URL, and the old URL 404s unless the page declares an aliases: entry. That's author-declared and measurably unreliable — across this repo's history, 291 of 598 URL-changing moves carry a matching alias. It also fails inconsistently within a single commit: 155277839 moved LangCache and Agent Memory under context-engine together, LangCache got an alias, Agent Memory didn't, and develop/ai/agent-memory is a live 404 today.

What's here

File
build/check_missing_aliases.py the scanner
build/test_check_missing_aliases.py 20 tests
Makefile make check_aliases, make check_aliases_fix

One implementation, three modes, so the rule can't drift between them:

make check_aliases                                           # full-history sweep
python3 build/check_missing_aliases.py --range main..HEAD     # what this branch moved
make check_aliases_fix                                        # write the aliases in

Also --json, --github (Actions annotations), --fail (exit non-zero). Warn-only by default, matching check_page_sizes.

Current output:

check_missing_aliases: 598 URL-changing move(s) found.
  291 already aliased, 257 missing an alias, 22 skipped (old URL is a live page),
  28 need a decision (collision).

The point of it is avoiding false positives

A naive first version reported 961 missing aliases against a true 257 — nearly three quarters noise, and every extra looked plausible in a list. Seven traps are handled explicitly, each documented in the module docstring with the measurement behind it:

  1. Hugo bundlesindex.md and _index.md both publish at the containing directory's URL, so commands/lpushx/index.mdcommands/lpushx.md changes no URL. 527 renames are this shape: the largest single source of noise.
  2. Non-published dirscontent/embeds/ has a build.render: never cascade (240 renames). Read off the tree, not hardcoded, so a future one is caught free.
  3. url: frontmatter — set on exactly the 3,107 versioned files and nowhere else, so path→URL derivation is exact for the rest and invalid for those.
  4. Chains — a page moved twice resolves to its final home.
  5. Path reuse — 22 old URLs are live pages today and must never be redirected.
  6. Alias shapes — 490 block lists, 104 bare scalars, 49 aliases: null, 44 single-line inline, 39 bare, 19 multi-line inline, and 1 folded multi-line scalar. A scalar is whitespace-separated, because Hugo casts it with cast.ToStringSlice.
  7. Collisions — 28 gaps name a URL another page already claims (24 by an existing alias, 4 by another moved page). Hugo picks a winner arbitrarily and only warns, so these are reported for a human and never auto-fixed.

Verification

--fix was run over the whole corpus and then reverted:

  • 257 aliases across 203 files
  • every changed file still valid YAML, no empty or null entries
  • no new duplicate alias claims anywhere in content/ (29 before, 29 after — all pre-existing)
  • a rescan closes to zero remaining, with no files skipped
  • 21 unit tests cover every frontmatter shape above, including round-tripping each one back out

Review round (eea076141)

Both Bugbot findings were valid and are fixed:

  • --fix always exited 0, even when it couldn't place some aliases — which would have let the planned weekly sweep open a PR captioned as a complete fix. apply_fixes now reports what it skipped and --fail exits 1.
  • Rename chains were assigned rather than merged, silently dropping an earlier chain if a second file later moved onto the same path. It happens once in this history, degenerately, but the loss would have been invisible.

Retaining that record then exposed something Bugbot hadn't flagged: 14 (old_url, new_path) pairs arrive twice, 13 of them via chains and so predating the merge fix. A page moved away and back, or a recurring rename like the monthly Cloud changelog, yields two records for one redirect. Now deduplicated.

Correction (f72e0a55b)

I had reported one file as a content bug — a folded multi-line aliases scalar whose second alias "had never worked". That was wrong, and it was my scanner's bug, not the content's. Hugo casts a scalar aliases value with cast.ToStringSlice, which runs strings.Fields, so a bare string is split on whitespace into several aliases. PyYAML instead folds it to the single string "/a/ /b/" — correct YAML, but not what Hugo does. Reproduced against Hugo 0.143.1 in a throwaway site, and both of that page's aliases resolve live today. The scanner now matches Hugo, that page reads as already aliased, and --fix has no skipped files at all.

Together these moved the figures from 611/302/259 to 598/291/257.

One content issue found on the way (not fixed here)

27 pre-existing duplicate aliases involving non-versioned pages, unrelated to this change — two or more pages claiming the same alias, which Hugo resolves arbitrarily.

(An earlier revision of this description also listed query-performance-factor.md as broken. It isn't — see the correction above.)

Known gap

No full Hugo build has been run against a fixed corpus, so freedom from build-time alias warnings is argued from duplicate-claim analysis rather than observed. That belongs with item B1, the historical backfill, where content actually changes.

Not in this PR

The 257-alias backfill (B1), the PR check and weekly sweep (C), and the published redirect map plus JSON tombstones (D). Deliberately split so the backfill's content diff is reviewable on its own.

🤖 Generated with Claude Code


Note

Low Risk
Build-time git analysis and optional frontmatter edits; no runtime or published-site behavior until CI or check_aliases_fix is adopted.

Overview
Adds warn-only tooling to catch Hugo content renames that change a published URL without an aliases: redirect, so old links 404.

build/check_missing_aliases.py walks git rename history (default origin/main..HEAD or --all), derives old/new URLs with Hugo-aware rules (bundles, build.render: never, versioned url: trees, draft pages, section splits, occupied paths, alias collisions), and reports gaps. Optional --fix inserts missing aliases via line-oriented frontmatter edits (no YAML round-trip), plus --json, --github, and --fail for CI.

Makefile exposes make check_aliases and make check_aliases_fix, mirroring the existing passive check_page_sizes pattern. build/test_check_missing_aliases.py covers URL derivation, move classification, and every aliases frontmatter shape the fixer handles.

No doc content is changed in this PR—scanner and tests only.

Reviewed by Cursor Bugbot for commit b72cfc1. Bugbot is set up for automated code reviews on this repo. Configure here.

Renaming a content file changes its URL, and the old URL dies unless the page
declares an alias for it. That is author-declared and measurably unreliable --
611 URL-changing moves in this repo's history, only 302 aliased. It fails
inconsistently even inside one commit: 1552778 moved LangCache and Agent
Memory under context-engine together, LangCache got an alias, Agent Memory did
not, and its old URL 404s today. So the check has to be mechanical.

The scanner walks git rename records, works out which renames actually changed a
published URL, and reports the ones with no alias. One implementation serves
three modes -- a branch range for a PR check, a full-history sweep, and a
writing fix -- deliberately, because the recurring failure on DOC-6939 has been
the same rule implemented twice and then drifting apart.

The whole difficulty is false positives. A naive first version reported 961
missing aliases against a true 259, and every one of the extras looked
plausible in a list, which is the dangerous kind of wrong. Two thirds of that
noise was Hugo bundles: index.md and _index.md both publish at the containing
directory's URL, so renaming commands/lpushx/index.md to commands/lpushx.md
changes no URL at all, and 519 of this repo's renames are that shape.

Writing the scanner a second time is what found the rest, after a 25-URL live
sample had already blessed the first version. Five things it had wrong. 49 files
spell the empty key "aliases: null", which the first fixer promoted into a list
containing a literal null. 104 files give aliases a bare scalar instead of a
list -- a shape I had not seen at all, and my earlier "192 empty keys" figure
was really 88 empty plus those 104. One file uses a folded multi-line scalar,
which is valid YAML that quietly folds two intended aliases into one string, so
that author's second alias has never worked; the fixer refuses that file rather
than guess. content/embeds/ is excluded from the site by a build.render never
cascade rather than by any naming convention, so the underscore heuristic missed
119 files. And 28 of the gaps are collisions where another page already claims
the old URL, where Hugo picks a winner arbitrarily and warns -- adding those
automatically would have made 28 redirects ambiguous instead of fixing them.

The frontmatter edit is line-based on purpose. Round-tripping through PyYAML
reorders keys alphabetically and renormalizes quoting, which would rewrite every
file it touched into an unreviewable diff.

Verified by running the fix over the whole corpus: 257 aliases across 203 files,
every changed file still valid YAML with no empty or null entries, no new
duplicate alias claims anywhere in content, and a rescan closing to the single
file the fixer correctly declines. Then reverted -- the backfill is its own item
and wants its own PR.

Learned: writing the measurement a second time found five bugs that a live-sampled first version had already passed, and the bundle trap alone was two thirds of the false positives
Constraint: --fix must skip any move whose old URL is already claimed by another page or wanted by another moved page, because Hugo resolves a duplicate alias by picking one arbitrarily and only warning
Constraint: frontmatter edits here stay line-based -- a yaml.safe_load/yaml.dump round-trip reorders keys and renormalizes quoting, rewriting every touched file
Constraint: whether a path is published is read off the tree via a build.render never cascade, not inferred from an underscore-prefixed directory name
Gaps: no full Hugo build has been run against the fixed corpus yet, so freedom from build-time alias warnings is argued from duplicate-claim analysis rather than observed
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

DOC-6951

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at feceb6d

Comment thread build/check_missing_aliases.py Outdated
Comment thread build/check_missing_aliases.py Outdated
Both Bugbot findings were real, and one of them led somewhere it had not
pointed.

The first is the one that mattered. With --fix, main returned 0 whether or not
apply_fixes had managed to place every alias, so the weekly sweep planned as item
C3 would have opened a pull request captioned as a complete fix while leaving
files untouched. There is already one such file, the folded multi-line scalar the
fixer declines. apply_fixes now returns what it skipped, main lists those files,
and --fail exits 1.

The second was assigning history[new] rather than merging, which silently drops
an earlier rename chain if a second file later moves onto the same path. Measured
across the whole history that happens exactly once, and in a degenerate form
where the dropped record carries the same old_path, so nothing was actually being
lost. Merging anyway, because the loss would be invisible and this script exists
to stop precisely that class of thing.

Retaining that record is what exposed the real problem underneath. It pushed the
totals to 612 moves and 29 collisions, and chasing the extra record showed that
14 old_url-to-new_path pairs arrive twice -- 13 of them through chains, so
predating the merge fix entirely. A page moved away and back, or a recurring
rename like the monthly Cloud changelog, produces two records for one redirect. A
redirect is identified by where it comes from and where it goes, so those are now
deduplicated, keeping the earliest.

That moves the published figures: 598 URL-changing moves rather than 611, 290
already aliased rather than 302, 258 safely fixable rather than 259. Collisions
are unchanged at 28, being 24 where another page already claims the URL and 4
where two moved pages both want it. Every figure in the module docstring was
re-measured rather than adjusted by hand, since they had already drifted once.

Learned: a review bot found a latent silent-drop and an automation trap in code that unit tests and a full-corpus dry run had both passed, and fixing the smaller one surfaced a larger duplicate-redirect bug that predated it
Constraint: a redirect is identified by the pair of old URL and target path, so moves are deduplicated on that pair -- chains and recurring renames otherwise report one redirect twice
Constraint: --fix must not exit 0 when it skipped a file, or an automated sweep reports a complete fix it did not make
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at eea0761

Corrects a false positive I had already written up as a content bug in three
places. One file declares its aliases as a YAML scalar folded across two lines.
PyYAML reads that as the single string "/a/ /b/", so the scanner reported the
page as missing an alias and declined to fix it, and I concluded the author's
second alias had never worked.

Hugo disagrees, and Hugo is the authority. It casts a scalar aliases value with
cast.ToStringSlice, which runs strings.Fields, so a bare string is split on
whitespace into several aliases. Reproduced against Hugo 0.143.1 in a throwaway
site: the folded frontmatter yields two entries from .Aliases and Hugo writes
both alias stubs. Both of that page's aliases resolve on the live site today, so
there was never anything wrong with it.

The lesson is one I had already written down and did not apply here. An
authoritative oracle beats a reimplementation of a spec, and a YAML library is a
reimplementation as far as Hugo frontmatter semantics are concerned. What made it
convincing was that PyYAML's reading is correct YAML -- the folding is real, and
it is Hugo that layers a whitespace split on top.

Only list items are exempt, since cast.ToStringSlice splits a string but converts
each element of a slice individually. Exactly one file in the corpus is affected,
and it now reads as already aliased, which takes the count from 290 aliased and
258 fixable to 291 and 257, and leaves --fix with no skipped files at all.

Learned: an authoritative oracle beats a library even when the library is right on its own terms -- PyYAML folds a multi-line scalar correctly, and Hugo then splits the result on whitespace, so only Hugo settles what an alias means
Constraint: a scalar aliases value is whitespace-separated because Hugo casts it with cast.ToStringSlice; list items are not split
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread build/check_missing_aliases.py
Comment thread build/check_missing_aliases.py
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at f72e0a5

An eighth trap, and the first one found by building the site rather than by
reading code. Applying the backfill and running a full Hugo build, then checking
every stub the build actually emitted, turned up two aliases Hugo had declined to
write. Both belonged to content/integrate/write-behind/_index.md, which carries
draft: true.

Production builds run plain hugo with no --buildDrafts, so a draft page emits
nothing at all -- and that includes its aliases. So the scanner was reporting a
gap it could not close: --fix wrote two aliases into a draft, reported success,
and Hugo ignored them. The same blindness runs the other way, because
published_urls treated drafts as occupying their URL, which would have suppressed
a real redirect had one pointed there. Nothing was affected today, but only by
luck: 31 files are drafts and 13 of them hold an eligible URL.

Drafts are now excluded from published_urls, a move whose target is a draft is
reported in its own category rather than counted as fixable, and the draft set is
found by grep before parsing so this does not read all 5,867 content files.

Worth recording how this was caught, because it is the argument for the build
step existing at all. The gap was invisible to unit tests, to a full-corpus dry
run, to duplicate-claim analysis, and to Bugbot. Absence of build warnings would
not have caught it either -- the build was clean at --logLevel warn, and Hugo says
nothing when it skips a draft's aliases. Only comparing the aliases we wrote
against the stubs Hugo emitted showed it.

Learned: the only check that found this compared what we wrote against what Hugo actually emitted; a clean build log is not evidence, because Hugo skips a draft's aliases silently
Constraint: a draft page publishes nothing including its aliases, so drafts are excluded from published_urls and a move whose target is a draft is never auto-fixed
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 782d3b5

Diffing two full builds -- one with the backfill applied, one without -- showed
256 new alias stubs, no real page overwritten, and one page lost. The lost path
was operate/kubernetes/release-notes/7-4-6-2, with a trailing comma, and it
returns 200 on the live site today while the comma-free spelling returns 404.

The cause was promoting a scalar aliases value to an inline list. One file holds

    aliases: /operate/kubernetes/release-notes/7-4-6-2,

because an author wrote a list without brackets, and Hugo publishes an alias whose
path ends in a comma. Written inline as [/...7-4-6-2, /new/] that comma becomes
the list separator, so the alias silently changes to the comma-free path: a
working URL turned into a 404 and a 404 into a working URL. I had also been
stripping the trailing comma deliberately, on the theory that it was a typo. It
probably is, but a mechanical backfill is not the place to decide that, and
nothing in the output would have shown the decision being made.

Scalars are now promoted to a block list instead, which has no separator to
confuse with content, so each existing token survives byte for byte. Tokens are
split on whitespace to match Hugo's cast.ToStringSlice. The repo's dominant style
is a block list anyway, so the diffs read better as a side effect. No inline list
is now written in the whole corpus.

The general point is that this was invisible in every artifact except a
build-to-build page-set diff. The build log was clean, the alias count was
correct at 1185, and 270 of 270 expected stubs were present -- the loss only
appeared as an index.html count of 6826 against a baseline 6571, one short of the
256 added.

Learned: comparing two full builds' page sets caught a silently changed live URL that a clean build log, a correct alias count and a stub-by-stub check all missed
Constraint: existing alias values are preserved byte for byte -- promote a scalar to a block list, never inline, because a value containing a comma changes meaning inside brackets
Rejected: stripping a stray trailing comma from an author's alias | it is probably a typo but the comma'd URL is live, so a mechanical backfill must not silently retire it
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 6c78b09

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6c78b09. Configure here.

Comment thread build/check_missing_aliases.py
Three findings from a second Bugbot pass, plus the one that matters, which came
from a review comment on the backfill rather than from the code.

The important one is conceptual. git records lineage, and lineage is not
equivalence. When a page is split up -- X.md becoming X/child.md -- the old URL
turns into a section URL while the file itself becomes one page inside it. The
scanner faithfully followed the file and proposed redirecting the old landing page
to that child. On the Java client docs that meant a bookmark for the generic
develop/connect/clients/java page landing on a Jedis connection guide, four
renames later, rather than on the Jedis hub that exists today. Reviewers spotted
two instances; there are six, including the same mistake in the redis-py docs.

Splits to X/_index.md are unaffected, because the URL does not change. Only a
split to a named child demotes the old URL, and those chains are now reported for
a person to decide rather than fixed. That drops the backfill from 256 to 253 and
moves three cases out of the collision bucket, where they had been landing for the
wrong reason.

Also fixed, both raised by Bugbot and both real but unrealized here. Renames
within a single commit were applied in git's listing order, so a commit holding
both A->B and B->C would lose the A move if git listed them the other way round;
ordering is now derived from the dependencies, with a cycle guard, and extracted
into order_renames so it can be tested directly. And the folded-scalar guard
treated any indented line as a continuation, including a whitespace-only one, so
an ordinary scalar followed by a padded blank line was skipped; it now requires
actual content.

One Bugbot finding was a false positive and is deliberately unchanged: it read the
inline-list branch as collapsing a two-item list into one string. The intermediate
does hold both items in one string, but the join puts them back correctly --
[/a/one/, /a/two/] plus /a/three/ yields all three, verified by parsing the result
back out.

Learned: the deepest bug in this scanner was not a coding error but a category error -- following the file instead of the meaning -- and it took a human reviewer looking at content to see it, after four rounds of measurement had passed
Constraint: a chain crossing a page-into-section split is never auto-fixed, because the old URL was a landing page and its lineage ends at one child
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 9a39894

@andy-stark-redis
andy-stark-redis requested a review from a team August 7, 2026 14:05
@andy-stark-redis andy-stark-redis self-assigned this Aug 7, 2026

@dwdougherty dwdougherty left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skimmed. LGTM.

@andy-stark-redis

Copy link
Copy Markdown
Contributor Author

Thanks @dwdougherty !

A finding and a failure shared exit code 1: --fail returned it when gaps remained,
and so did an internal git failure. The workflow in the companion PR reads that code
to decide whether the fixer declined a file, so a git failure would have produced
"some aliases could not be added automatically" -- pointing whoever read it at the
content rather than at the broken scan.

Named codes now: 0 nothing to report, 1 findings under --fail, 2 the scan itself
failed. The workflow already treats anything above 1 as a hard error, so it needs no
change beyond a comment.

Learned: a caller that cannot distinguish "I found a problem" from "I broke" will report the wrong one, and the cost lands on whoever reads the message rather than on the code
Constraint: exit 1 means findings and exit 2 means the scan failed; do not collapse them, because CI decides what to say from this
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at b72cfc1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants