Resolve the project once instead of in every command - #167
Resolve the project once instead of in every command#167KayleeWilliams wants to merge 12 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
Important
Three paths that worked before this PR now fail: createDocsProject({ configPath }) resolves a content root one directory too deep, a fresh leadtype init scaffold throws on first run because nothing installs jiti, and leadtype nav refuses a project that has no config file. None of the three has test coverage.
Reviewed changes
Reviewed the new resolveProject pipeline and every consumer it replaces, tracing the deleted per-command assembly to confirm behaviour was preserved.
-
New shared resolution pipeline —
config/project.tsdiscovers the config, applies source-owned inheritance, normalizes, derives what wasn't authored, and resolves each collection's content dir through the sync cache. -
Environmental failures become diagnostics —
ProjectDiagnosticcarrieslevel,collection,owner, andfix, sodoctorreports and continues whilecreateDocsProjectthrows on the firsterror. -
Normalization runs twice — inheritance changes what the collections are, so the config is re-normalized, with
sources,deprecations, and per-collectionsourceIdcarried forward from the first pass. -
createDocsProjectconfig is now optional — apps can omitconfigand let discovery findleadtype.config.*ordocs.config.*fromcwd. -
doctor,nav,sync, andlintread the resolved project —inspectNavigation,applySourceInheritance, andresolveCollectionDirare deleted in favour ofcollection.navigationOrigin/collection.contentDir. -
Scaffolds stop importing the config — all four
initplans now emit a barecreateDocsProject({ baseUrl })and rely on discovery.
⚠️ generate never reads the resolved project the changeset says it reads
generate.ts still hand-assembles syncCollections → inheritCollectionSourceConfigs → resolveDocsSourcesFromCollections → loadDocsConfig, and never re-normalizes after inheritance — the exact second pass resolveProject was built to add. Meanwhile the changeset and docs/concepts/config-model.mdx both state that generate now reads one resolved project. doctor's job is to predict what generate produces, so this is the one pairing where a divergent pipeline matters most, and it is the pairing that still diverges.
Technical details
# `generate` bypasses `resolveProject`
## Affected sites
- `packages/leadtype/src/cli/generate.ts` ~1689-1715 — `syncCollections(...)`,
`inheritCollectionSourceConfigs(loadedConfig.config.collections, configDir)`,
`resolveDocsSourcesFromCollections(...)`, then `loadDocsConfig({ docsDirs })`.
No call to `resolveProject`, and no re-normalization after inheritance.
- `.changeset/resolve-project.md` — claims `generate`, `doctor`, and `nav` "now
read one resolved project".
- `docs/concepts/config-model.mdx` — same claim, now published documentation.
## Required outcome
Either `generate` resolves through `resolveProject` like the other commands, or
the changeset and `config-model.mdx` are corrected so they do not promise a
convergence that has not happened. Shipping the doc claim without the code
change is the outcome to avoid: a reader who trusts it will assume `doctor` and
`generate` cannot disagree about inherited navigation, and they still can.
## Open questions for the human
- Is the `generate` migration a deliberate follow-up PR, or an oversight? If
deliberate, it would help to say so in the changeset rather than describing
the end state as already reached.
- `generate` skips the second normalization pass. Does any currently-shipping
config reach `generate` with post-inheritance collections that need
re-canonicalizing (deprecated field names inside a source-owned
`docs.config.*`, for instance)? That would be a real behavioural split between
`doctor` and `generate` today, not just a docs inaccuracy.ℹ️ Nitpicks
-
doctor.ts~611-626:inspectOutputsnow globs**/*.{md,mdx}per collection instead of reusing the already-filtered file list, soinclude/excludeare ignored when deciding freshness (editing an excluded file marks artifacts stale) and every collection is globbed twice per run. -
config/project.ts~331-341: the cherry-pick after re-normalization carriescollections,sources, anddeprecationsforward but not top-levelprovenance. For asources-authored config the second pass seessources: undefinedandcollections: {...}, soprovenance.sourcesdisappears andprovenance.collectionsis reported as explicitly authored. This is user-visible throughdoctor --json(doctor.ts:569). -
config/project.ts~384-386: whenoptions.infer === falseand nothing was authored,navigationOriginis still set to"inferred"— labelling "nothing was derived" as "derived". The test atproject.test.ts:122only assertsnavigationisundefined, so the label isn't pinned either way.
Claude Opus | 𝕏
56aaa7f to
008150a
Compare
There was a problem hiding this comment.
ℹ️ The delta itself is clean — I verified the two behavioural claims it makes rather than taking them on faith. One inline note, and one sharpening of an already-open thread. All three prior threads remain open; nothing in these commits addressed them.
Reviewed changes
Since the prior pullfrog review the branch was rebased (56aaa7f → 65e0e0c, content-identical for packages/leadtype/src) and one new commit landed, so the reviewable delta is 008150a alone. In it you:
- made
createDocsProject's parameter optional (input: CreateDocsProjectConfig<TFrontmatter> = {}), socreateDocsProject()now takes no arguments at all; - migrated
apps/fumadocs-example/lib/source.tsoff its../../../docs/docs.configimport ontocwd: repoRootdiscovery — this removed the last first-party caller that passedconfigPath; - rewrote the published fumadocs snippet in
docs/integrations/integrate-with-fumadocs.mdxas a barecreateDocsProject()and dropped its// @noErrorstwoslash directive; - refreshed the
docs/paths.lock.jsonhashes for the pages you touched.
Two things I checked rather than assumed:
- Dropping
// @noErrorsis safe.docs.config.tssetslint: { snippets: { typecheck: true } }and CI gates onleadtype lint docs --error-unknown --max-warnings 0, so those fences are now really typechecked. I built the package and ran that exact command: all 53 files pass. The directive was only ever needed for the removed relative../docs/docs.configimport. cwddiscovery works in both examples. I executedcreateDocsProject({ cwd: repoRoot })fromapps/fumadocs-exampleunder plain node, andcreateDocsProject()from the repo root under bun. Both discoverdocs/docs.config.ts, report zero diagnostics,navigationOrigin: "explicit", and list 55 pages.
ℹ️ configPath is worse than the open thread describes, and now has no callers left to notice
The still-open thread on src/project/index.ts covers the content-dir regression: resolveProject never inspects the config basename, so configPath: "/repo/docs/docs.config.ts" resolves contentDir to /repo/docs/docs where the deleted configIsSourceOwned branch gave /repo/docs. Reading config/project.ts closely, the option is broken a second, more basic way — and 008150a removes the last caller who would have hit it.
config/project.ts branches on options.config, not on options.configPath. Pass configPath without config and the else branch runs loadDocsConfig({ cwd: rootDir }): the file you named is never opened. What you get is ordinary discovery rooted at dirname(configPath) — which usually finds the same file by luck, and silently finds a different one when it doesn't (e.g. a leadtype.config.ts sitting beside it wins the discovery order).
Not a request to fix it in this PR, but the option is now undocumented-by-divergence and untested: the JSDoc on createDocsProject still describes the pre-PR contract, docs/pipeline/use-the-source-primitive.mdx dropped its configPath paragraph entirely, and src/config/project.test.ts — thorough as it is on cwd discovery — has no case that passes configPath. Deleting the option outright would be a defensible call.
Technical details
packages/leadtype/src/config/project.ts256-266 —options.config ? { ...normalizeDocsConfig(options.config, …) } : await loadDocsConfig({ cwd: rootDir, docsDirs }).options.configPathis consumed only inside the then branch.packages/leadtype/src/project/index.ts156-162 — the forwarding block derivescwd: path.dirname(input.configPath)and passesconfigPaththrough, which is what makes the miss look like a working path.packages/leadtype/src/project/index.ts98-115 — JSDoc still promises "adocs.config.*sits inside the docs directory, aleadtype.config.*sits at the project root above it".docs/pipeline/use-the-source-primitive.mdx~50 — theconfigPathparagraph is removed in this PR, so prose and type no longer agree.
Claude Opus | 𝕏
008150a to
e8b81ad
Compare
Answering "what is this project?" takes five ordered steps: discover the
config, apply source-owned inheritance, normalize to canonical names, derive
what wasn't authored, and resolve each collection's content directory through
the sync cache. Every command needs all five, and each assembled them by hand.
The cost was not hypothetical. `doctor` and `nav` shipped with the *same two*
bugs: both skipped inheritance, so a project whose navigation lives in its
source repo reported as "inferred" with a filesystem-derived tree the real
build never uses; and both resolved a remote collection's `dir` against the
config directory instead of its checkout, so every page vanished. Two commands,
written days apart, same two omissions. That is a design problem, not two
mistakes.
`resolveProject()` runs the pipeline once and the commands read its result.
It throws only for a genuinely malformed config — there is no project to
describe. Everything environmental is a diagnostic on the result, carrying a
stable id, the owning config field, and the command that fixes it. That split
is what lets one function serve both kinds of caller: doctor reports an
unsynced source and keeps going, while `createDocsProject` refuses to hand a
renderer a source it cannot read. Only the caller knows which is right, so the
resolver doesn't decide.
Two details worth knowing, both found by running it against the real c15t
project rather than a fixture. Deprecations come from the load-time
normalization, because aliases are folded there and a second pass over the
canonical config correctly finds none — reporting none would tell a legacy
config it has nothing to migrate. And the acquisition graph comes from that
same first pass: normalization expands `sources` into `collections`, so only
the first pass ever sees authored source names, and re-deriving reported c15t's
source as `repo#ref` instead of `c15t`.
Config loading moves out of `cli/generate.ts` into the config module. The
runtime needs it — `createDocsProject` and `doctor` both ask which config
describes a project — and reaching through the generate pipeline to ask would
drag staging and conversion into an app bundle.
`createDocsProject()` now discovers the config itself, so an app that has one
doesn't import it just to hand it straight back:
export const source = await createDocsProject({ baseUrl });
The scaffolds and the Astro example use that shape. Docs make the single-repo
path unambiguous: use `defineDocsConfig`; ownership only becomes a question
once a second repo is involved.
`config` became optional when the project learned to discover it, but the options object itself stayed required, so the shortest correct call — `createDocsProject()` — did not typecheck. Our own snippet typechecking caught it in the fumadocs integration page before a user would have. Also simplifies the fumadocs example onto config discovery now that this branch provides it.
Seven findings, all of the same shape: a config field that generation honours
and the runtime silently ignored — which is the drift this primitive exists to
end, reappearing inside it.
The two that matter most:
`include`/`exclude` were never applied. They are a page-existence filter, not
a display filter, so an author excluding `drafts/**` got them kept out of the
build and served by the site — publishing exactly the content the field
withholds. `createDocsSource` now takes them and applies them in its glob.
Cross-collection slug lookup was first-declared-wins. Two collections each
holding `overview.mdx` both produce `["overview"]`, and `index.mdx` produces
`[]` in every collection, so `loadPage(["overview"])` silently returned
whichever was declared first — and adapters build static params from `slug`,
so a route resolved to another collection's content. Route paths are unique by
construction and are now tried first; an ambiguous local slug throws naming
both collections rather than guessing.
The rest: top-level `mounts` were dropped for multi-collection projects, so a
site-wide remap applied to the artifacts and not the site; `typeTableBasePath`
had no config fallback while its sibling `typeTableStrict` did, so
`<AutoTypeTable>` resolved correctly at build time and not at render time; a
`flatteners` spread evaluated to `{}` on both branches and did nothing; and a
cache checked out with fewer sparse paths than the config asks for passed every
validity check, degrading type tables to nothing with no error — `sync` already
rejects that cache, and now so does the runtime.
`openapi` alongside `collections` now throws. Generation emits those pages
regardless, so silently skipping them left the site serving fewer routes than
its own sitemap advertised — the incident this whole branch was written about.
`FumadocsSourceConfig` was a plain union, so TypeScript relaxed excess-property
checking across members and `{ source, typeTableBasePath }` compiled while
dropping the second key. The arms are now mutually exclusive, with a test that
fails to compile if that regresses.
**nav pins could be silently discarded.** Group assembly is first-entry-wins by urlPath, so a page an earlier entry already placed swallowed a later entry's pin: the pin resolved, reordered within its own expansion, and never reached the tree. That now throws naming the page and the conflict. `pin` also accepts a bare string like `exclude` — `pin: "setup"` used to iterate character by character and fail with `Nav pin "s" did not match` — and is validated, so a wrong-typed pin from a JS or inherited config can't reach the resolver. **The unplaced check silently switched itself off for inherited trees.** The `origin !== "explicit"` shortcut was safe while every other origin had empty root entries; `inherited` is the first one carrying real ones, so the guard short-circuited and every glob-placed page in an inherited tree read as unplaced. **Inheritance was all-or-nothing.** `inheritCollectionSourceConfigs` throws on the first unreadable source config, and the whole result was discarded — so one unsynced collection degraded every other collection's tree to a filesystem-derived one. It now runs per collection. And an unreadable source config is an `error`, not a `warn`: `generate` throws there, so exiting 0 let CI gate on doctor and pass a project the build then fails on. **Doctor reported a cache path that can never exist.** `sync` derives it through `repositorySlug`; doctor hand-joined the raw URL, so every unpinned source read "not synced" while the collection checks reported nothing wrong — one report contradicting itself. **Doctor resolved navigation from different inputs than generate.** Missing i18n made every translation its own page and report as unplaced; per-collection rather than globally merged groups made a page grouped by a sibling collection an `error`, which changes the exit code; and include/exclude meant doctor read a different file set than generate stages, so excluded pages read as unplaced. **Provenance contradicted itself.** A field taken from a source repo reported `origin: "explicit"` in the same report that called the tree `inherited`. **resolveProject regressions.** `configPath` stopped fixing the content root — `docs.config.ts` resolved to `<dir>/docs/docs` — because the root was derived as `dirname(configPath)` without consulting the basename. The scaffold needs `jiti` now that it discovers a `.ts` config at runtime rather than importing it through the app's bundler. And `nav` refused a project with no config, a state `doctor` supports and reports.
e8b81ad to
0d7ca46
Compare
There was a problem hiding this comment.
Important
Two of the parity fixes added in these commits open fresh generate-vs-runtime splits — the exact failure class this PR exists to close. Both are inline. The three prior threads the branch addressed are now replied to and resolved; the jiti install-line thread stays open.
Reviewed changes
checkout_pr couldn't compute an incremental diff (e8b81ad was orphaned by the rebase and isn't reachable in the shallow checkout), so I derived the delta as 14bd791..0d7ca46. In it you:
- derived the project root from the config basename (
projectRootForConfig), fixing thecontentDirregression from the openproject/index.tsthread, with a test; - added
source.cache-narrowand promotedsource.inherit-failedtolevel: "error", moving inheritance failure to per-collection; - added
include/excludepass-through tocreateDocsSourceand todoctor's newcountCollectionPages; - rewrote
doctor'sinspectNavigationto pass every collection'sgroupsplus top-leveli18n, and to skip the unrepresented-page check for filtered collections; - added
reportInferredTreetonav,RUNTIME_CONFIG_DEPS = ["jiti"]to all fourinitplans, a pin-conflict throw pluspin: string | string[], anopenapi+collectionsrefusal, and a mutually-exclusiveFumadocsSourceConfigunion.
Verified rather than assumed — none of these are findings, listing them so they don't get re-litigated:
stampInherited'sinheritedFrom: collectionKeymatches the documented meaning inconfig/types.ts:52-53.defaultCacheDir(source.repository, source.ref)indoctor.ts:531matchessync.ts:139exactly.- The pin-conflict throw only fires where the pin was already a silent no-op pre-PR (
buildNavigationGroupFromNavdropped the duplicate atllm.ts:3661), so it converts a silent no-op into a loud error rather than rejecting configs that worked.authoring.test.ts(9),nav.test.ts(16) andllm.test.ts(86) pass;docs/docs.config.tsuses noinclude/pinat all. - The
pin: string | string[]widening is complete — the only readers arellm.tsandvalidateDocsNavPageEntry(config/inherit.ts:154-161), both updated; no array-only schema survives. - Removing
resetConfigDeprecationWarningsleaves zero references repo-wide, andconfig/load.tsis a faithful lift of the validators out ofcli/generate.ts.
generate still doesn't read the resolved project, and 0d7ca46 re-added a second copy of the assembly
The changeset says generate, doctor, nav and createDocsProject "now read one resolved project", and docs/concepts/config-model.mdx:139 says "resolveProject() is the one function that runs them". generate calls neither resolveProject nor anything in config/project.ts. This commit went the other way: generate.ts:1696-1720 now re-implements the inheritance→re-normalize→cherry-pick sequence inline.
That second copy already diverges from the original on three axes, so the prose describes a state the code isn't in:
resolveProject |
generate.ts:1696-1720 |
|
|---|---|---|
| inheritance | per collection, one failure isolated | whole-map, throws on the first |
| failure surface | source.inherit-failed diagnostic |
exception |
| provenance | stampInherited per field |
not stamped |
I checked whether the missing per-collection sourceId cherry-pick matters and it doesn't — generate reads only .resolved.sources (2362) and .deprecations. So this is a maintenance and accuracy problem, not a live bug. But it's the specific thing the changeset's own argument warns about ("two commands, written days apart, same two omissions"), and the two inline findings below are both instances of it. Either route generate through resolveProject, or soften the docs to say which commands actually share the pipeline.
nit: source.cache-narrow also rejects a cache that is a strict superset
sameSparse(undefined, ["docs"]) is false (sync/sync.ts:178-183), so a fully-cloned cache plus a newly-added sparse: list in config trips source.cache-narrow — and the message reads "was checked out with the full repository, but the config asks for [docs]", which contradicts the id and the helper's own doc comment ("a checkout narrower than the config asks for"). A full checkout contains everything the collection needs. sync --refresh clears it, so this is a nuisance rather than data loss; the new test only covers the true-positive direction (["docs"] vs ["docs","packages"]).
Claude Opus | 𝕏
Site-wide mounts were re-anchored to every collection's own content root, so a mount like legal -> /legal claimed /guides/legal/* at runtime while the sitemap and llms.txt advertised it under /guides. The runtime now builds the same flat staged-tree mount list generate composes and gives each collection its translated view of it. The runtime include glob also ran on tinyglobby defaults, so a bare directory entry fanned out to dir/** and dotfile pages were hidden — both opposite to copySourceFiles' deliberate settings. createDocsSource and doctor's page counter now use the staging semantics. Derived navigation gains generate's two opt-outs: include/exclude filters (derivation walks the raw tree while listPages serves the filtered one) and i18n (sections would key off locale directories).
Navigation inference had no i18n guard, so nav and doctor printed a locale-keyed tree the real build never produces — generate skips derivation for localized projects, and resolveProject now does too. The source.cache-narrow diagnostic fired for a fully-cloned cache the moment sparse entered the config, with a message inverting what narrow means. A manifest without sparse records a full clone — a superset of any configured path set — so the check now only rejects checkouts that were themselves sparse.
The @ts-expect-error in union-exclusivity.test.ts asserted nothing: tsconfig excludes *.test.ts from check-types and vitest does not typecheck, so reverting the union to a plain one still passed. The contract now lives in a .typecheck.ts file inside the normal include set, where an unused directive is itself a compile error — verified to fail check-types when the union's never-arm is removed. createDocsProject() discovery of a .ts config does await import("jiti"), an optional peer, so the fumadocs and source-primitive guides' install lines produced module-not-found at first render. Both now install jiti and say why; the init scaffolds already carry it via RUNTIME_CONFIG_DEPS and the quickstart goes through init.
The leaked-tempdir scan in the mirrored-sources cleanup test globbed leadtype-generate-*, which also matches the cross-process lock's <hash>.lock dirs and their .lock.reclaim-* trash. Any concurrent generate run — including the generate-lock tests in a parallel vitest worker — creates and removes those in the shared tmpdir, so the snapshot diff flagged whatever was transiently in flight, which is what failed CI on a lock dir the pipeline had in fact released (generate always releases in its finally). Reproduced deterministically by churning lock dirs during the run; the scan now ignores lock-protocol dirs and stays pinned on the source-mirror staging dirs it is about.
There was a problem hiding this comment.
Important
The four new commits close five of my earlier findings, but the include/exclude work introduces a new correctness gap: the exclude promise in the new JSDoc is not kept by getNavigation(), and the new derivation opt-out keys on a condition generate does not actually use — so it inverts the very drift it cites as its reason for existing.
Reviewed changes
- Traced
stagedTreeMounts/collectionMountsinproject/index.tsagainstgenerate'seffectiveMountscomposition andresolveDocsPathMount's longest-prefix tie-break, and confirmed the arithmetic and ordering match for nested prefixes, collection-ownedmounts, ancestor mounts, and themountPath === ""verbatim branch. The newproject.test.tsexpectation of["/archive/old", "/docs", "/guides", "/guides/legal/refund", "/legal/terms"]is what generation produces. - Confirmed the runtime and
doctorglobs now setdot: trueandexpandDirectories: false, matchingcopySourceFiles, with tests covering both the bare-directory and dot-directory directions. - Confirmed the sparse-cache narrow is now gated on
manifest.sparse !== undefined, with a superset-cache regression test. - Verified
union-exclusivity.typecheck.tsis still gated:tsconfig.jsonexcludes only*.test.ts/.test.tsx,check-typesrunstsgo --noEmitin CI, the file is not a rollup entry, and vitest's default include no longer matches it. Removing the union's exclusivity makes the@ts-expect-errorunused, so it fails in both directions as claimed. - Confirmed
jitiis apeerDependenciesentry marked optional inpeerDependenciesMeta, so the new fumadocs install line and its explanatory sentence are accurate. - Traced
excludethroughlistPages,loadPage,buildSearchIndexandgetNavigationto check the new JSDoc contract. - Compared each of the runtime's new derivation opt-outs against the corresponding condition in
generate.ts. - Checked the rewritten search-index assertion in
project.test.ts— resolving every chunk's document reference and requiring full coverage does catch the concatenated-index failure the old count assertion could not.
⚠️ Nothing tests the parity this PR exists to establish
Three of the four findings I have raised across the last two rounds are the same shape: a runtime path and its generate counterpart disagree about which pages exist or how they are grouped. Each was found by reading, not by a failing test, because there is still no test that runs generate and createDocsProject() over one config and diffs the emitted artifacts against listPages()/getNavigation(). The per-side unit tests cannot catch this class by construction: each asserts what its own side does, so a shared misconception passes both. One fixture with two collections, an include, an exclude and a site-wide mount, asserting the sitemap URL set equals the listPages() URL set and the llms.txt grouping equals getNavigation(), would have failed on all three.
📝 A runtime behaviour change is missing from the changeset
createDocsSource({ contentDir, i18n }) with no authored nav or groups previously derived a section tree and now returns a flat ungrouped list. That is the right call — the reasoning in the new comment about locale segments is correct, and I checked that generate skips derivation the same way — but it changes rendered navigation for existing localized consumers on upgrade. .changeset/resolve-project.md is a minor and does not mention it.
ℹ️ Nitpicks
- The runtime has no equivalent of
generate'sseenMountscollision throw (generate.ts:910-936). Collections withroutePrefix: "/guides"androutePrefix: "/docs/guides"both map to staging mountguidesviapathPrefixForUrlPrefix, andassertUniqueRoutePrefixesdoes not fire because the route prefixes differ.generaterefuses to build;createDocsProject()succeeds and servescontent/docs-guides/setup.mdxat/guides/setup. This is pre-existing rather than introduced here, but it is a parity hole the new mount code sits directly on top of.
Technical details
The exclude leak, end to end:
getNavigation() source/index.ts:667-680
→ resolveDocsNavigation llm.ts:3770 (srcDir + docsDirName, no filters)
→ readSourceDocs llm.ts:1688-1702
→ collectFiles llm.ts:1541-1558 (recursive readdir, extension filter only)
config.exclude is consumed only by the ignore option on the fg call that populates cachedFilesByRoot (source/index.ts:516-533), which is what listPages, loadPage and buildSearchIndex read. getNavigation does not touch that cache, so excluded files reach ungrouped via llm.ts:1948-1951 and llm.ts:3740-3743. The new project.test.ts case at 149-167 covers exactly the three methods that do honour it (listPages, loadPage) and stops short of getNavigation.
Why the new guard never runs under createDocsProject:
config/project.ts:487-499 derives over the raw contentDir; gate checks i18n only, not include/exclude
project/index.ts:314 ...(collection.navigation ? { nav: collection.navigation } : {})
source/index.ts:635-637 if (config.nav?.length) return nav; ← short-circuits
source/index.ts:648-654 the new include/exclude guard ← unreachable
So exclude: ["drafts/**"] through createDocsProject produces a derived tree keyed off draft files that listPages() and loadPage() then refuse to serve.
Claude Opus | 𝕏
getNavigation() resolved navigation through a plain recursive readdir, so pages withheld by a collection's include/exclude still landed in the returned tree — linking to URLs loadPage refuses to serve. The source now threads its file selection into resolveDocsNavigation as a filter, so the exclude contract (not listed, not loadable, not indexed) extends to the navigation surface. The runtime derivation guard also refused to derive whenever collection filters were set, claiming parity with generate — but generate's gate only tests the --include/--exclude CLI flags; collection filters affect staging, and a plain generate still derives from the filtered mirror. Derivation now runs over the same filtered file set on both the source and resolveProject paths, keeping i18n as the only structural opt-out.
There was a problem hiding this comment.
Important
All three findings from the last round are genuinely closed, and I confirmed the filtered-derivation work matches generate at every call site. But threading the file selection into resolveDocsNavigation has one interaction the tests don't cover: a collection with both openapi and include now makes getNavigation() throw. I reproduced it by execution, and confirmed it does not throw on the parent commit.
Reviewed changes
Since review 4911276071 one commit landed — 454fe0f, "Run navigation and nav derivation over the filtered file set". In it you:
- Gave
inferNavigationFromContenta membershipfilter—config/infer.tsapplies it inreadContentPagesover docs-relative POSIX paths, so a caller that already knows its file selection derives over exactly that set rather than re-encoding the globs. - Gave
resolveDocsNavigationafilterFilepredicate — threaded intoreadSourceDocsfor both the primary docs dir and everyextraDocsDirsentry, which is what finally makesexcludea page-existence filter for navigation as the JSDoc claims. - Dropped the include/exclude half of the runtime's derivation opt-out —
resolveNavnow derives over the filtered set instead of refusing to derive, and onlyi18nstill opts out. - Added
derivationPathFiltertoresolveProjectso thecreateDocsProjectpath derives over the same filtered set, closing the dead-guard half of the earlier finding. - Rewrote the vacuous exclude test and added three cases covering derivation over a filtered tree, an authored
include: "**"not resurrecting excluded pages, and theresolveProjectequivalent.
Checked rather than assumed, so these don't get re-litigated:
- The three filtered-file-set call sites agree.
copySourceFiles,derivationPathFilterandlistFilesByRootread the samecollection.include/excludewith the same glob options (dot,expandDirectories,ignore,onlyFiles) against the same cwd. The default-pattern difference (["**/*"]vs["**/*.{md,mdx}"]) andderivationPathFilterskippingisDocFileare both inert, becausereadContentPagesre-globsDOC_GLOBitself before anyfiltercallback is consulted. - The comment's claim about
generateis accurate.hasExplicitPathFilters(generate.ts:1792) reads only the CLI flags; collection-level filters reach staging alone, andgeneratethen derives from the already-filteredsourceMirror.docsDir(generate.ts:1872). Deriving rather than refusing is the matching behaviour. - Path shapes line up, including on Windows.
source/index.tsimports theinternal/docs-urlnormalizeDocsPath(separator normalization, extension preserved), not the trailing-slash variant innavigation/index.ts, andselectedFileFiltercompares throughpath.resolveon both sides — so tinyglobby's forward slashes andcollectFiles'path.joinseparators still match. - The new tests can fail. Reverting the filter reintroduces a
Draftsgroup, and groups are emitted even with zero resolved pages (llm.ts:3645-3695), so thetoEqual(["Guides"])assertions are real.
ℹ️ The runtime and generate still disagree about whether generated OpenAPI pages are filterable
The inline finding is one symptom of a rule the two sides never agreed on: generate writes OpenAPI pages into the staged mirror after staging, so collection filters never touch them, while the runtime treats the overlay as just another content root. Worth deciding the rule once and stating it, because the same question returns for any future generated-content overlay — and doctor, which is meant to predict generate, does not model the overlay at all.
Technical details
# Generated-content overlays and collection path filters
## Affected sites
- `packages/leadtype/src/source/index.ts:451-476` — `contentRoots` treats
`openApiOverlayDir` as peer to `sourceContentDir`, so every root-wide operation
applies collection filters to generated pages.
- `packages/leadtype/src/cli/generate.ts:1799-1809` — `writeOpenApiPages` targets
`sourceMirror.docsDir`, i.e. after `copySourceFiles` has applied the filters.
Generated pages are unconditionally present.
- `packages/leadtype/src/cli/doctor.ts` — `countCollectionPages` globs the content
dir only; there is no overlay, so `doctor`'s page count and drift report differ
from both of the above for an `openapi` collection.
## Required outcome
- One stated rule for whether `include`/`exclude` apply to generated pages, held
by `generate`, the runtime, and `doctor` alike.
## Open questions for the human
- Is `generate`'s behaviour (generated pages are never filterable) the intended
contract, or an accident of staging order? If intended, the runtime's
`contentRoots` needs to encode it rather than infer it.
- Should `doctor` model the OpenAPI overlay at all, or is that explicitly out of
scope until #179?Claude Opus | 𝕏
listFilesByRoot applied config.include/exclude to every content root, but those patterns are authored against the content tree — under the OpenAPI overlay temp dir they match nothing. With an include filter set, the overlay contributed zero files, generated pages vanished from listPages, and since 454fe0f threads that selection into resolveDocsNavigation as filterFile, the generated nav node's string page refs made getNavigation throw outright. generate never subjects generated pages to collection filters: it writes them into the mirror after copySourceFiles applies the staging globs. The runtime now matches by scoping include/exclude to sourceContentDir and globbing overlay roots unfiltered, which fixes both the throw and the pre-existing listPages omission in one place.
There was a problem hiding this comment.
ℹ️ The
openapi+includecrash from the last round is genuinely fixed — I reproduced both directions by execution rather than reading. One JSDoc nit inline; nothing blocking.
Reviewed changes
Since review 4917032791 one commit landed — 1d82241, "Exempt the OpenAPI overlay root from collection include/exclude globs". In it you:
- Scoped the collection path filters to the authored content tree —
listFilesByRootcomputesapplyPathFilters = root === sourceContentDirand gates both theconfig.includepattern choice and theignore: config.excludeoption on it, so the OpenAPI overlay temp root is always globbed with the default["**/*.{md,mdx}"]and no ignores. - Added a regression test —
source.test.ts:481-532builds a source withinclude: ["guides/**"]plus anopenapispec and asserts the generated pages survive in bothlistPages()andgetNavigation(), while the non-includeddrafts/wipstays out.
Checked rather than assumed, so these don't get re-litigated:
- The predicate is exact, not a heuristic.
contentRoots[0]issourceContentDirby identity (source/index.ts:451) andopenApiOverlayDiris the only other member ever pushed (473), so reference equality partitions the roots correctly.selectedFileFilter'sallowedSet now contains the generated files, which is what stopsresolveNavEntryPagesthrowing on the generated node's plain string page refs. - The new test can fail. I forced
applyPathFilters = trueand it failed atsource.test.ts:510withexpected [ 'guides/setup' ] to deeply equal [ 'api', 'api/read-pet', 'guides/setup' ], then restored the line and it passed. All 70 tests acrosssource.test.ts,config/project.test.tsandproject/project.test.tspass on1d82241. generatereally is the oracle the comment claims.hasExplicitPathFilters(generate.ts:1786) reads only the--include/--excludeCLI flags, so collection-level filters never suppress OpenAPI generation, andwriteOpenApiPagestargetssourceMirror.docsDiraftercopySourceFiles(1802-1809). Generated pages are unconditionally present there, which is now what the runtime does too.- Spec resolution is at parity as well. The runtime anchors relative
inputpaths atconfig.openapiCwd ?? sourceContentDir(source/index.ts:461) andcreateDocsProjectpassesopenapiCwd: project.configDir(project/index.ts:332-334);generateusesdirname(metadata.configPath)(generate.ts:1805). Same anchor, so a root-levelleadtype.config.*resolves its spec identically on both sides. resolveNav's derivation filter is correctly left alone. It still restricts toentry.contentDir === sourceContentDir(686-699), which is right —inferNavigationFromContentonly ever readssourceContentDir, so admitting overlay files there would have derived sections for pages that live outside the tree being walked.
Claude Opus | 𝕏

