Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Tutorial code is single-sourced from `examples/` via docfx region includes; template pack strips the markers
# Tutorial code is single-sourced from `examples/` via `#region docs:` markers, synced into docs by a snippet pipeline

Tutorial markdown contains no inline C# snippets for load-bearing code; instead it embeds `[!code-csharp[](<path>#docs:<name>)]` includes that pull `#region docs:<name> ... #endregion` ranges out of the canonical example projects under `examples/starter/`. The example files are the source of truth — touching the example changes the tutorial mechanically, and touching the tutorial requires touching the example, so the two cannot drift. The `docs:` prefix is the contract: only regions named `docs:*` are doc-relevant, and the example's own `#region`s (e.g., `#region Constructors`) are left alone. Because `examples/starter/*` projects also ship as `dotnet new` templates, a private nx subtarget `_strip-doc-regions` runs before pack and deletes every `^\s*#(region|endregion)\s+docs:.*$` line into a `pack-staging/` dir; the pack target consumes the staging dir, not the source tree, so contributors and the docs build see the real example with regions intact while template instantiators get a clean copy. The rejected alternatives were (A) inline ```csharp blocks compiled in isolation by a CI extractor — caught syntactic drift but not semantic mismatch against `examples/`, and required per-snippet scaffolding — and (C) a hybrid convention where small blocks stay inline and only "load-bearing" code uses includes; the line between the two was fuzzy enough that we expect convention drift, so we picked the all-includes rule for tutorials specifically. Guides and Explanation pages still allow inline blocks because their snippets are illustrative-of-a-concept rather than prescriptive-code-the-reader-will-copy. See [ADR-0008](./0008-documentation-honesty-three-error-phases.md) for the broader honesty framework this fits into.
Tutorial markdown contains no hand-written load-bearing C#; the canonical example projects under `examples/starter/` own that code and mark the doc-relevant ranges with `#region docs:<label> … #endregion`. The `docs:` prefix is the contract — it distinguishes a doc-region from an ordinary `#region Constructors`, it is what the template-pack strip step removes, and the `<label>` is globally unique and identical on both sides of the pipeline. Two NX targets move the code across a clean `dist` boundary: `examples:generate-snippets` extracts each `docs:` region into `dist/examples/docs/snippets/docs-<label>.md` as a dedented fenced code block (the `examples` project owns the source language, so it picks the `csharp`/`python` fence), and `docs:sync-snippets` — which `dependsOn` it — splices each snippet into a managed marker block in `docs/**/*.md`, expanding an author's one-line sentinel `<!-- flowthru:snippet docs:<label> -->` in place on first run and refreshing the body between markers thereafter, the same managed-block mechanism `scripts/update-example-readmes.mjs` uses for README mermaid/filetree blocks. The source of truth is the C# source; the two projects communicate only through the `dist` artifact (a clear chain of possession), never by reaching into each other's trees. Honesty is enforced three ways, all per [ADR-0008](./0008-documentation-honesty-three-error-phases.md): `sync-snippets` hard-fails atomically — writing nothing — if a sentinel references a label absent from `dist` (a doc pointing at code that no longer exists) or if a `dist` label is never referenced by any sentinel (an orphan doc-region marking code that documents nothing), and CI additionally runs `git diff --exit-code docs/` to catch C# edits that were never re-synced. None of this touches the example build or run: `#region` is a compile-time no-op, so the examples build and run unchanged whether markers are present, orphaned, or stripped. Because `examples/starter/*` also ship as `dotnet new` templates, a strip step deletes every `^\s*#(region|endregion)\s+docs:.*$` line from the packaged artifact so template instantiators never see doc-only markers. The rejected alternative was docfx's `[!code-csharp[](<path>#region)]` include directive: docfx is present in this repo but only for *metadata* — API-reference extraction into `docs/reference/src/` via `scripts/docfx-metadata.sh` — while the site itself is built by Starlight/Astro, which does not process docfx include directives, so adopting that syntax would have shipped literal unresolved `[!code-csharp[]` text to the rendered site. We kept docfx's spirit (single-source tutorial code from `examples/`) but built our own resolver, and deliberately chose a path-free, refactor-resilient token (`docs:<label>`, the same string in the C# marker and the markdown sentinel) over docfx's file-relative `[!code]` path, so moving a snippet's source file between locations never breaks its reference.
3 changes: 3 additions & 0 deletions .claude/docs/adr/0018-docs-review-provenance-frontmatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Docs frontmatter carries a `review` provenance field; `draft` is a pre-flight warning

Every page under `docs/{tutorials,guides,explanation}/` may declare a `review` frontmatter field with the enum value `draft` or `reviewed`. The field does *not* record who typed the characters — AI-assisted drafting is now a common and legitimate origin — it records whether a human has refined and signed off on the content. An AI draft a maintainer has gone through is `reviewed` and trustworthy; a draft nobody re-read is not, regardless of who wrote it. The lifecycle is a two-state loop driven entirely by humans: a page is `draft` on initial creation (the boilerplate default), it is **always manually** promoted to `reviewed` — promotion is never automated, because the whole point is to assert a human looked — and any substantive update flips it back to `draft`, because reviewed-ness attaches to specific content and stale approval is worse than none. **Absent is treated as `draft`**: the field fails toward "needs review" so a forgotten field can never masquerade as reviewed, which also means existing pages read as `draft` automatically and the rollout requires *zero* backfill — a maintainer flips each page to `reviewed` as they sweep it. `draft` status is a **pre-flight warning, not a design-time gate** under [ADR-0008](./0008-documentation-honesty-three-error-phases.md): draft docs still ship, they surface in the same non-blocking warnings list as the terminology lint and through the same GitHub step-summary surface — a single warning harness, not a second parallel one. The field is registered as an optional enum (default `draft`) in the Starlight `docsSchema` extension alongside the required `description`, so it is first-class in the content layer and a future Starlight component could render a "needs review" banner from it; the warning meta-test that emits the draft list reads raw frontmatter, parallel to how `lint-docs.mjs` validates the hard `title`/`description` contract before the Astro build. The meta-test itself is deferred — this ADR commits to the field and its semantics so content authored under it is correct from birth, and the emitter lands with the terminology-lint warning surface. Rejected alternatives were (A) a boolean `reviewed` — equivalent in expressiveness to the two-value enum but it framed the question as a static flag rather than a lifecycle, and an enum leaves room to name a third state later without a type change; (B) a `review: { state, by, at }` object — the audit trail is appealing but the per-page ceremony invites rot, and `by`/`at` duplicate what `git blame` already records authoritatively; and (C) an authorship-origin framing (`authored-by: ai | human`) — rejected outright because origin is not the risk, un-reviewed-ness is, so recording origin would gate on the wrong signal.
64 changes: 64 additions & 0 deletions .github/workflows/docs-external-links.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Docs External Links

# Out-of-band external link checking. External link rot is third-party reality
# changing, not a Flowthru contract violation, so it must NOT gate PRs — it is
# surfaced on a schedule and filed as an issue for human triage.
#
# Internal links are already gated at site-build time by Starlight; in-source
# snippet references are gated by docs:sync-snippets. This workflow only checks
# external (http/https) links under docs/.

on:
schedule:
# Mondays 09:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: docs-external-links
cancel-in-progress: false

jobs:
link-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Check external links
id: lychee
uses: lycheeverse/lychee-action@v2
with:
# Scope to external links only; internal/relative links are gated at
# build time. `--no-progress` keeps the log readable in CI.
args: >-
--no-progress
--scheme https --scheme http
--exclude-loopback
"docs/**/*.md"
output: ./lychee-report.md
fail: false

- name: File or update broken-links issue
if: steps.lychee.outputs.exit_code != 0
env:
GH_TOKEN: ${{ github.token }}
run: |
# Dedupe: append to the existing open issue if one is already filed,
# otherwise create a fresh one. Keeps the tracker from accumulating a
# duplicate every Monday.
existing=$(gh issue list --state open --label documentation \
--search 'Broken external links in docs/ in:title' \
--json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --body-file lychee-report.md
else
gh issue create \
--title 'Broken external links in docs/' \
--body-file lychee-report.md \
--label documentation --label needs-triage
fi
46 changes: 46 additions & 0 deletions docs/diagnostics/site-build-honesty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# site:build honesty verification

Verification record for whether the docs build fails on the design-time /
pre-flight documentation errors the honesty model requires it to catch.
`docs/diagnostics/` is repo-internal — not ingested or served.

## Audit: no masking flags

`.github/workflows/website-deploy.yml` runs `pnpm nx run site:build` with no
`continue-on-error`, no `--ignore-errors`, no `--no-fail-on-error`.
`src/website/project.json`'s `build` target runs `astro build` plainly.
There is nothing suppressing a build failure.

## Broken in-source snippet reference → GATED ✓

A `<!-- flowthru:snippet docs:<label> -->` sentinel referencing a label with no
extracted snippet causes `docs:sync-snippets` to exit non-zero (verified:
"references with no snippet in dist … Nothing written"). `docs-checks.yml` runs
`docs:sync-snippets`, so this fails the PR check. The reverse (an orphan
`#region docs:` nothing references) is gated the same way.

## Broken internal markdown link → NOW GATED ✓ (was a real gap)

The ticket assumed "Starlight errors on broken internal links by default." It
did NOT — an early smoke (`[x](./does-not-exist.md)`) built clean, exit 0,
silently. Closed by adding the `starlight-links-validator` plugin to
`src/website/astro.config.mjs` (`errorOnRelativeLinks: false`, so relative
links — the repo convention — are allowed but still resolved).

Proven the hard way: on its first run the validator failed the build with
**5 real pre-existing broken links** in `index.mdx` (root-absolute hrefs that
double-encoded the `/flowthru` base — never shipped only because `index.mdx`
was untracked). Fixing them to relative links returned the build to green:

```
pnpm nx run site:build --skip-nx-cache
# with broken links: "✗ Found 5 invalid links", exit 1
# after fixing: "✓ All internal links are valid", "[build] Complete!", exit 0
```

PR-time gating: `pr-tests.yml` runs `nx affected -t build`, which includes
`site:build` whenever docs change (site `implicitDependencies` docs), so a
broken internal link now fails the PR check — not just the release deploy.

External link rot remains out-of-band (scheduled `docs-external-links.yml`),
per the honesty model.
Loading
Loading