Skip to content

Route generate and lint through the shared project resolver - #180

Open
KayleeWilliams wants to merge 1 commit into
dx/sync-resolved-graphfrom
dx/generate-lint-resolve-project
Open

Route generate and lint through the shared project resolver#180
KayleeWilliams wants to merge 1 commit into
dx/sync-resolved-graphfrom
dx/generate-lint-resolve-project

Conversation

@KayleeWilliams

@KayleeWilliams KayleeWilliams commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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, executeGenerate hand-assembled inherit → re-normalize: inheritCollectionSourceConfigs, then normalizeDocsConfig, then a hand-written "keep the first pass's sources and deprecations" splice — a near-verbatim copy of the canonical block in resolveProject (config/project.ts), comment and all. The invariant is subtle because it's invisible when violated: normalization expands sources into collections, 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.ts loaded the config and went straight to resolveAllCollections, so a collection whose frontmatterSchema, mounts, or flatteners come from its source repository via inheritConfig: true was linted against the defaults. Confirmed by a failing test first: a synced fixture whose source-owned schema requires an owner field 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 pattern createDocsProject established. Since generate just synced, the resolver's cache checks are satisfied by construction; what remains fatal is what was fatal before — an unsatisfiable inheritConfig, a missing collection dir.
  • lint: load → shared resolver, cache-only like the runtime. A missing or stale checkout is now a clear error naming leadtype sync (diagnostic message + fix, in the existing diagnostic style) instead of a silent empty pass.

generate.ts no longer imports inheritCollectionSourceConfigs or normalizeDocsConfig; lint/cli.ts no longer imports resolveAllCollections. Both pieces keep their module exports only because resolveProjectFromLoaded itself consumes them; neither is public API.

Behavior

  • The c15t example (gitSource + inheritConfig + sparse — the exact changed path) regenerates byte-identical output modulo generatedAt timestamps, 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.
  • lint behavior changes only where it was wrong: inherited schemas/mounts/flatteners now apply, and unsynced/stale caches fail with the sync pointer instead of passing on zero files.

Tests

  • Regression: lint against an inherited frontmatterSchema (fails on base, passes here), and the unsynced-source hard error.
  • Cross-subsystem agreement, the pattern Make sync consume the resolved source graph #178 introduced for sync: for a shared fixture (named gitSource with inherited navigation + local collection authored with a deprecated alias), generate's exact resolution path and resolveProject produce identical sources, deprecations, collection content dirs, source ids, and navigation origins — plus an end-to-end check that generate --json's acquisition graph deep-equals resolveProject's, authored source names included.

Verification

887 tests pass (bun run test), lint clean, tsgo --noEmit clean for the package. bun run check-types at the workspace level still trips the pre-existing parallel-build race on this stack; that fix is #166, off main.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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.
@coderabbitai

coderabbitai Bot commented Aug 11, 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: de2c405b-4884-4e9e-a64c-cfc788ef59f4

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

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 extractedresolveProjectFromLoaded(loaded, { rootDir, fallbackContentDir?, inherit?, infer? }) in config/project.ts now owns per-collection source-owned inheritance, the re-normalization that carries the first pass's sources/deprecations/sourceId through, provenance stamping, and content-dir resolution through the sync cache.
  • resolveProject reduced to discovery — it loads (or accepts) a config and delegates; contentDir/docsDirs[0] become fallbackContentDir. Behaviorally unchanged.
  • generate routed through itexecuteGenerate drops inheritCollectionSourceConfigs, normalizeDocsConfig, and the hand-written sources/deprecations splice, syncs first, then converts the first error-level diagnostic into a throw.
  • lint routed through it — collections-based projects now get source-owned frontmatterSchema/mounts/flatteners, exit 1 on blocking diagnostics, and read contentDir/routePrefix/frontmatterSchema off ResolvedProjectCollection instead of resolveAllCollections.
  • Tests — new cli/generate.test.ts (cross-subsystem agreement plus --json graph parity) and a lint.test.ts block covering the inherited frontmatterSchema and 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 (loadLeadtypeConfigsyncSourcesresolveProjectFromLoaded) 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 — this resolveProjectFromLoaded call sits just outside the try/catch at 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"; generate wraps the identical call inside its own catch. I could not construct a reachable throw (inherited fields are validated in validateSourceOwnedConfigFields before 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-377collection.contentDir as string is correct today only because every resolveContentDir early return happens to be level: "error". A future warn-level content-dir diagnostic would silently hand undefined to collectRouteSet/lintDocs rather than fail; the invariant is load-bearing and only a comment enforces it.
  • packages/leadtype/src/lint/lint.test.ts:1590-1591 — asserting stderr contains "leadtype sync" matches the generic fix suffix shared by several diagnostics, so a message naming the wrong collection (or a different source.* id) still passes. Asserting on the collection name or the source.not-synced message would pin the behavior the test is named for.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

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