From b848f56abb1fba82a4cab60e8d289d5c73f71f49 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 9 Oct 2026 14:16:47 +0100 Subject: [PATCH 1/3] docs: moved content around --- package.json | 2 +- src/src/lib/releases/normalize.ts | 20 +++++++++++ src/src/pages/projects/[project].astro | 49 ++++++++++++++------------ src/src/pages/releases/index.astro | 5 ++- src/tests/dist/built-output.test.ts | 22 ++++++++++++ src/tests/unit/releases.test.ts | 26 ++++++++++++++ 6 files changed, 99 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 13d7e5e..6aa2578 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-dev", - "version": "0.5.1", + "version": "0.5.2", "private": true, "description": "Purview-Dev public website and unified documentation portal (workspace root).", "license": "MIT", diff --git a/src/src/lib/releases/normalize.ts b/src/src/lib/releases/normalize.ts index 1cce871..321cd70 100644 --- a/src/src/lib/releases/normalize.ts +++ b/src/src/lib/releases/normalize.ts @@ -60,6 +60,26 @@ export function selectVersions(versions: Iterable): VersionSelection { }; } +/** + * The prerelease worth surfacing alongside a stable release: the given + * prerelease when it outranks the stable, otherwise null. A prerelease that + * trails the stable (e.g. `5.0.0-prerelease.19` beside stable `5.0.2`) is + * withheld so the package table never advertises an older build as newer. + * A prerelease is always kept when there is no stable version to compare to. + */ +export function effectivePrerelease( + stable: string | null, + prerelease: string | null, +): string | null { + if (prerelease === null) { + return null; + } + if (stable === null) { + return prerelease; + } + return compareNuGetVersions(prerelease, stable) > 0 ? prerelease : null; +} + /** Compare two GitHub releases by version tag, descending. */ export function sortGitHubReleasesDescending(releases: GitHubReleaseInfo[]): GitHubReleaseInfo[] { return [...releases].toSorted((a, b) => { diff --git a/src/src/pages/projects/[project].astro b/src/src/pages/projects/[project].astro index 8a32436..2871aed 100644 --- a/src/src/pages/projects/[project].astro +++ b/src/src/pages/projects/[project].astro @@ -10,6 +10,7 @@ import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from ' import { installAnchorId, installKindFor, installTabs } from '~/lib/install'; import { LLMS_LINK_ATTRS } from '~/lib/llms'; import { loadProjects } from '~/lib/manifest/load'; +import { effectivePrerelease } from '~/lib/releases/normalize'; import { projectRepoEnrichment } from '~/lib/releases/repo'; import { projectReleaseSummary } from '~/lib/releases/transform'; import { getReleaseIndex } from '~/lib/releases/runtime'; @@ -132,7 +133,9 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({ projectUrl, packageId: pkg.packageId, latestStable: pkg.latestStable, - latestPrerelease: pkg.latestPrerelease, + // Only surface a prerelease that outranks the stable release, so a trailing + // prerelease never sits beside a newer stable version in the table. + latestPrerelease: effectivePrerelease(pkg.latestStable, pkg.latestPrerelease), totalDownloads: pkg.totalDownloads, nugetUrl: pkg.nugetUrl, deprecated: pkg.deprecated, @@ -315,28 +318,6 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({
- { - project.useCases.length > 0 && ( -
-

Use cases

-

- Concrete examples of what {project.name} does, and who each one is for. -

-
- {project.useCases.map((useCase) => ( - - ))} -
-
- ) - } -

The problem

{project.description}

@@ -403,6 +384,28 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({
+ { + project.useCases.length > 0 && ( +
+

Use cases

+

+ Concrete examples of what {project.name} does, and who each one is for. +

+
+ {project.useCases.map((useCase) => ( + + ))} +
+
+ ) + } + { project.packages.length > 0 && (
diff --git a/src/src/pages/releases/index.astro b/src/src/pages/releases/index.astro index a69f3eb..688d490 100644 --- a/src/src/pages/releases/index.astro +++ b/src/src/pages/releases/index.astro @@ -3,6 +3,7 @@ import SiteLayout from '~/layouts/SiteLayout.astro'; import ReleaseFilter from '~/components/islands/ReleaseFilter.tsx'; import PackageVersions, { type PackageRow } from '~/components/islands/PackageVersions.tsx'; import { loadProjects } from '~/lib/manifest/load'; +import { effectivePrerelease } from '~/lib/releases/normalize'; import { flattenReleases, projectReleaseSummary } from '~/lib/releases/transform'; import { getReleaseIndex } from '~/lib/releases/runtime'; import { SITE } from '~/lib/site'; @@ -30,7 +31,9 @@ const packageRows: PackageRow[] = summaries projectName: summary.projectName, packageId: pkg.packageId, latestStable: pkg.latestStable, - latestPrerelease: pkg.latestPrerelease, + // Only surface a prerelease that outranks the stable release, so a + // trailing prerelease never sits beside a newer stable version. + latestPrerelease: effectivePrerelease(pkg.latestStable, pkg.latestPrerelease), totalDownloads: pkg.totalDownloads, nugetUrl: pkg.nugetUrl, projectUrl: withBase(`/projects/${summary.projectId}/`), diff --git a/src/tests/dist/built-output.test.ts b/src/tests/dist/built-output.test.ts index e1f8e4e..63a0690 100644 --- a/src/tests/dist/built-output.test.ts +++ b/src/tests/dist/built-output.test.ts @@ -4,6 +4,9 @@ import { resolve } from 'node:path'; import { LOCAL_PATH_PATTERNS, SECRET_PATTERNS } from '../../src/lib/build-paths'; import { loadProjects } from '../../src/lib/manifest/load'; +import { compareNuGetVersions } from '../../src/lib/nuget-version'; +import { getReleaseIndex } from '../../src/lib/releases/runtime'; +import { projectReleaseSummary } from '../../src/lib/releases/transform'; const DIST = resolve('dist'); @@ -177,6 +180,25 @@ describe('built use cases', () => { }); }); +describe('built package versions', () => { + test('a package prerelease that trails its stable release is withheld', () => { + const telemetry = loadProjects().find((project) => project.id === 'telemetry-sourcegenerator'); + expect(telemetry).toBeDefined(); + const index = getReleaseIndex(); + const summary = projectReleaseSummary(telemetry!, index.data, index.source); + const pkg = summary.packages.find( + (entry) => entry.packageId === 'Purview.Telemetry.SourceGenerator', + ); + expect(pkg?.latestStable).not.toBeNull(); + expect(pkg?.latestPrerelease).not.toBeNull(); + // The package is a trailing prerelease (e.g. 5.0.0-prerelease.19 beside + // stable 5.0.2), so the table must not advertise it as a current version. + expect(compareNuGetVersions(pkg!.latestPrerelease!, pkg!.latestStable!)).toBeLessThan(0); + const content = requireBuilt('projects/telemetry-sourcegenerator/index.html'); + expect(content).not.toContain(pkg!.latestPrerelease!); + }); +}); + describe('built llms links', () => { // The LLM text bundles are plain files rather than site pages, so every link // to one must opt into the external-link treatment. diff --git a/src/tests/unit/releases.test.ts b/src/tests/unit/releases.test.ts index ac7d874..540d811 100644 --- a/src/tests/unit/releases.test.ts +++ b/src/tests/unit/releases.test.ts @@ -16,6 +16,7 @@ import { parseNuGetSearchEntry, } from '../../src/lib/releases/github'; import { + effectivePrerelease, isPrereleaseRelease, isPrereleaseVersion, isValidVersion, @@ -266,6 +267,31 @@ describe('version selection (NuGet precedence)', () => { }); }); +describe('effective prerelease (trailing prereleases are withheld)', () => { + test('withholds a prerelease that trails the stable release', () => { + // The Telemetry SourceGenerator case: stable 5.0.2 beside 5.0.0-prerelease.19. + expect(effectivePrerelease('5.0.2', '5.0.0-prerelease.19')).toBeNull(); + }); + + test('keeps a prerelease that outranks the stable release', () => { + expect(effectivePrerelease('1.1.1', '2.0.0-prerelease.38')).toBe('2.0.0-prerelease.38'); + }); + + test('keeps a prerelease when there is no stable release', () => { + expect(effectivePrerelease(null, '2.0.0-prerelease.32')).toBe('2.0.0-prerelease.32'); + }); + + test('returns null when there is no prerelease', () => { + expect(effectivePrerelease('1.1.1', null)).toBeNull(); + expect(effectivePrerelease(null, null)).toBeNull(); + }); + + test('compares four-component versions by their build revision', () => { + expect(effectivePrerelease('13.5.3.6', '13.3.0-prerelease.10')).toBeNull(); + expect(effectivePrerelease('13.5.3', '13.5.3.1-prerelease.1')).toBe('13.5.3.1-prerelease.1'); + }); +}); + describe('project/package association and release summaries', () => { test('enriches a project with repo description and tags', () => { const cache = readReleaseFixture('index'); From 80ad0c4cdd3341d5918c18407493c270fd7fd379 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 08:45:03 +0100 Subject: [PATCH 2/3] style: updated to support wide views --- src/src/components/starlight/Header.astro | 24 +- src/src/components/starlight/PageFrame.astro | 16 +- .../data/projects/engineering-playbook.yml | 222 ++++++++++++++++++ src/src/pages/about.astro | 12 +- src/src/pages/docs/index.astro | 2 +- src/src/pages/index.astro | 15 +- src/src/pages/projects/[project].astro | 6 +- src/src/pages/projects/index.astro | 4 +- src/src/pages/use-cases/index.astro | 2 +- src/src/styles/global.css | 69 +++++- 10 files changed, 344 insertions(+), 28 deletions(-) create mode 100644 src/src/data/projects/engineering-playbook.yml diff --git a/src/src/components/starlight/Header.astro b/src/src/components/starlight/Header.astro index 053a5e8..5b6433a 100644 --- a/src/src/components/starlight/Header.astro +++ b/src/src/components/starlight/Header.astro @@ -4,4 +4,26 @@ import SiteHeader from '~/components/SiteHeader.astro'; const { hasSidebar } = Astro.locals.starlightRoute; --- - \ No newline at end of file +
+ +
+ + diff --git a/src/src/components/starlight/PageFrame.astro b/src/src/components/starlight/PageFrame.astro index 0004a97..fbf6500 100644 --- a/src/src/components/starlight/PageFrame.astro +++ b/src/src/components/starlight/PageFrame.astro @@ -66,17 +66,14 @@ const { hasSidebar } = Astro.locals.starlightRoute; width: 100%; height: var(--sl-nav-height); border-bottom: 1px solid var(--sl-color-hairline-shade); - padding: var(--sl-nav-pad-y) var(--sl-nav-pad-x); - padding-inline-end: var(--sl-nav-pad-x); + /* Horizontal padding is owned by the header's `.pv-container` wrapper (see + * the Header override) so the documentation header aligns with the site's + * page shell; the mobile menu-button reservation lives on that wrapper. */ + padding-block: var(--sl-nav-pad-y); + padding-inline: 0; background-color: var(--sl-color-bg-nav); } - :global([data-has-sidebar]) .header { - padding-inline-end: calc( - var(--sl-nav-gap) + var(--sl-nav-pad-x) + var(--sl-menu-button-size) - ); - } - .sidebar-pane { /* By default, user agents apply some opinionated styles to popovers, which we don't want. */ border: unset; @@ -115,9 +112,6 @@ const { hasSidebar } = Astro.locals.starlightRoute; } @media (min-width: 50rem) { - :global([data-has-sidebar]) .header { - padding-inline-end: var(--sl-nav-pad-x); - } .sidebar-pane { /* Make the sidebar visible on larger screens instead of deferring to popover state. */ display: block; diff --git a/src/src/data/projects/engineering-playbook.yml b/src/src/data/projects/engineering-playbook.yml new file mode 100644 index 0000000..c8a9775 --- /dev/null +++ b/src/src/data/projects/engineering-playbook.yml @@ -0,0 +1,222 @@ +$schema: ../../lib/manifest/schema.ts + +id: engineering-playbook +name: Engineering Playbook +shortDescription: The reusable Purview engineering conventions — repository shape, architecture as tests, verification tiers, and agent enablement. +description: >- + The conventions the Purview projects are built on, written down once with the failure each one prevents, + the mechanism that makes it true, and what a smaller repository does instead. It covers how a repository + is laid out and named, how the architecture is held by tests that read the compiled assemblies, which + checks run in which verification tier, how value objects, schemas and results cross a boundary, how tests + are written and where their data comes from, how a repository is made legible to a coding agent, and how + its documentation is published. It is documentation-first and ships no library: it holds the conventions, + ready-to-copy templates, and a small layered reference implementation whose dependency-direction rules + fail the build, so the rules are demonstrated rather than asserted. +origin: >- + The conventions were already real and already enforced, but they were only discoverable by reading the + repositories and inferring the pattern from the repetition. That worked while one person held the whole + picture and stopped working as soon as a second contributor or a coding agent had to decide which of two + plausible patterns was the intended one. Writing them down in one place — with the evidence attached, and + scaled so a smaller repository can take the part that fits — turned an oral tradition into something a + repository can be pointed at. +useWhen: >- + You are starting a repository that should look and behave like the Purview projects, adopting the SDK into + an existing one, or deciding which of the conventions are worth the cost at your size. The playbook's + profiles, templates and reference implementation are all meant to be taken in part. +avoidWhen: >- + You want the SDK's own behaviour documented rather than the conventions built on top of it — read the + Build SDK's wiki instead — or you are looking for a scaffolding tool: the templates are files to read and + copy, not a generator. +repository: purview-dev/engineering-playbook +category: developer-tooling +status: preview +experimental: true +order: 95 +docs: + source: github-path + path: docs/wiki + rootPage: Home.md + exclude: + - _Sidebar.md + - index.md +related: + - build-sdk + - containers + - value-objects + - results + - zodsharp +useCases: + - audience: developer + title: A repository that satisfies every convention on its first build + scenario: >- + You are starting a repository and want the project defaults, code style, test wiring and assembly + identity to be right from the first commit, instead of discovering the conventions one review comment + at a time. + code: | + { + "sdk": { "allowPrerelease": false }, + "msbuild-sdks": { "Purview.BuildSdk": "1.0.4" }, + "test": { "runner": "Microsoft.Testing.Platform" } + } + language: json + outcome: >- + A pinned SDK plus a `Directory.Build.props` that sets one namespace prefix is the whole of the + repository's build configuration. Every project beneath it inherits the target framework, language + version, nullable context, analyzers and assembly identity, so a project file is two lines and a test + project file is empty. + evidence: >- + Getting Started walks the steps in the order you would create the files, and every repository-level + file has a copy-paste version in the templates folder with a comment explaining the one thing about it + that is easy to get wrong. + docsPage: getting-started + - audience: architect + title: Architecture that fails the build when a dependency points the wrong way + scenario: >- + Your layering is documented and agreed, and you want it to stay true — rather than being eroded one + convenient project reference at a time, each of which looks reasonable in isolation. + code: | + [Test] + public async Task Application_GivenItsReferences_DependsOnTheDomainAndNotOnInfrastructure() + { + // Arrange + var application = typeof(ConventionCatalogue).Assembly; + + // Act + var referenced = ReferencedAssemblyNames(application); + + // Assert + await Assert.That(referenced).Contains($"{RepositoryPrefix}.Domain"); + await Assert.That(referenced).DoesNotContain($"{RepositoryPrefix}.Infrastructure"); + } + language: csharp + outcome: >- + The allowed dependency directions are data in a test that reads the compiled assemblies' metadata + references, so a reference that points the wrong way fails the build with the offending assembly + named — no diagram to keep in step and no separate tool to run. + evidence: >- + The reference implementation's Architecture.UnitTests project asserts four rules against three real + assemblies and is built by the same pipeline as everything else. The page also records the two traps + found by writing it, including a source generator that emits public types into its own namespace. + docsPage: architecture-as-tests + - audience: team-lead + title: A verification gate that stays credible + scenario: >- + The pull-request build has started needing a database and four minutes of waiting, and somebody is + about to make it optional — at which point nothing is gated at all. + code: | + { + "Build": { + "Solution": "src/Acme.slnx", + "TestRoot": "src/tests", + "TestPatterns": "*UnitTests.csproj", + "TestFilter": "/*/*/*/*[Category=Unit]" + } + } + language: json + outcome: >- + Tiers are expressed by where a test lives rather than by an attribute: the SDK reads the test project's + name suffix and emits the assembly-level category the pipeline filters on, so adding a slow suite keeps + it out of the fast tier without editing a filter or remembering an attribute. + evidence: >- + The verification model page documents the shared workflow's inputs, the configuration probe order, and + the two ways a repository and the shared workflow quietly disagree about what a green build means — and + names the repository that runs its container-backed suites as a second job on the same runner. + docsPage: verification-model + - audience: developer + title: One builder for every test's domain content + scenario: >- + You are writing a test, need a domain object, and do not want to spell out a constructor that will + change — nor leave the reader guessing which of the values you passed actually matter to the assertion. + code: | + public static Domain.Convention Convention( + Domain.ConventionId? id = null, + string? title = null, + VerificationTier? tier = null, + bool? enforcedByBuild = null) => + Domain.Convention.Create( + id ?? ConventionId(), + title ?? Faker.Lorem.Sentence(4), + tier ?? Faker.Random.Enum(), + enforcedByBuild ?? Faker.Random.Bool()); + language: csharp + outcome: >- + An omitted argument is generated and a supplied argument is used exactly, so a test reads as a list of + the values it is actually proving and everything else is realistic without the test saying so. One + constructor change is one edit rather than a repository-wide one. + evidence: >- + TestBuilder lives in a shared testing project the SDK references from every test project + automatically. The page also explains the two SDK rules that shaped its flat, ungrouped shape, and the + value-object exception where a test must name the rejected input itself. + docsPage: testing-and-test-data + - audience: contributor + title: A repository an agent can work in without hand-holding + scenario: >- + You want a coding agent to make a change that follows the repository's conventions rather than + confidently generalising from whichever pattern it found first. + code: | + | Path | Purpose | + | -- | -- | + | src/src/Domain | The reference implementation's value objects and aggregate | + | src/src/Application | The port and the service that owns the rules | + | src/tests/Architecture.UnitTests | Dependency-direction rules; a wrong reference fails the build | + language: markdown + outcome: >- + AGENTS.md states the purpose, the layout, the conventions and the definition of done, and the build + mirrors the SDK's own skills into the repository so the agent-facing guidance tracks the SDK version + rather than the repository's memory of it. + evidence: >- + The agent enablement page documents the layout of the agent folder, why the SDK's mirror writes a + generated .gitignore that lists exactly the files it owns, and why the guidance that makes a repository + obvious to an agent is the same guidance that makes it obvious to a new joiner. + docsPage: agent-enablement + - audience: architect + title: Take only the part of the playbook that fits + scenario: >- + You are convinced by some of the conventions and not others, and you want to know which ones are worth + the cost in a repository the size of yours — rather than adopting everything and discovering the + ceremony. + outcome: >- + Three profiles — one project, a few layers, several packages — each list what to take and what to + leave, and every page ends with an "at your scale" section that names the smaller version of the rule + explicitly. The templates are mapped to the profile that needs them. + evidence: >- + The playbook also documents what not to adopt early, with the reason each one is more expensive out of + order, and a migration order for an existing repository that keeps each step cheap and the build green. + docsPage: adopting-this-at-your-scale + - audience: team-lead + title: Data that crosses a boundary once, and is trusted after that + scenario: >- + Inbound payloads are validated in several places, an identifier is a string in one layer and a type in + another, and expected failures are thrown as exceptions that callers catch by message. + code: | + [Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; + } + language: csharp + outcome: >- + A value is a type rather than a primitive, `Create` validates at the boundary and `Hydrate` trusts + everything past it, normalisation runs before validation so there is exactly one canonical form, and + expected failures are values while exceptional circumstances still throw. + evidence: >- + The contracts page names the three public repositories that implement the rules — source-generated + value objects, result types with union errors, and a schema layer that also exports and imports JSON + Schema — and shows how a validation result is read rather than parsed. + docsPage: contracts-and-data-exchange + - audience: contributor + title: Extend the playbook without turning it into a second source of truth + scenario: >- + You have found a convention that is real, enforced and not written down, and you want to add it without + duplicating something the SDK already documents. + outcome: >- + Four tests decide whether a rule belongs: it came from a real repository and a real failure, it is + reusable at more than one size, it is not the SDK's to state, and it is either enforced or explicitly + labelled a review obligation. + evidence: >- + The contributing page also sets the expectations for changing an existing rule — update every page that + states it, check the reference implementation still demonstrates it, and re-check whether the + enforcement moved between the build and review. + docsPage: contributing diff --git a/src/src/pages/about.astro b/src/src/pages/about.astro index 52b12a2..e3c8536 100644 --- a/src/src/pages/about.astro +++ b/src/src/pages/about.astro @@ -28,9 +28,9 @@ const active = projects.filter((project) => project.status !== 'archived');

-
+
-
+

Why Purview?

@@ -96,7 +96,7 @@ const active = projects.filter((project) => project.status !== 'archived');

-
+

The projects

@@ -117,7 +117,7 @@ const active = projects.filter((project) => project.status !== 'archived');

-
+

How the projects fit together

@@ -147,7 +147,7 @@ const active = projects.filter((project) => project.status !== 'archived');

-
+

Building in the open

@@ -172,7 +172,7 @@ const active = projects.filter((project) => project.status !== 'archived');

-
+

Open source

diff --git a/src/src/pages/docs/index.astro b/src/src/pages/docs/index.astro index 695cf90..be3edb5 100644 --- a/src/src/pages/docs/index.astro +++ b/src/src/pages/docs/index.astro @@ -33,7 +33,7 @@ const pageCount = docsManifest?.projects.reduce(

-
+
{ projects.map((project) => { const entry = docsManifest?.projects.find((item) => item.projectId === project.id); diff --git a/src/src/pages/index.astro b/src/src/pages/index.astro index fcf40ad..4a89e9c 100644 --- a/src/src/pages/index.astro +++ b/src/src/pages/index.astro @@ -158,12 +158,14 @@ const organizationStructuredData = { All use cases →
-
+
{examples.map(({ project, useCase }, index) => (
diff --git a/src/src/pages/projects/[project].astro b/src/src/pages/projects/[project].astro index 2871aed..2c11bff 100644 --- a/src/src/pages/projects/[project].astro +++ b/src/src/pages/projects/[project].astro @@ -316,9 +316,9 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({
-
+
-
+

The problem

{project.description}

{ @@ -335,7 +335,7 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({ }
-
+

Why it became a project

{project.origin}

diff --git a/src/src/pages/projects/index.astro b/src/src/pages/projects/index.astro index 9b727f7..9e1d501 100644 --- a/src/src/pages/projects/index.astro +++ b/src/src/pages/projects/index.astro @@ -35,7 +35,7 @@ const statuses = ['stable', 'preview', 'experimental'];
-