Skip to content

ci(docs): nothing verifies apps/docs/redirects.mjs destinations resolve — the table can rot with CI fully green #9014

Closed

Description

@os-project-manager

Follows from #8948, where three entries in apps/docs/redirects.mjs were found aiming at pages that do not exist — live URLs answering a permanent 308 into a 404. Those three destinations are repaired on the #8948 branch; this card is about the blind spot that let them rot unnoticed, not the three entries.

The blind spot, measured

apps/docs/redirects.mjs is verified by nothing:

  • node scripts/pm/dispatch-gates.mjs apps/docs/redirects.mjs → "No check family names the given paths in its own source", over 98 discovered families.
  • git grep -l 'redirects.mjs' -- scripts .github → 0 hits. Its only consumer is apps/docs/next.config.mjs.
  • Check Documentation Links (check-links.yml) runs lychee --offline over content/** plus the two root markdown files. The redirect table is outside that surface, and a redirect destination is not a link in any file lychee reads.

So the failure is silent in the worst way: the rot is invisible to CI and invisible to the docs-links lane that looks like it would cover it.

What a gate would assert

A dependency-free resolver over the exported docsRedirects, run in Node with no install step. Three assertions, all of which have already been exercised by hand on the #8948 branch:

  1. Every destination resolves to a real page, the way Fumadocs routes it: /docs/x → content/docs/x.mdx, content/docs/x.md, content/docs/x/index.mdx, or content/docs/x/index.md; bare /docs → content/docs/index.mdx.
  2. Wildcard destinations resolve to a real directory. /docs/kernel/runtime-services/:path* cannot be resolved as a page — assert the target directory exists so a rewritten path can land on something.
  3. No chains. A destination that is itself matched by a source in the table costs the old URL a second round trip, and every hop is SEO signal lost. The rule is already written into the table's own comments (maintainer ruling 2026-08-15, the retired-deployment-pages block) — it is currently enforced by nothing but reviewer attention. Matching must follow Next's own semantics: '/a/:path*' matches /a and /a/deep/path, exact sources otherwise, first match wins.

Scale for context: 92 entries today.

Where it hooks in

scripts/check-docs-redirects.mjs + a check:docs-redirects script in the root package.json. Two candidate lanes, and the choice is a real decision rather than an obvious one:

  • lint.yml's required TypeScript Type Check job — where the other check:* families live, no paths filter, so it cannot go dormant. Correct home if the gate is meant to block.
  • check-links.yml — topically the right neighbour, but that lane is deliberately advisory and carries no merge_group trigger; its header warns that promoting it into the required set without adding merge_group in the same change stalls the queue. Landing there means the gate does not block.

Recommendation: the required lane. This class of defect reaches users directly and the check is cheap (pure filesystem, no deps, no network, milliseconds).

How it would be proven capable of failing

This is the part that matters — a resolver that silently resolves everything is exactly the failure mode being fixed, and a table that is currently 100% green cannot distinguish a working gate from a no-op one. Both directions were already measured by hand while fixing #8948, and both belong in a --self-test in the script itself (the check-nul-bytes.mjs / check-role-word.mjs pattern — a self-test over a temp fixture that runs the real code path, ahead of the real scan):

  • Dead detection: a genuine red → green transition on the same script — 3 dead reported against main, 0 after the repair, same 92 entries. Not a constructed case; it is the defect the card was filed for.
  • Chain detection: has never fired against the real table, so it needs a positive control. Pointing one entry at /docs/guides/skills — a URL that is itself a source at line 79 — produced DEAD/CHAIN … [CHAIN -> matched by source '/docs/guides/skills'], chained: 1, exit 1. Reverted afterwards.
  • The wildcard limb needs its own control too: it is the one assertion with no live failing example, so a fixture whose wildcard destination names a missing directory should be part of the self-test rather than trusted on inspection.

A gate whose chain and wildcard limbs have never been observed to fire is two-thirds a no-op, whatever its exit code says.

Note for whoever picks this up

The ~30-line resolver used on #8948 is in that PR's description as the verification method, not as shipped code — the card was scoped to the three destinations deliberately. Reimplementing it here as a self-tested gate is the work.

Generated by Claude Code

Activity

  1. changed the issue type fromtoon Aug 16, 2026
  2. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor

    Triage: lands in scripts/check-docs-redirects.mjs + root package.json + lint.yml; rationale: concrete gate with a measured blind spot (0 verifiers over 92 entries, both misses demonstrated by hand on the #8948 branch) and a self-test discipline already specified — no open question ⇒ pm:queue, domain:devx.

    Triage: this comment comes from the triage seat Routine; not a claim.


    Generated by Claude Code

  3. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor

    PM dispatch: claimed for the domain:devx lane, pm:queue → pm:dispatched. Session session_011RB4waLuNbdruCo6X9oobm, branch claude/issue-9014-docs-redirects-gate. The implementing agent will post its own claim comment with its session ID before it writes code; if you are reading this and hold an earlier claim, say so and I will stand down.

    Two sequencing facts that changed since triage wrote its comment, both now settled:

    The consequence of the first is the reason the card's own hardest requirement is now load-bearing rather than nice-to-have: with the table at 92/92 green, a live run can no longer distinguish a working gate from one that resolves everything. The dead-detection red→green transition is recoverable from history (redirects.mjs at 62b2655d8^), the chain and wildcard limbs are not — they need fixtures.


    Generated by Claude Code

  4. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor

    Dev claim: implementing agent for this card. Session session_011RB4waLuNbdruCo6X9oobm, branch claude/issue-9014-docs-redirects-gate, worktree cut from origin/main at 6468df115.

    Re-read the comment thread before this claim: the only prior claim is the PM dispatch comment above, carrying the same session ID, so there is no competing claim to stand down for.

    Plan follows the card: scripts/check-docs-redirects.mjs (dependency-free, pure filesystem) + check:docs-redirects in the root package.json + wiring into the required lane, with a --self-test carrying positive controls for all three limbs — dead destination, wildcard-directory, and chain — over a temp fixture running the real code path. The dead-detection limb additionally gets a real red→green transition recorded against apps/docs/redirects.mjs at 62b2655d8^ vs. main. The check-links.yml merge_group claim will be re-measured against the workflow file before the lane choice is wired.


    Generated by Claude Code

  5. os-project-manager commented on Aug 16, 2026

    @os-project-manager
    CollaboratorAuthor
    {
      "issue": 9014,
      "status": "done",
      "branch": "claude/issue-9014-docs-redirects-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9036",
      "premise_still_valid": true,
      "summary": "Shipped scripts/check-docs-redirects.mjs (dependency-free, pure filesystem, no install step), the check:docs-redirects root script, and a step in lint.yml's required ESLint job. Three assertions: destinations resolve the way Fumadocs routes them (.mdx / .md / dir index, bare /docs), wildcard destinations resolve to a real directory, and no destination is itself matched by a source, following Next's semantics ('/a/:path*' matches /a and /a/deep/path, exact otherwise, first match wins). Premise re-derived on the branch base 6468df115 and still true: dispatch-gates named no family for apps/docs/redirects.mjs over 99 discovered, git grep in scripts/.github had 0 hits, table is 92 entries. DEVIATION, evidence in the PR body: the card's lane recommendation is right (check-links.yml verified to carry only workflow_dispatch + pull_request, no merge_group) but its JOB is not — the step goes in lint.yml's ESLint job, not TypeScript Type Check. Both are required contexts on a merge_group workflow so the blocking argument is unaffected, but REQUIRED_CONTEXTS records ESLint as carrying 'the whole check:* gate family' and every docs guard sits there, while typecheck additionally runs a full workspace build this check has no use for. Also noting for the record: origin/main moved to 67f2892fc under me mid-task (shared refs), so `git diff origin/main HEAD` was contaminated by main's advance; the gate union was derived from the merge-base diff instead.",
      "tests": "All at the head commit 58b0b9d97, tree clean. Union re-derived from the final merge-base diff via `node scripts/pm/dispatch-gates.mjs .github/workflows/lint.yml package.json scripts/check-docs-redirects.mjs`, then run: check:docs-redirects PASS, check:node-version PASS, check:required-contexts PASS, check:shard-attestation PASS, check:workflow-status-functions PASS, check:nul-bytes PASS (5953 files), check:type-check-coverage PASS, `pnpm exec eslint scripts/check-docs-redirects.mjs` PASS. check:type-check-debt NOT run and not glossed: its coupling is real (check-type-check-coverage.mjs's observed() reads turbo.json, lint.yml and the root package.json — two files this PR edits), but that half ran green as check:type-check-coverage; what --re-measure adds is tsc per DEBT/TEST_DEBT ledger entry, which refuses on an unbuilt closure and cannot be moved by a diff with no TypeScript, tsconfig or test file. || PROOF THE GATE CAN FAIL, which the card made the deciding requirement. (1) Real red->green from history, not construction: the finished script run unchanged in a detached worktree reports 3 DEAD / exit 1 at 62b2655d8^ and OK / exit 0 at 62b2655d8, same 92 entries, and the three it names are exactly the #8948 entries at positions 45, 58, 77. `git diff --diff-filter=DR 62b2655d8^ HEAD -- content/docs` is empty, so no page moved to do the work. (2) The chain limb fired against REAL data for the first time: pointing live entry 56 at /docs/guides/skills (itself the source at entry 57) produced 1 DEAD + 1 CHAIN, exit 1, chain attributed to entry 57; reverted, `cmp` against a pre-ablation copy confirms byte-identical restore and the gate green again. (3) --self-test: 48 assertions over a temp fixture running the real loadTable + checkTable path (module import included). A clean table over the fixture content tree reports ZERO findings and a dirty table is asserted by its EXACT 10-finding set, so an over-eager checker fails as loudly as a blind one. Controls include the two that separate a real resolver from a lazy one — a destination at a directory that exists but has no index page, and a wildcard prefix that exists as a file — plus chain controls for exact source, :path* on a deep url, :path* on the BARE url (zero segments), and a :path+ reachable only by the deep probe; and loader refusals for empty table / missing export / malformed entry. Worth flagging: the first draft of the dirty fixture accidentally contained two chains I had not intended and the checker reported them — the fixture was wrong and the gate was right, recorded in a comment there. || Blind spot measurably closed: `git grep -l 'redirects.mjs' -- scripts .github` 0 -> 2 hits, and dispatch-gates for apps/docs/redirects.mjs went from 'No check family names the given paths in its own source' to naming pnpm check:docs-redirects.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Notes for the PM, outside the JSON:


    Generated by Claude Code

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

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions