Route generate and lint through the shared project resolver - #180
Route generate and lint through the shared project resolver#180KayleeWilliams wants to merge 1 commit into
Conversation
generate hand-assembled the full resolution pipeline after syncing — including a near-verbatim copy of the subtlest invariant in the codebase: re-normalize after inheritance, but keep the FIRST pass's sources and deprecations, because normalization expands `sources` into `collections` and folds aliases, so only the load-time pass ever sees authored source names or deprecated fields. Two hand-synced copies of that rule is the bug pattern resolveProject was written to kill. lint was worse: it loaded the config and skipped inheritance entirely, so a collection whose frontmatterSchema, mounts, or flatteners come from its source repository via `inheritConfig: true` was linted against the defaults — and an unsynced source linted an empty tree and passed with zero files scanned. The post-load resolution now lives once, in resolveProjectFromLoaded: resolveProject calls it after discovery (cache-only, never cloning), generate calls it after running its own syncSources and converts blocking diagnostics into hard failures, and lint calls it cache-only with a clear error naming `leadtype sync` when a checkout is missing or stale. New tests: a lint regression fixture where the inherited schema changes the result, a cache-only failure for unsynced sources, and a cross-subsystem agreement test pinning that generate's resolution path and resolveProject see identical sources, deprecations, content dirs, and navigation origins — plus generate's --json graph matching resolveProject's. The c15t example regenerates byte-identical output modulo generatedAt timestamps.
|
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
lint now enforces the project-level gate the published docs assign exclusively to doctor. The code change is right; the docs that describe the split are now wrong, and there is no changeset for a user-visible CLI behavior change.
Reviewed changes — the full diff at 5002598: the extracted resolver, both new callers, and the two new test surfaces.
- Shared post-load resolver extracted —
resolveProjectFromLoaded(loaded, { rootDir, fallbackContentDir?, inherit?, infer? })inconfig/project.tsnow owns per-collection source-owned inheritance, the re-normalization that carries the first pass'ssources/deprecations/sourceIdthrough, provenance stamping, and content-dir resolution through the sync cache. resolveProjectreduced to discovery — it loads (or accepts) a config and delegates;contentDir/docsDirs[0]becomefallbackContentDir. Behaviorally unchanged.generaterouted through it —executeGeneratedropsinheritCollectionSourceConfigs,normalizeDocsConfig, and the hand-written sources/deprecations splice, syncs first, then converts the firsterror-level diagnostic into a throw.lintrouted through it — collections-based projects now get source-ownedfrontmatterSchema/mounts/flatteners, exit 1 on blocking diagnostics, and readcontentDir/routePrefix/frontmatterSchemaoffResolvedProjectCollectioninstead ofresolveAllCollections.- Tests — new
cli/generate.test.ts(cross-subsystem agreement plus--jsongraph parity) and alint.test.tsblock covering the inheritedfrontmatterSchemaand the unsynced-source hard error.
I traced the equivalence claims and they hold: ResolvedDocsCollection.routePrefix is byte-identical to resolveCollection's urlPrefix (both normalizeUrlPrefix(routePrefix ?? "/"+key)); the second normalizeDocsConfig pass preserves every field resolveDocsSourcesFromCollections reads; and generate's remote cache diagnostics really are satisfied by construction, since the default syncMode is "missing" and syncOne already throws on any repository/ref/sparse manifest mismatch. The contentDir as string cast is sound today — all five early returns in resolveContentDir push level: "error".
⚠️ lint now fails on project problems the docs promise only doctor reports
The new blocking-diagnostic gate covers every error from resolveContentDir, which includes the unconditional existsSync on a local collection's dir — not just the unsynced/stale remote cases the PR description enumerates. Meanwhile docs/pipeline/validate-in-ci.mdx:130 still tells readers that lint checks content while doctor is the one that checks "that every collection's directory exists, that remote sources are synced to the revision the config asks for", and the recipe at :140-150 runs doctor → lint → generate with no leadtype sync. Anyone who gated only on lint for a collections-based project now gets exit 1 on a fresh CI runner with a doc that says they shouldn't.
Technical details
# `lint`'s new project gate is undocumented and unversioned
## Affected sites
- `packages/leadtype/src/lint/cli.ts:276-284` — any `level: "error"` diagnostic returns 1 before a single file is read.
- `packages/leadtype/src/config/project.ts:231-240` — `source.dir-missing` fires for local collections too; the `remote` branch at 182-229 is layered on top, not a precondition. Previously lint globbed the missing directory and exited 0 on zero files.
- `docs/pipeline/validate-in-ci.mdx:130` — "lint checks your content. `leadtype doctor` checks the *project* — that the config resolves, that every collection's directory exists, that remote sources are synced…". No longer the split.
- `docs/pipeline/validate-in-ci.mdx:138` — the "either run `leadtype sync` first, or gate only on the repo whose docs you own" caveat is scoped to doctor; it now applies to lint identically.
- `docs/pipeline/validate-in-ci.mdx:140-150` — the "Run before generate" recipe has no `sync` step.
- `.changeset/` — this PR adds none. `.changeset/resolve-project.md` enumerates `generate`, `doctor`, `nav`, and `createDocsProject`; `lint` is not mentioned, and its new failure mode is the most user-visible change in this PR.
## Required outcome
- The CI-gating docs describe lint's actual contract for collections-based projects: it is now sync-sensitive and directory-existence-sensitive in exactly the way doctor is.
- A changeset entry (new, or an addition to `resolve-project.md`) records that `leadtype lint` gained source-owned inheritance and now exits 1 on unresolvable collections.
## Open questions for the human
- Is failing on a *local* collection whose `dir` does not yet exist the intended contract? It is consistent with the fix and arguably correct, but it is a strictly new failure for anyone whose build populates a collection directory before lint runs. If it is intended, the docs should say so; if not, `source.dir-missing` for non-remote collections would need to stay non-blocking for lint.ℹ️ No test fails if generate stops using the shared resolver
The PR's central claim is that generate no longer hand-assembles the inherit → re-normalize invariant, but neither new test is sensitive to that. generate.test.ts test 1 hand-rolls generate's pipeline (loadLeadtypeConfig → syncSources → resolveProjectFromLoaded) rather than driving executeGenerate, so it exercises the resolver and not generate. Test 2 does drive runGenerateCommand, but asserts only result.sources — and the old hand-assembled path preserved sources from the first-pass graph identically, so that assertion passes on either implementation.
Technical details
# Generate's use of the shared resolver is unpinned
## Affected sites
- `packages/leadtype/src/cli/generate.test.ts:128-170` — reconstructs generate's steps by hand; a revert of `generate.ts` to `inheritCollectionSourceConfigs` + `normalizeDocsConfig` + splice leaves this test green.
- `packages/leadtype/src/cli/generate.test.ts:172-190` — the only end-to-end assertion is `result.sources` vs `project.sources`; both implementations source that from `loadedConfig.resolved.sources`.
- `packages/leadtype/src/cli/generate.ts:1707-1714` — the new blocking-diagnostic → throw branch has no test at all. The nearest existing coverage (`cli.test.ts:2391`) is the `--docs-dir` single-source path, which does not reach it.
## Required outcome
- At least one assertion that fails if generate's resolution stops flowing through `resolveProjectFromLoaded` — e.g. drive `runGenerateCommand` against the shared fixture and assert something only the resolved view carries (the inherited navigation reaching the emitted output, or `resolved.collections[].sourceId` in `--json`).
- One test for the blocking-diagnostic throw, so the new fatal path is a contract rather than an accident.
## Suggested approach (optional)
`resolveProjectFromLoaded` rewrites `resolved.collections[].sourceId` from the first pass specifically because the second normalize pass would otherwise report `repo#ref` instead of the authored source name — that is a value the old generate path did not preserve, so asserting it through `generate --json` would pin the new wiring precisely.ℹ️ Nitpicks
packages/leadtype/src/lint/cli.ts:269— thisresolveProjectFromLoadedcall sits just outside thetry/catchat 229-256 whose comment states "A config that fails to load or validate is a lint failure in its own right — report it instead of crashing";generatewraps the identical call inside its own catch. I could not construct a reachable throw (inherited fields are validated invalidateSourceOwnedConfigFieldsbefore the second normalize pass, and the top-level CLI catch still yields exit 1), so this is consistency rather than a bug — but the fix is moving one brace.packages/leadtype/src/lint/cli.ts:376-377—collection.contentDir as stringis correct today only because everyresolveContentDirearly return happens to belevel: "error". A futurewarn-level content-dir diagnostic would silently handundefinedtocollectRouteSet/lintDocsrather than fail; the invariant is load-bearing and only a comment enforces it.packages/leadtype/src/lint/lint.test.ts:1590-1591— assertingstderrcontains"leadtype sync"matches the genericfixsuffix shared by several diagnostics, so a message naming the wrong collection (or a differentsource.*id) still passes. Asserting on the collection name or thesource.not-syncedmessage would pin the behavior the test is named for.
Claude Opus | 𝕏

