move the demo's coverage test beside the wiring it proves

The test rebuilt the feature list by hand, because a package cannot import a
main, and it drifted three times in one session — the dialect, notation, Compose
— each caught by a failing case rather than by the copy.

The obvious fix was a composition package under internal/ext, and it was the
wrong one: ADR-0069 forbids a feature importing its sibling, so that package
would have failed the gate on its first build and the invariant would have been
weakened one commit after becoming mechanical. Moving the test is smaller and
points the other way — cmd stays the only place a feature is named, which is
what extensions.md asks for, and wire_test.go was already here for the same
reason.

runServe and the test now call one function for the renderer, so there is no
list to keep in step. Four files named a feature before; three do now, all of
them package main, plus a benchmark that deliberately wires one extension to
measure the render path and never claimed to be the shipped list.

The core ceiling paid for it rather than being raised a third time: `given` was
a helper with one caller and is now inlined into the only function that used it.
core 2842/2850.

Latent item cleared — the one that was marked due.
This commit is contained in:
Claude Opus 5
2026-08-01 23:47:55 +06:00
committed by bdeshi
parent 1b898fdfc3
commit 751ab9c06f
7 changed files with 59 additions and 50 deletions
+2 -2
View File
@@ -54,8 +54,8 @@ changing, because in practice those two drift together — and it fails on a `<s
because a reference theme that grows taste stops being a reference (ADR-0026).
**The demo is gated like the docs.** `examples/demo-site/` is a real site, tracked as files so you can read it,
edit it and serve it with `make demo`. It stays true by two gates: a coverage test in `internal/web` serves it
through the real handler with one case per feature, and `verify.sh` runs `khosra check` over it. A feature added
edit it and serve it with `make demo`. It stays true by two gates: a coverage test in `cmd/khosra` serves it
through the real handler and the real feature list with one case per feature (ADR-0072), and `verify.sh` runs `khosra check` over it. A feature added
without a case there is a feature the demo does not show, and the build says so (ADR-0051).
`.claude/launch.json` points the editor's preview at that same `make demo` on `localhost:8080`, so
"look at it" and "test it" are the one site. It is the only dev server this repo has: the engine serves
@@ -1,4 +1,4 @@
package web
package main
import (
"net/http"
@@ -7,13 +7,9 @@ import (
"strings"
"testing"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/content"
"khosra/internal/ext/notation"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
"khosra/internal/web"
)
// exampleSite serves examples/demo-site the way the binary does.
@@ -43,24 +39,15 @@ func exampleSite(t *testing.T) http.Handler {
if _, err := shortcodes.Derive(fsys, t.TempDir()); err != nil {
t.Fatal(err)
}
// This list has to match cmd/khosra/wire.go, which a package cannot import because it is a main. Kept in
// step by hand, and by the dialect's own test living beside the list it ships (ADR-0058).
r, err := render.New(fsys, settings, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{
extension.Table,
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
extension.DefinitionList,
notation.New(),
shortcodes.New(p),
}
})
// The renderer the binary ships, from the one function that builds it — not a copy of the list, which
// is the whole reason this test lives in package main (ADR-0072).
r, err := theme(fsys, settings)
if err != nil {
t.Fatal(err)
}
r.Compose(shortcodes.Merge)
site := content.NewSite(bundles)
r.Navigation(site.Sections)
return Handler(Fixed(site), r, fsys, nil, settings)
return web.Handler(web.Fixed(site), r, fsys, nil, settings)
}
func get(t *testing.T, h http.Handler, path string) (int, string) {
+5 -16
View File
@@ -65,11 +65,10 @@ func runServe() {
if *base != "" {
settings.Base = strings.TrimSuffix(*base, "/")
}
renderer, err := render.New(fsys, settings, extenders)
renderer, err := theme(fsys, settings)
if err != nil {
fatal("cannot prepare the theme", err)
}
renderer.Compose(shortcodes.Merge)
// Never on by default and never a bare boolean flag: revealing unpublished work is a visibility change, and
// it should be impossible to enable by fumbling an argument (ADR-0024).
interval := pollInterval(*dev == "on", *poll)
@@ -105,7 +104,10 @@ func runServe() {
// pollInterval is how often to look for a change, and where `-dev on` gets its promptness (ADR-0056):
// authoring wants an edit applied quickly, but never faster than an interval the operator chose deliberately.
func pollInterval(dev bool, chosen time.Duration) time.Duration {
if dev && !given("poll") {
// flag.Visit reports only what was passed, which is the one way to tell a default from a chosen value.
explicit := false
flag.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "poll" })
if dev && !explicit {
return 250 * time.Millisecond
}
return chosen
@@ -157,19 +159,6 @@ func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[conte
}
}
// given reports whether a flag was passed rather than left at its default.
//
// Needed once: `-dev on` polls faster, and "faster" must not silently override an interval the operator chose.
func given(name string) bool {
passed := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
passed = true
}
})
return passed
}
// defaultCache is where generated files go when nothing says otherwise: the user's cache directory, never
// the site root, because the engine reads that and must not litter somebody's content git (ADR-0042).
func defaultCache() string {
+14
View File
@@ -1,14 +1,28 @@
package main
import (
"io/fs"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/content"
"khosra/internal/ext/notation"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
// theme builds the renderer this build ships: the list below, plus what `include: merge` needs (ADR-0066).
// One function, because the only other caller is the demo's coverage test, and a copy of this wiring drifted
// three times before it stopped being a copy (ADR-0072).
func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error) {
r, err := render.New(siteFS, settings, extenders)
if err == nil {
r.Compose(shortcodes.Merge)
}
return r, err
}
// extenders is the only list of features this build includes (extensions.md). Source order is the
// semantics; enabling or disabling one is a one-line diff and a rebuild.
//
+20
View File
@@ -1186,3 +1186,23 @@ embedded name must exist, had to go, since `shortcodes.html` is deliberately abs
stronger: a set that matches nothing at all fails at startup, which also catches a renamed `base.html`.
Revisit if: the page and listing sets want the same treatment, which they do not yet — each is one file with
one block.
## ADR-0072 — The demo's coverage test lives in `package main`, beside the wiring it proves
Date: 2026-08-01 · Status: accepted (clears the latent item against `internal/web/example_test.go`)
Decision: `TestTheExampleSiteExercisesEveryFeature` moves from `internal/web` to `cmd/khosra`, and the
renderer both it and `runServe` use is built by one function, `theme`. No package rebuilds the feature list.
Why: the test rebuilt the list by hand because a package cannot import a `main`, and it drifted three times
in one session — the dialect, `notation`, `Compose` — each caught by a failing case rather than by the copy.
The obvious fix was a composition package under `internal/ext`, and it is the wrong one: ADR-0069 forbids a
feature importing its sibling, so that package would fail the gate on its first build and the invariant would
have to be weakened one commit after it became mechanical. Moving the test is smaller and points the other
way — `cmd` stays the only place a feature is named, which is what `extensions.md` asks for. The test was
arguably misplaced anyway: it exercises the whole binary's wiring, not the web package, which is why
`wire_test.go` already lived here.
Consequence: cheap — one list, no copy, no gate exemption, and the latent row is gone. `internal/web` keeps
its own tests and loses only the one that was never about `web`. Expensive — `cmd/khosra` now holds the
largest test in the repo, and anything else wanting the shipped wiring must live here too. The benchmark in
`internal/web` still names `shortcodes`, deliberately: it measures the render path with one extension and
never claimed to be the shipped list, so there is nothing for it to drift from.
Revisit if: something outside `cmd` genuinely needs the composed renderer — which is the registry question,
and this decision deliberately does not answer it.
+2 -3
View File
@@ -30,7 +30,7 @@ table owns.
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering, and calls left in the retired shortcode form (ADR-0059) |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough (ADR-0058) |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`), the `theme` function that builds the renderer this build ships (ADR-0072), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough (ADR-0058) |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect |
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup |
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) |
@@ -40,7 +40,7 @@ table owns.
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the atomic swaps a change goes through — theme in the watcher's callback, index in `rebuilder`. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
| `*_test.go` | table-driven, one file per source file — `shortcodes` has one each for icons, containers and the contents list; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end |
| `*_test.go` | table-driven, one file per source file — `shortcodes` has one each for icons, containers and the contents list; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end — that last one in `cmd/khosra`, beside the wiring it proves (ADR-0072) |
Serves a listing of everything at `/` (ADR-0050), a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
@@ -123,7 +123,6 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The root listing's `<title>` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The first time the reference theme is worked on (Phase G4 touches it), or sooner if a feed or OpenGraph title inherits the same doubling |
| The theme and the index are two separate `atomic.Pointer` stores, so a request landing between them sees a new theme with the previous index | Accepted 2026-08-01 with ADR-0056: both halves are internally coherent and the gap is microseconds, so no page is ever internally inconsistent — it is simply not a snapshot of the disk. Closing it means one pointer holding both, which changes `web.Handler`'s signature and 20 test construction sites | Anything that makes the gap observable — a request rate high enough to land in it, or a feature where content and theme must agree exactly (an export, where every page is generated in one pass) |
| `internal/web/example_test.go` rebuilds the extender list by hand, so it can drift from `cmd/khosra/wire.go` | A package cannot import a `main`, and `extensions.md` puts the list in `cmd` on purpose — nothing below it may know which features exist. Bounded today: the dialect's own test lives in `cmd/khosra/wire_test.go`, beside the real list, and the demo test fails loudly when the copy lags | **Due.** It has now drifted three times in one session — the dialect, `notation`, and `Compose` — each caught by a demo case rather than by the copy itself. The next change to the wiring should move it somewhere a test can import, which needs the list to leave `cmd` without a package below it knowing which features exist |
| Under the default include model, a fragment's footnotes render where the include sits, so a long one puts an `<hr>` and a numbered list mid-article | Spotted 2026-08-01 by looking at the served page, not by any test. It is ADR-0038's documented consequence, and the ids are correctly namespaced (ADR-0058); only the placement reads badly. **An author who minds now says `include: merge`** (ADR-0066), which makes the page one document and puts every note at its end, so this is a default rather than a limit | The default itself proving wrong — a site where every composed page sets the flag, at which point the flag is the wrong way round |
| The picture memo is never evicted — one entry per picture on the site, for the life of the process | Correct for one author's site, and the alternative is an eviction policy nothing needs. It is keyed on size and modification time, so it cannot go stale, only grow | A site root large enough that memory matters, or a long-running process where pictures churn |
+10 -10
View File
@@ -6,21 +6,21 @@ Every top-level declaration in the engine, with its line. Read this before openi
file: it answers "where does X live" and "what is in this package" without the bodies. What each
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
## cmd/khosra — 306 lines + 91 test
## cmd/khosra — 309 lines + 288 test
check.go 45 · main.go 192 · new.go 42 · wire.go 27
check.go 45 · main.go 181 · new.go 42 · wire.go 41
- check.go:16 func runCheck(args []string)
- main.go:24 func main()
- main.go:45 func runServe()
- main.go:107 func pollInterval(dev bool, chosen time.Duration) time.Duration
- main.go:120 func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int)
- main.go:137 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int
- main.go:163 func given(name string) bool
- main.go:175 func defaultCache() string
- main.go:185 func fatal(msg string, err error)
- main.go:106 func pollInterval(dev bool, chosen time.Duration) time.Duration
- main.go:122 func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int)
- main.go:139 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int
- main.go:164 func defaultCache() string
- main.go:174 func fatal(msg string, err error)
- new.go:12 func runNew(args []string)
- wire.go:17 func extenders(partial render.Partial) []goldmark.Extender
- wire.go:18 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
- wire.go:31 func extenders(partial render.Partial) []goldmark.Extender
## internal/content — 1043 lines + 558 test
@@ -318,7 +318,7 @@ chrome.go 115 · render.go 456 · view.go 185
- view.go:160 type Picture struct
- view.go:177 type Origin struct
## internal/web — 734 lines + 1633 test
## internal/web — 734 lines + 1423 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217