Stacked on #164, the follow-up layer for #157. Implements the four gaps flagged when that stack landed.
The evidence
Answering "what is this project?" takes five ordered steps — discover the config, apply source-owned inheritance, normalize to canonical names, derive what wasn't authored, resolve each collection's content directory through the sync cache. Every command needs all five, and each assembled them by hand.
That cost was not hypothetical.
doctorandnavshipped with the same two bugs:inferred, showing a filesystem-derived tree the real build never uses.diragainst the config directory instead of its checkout, so every page vanished.Two commands, written days apart, same two omissions. That is a design problem, not two mistakes.
resolveProject()One function runs the pipeline; the commands read its result.
Diagnostics, not exceptions. It throws only for a genuinely malformed config — there is no project to describe. Everything environmental carries a stable id, the owning config field, and the command that fixes it. That split is what lets one function serve both kinds of caller:
doctorreports an unsynced source and keeps going, whilecreateDocsProjectrefuses to hand a renderer a source it cannot read. Only the caller knows which is right, so the resolver doesn't decide.Two details found against real c15t, not fixtures
sourcesintocollections, so only the first pass ever sees authored source names; re-deriving reported c15t's source asrepo#refinstead ofc15t. Both have regression tests.The other three follow-ups
Config loading moves out of
cli/generate.tsinto the config module. The runtime needs it —createDocsProjectanddoctorboth ask which config describes a project — and reaching through the generate pipeline to ask would drag staging and conversion into an app bundle. This is the last piece of #151's "one normalizer shared by every command and runtime helper".createDocsProject()discovers its own config, so an app that has one doesn't import it just to hand it straight back:The scaffolds and the Astro example use that shape. It also collapses the
configDir/contentDir/configPathcluster into one rule, stated once: adocs.config.*sits inside the docs directory, aleadtype.config.*at the root above it.The single-repo path is unambiguous in the docs: use
defineDocsConfig; ownership only becomes a question once a second repo is involved.Verification
805 tests pass. Re-verified against the migrated c15t example:
doctorandnavreportinherited, 252/254 pages, the seven sections c15t publishes, 63 unplaced pages and one duplicate — identical to before the refactor, with the source namedc15tagain.bun run check-typesstill trips the parallel-build race on this branch; that fix is #166, offmain.