Stacked on #178 → #167. Closes the last two hand-assembled resolution pipelines #167's review found.
The evidence
generate duplicated the subtlest invariant in the codebase. After syncing,
executeGeneratehand-assembled inherit → re-normalize:inheritCollectionSourceConfigs, thennormalizeDocsConfig, then a hand-written "keep the first pass'ssourcesanddeprecations" splice — a near-verbatim copy of the canonical block inresolveProject(config/project.ts), comment and all. The invariant is subtle because it's invisible when violated: normalization expandssourcesintocollections, so only the load-time pass ever sees authored source names, and it folds deprecated aliases onto canonical names, so a second pass over the canonical config correctly finds no deprecations — and reporting that "nothing" tells a legacy config it has nothing to migrate. Two hand-synced copies of that rule is exactly the drift pattern that gave doctor and nav the same two bugs before #167.lint skipped inheritance entirely — the same bug class, still live.
lint/cli.tsloaded the config and went straight toresolveAllCollections, so a collection whosefrontmatterSchema,mounts, orflattenerscome from its source repository viainheritConfig: truewas linted against the defaults. Confirmed by a failing test first: a synced fixture whose source-owned schema requires anownerfield lints clean on this PR's base — the page violates the contract its own source repo declares. Worse, an unsynced source resolved to a cache path that doesn't exist, so lint scanned an empty tree and passed with zero files: exit 0 on a project whose build would fail.The design
The post-load resolution — per-collection source-owned inheritance, the re-normalization that preserves the first pass's graph and deprecations, provenance stamping, and content-dir resolution through the sync cache — now lives once, as
resolveProjectFromLoaded(loaded, { rootDir, … })in config/project.ts. The three callers differ only in what they do around it:resolveProject: discover config → shared resolver. Unchanged behavior, cache-only, never clones.generate: load →syncSources(generate still owns acquisition; the resolver deliberately never clones) → shared resolver → convert blocking diagnostics to throws, the same patterncreateDocsProjectestablished. Since generate just synced, the resolver's cache checks are satisfied by construction; what remains fatal is what was fatal before — an unsatisfiableinheritConfig, a missing collection dir.lint: load → shared resolver, cache-only like the runtime. A missing or stale checkout is now a clear error namingleadtype sync(diagnostic message + fix, in the existing diagnostic style) instead of a silent empty pass.generate.tsno longer importsinheritCollectionSourceConfigsornormalizeDocsConfig;lint/cli.tsno longer importsresolveAllCollections. Both pieces keep their module exports only becauseresolveProjectFromLoadeditself consumes them; neither is public API.Behavior
inheritConfig+ sparse — the exact changed path) regenerates byte-identical output modulogeneratedAttimestamps, verified by generating with the base commit and this branch and diffing the trees. The fumadocs/astro examples use the single-source branch this PR doesn't touch.Tests
frontmatterSchema(fails on base, passes here), and the unsynced-source hard error.resolveProjectproduce identical sources, deprecations, collection content dirs, source ids, and navigation origins — plus an end-to-end check thatgenerate --json's acquisition graph deep-equalsresolveProject's, authored source names included.Verification
887 tests pass (
bun run test), lint clean,tsgo --noEmitclean for the package.bun run check-typesat the workspace level still trips the pre-existing parallel-build race on this stack; that fix is #166, offmain.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.