Route generate and lint through the shared project resolver - #180
Route generate and lint through the shared project resolver#180KayleeWilliams wants to merge 3 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
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 | 𝕏
5002598 to
6524df4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6524df4f4c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
6524df4 to
8c7cdb2
Compare
|
Rebased onto |
There was a problem hiding this comment.
ℹ️ No new issues in this delta. The stale-cache fix is correct, complete, and genuinely covered — one carry-over from the previous review is still open, and this commit widened it.
Reviewed changes — the delta since the prior pullfrog review at 5002598: a pre-inheritance remote-cache gate, two regression tests, and the rebase onto the new base tip 8ba12b6a. lint/cli.ts and cli/generate.test.ts are byte-identical to the previously reviewed revision.
- Verified the remote cache before inheritance runs —
remoteCacheDiagnostic(config/project.ts:198-261) is lifted out ofresolveContentDirand now also called at the top ofresolveProjectFromLoaded's inheritance loop (:902-912), so a stale, unverifiable, or missing checkout is reported and skipped beforeinheritCollectionSourceConfigsimports and executes that checkout'sdocs.config.*. - Suppressed the resulting duplicate diagnostic —
pushUniqueDiagnostic(:263-276) keepsresolveContentDir's now-redundant second check from reporting the sameid+collectiontwice. - Added two regression tests —
does not import inherited config from a stale cache(config/project.test.ts:457-485) andrejects a stale cache before importing its inherited config(lint/lint.test.ts:1594-1626), the latter using athrow new Error('SHOULD_NOT_IMPORT')source config to prove the module never evaluates. - Absorbed the base branch's
--docs-dirwork — the multi-dir synthesis block moved back out ofresolveProjectFromLoadedintoresolveProject; the extracted function now forwardswarn, accepts a loaded config with nopath, and takesfallbackContentDirrather than re-deriving it.
Both new tests can actually fail: disabling the continue at project.ts:909-912 flips navigationOrigin back to "inherited" in the project test and surfaces SHOULD_NOT_IMPORT through source.inherit-failed in the lint test. vitest run src/config/project.test.ts src/lint/lint.test.ts src/cli/generate.test.ts is 83/83 green.
Two things I chased specifically and found clean. The gate is complete: inheritCollectionSourceConfigs (config/inherit.ts:405) is the only code in the repo that imports a source-owned config, its only caller is project.ts:914, generate/lint/doctor/nav/createDocsProject all reach it through resolveProjectFromLoaded, and watch mode re-enters via a fresh executeGenerate per rebuild — no unverified cacheDir import path remains. The loop's gate and resolveContentDir's gate also cannot disagree, since loaded.config is already normalizeDocsConfig output (so sources: { name: gitSource(...) } and deprecated aliases are flattened before the loop) and mergeInheritedSourceConfig never touches repository/ref/cacheDir/sparse. The --docs-dir relocation is faithful: diagnostic ordering is preserved ([load warnings] → [inherit/content-dir] → [extras]), configOrigin is optional on ResolvedProject and read by neither new caller, and the pre-existing project.test.ts:720-803 multi-dir tests still cover the moved block. source.inherit-failed also isn't lost — it still fires for a verified-good checkout whose source config is unreadable (doctor.test.ts:690-758).
ℹ️ This commit adds a second undocumented behavior change to the one that already has no changeset
The previous review asked for a changeset covering lint's new project gate. That is still open, and 8c7cdb23 adds a second user-visible change on top of it: resolveProject now skips source-owned inheritance for a stale or unverifiable cache rather than inheriting from it, so doctor, nav, and createDocsProject stop reporting the source repo's navigation, schema, mounts, and flatteners in that state. Those three commands are named in .changeset/resolve-project.md, so unlike the lint gap this isn't an omission of scope — the existing entry's text is simply now incomplete for commands it already claims to describe.
Technical details
# Changeset and CI-gating docs don't cover either behavior change this PR ships
## Affected sites
- `.changeset/resolve-project.md` — names `generate`, `doctor`, `nav`, `createDocsProject`. Does not mention `lint` at all, and does not mention that a stale/unverifiable cache now suppresses inheritance rather than feeding it.
- `packages/leadtype/src/config/project.ts:902-912` — the new `continue` means a collection with `inheritConfig: true` behind a stale cache resolves with no inherited `navigation`/`groups`/`frontmatterSchema`/`flatteners`/`mounts`. Always accompanied by an error diagnostic, so `doctor` and `nav` still exit 1 — but the *reported* project changed.
- `packages/leadtype/src/lint/cli.ts:276-284` — unchanged in this delta; still the first-error-exits-1 gate flagged in the prior review.
- `docs/pipeline/validate-in-ci.mdx:130` — still assigns "every collection's directory exists, remote sources are synced" exclusively to `doctor`.
- `docs/pipeline/validate-in-ci.mdx:140-150` — the "Run before generate" recipe is `doctor → lint → generate` with no `leadtype sync` step, which now fails at `lint` on a fresh CI runner for a collections-based project.
## Required outcome
- A changeset entry records both changes: `leadtype lint` gained source-owned inheritance and now exits 1 on unresolvable collections, and a stale/unverifiable cache is no longer a source of inherited config for any consumer.
- The CI-gating docs describe `lint`'s actual contract for collections-based projects — sync-sensitive and directory-existence-sensitive in the same way `doctor` is.
## Open questions for the human
- Is failing on a *local* collection whose `dir` does not yet exist the intended contract for `lint`? It follows from routing through `resolveContentDir` and is arguably correct, but it is a strictly new failure for anyone whose build populates a collection directory before lint runs.Claude Opus | 𝕏
|
Windows CI failed at
|
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.
Windows CI interpolates D:\a\leadtype\... into generated configs; raw backslashes become JS escapes. JSON.stringify the filesystem paths.
b774e68 to
d725444
Compare
There was a problem hiding this comment.
ℹ️ No new issues in the code. The changeset closes most of the carry-over finding; two of the behavior changes it was asked to record are still missing from it.
Reviewed changes — the delta since the prior pullfrog review at 8c7cdb23: one new changeset, the Windows fixture-escaping commit, and a rebase onto the new base tip.
- Added a changeset for the routing change —
.changeset/generate-lint-resolve-project.md,patchonleadtype, recording thatgenerateandlintnow resolve throughresolveProjectFromLoaded, that lint applies source-owned inheritance, and that a missing or wrong-revision cache is now a diagnostic namingleadtype syncinstead of a silently empty lint.patchmatches how the siblingsync-resolved-graph.mdentry versions a comparable behavior change at0.4.3. - Escaped fixture import paths so Windows configs load — the two new test files build their fixture entry import with
JSON.stringify(nativePath)rather than splicing a raw native path into a string literal. Verified and closed in the previous run; unchanged here. - Rebased onto
99fb9a4— the base branch gained its own changeset commit. The range-diff againstb774e685shows no branch-owned content moved;config/project.ts,lint/cli.ts,cli/generate.tsand all three test files are byte-identical to the previously reviewed revision.
I re-verified the two claims behind the inline note against the tree rather than the diff: resolveContentDir still pushes an unconditional source.dir-missing for a local collection at config/project.ts:326-333, and the pre-inheritance cache gate at config/project.ts:904-912 sits inside resolveProjectFromLoaded, which resolveProject — and therefore doctor, nav, and createDocsProject — calls.
Claude Opus | 𝕏
| Route `leadtype generate` and `leadtype lint` through the same resolved project as doctor and the runtime. | ||
|
|
||
| Generate used to assemble a project of its own after sync, and lint read collections without running source-owned inheritance — so a remote collection with `inheritConfig: true` was checked against the defaults, and a stale checkout's `docs.config` could still be imported before the cache was verified. Both commands now call `resolveProjectFromLoaded`. Inheritance is applied; a missing or wrong-revision cache is a diagnostic naming `leadtype sync` rather than a silently empty lint or a module that should never have run. |
There was a problem hiding this comment.
This closes most of the gap, but two of the user-visible changes the previous review asked for are still absent. lint now also exits 1 for a local collection whose dir does not exist — source.dir-missing at config/project.ts:326-333 is unconditional, has no remote or cache involved, and carries no fix, so the entry's leadtype sync pointer doesn't apply to it. And the stale-cache inheritance suppression lives in resolveProjectFromLoaded (config/project.ts:904-912), so it changes what doctor, nav, and createDocsProject report too — not just the two commands this entry names.
Technical details
# Changeset omits lint's local-`dir` failure and the cross-command scope of the inheritance change
## Affected sites
- `.changeset/generate-lint-resolve-project.md:7` — attributes the whole change to `generate` and `lint`, and describes the new lint failure exclusively as "a missing or wrong-revision cache".
- `packages/leadtype/src/config/project.ts:326-333` — `source.dir-missing` fires for any collection whose resolved `absoluteDir` is absent, local or remote, at `level: "error"` and with no `fix` field. Combined with the gate at `packages/leadtype/src/lint/cli.ts:276-284`, a collections-based project whose build populates a directory before lint runs now gets exit 1 where it previously globbed an empty tree and exited 0.
- `packages/leadtype/src/config/project.ts:904-912` — the pre-inheritance `remoteCacheDiagnostic` + `continue` is inside `resolveProjectFromLoaded`, which `resolveProject` calls, so `doctor`, `nav`, and `createDocsProject` also stop reporting a stale source's inherited `navigation` / `groups` / `frontmatterSchema` / `flatteners` / `mounts`.
- `docs/pipeline/validate-in-ci.mdx:130` and `:140-150` — unchanged in this PR; still assign "every collection's directory exists, remote sources are synced" exclusively to `doctor`, and still document a `doctor → lint → generate` recipe with no `sync` step. This was the second bullet of the previous review's required outcome and is the remaining half of that finding.
## Required outcome
- The changeset states that `leadtype lint` now exits 1 on any collection whose content directory cannot be resolved, including a local `dir` that does not exist — not only on an unsynced or stale remote cache.
- The changeset scopes the stale-cache inheritance change to every consumer of `resolveProject`, so a reader of the release notes learns that `doctor` / `nav` / `createDocsProject` output changed in that state as well.
- The CI-gating docs describe lint's actual contract for collections-based projects.
## Open questions for the human
- Still unanswered from both prior reviews: is failing on a local collection whose `dir` does not yet exist the intended contract for `lint`? If yes, it belongs in the changeset and the docs as written. If not, `source.dir-missing` would need to stay non-blocking for lint on non-remote collections, and the changeset text is already correct as-is.
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.