Skip to content

Resolve the project once instead of in every command - #167

Open
KayleeWilliams wants to merge 12 commits into
dx/156-navigation-authoringfrom
dx/157-resolve-project
Open

Resolve the project once instead of in every command#167
KayleeWilliams wants to merge 12 commits into
dx/156-navigation-authoringfrom
dx/157-resolve-project

Conversation

@KayleeWilliams

@KayleeWilliams KayleeWilliams commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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. 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, showing a filesystem-derived tree the real build never uses.
  • 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()

One function runs the pipeline; the commands read its result.

const project = await resolveProject({ cwd: "." });

project.collections[0].contentDir;        // resolved through the sync cache
project.collections[0].navigationOrigin;  // explicit | inherited | groups | inferred
project.sources;                          // the acquisition graph
project.inference;                        // what was derived, and from what
project.diagnostics;                      // what stopped a step, and the fix

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: 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 found against real c15t, not fixtures

  • Deprecations come from the load-time normalization. Aliases are folded there, so a second pass over the canonical config correctly finds none — and reporting none would tell a legacy config it has nothing to migrate.
  • The acquisition graph comes from that same first pass. Normalization expands sources into collections, so only the first pass ever sees authored source names; re-deriving reported c15t's source as repo#ref instead of c15t. Both have regression tests.

The other three follow-ups

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. 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:

export const source = await createDocsProject({ baseUrl: "https://example.com" });

The scaffolds and the Astro example use that shape. It also collapses the configDir / contentDir / configPath cluster into one rule, stated once: a docs.config.* sits inside the docs directory, a leadtype.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: doctor and nav report inherited, 252/254 pages, the seven sections c15t publishes, 63 unplaced pages and one duplicate — identical to before the refactor, with the source named c15t again.

bun run check-types still trips the parallel-build race on this branch; that fix is #166, off main.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3c56fe99-6cb9-4da9-aa40-d8e6878ea159

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 pipelineconfig/project.ts discovers 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 diagnosticsProjectDiagnostic carries level, collection, owner, and fix, so doctor reports and continues while createDocsProject throws on the first error.

  • Normalization runs twice — inheritance changes what the collections are, so the config is re-normalized, with sources, deprecations, and per-collection sourceId carried forward from the first pass.

  • createDocsProject config is now optional — apps can omit config and let discovery find leadtype.config.* or docs.config.* from cwd.

  • doctor, nav, sync, and lint read the resolved projectinspectNavigation, applySourceInheritance, and resolveCollectionDir are deleted in favour of collection.navigationOrigin / collection.contentDir.

  • Scaffolds stop importing the config — all four init plans now emit a bare createDocsProject({ baseUrl }) and rely on discovery.

⚠️ generate never reads the resolved project the changeset says it reads

generate.ts still hand-assembles syncCollectionsinheritCollectionSourceConfigsresolveDocsSourcesFromCollectionsloadDocsConfig, 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: inspectOutputs now globs **/*.{md,mdx} per collection instead of reusing the already-filtered file list, so include/exclude are 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 carries collections, sources, and deprecations forward but not top-level provenance. For a sources-authored config the second pass sees sources: undefined and collections: {...}, so provenance.sources disappears and provenance.collections is reported as explicitly authored. This is user-visible through doctor --json (doctor.ts:569).

  • config/project.ts ~384-386: when options.infer === false and nothing was authored, navigationOrigin is still set to "inferred" — labelling "nothing was derived" as "derived". The test at project.test.ts:122 only asserts navigation is undefined, so the label isn't pinned either way.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/leadtype/src/project/index.ts Outdated
Comment thread packages/leadtype/src/cli/init-templates.ts
Comment thread packages/leadtype/src/cli/nav.ts Outdated
@KayleeWilliams
KayleeWilliams force-pushed the dx/157-resolve-project branch from 56aaa7f to 008150a Compare August 4, 2026 09:22

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 (56aaa7f65e0e0c, 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> = {}), so createDocsProject() now takes no arguments at all;
  • migrated apps/fumadocs-example/lib/source.ts off its ../../../docs/docs.config import onto cwd: repoRoot discovery — this removed the last first-party caller that passed configPath;
  • rewrote the published fumadocs snippet in docs/integrations/integrate-with-fumadocs.mdx as a bare createDocsProject() and dropped its // @noErrors twoslash directive;
  • refreshed the docs/paths.lock.json hashes for the pages you touched.

Two things I checked rather than assumed:

  • Dropping // @noErrors is safe. docs.config.ts sets lint: { snippets: { typecheck: true } } and CI gates on leadtype 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.config import.
  • cwd discovery works in both examples. I executed createDocsProject({ cwd: repoRoot }) from apps/fumadocs-example under plain node, and createDocsProject() from the repo root under bun. Both discover docs/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.ts 256-266 — options.config ? { ...normalizeDocsConfig(options.config, …) } : await loadDocsConfig({ cwd: rootDir, docsDirs }). options.configPath is consumed only inside the then branch.
  • packages/leadtype/src/project/index.ts 156-162 — the forwarding block derives cwd: path.dirname(input.configPath) and passes configPath through, which is what makes the miss look like a working path.
  • packages/leadtype/src/project/index.ts 98-115 — JSDoc still promises "a docs.config.* sits inside the docs directory, a leadtype.config.* sits at the project root above it".
  • docs/pipeline/use-the-source-primitive.mdx ~50 — the configPath paragraph is removed in this PR, so prose and type no longer agree.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread docs/integrations/integrate-with-fumadocs.mdx
@KayleeWilliams
KayleeWilliams force-pushed the dx/157-resolve-project branch from 008150a to e8b81ad Compare August 4, 2026 10:05
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.
@KayleeWilliams
KayleeWilliams force-pushed the dx/157-resolve-project branch from e8b81ad to 0d7ca46 Compare August 4, 2026 10:48

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the contentDir regression from the open project/index.ts thread, with a test;
  • added source.cache-narrow and promoted source.inherit-failed to level: "error", moving inheritance failure to per-collection;
  • added include/exclude pass-through to createDocsSource and to doctor's new countCollectionPages;
  • rewrote doctor's inspectNavigation to pass every collection's groups plus top-level i18n, and to skip the unrepresented-page check for filtered collections;
  • added reportInferredTree to nav, RUNTIME_CONFIG_DEPS = ["jiti"] to all four init plans, a pin-conflict throw plus pin: string | string[], an openapi + collections refusal, and a mutually-exclusive FumadocsSourceConfig union.

Verified rather than assumed — none of these are findings, listing them so they don't get re-litigated:

  • stampInherited's inheritedFrom: collectionKey matches the documented meaning in config/types.ts:52-53.
  • defaultCacheDir(source.repository, source.ref) in doctor.ts:531 matches sync.ts:139 exactly.
  • The pin-conflict throw only fires where the pin was already a silent no-op pre-PR (buildNavigationGroupFromNav dropped the duplicate at llm.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) and llm.test.ts (86) pass; docs/docs.config.ts uses no include/pin at all.
  • The pin: string | string[] widening is complete — the only readers are llm.ts and validateDocsNavPageEntry (config/inherit.ts:154-161), both updated; no array-only schema survives.
  • Removing resetConfigDeprecationWarnings leaves zero references repo-wide, and config/load.ts is a faithful lift of the validators out of cli/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"]).

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/leadtype/src/project/index.ts Outdated
Comment thread packages/leadtype/src/source/index.ts Outdated
Comment thread packages/leadtype/src/cli/doctor.ts
Comment thread packages/leadtype/src/cli/nav.ts
Comment thread packages/leadtype/src/fumadocs/union-exclusivity.test.ts Outdated
Comment thread packages/leadtype/src/llm/llm.ts
Comment thread packages/leadtype/src/cli/doctor.ts
Comment thread packages/leadtype/src/config/project.ts
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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/collectionMounts in project/index.ts against generate's effectiveMounts composition and resolveDocsPathMount's longest-prefix tie-break, and confirmed the arithmetic and ordering match for nested prefixes, collection-owned mounts, ancestor mounts, and the mountPath === "" verbatim branch. The new project.test.ts expectation of ["/archive/old", "/docs", "/guides", "/guides/legal/refund", "/legal/terms"] is what generation produces.
  • Confirmed the runtime and doctor globs now set dot: true and expandDirectories: false, matching copySourceFiles, 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.ts is still gated: tsconfig.json excludes only *.test.ts/.test.tsx, check-types runs tsgo --noEmit in 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-error unused, so it fails in both directions as claimed.
  • Confirmed jiti is a peerDependencies entry marked optional in peerDependenciesMeta, so the new fumadocs install line and its explanatory sentence are accurate.
  • Traced exclude through listPages, loadPage, buildSearchIndex and getNavigation to 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's seenMounts collision throw (generate.ts:910-936). Collections with routePrefix: "/guides" and routePrefix: "/docs/guides" both map to staging mount guides via pathPrefixForUrlPrefix, and assertUniqueRoutePrefixes does not fire because the route prefixes differ. generate refuses to build; createDocsProject() succeeds and serves content/docs-guides/setup.mdx at /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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/leadtype/src/source/index.ts Outdated
Comment thread packages/leadtype/src/source/index.ts Outdated
Comment thread packages/leadtype/src/source/source.test.ts
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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 inferNavigationFromContent a membership filterconfig/infer.ts applies it in readContentPages over 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 resolveDocsNavigation a filterFile predicate — threaded into readSourceDocs for both the primary docs dir and every extraDocsDirs entry, which is what finally makes exclude a page-existence filter for navigation as the JSDoc claims.
  • Dropped the include/exclude half of the runtime's derivation opt-outresolveNav now derives over the filtered set instead of refusing to derive, and only i18n still opts out.
  • Added derivationPathFilter to resolveProject so the createDocsProject path 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 the resolveProject equivalent.

Checked rather than assumed, so these don't get re-litigated:

  • The three filtered-file-set call sites agree. copySourceFiles, derivationPathFilter and listFilesByRoot read the same collection.include/exclude with the same glob options (dot, expandDirectories, ignore, onlyFiles) against the same cwd. The default-pattern difference (["**/*"] vs ["**/*.{md,mdx}"]) and derivationPathFilter skipping isDocFile are both inert, because readContentPages re-globs DOC_GLOB itself before any filter callback is consulted.
  • The comment's claim about generate is accurate. hasExplicitPathFilters (generate.ts:1792) reads only the CLI flags; collection-level filters reach staging alone, and generate then derives from the already-filtered sourceMirror.docsDir (generate.ts:1872). Deriving rather than refusing is the matching behaviour.
  • Path shapes line up, including on Windows. source/index.ts imports the internal/docs-url normalizeDocsPath (separator normalization, extension preserved), not the trailing-slash variant in navigation/index.ts, and selectedFileFilter compares through path.resolve on both sides — so tinyglobby's forward slashes and collectFiles' path.join separators still match.
  • The new tests can fail. Reverting the filter reintroduces a Drafts group, and groups are emitted even with zero resolved pages (llm.ts:3645-3695), so the toEqual(["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?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/leadtype/src/source/index.ts
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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The openapi + include crash 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 treelistFilesByRoot computes applyPathFilters = root === sourceContentDir and gates both the config.include pattern choice and the ignore: config.exclude option on it, so the OpenAPI overlay temp root is always globbed with the default ["**/*.{md,mdx}"] and no ignores.
  • Added a regression testsource.test.ts:481-532 builds a source with include: ["guides/**"] plus an openapi spec and asserts the generated pages survive in both listPages() and getNavigation(), while the non-included drafts/wip stays out.

Checked rather than assumed, so these don't get re-litigated:

  • The predicate is exact, not a heuristic. contentRoots[0] is sourceContentDir by identity (source/index.ts:451) and openApiOverlayDir is the only other member ever pushed (473), so reference equality partitions the roots correctly. selectedFileFilter's allowed Set now contains the generated files, which is what stops resolveNavEntryPages throwing on the generated node's plain string page refs.
  • The new test can fail. I forced applyPathFilters = true and it failed at source.test.ts:510 with expected [ 'guides/setup' ] to deeply equal [ 'api', 'api/read-pet', 'guides/setup' ], then restored the line and it passed. All 70 tests across source.test.ts, config/project.test.ts and project/project.test.ts pass on 1d82241.
  • generate really is the oracle the comment claims. hasExplicitPathFilters (generate.ts:1786) reads only the --include/--exclude CLI flags, so collection-level filters never suppress OpenAPI generation, and writeOpenApiPages targets sourceMirror.docsDir after copySourceFiles (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 input paths at config.openapiCwd ?? sourceContentDir (source/index.ts:461) and createDocsProject passes openapiCwd: project.configDir (project/index.ts:332-334); generate uses dirname(metadata.configPath) (generate.ts:1805). Same anchor, so a root-level leadtype.config.* resolves its spec identically on both sides.
  • resolveNav's derivation filter is correctly left alone. It still restricts to entry.contentDir === sourceContentDir (686-699), which is right — inferNavigationFromContent only ever reads sourceContentDir, so admitting overlay files there would have derived sections for pages that live outside the tree being walked.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/leadtype/src/source/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant