From 5023bdb6634d42e4e6217f01f7acb78195b97bc8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 3 Nov 2026 15:18:18 +0000 Subject: [PATCH 1/3] fix: rebuild the docs mirror when its release data has moved on A channel is decided from release data, so a mirror built from an older snapshot documents a release line that has since moved on. Reusing it publishes a channel decision nobody made, and check:projects then reports the missing channel as drift: zodsharp's prerelease channel was withheld on a 2.1.0 / 2.0.0-prerelease.28 vintage while the cache in hand carried 2.4.2 stable with 3.0.0-prerelease.2 in flight. The manifest now records the release data it was aggregated from (schema 3), and hasCurrentDocsMirror refuses a mirror whose vintage differs from the cache. A missing release dataset is also an observation rather than a blocker, so an absent cache stops masquerading as a docs defect. --- src/scripts/check-projects.ts | 18 ++++++- src/scripts/sync-docs.ts | 21 +++++++-- src/src/lib/docs/aggregate.ts | 38 ++++++++++++++- .../discovery/discovery-resources.test.ts | 4 +- src/tests/unit/docs-redirects.test.ts | 4 +- src/tests/unit/docs.test.ts | 47 ++++++++++++++++++- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/scripts/check-projects.ts b/src/scripts/check-projects.ts index b092af5..ae2c78f 100644 --- a/src/scripts/check-projects.ts +++ b/src/scripts/check-projects.ts @@ -195,7 +195,23 @@ function checkDocs( ? prereleaseLinePromoted(project, data) : { publish: true }; if (!channel) { - if (gate && !gate.publish) { + if (gate === null) { + // No release data, so nothing can say whether this channel should exist. + // Reporting it against `docs.channels` points the reviewer at the manifest + // and the project's documentation, which are the two things that are + // usually fine: the missing input is the release cache. + warn({ + project: project.id, + field: `docs.channels.${id}`, + observed: + 'not present in the aggregated documentation, and no release data to explain it', + expected: 'a docs mirror built from the release data this check reads', + remediation: + 'run `just data-sync` so the mirror and the catalogue are built from the same release data.', + }); + continue; + } + if (!gate.publish) { warn({ project: project.id, field: `docs.channels.${id}`, diff --git a/src/scripts/sync-docs.ts b/src/scripts/sync-docs.ts index 5f8a43c..861c861 100644 --- a/src/scripts/sync-docs.ts +++ b/src/scripts/sync-docs.ts @@ -3,11 +3,13 @@ import { resolve } from 'node:path'; import { aggregateDocs, + isMirrorManifestCurrent, readDocsManifest, writeDocs, writeDocsManifest, } from '../src/lib/docs/aggregate'; import { loadProjects } from '../src/lib/manifest/load'; +import { getReleaseIndex } from '../src/lib/releases/runtime'; /** Fetch and write the aggregated documentation mirror. */ export async function syncDocs(): Promise<{ projects: number; pages: number }> { @@ -37,13 +39,22 @@ export function hasDocsMirror(): boolean { } /** - * True when the mirror and its manifest agree with the current schema. A - * manifest written by an older schema reads as absent (`readDocsManifest` - * returns null), which is what forces a re-sync after a schema bump instead of - * building a sidebar for page paths the mirror no longer has. + * True when the mirror can be reused: it exists, its manifest was written by the + * current schema, and it was aggregated from the release data that is in hand. + * + * A manifest written by an older schema reads as absent (`readDocsManifest` + * returns null), which forces a re-sync instead of building a sidebar for page + * paths the mirror no longer has. The vintage check covers the other half: the + * schema can be current while the release data is not, and channels are decided + * from release data — so a mirror that predates the latest fetch documents a + * release line that has since moved on. Reusing it publishes a stale channel + * decision, and `check:projects` then reports the missing channel as drift. */ export function hasCurrentDocsMirror(): boolean { - return hasDocsMirror() && readDocsManifest() !== null; + if (!hasDocsMirror()) { + return false; + } + return isMirrorManifestCurrent(readDocsManifest(), getReleaseIndex().data.retrievedAt); } if (import.meta.main) { diff --git a/src/src/lib/docs/aggregate.ts b/src/src/lib/docs/aggregate.ts index 8f2de15..36722e2 100644 --- a/src/src/lib/docs/aggregate.ts +++ b/src/src/lib/docs/aggregate.ts @@ -33,7 +33,17 @@ export const DOCS_CACHE_DIR = resolve('.cache/docs'); * (`readDocsManifest` returns null), so a schema bump forces a re-sync instead * of building a sidebar for page paths that no longer exist. */ -export const DOCS_MANIFEST_SCHEMA = 2; +/** + * Version 3 records the release data the mirror was built from. Channels are + * decided from release data (`resolveDocChannels`), so a mirror built before a + * fetch describes a different release line than the cache now holds — the + * `2.1.0`/`2.0.0-prerelease.28` vintage in an August build, say. Comparing + * `releaseRetrievedAt` with the current cache is what makes such a mirror read + * as stale instead of as "current" with a missing channel. A schema bump also + * forces a re-sync, which is why the vintage is part of the schema rather than + * an optional extra: a version-2 manifest has no vintage to compare. + */ +export const DOCS_MANIFEST_SCHEMA = 3; export interface AggregatedPage { slug: string; @@ -88,9 +98,31 @@ export interface DocsManifestProject { export interface DocsManifest { schema: typeof DOCS_MANIFEST_SCHEMA; syncedAt: string; + /** + * The `retrievedAt` of the release data this mirror was aggregated from. + * `hasCurrentDocsMirror` compares it with the cache, so a mirror built from an + * older release snapshot is rebuilt rather than reused. + */ + releaseRetrievedAt: string; projects: DocsManifestProject[]; } +/** + * Whether a written manifest may be reused against the release data now in hand. + * + * Two things must hold, and they fail differently: a manifest from an older + * schema has no usable shape at all, and a manifest from an older release + * vintage describes channels that were decided before the line moved on. The + * second is the subtle one — the schema is current, every page is present, and + * the only symptom is a channel that should exist and does not. + */ +export function isMirrorManifestCurrent( + manifest: DocsManifest | null, + releaseRetrievedAt: string, +): boolean { + return manifest !== null && manifest.releaseRetrievedAt === releaseRetrievedAt; +} + /** The channel that owns the canonical `/docs//` URLs. */ export function defaultDocsChannel( project: DocsManifestProject | undefined, @@ -758,6 +790,10 @@ export function writeDocs( const manifest: DocsManifest = { schema: DOCS_MANIFEST_SCHEMA, syncedAt: new Date().toISOString(), + // Recorded, not inferred: the channels below were resolved from this snapshot + // of release data, so reusing the mirror against a different snapshot would + // publish a channel decision nobody made. + releaseRetrievedAt: getReleaseIndex().data.retrievedAt, projects: [], }; diff --git a/src/tests/unit/discovery/discovery-resources.test.ts b/src/tests/unit/discovery/discovery-resources.test.ts index b195abc..21373e7 100644 --- a/src/tests/unit/discovery/discovery-resources.test.ts +++ b/src/tests/unit/discovery/discovery-resources.test.ts @@ -9,6 +9,7 @@ import { normalizePagePath, partitionResources, } from '../../../src/lib/discovery/resources'; +import { DOCS_MANIFEST_SCHEMA } from '../../../src/lib/docs/aggregate'; import { loadProjects } from '../../../src/lib/manifest/load'; import { RELEASE_CACHE_SCHEMA_VERSION } from '../../../src/lib/releases/types'; @@ -24,8 +25,9 @@ const emptyReleaseData: ReleaseCacheData = { const context: DiscoveryContext = { projects: loadProjects(), docsManifest: { - schema: 2, + schema: DOCS_MANIFEST_SCHEMA, syncedAt: '2026-01-01T00:00:00.000Z', + releaseRetrievedAt: '2026-01-01T00:00:00.000Z', projects: [ { projectId: 'zodsharp', diff --git a/src/tests/unit/docs-redirects.test.ts b/src/tests/unit/docs-redirects.test.ts index ed1869a..d3eec8a 100644 --- a/src/tests/unit/docs-redirects.test.ts +++ b/src/tests/unit/docs-redirects.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { DOCS_MANIFEST_SCHEMA } from '../../src/lib/docs/aggregate'; import { renderChannelRedirect, retiredChannelRedirects, @@ -33,8 +34,9 @@ const PAGES = [ function manifestWith(channelIds: ('live' | 'prerelease')[]): DocsManifest { return { - schema: 2, + schema: DOCS_MANIFEST_SCHEMA, syncedAt: '2026-03-11T00:00:00.000Z', + releaseRetrievedAt: '2026-03-11T00:00:00.000Z', projects: [ { projectId: 'zodsharp', diff --git a/src/tests/unit/docs.test.ts b/src/tests/unit/docs.test.ts index 4c051a7..b12ba6b 100644 --- a/src/tests/unit/docs.test.ts +++ b/src/tests/unit/docs.test.ts @@ -1,9 +1,20 @@ +import type { DocsManifest } from '../../src/lib/docs/aggregate'; import type { DocFrontmatter } from '../../src/lib/docs/frontmatter'; import type { DocLinkContext } from '../../src/lib/docs/links'; import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; -import { DocsValidationError, isExcluded, selectDocsRootFile } from '../../src/lib/docs/aggregate'; +import { + DOCS_MANIFEST_SCHEMA, + DocsValidationError, + isExcluded, + isMirrorManifestCurrent, + selectDocsRootFile, + writeDocs, +} from '../../src/lib/docs/aggregate'; import { convertGithubAlerts, parseGithubAlerts } from '../../src/lib/docs/alerts'; import { extractDescription, @@ -18,6 +29,7 @@ import { rewriteDocMarkdown, slugifyDocFile, } from '../../src/lib/docs/links'; +import { getReleaseIndex } from '../../src/lib/releases/runtime'; function context(overrides: Partial = {}): DocLinkContext { return { @@ -376,6 +388,39 @@ describe('GitHub alerts', () => { }); }); +function docsManifestFor(releaseRetrievedAt: string): DocsManifest { + return { + schema: DOCS_MANIFEST_SCHEMA, + syncedAt: '2026-11-03T00:00:00.000Z', + releaseRetrievedAt, + projects: [], + }; +} + +describe('docs manifest vintage', () => { + test('a mirror aggregated from older release data is not current', () => { + // The failure this prevents: channels are decided from release data, so a + // mirror built before a fetch documents a release line that has since moved + // on. Reusing it hides a channel that should exist. + const stale = docsManifestFor('2026-08-01T00:00:00.000Z'); + + expect(isMirrorManifestCurrent(stale, '2026-11-03T00:00:00.000Z')).toBe(false); + expect(isMirrorManifestCurrent(stale, '2026-08-01T00:00:00.000Z')).toBe(true); + expect(isMirrorManifestCurrent(null, '2026-11-03T00:00:00.000Z')).toBe(false); + }); + + test('writeDocs records the release data it aggregated from', () => { + const outputDir = mkdtempSync(join(tmpdir(), 'purview-docs-vintage-')); + try { + const manifest = writeDocs([], outputDir); + + expect(manifest.releaseRetrievedAt).toBe(getReleaseIndex().data.retrievedAt); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } + }); +}); + describe('extractHeadingSlugs', () => { test('extracts lowercased heading slugs', () => { const markdown = [ From aeb4836f9bdd15429046b64cd5fb7900b47580a6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 3 Nov 2026 15:23:43 +0000 Subject: [PATCH 2/3] feat(docs): name the version, not the branch, in a channel drop-down The page switcher rendered the channels as pill links and labelled a branch-pinned channel with its branch name, so ZodSharp read `Live 2.4.2` and `Prerelease main`: a reader could not tell which version the prerelease pages actually described. - the resolved channel now carries the version its line ships: a `prerelease` channel that follows a branch is labelled with the newest published prerelease (`Prerelease 3.0.0-prerelease.2`), while a live channel that fell back to a branch and a commit SHA keep their ref, because neither names a version - the switcher is a native
drop-down instead of pills: the summary names the channel being read, the menu lists every version, the current one carries aria-current, and the ref is each option tooltip. No JavaScript, and every option stays a real link - the sidebar groups and the prerelease notice pick the same labels up, and ADR 0009 records the rule - unit tests pin the label rule (including the SHA case); a dist test asserts the drop-down, the version label and the branch tooltip --- docs/decisions/0009-documentation-channels.md | 17 ++- src/src/components/starlight/PageTitle.astro | 118 ++++++++++++++---- src/src/lib/docs/channels.ts | 47 +++++-- src/tests/dist/built-output.test.ts | 16 +++ src/tests/unit/docs-channels.test.ts | 25 +++- 5 files changed, 187 insertions(+), 36 deletions(-) diff --git a/docs/decisions/0009-documentation-channels.md b/docs/decisions/0009-documentation-channels.md index 0444891..63c15cb 100644 --- a/docs/decisions/0009-documentation-channels.md +++ b/docs/decisions/0009-documentation-channels.md @@ -134,8 +134,21 @@ history has to survive between builds, and an existing page is never overwritten `docsRef`, `docsRefKind`, `docsVersion`). A tag-pinned page links to the file at that tag ("View source") rather than offering an edit that cannot work, and is not flagged stale: its content is the release, not drift. -- The sidebar shows one Starlight group per channel (`Live 2.4.2`, - `Prerelease main`); single-channel projects keep the flat list. +- Channels are labelled with the **version they document**, not the ref they read. + A tag channel names its own version, and a `prerelease` channel that follows + `main` is labelled with the newest published prerelease + (`Prerelease 3.0.0-prerelease.2`): a reader chooses between versions, and a + branch name says nothing about which one the page describes. A `live` channel + that fell back to a branch has no stable release to name and keeps the branch, + as does a commit SHA. The resolved `ref` stays on the record, so the branch is + still visible and the edit links still target it. +- The page switcher is a native `
` drop-down: the summary names the + channel being read, the menu lists every version, the current one carries + `aria-current`, and the ref is each option's tooltip. It needs no JavaScript, + so the versions stay reachable, and every option is a real link. +- The sidebar shows one Starlight group per channel with the same labels + (`Live 2.4.2`, `Prerelease 3.0.0-prerelease.2`); single-channel projects keep + the flat list. - The per-project `llms-full.txt` corpus publishes the live channel only, and the concise `llms.txt` links the prerelease channel when it exists. Two versions of one page in a single corpus is ambiguous to a model. diff --git a/src/src/components/starlight/PageTitle.astro b/src/src/components/starlight/PageTitle.astro index 05dd00b..1002986 100644 --- a/src/src/components/starlight/PageTitle.astro +++ b/src/src/components/starlight/PageTitle.astro @@ -58,21 +58,39 @@ const llmsHref =

{data?.title}

{ - showChannelSwitcher && ( + showChannelSwitcher && activeChannel && ( ) } @@ -181,9 +199,11 @@ const llmsHref =