Skip to content

Warn on unknown config keys and harden diagnostic channels - #181

Open
KayleeWilliams wants to merge 5 commits into
dx/nav-drift-resolverfrom
dx/diagnostics-hardening
Open

Warn on unknown config keys and harden diagnostic channels#181
KayleeWilliams wants to merge 5 commits into
dx/nav-drift-resolverfrom
dx/diagnostics-hardening

Conversation

@KayleeWilliams

@KayleeWilliams KayleeWilliams commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #179#167. Those PRs made resolution and navigation shared and diagnosable; review of the result found six places where the diagnostics story itself leaks — a warning that bypasses the injected io, a config path that names no file, a typo that does nothing at all. Each was reproduced before fixing.

The evidence

  1. Unknown config keys vanished silently. Only organization.* rejected unknown keys (they become invalid Schema.org properties); everywhere else a typo'd field was dropped without a word. In an untyped .js/.mjs config — or one written by an agent — navigatoin: quietly reverts the tree to inferred and routePrefx: quietly keeps the default, which is the exact opposite of the provenance/"explain why" story. Validation now warns (never errors — an older CLI reading a newer config must still load it) on unrecognized keys at the top level, collection level, gitSource level, and inside navigation entry objects, with a did-you-mean when the key is within edit distance of a known one: unknown field "collections.docs.routePrefx" — did you mean "routePrefix"?. The suggestion reuses search's bounded Levenshtein, extracted to internal/edit-distance.ts. Deliberately open surfaces are exempt — frontmatterSchema contents, mounts entries, llms sections, and the separately validated organization/agents/openapi/lint objects. Warnings flow through the normal diagnostic channel, so doctor reports them as config.unknown-key findings with the owning field path, exit 0.

  2. The deprecation warning bypassed the injected io. warnConfigDeprecations wrote to the process logger, which runDoctorCommand's injected DoctorIo never sees — observed leaking into test-runner output while --json stdout stayed clean, and unverifiable from any test. Load-time warnings now route through a caller-provided sink; doctor and nav pass their io, entry points without injected io keep the process logger. A regression test spies on real process.stderr and asserts it stays untouched while the injected stderr carries the warning and stdout stays parseable JSON.

  3. --docs-dir was documented repeatable but only [0] was read. generate's legacy path honors every value — first directory at the docs root, each further one mounted under its folder name — so doctor reported a project missing every page the build actually stages. Doctor and nav now expand additional directories into synthesized collections with the same mount shape (/docs/<basename>), and colliding basenames fail with generate's exact message. Consistency over cleverness: same flag, same reading, same error.

  4. resolveProject fabricated leadtype.config.ts as the path of an in-memory config. A caller-supplied config without configPath was stamped <root>/leadtype.config.ts — a path that names nothing on disk, which a doctor-style consumer would then report as the discovered config file. configPath is now absent when there is no file, and configOrigin: "file" | "caller" records how the config arrived; doctor's report omits config.path rather than inventing one.

  5. collection.contentDir as string rested on an implicit invariant — that every unresolved contentDir produced an error-level diagnostic the blocking check already threw on. True today, and one future warn-level directory diagnostic away from handing createDocsSource an undefined path to crash on somewhere deeper. The cast is now requireResolvedContentDir, which throws a named internal-invariant error naming the collection.

  6. The docs said source.inherit-failed is a warn; the code emits an error. Error is correct: createDocsProject and generate both refuse to run on it, so a doctor that exits 0 would let CI pass a project the build then fails on — the divergence Resolve the project once instead of in every command #167 fixed, reintroduced in prose. The finding-id table now says error, and documents config.unknown-key.

Verification

Each fix carries a regression test: unknown-key warnings present for typos at every covered level (including sources.<id>.… and navigation[0].pages[0].pins paths) and absent for known keys and open surfaces; the deprecation warning captured via injected io with real stderr spied clean; repeated --docs-dir honored (two collections, /docs + /docs/guides, both counted) and collisions rejected; the in-memory config resolving with no configPath and configOrigin: "caller"; the contentDir invariant throwing by name.

841 tests pass (829 on the base), plus this repo's own doctor and lint docs CI gates re-run locally — doctor on this repo reports no unknown-key findings, so the new check adds no noise to a clean config. docs/reference/doctor.mdx's lockfile entry regenerated for the edited page only.

tsgo --noEmit and lint pass at the package level; the repo-level parallel-build race 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.

@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: 03a884aa-95b5-4492-8311-98e57c388a14

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f29a59c30

ℹ️ 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".

Comment thread packages/leadtype/src/config/project.ts Outdated

@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 --docs-dir parity work reads the flag differently from generate for the documented <dir>=<url-prefix> form, which turns a working invocation into a doctor exit 1. Details inline.

Reviewed changes — full diff of the single commit 1f29a59 across 12 files, plus the generate implementations this PR claims parity with and the DocsConfig / DocsCollection / DocsNavNode types the new allow-lists mirror.

  • Unknown config keys now warnvalidateDocsConfig collects ConfigWarnings for keys outside hand-maintained allow-lists at the top level, collection level, gitSource level and inside navigation entries, with a bounded-Levenshtein did-you-mean; they surface as warn diagnostics and config.unknown-key doctor findings, never as errors.
  • Load-time warnings route through a caller-supplied sinkConfigWarningSink is threaded from resolveProject down to warnConfigDeprecations / the new warnConfigUnknownKeys, defaulting to the process logger; doctor and nav pass their injected io.stderr.
  • Repeated --docs-dir is honoredresolveProject synthesizes an extra collection per additional directory at /docs/<basename> in single-source mode, reusing generate's collision message.
  • configPath is no longer fabricated — an in-memory config gets no path, and the new configOrigin: "file" | "caller" records how the config arrived.
  • contentDir invariant is namedrequireResolvedContentDir replaces collection.contentDir as string in createDocsProject.
  • Docs correctedsource.inherit-failed is documented as an error, config.unknown-key is added to the finding table, and editDistanceWithin moves to internal/edit-distance.ts shared with search.

I checked the new tests against the bugs they claim to pin: the assertions are exact (toEqual on sorted owner paths, literal did you mean "…" substrings) rather than loose, the process.stderr spy works because the logger's module-global stream is that same object, and every fixture uses a fresh mkdtemp so the path-keyed dedupe sets cannot bleed across tests. I also verified the three allow-lists carrying satisfies are complete against their types today.

ℹ️ --docs-dir alongside a collections config is still accepted where generate rejects it

generate refuses the flag outright when the root config declares collections (packages/leadtype/src/cli/generate.ts:1684-1689), and the docs state the rule twice. The new expansion is gated on mode === "single-source", so in multi-source mode doctor and nav drop the extra directories with no error and no diagnostic — a project that generate rejects gets a clean ok: true report. This is pre-existing at the entry points rather than introduced here, but it is the other half of the "same flag, same reading, same error" claim.

Technical details
# `--docs-dir` is not rejected in multi-source mode

## Affected sites
- `packages/leadtype/src/config/project.ts:542-546` — the expansion is skipped in multi-source mode, and nothing else reports that the flag was ignored.
- `packages/leadtype/src/cli/doctor.ts:376-392` and `packages/leadtype/src/cli/nav.ts:250-267` — pass `docsDirs` through unconditionally, with no equivalent of generate's guard.
- `packages/leadtype/src/cli/generate.ts:1684-1689` — the authoritative rule and its message.
- `docs/pipeline/configure-sources.mdx:193` and `docs/reference/cli.mdx:116` — document the rule.

## Required outcome
- Passing `--docs-dir` to `doctor` or `nav` on a project whose config declares `collections` should end the same way it ends under `generate`, rather than being silently discarded.

## Open questions for the human
- Should this be generate's hard error (consistent, but makes `doctor` refuse to diagnose the very project the user is confused about), or a `warn`-level `config.unknown-key`-style finding naming `--docs-dir` as the owner? A read-only diagnostic tool arguably wants the latter.

ℹ️ Nitpicks

  • NAV_NODE_KEYS and NAV_INCLUDE_KEYS (packages/leadtype/src/config/load.ts:139-155) are the only two allow-lists without the as const satisfies readonly (keyof T)[] guard their three siblings carry; adding it against DocsNavNode / DocsNavIncludeEntry would make a future rename of a nav field a compile error instead of a false "unknown field" warning. Note that no satisfies catches the other drift direction — a new field added to any of these types without touching the list makes every config using it warn — so a Exclude<keyof T, (typeof LIST)[number]> extends never assertion would be the guard that actually covers the false-positive case.
  • writeLoadWarning in packages/leadtype/src/cli/doctor.ts:246-251 and the inline callback in packages/leadtype/src/cli/nav.ts:261-266 are the same six lines; one shared helper next to ConfigWarningSink would keep the two commands' warning format from drifting.
  • Both injected sinks always emit human-readable text, so under LEADTYPE_LOG_FORMAT=json a doctor/nav run mixes a human warning line into otherwise-JSON stderr, where the default logger.warn path would have honored the format.

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

Comment thread packages/leadtype/src/config/project.ts Outdated
Comment thread packages/leadtype/src/config/load.ts Outdated
Comment thread packages/leadtype/src/config/load.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6502c7883e

ℹ️ 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".

Comment thread packages/leadtype/src/config/project.ts Outdated
Comment thread packages/leadtype/src/config/project.ts Outdated

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

ℹ️ No critical issues — minor suggestions inline.

6502c788 addresses all three inline findings from the previous review, and I verified each fix independently rather than taking the tests at their word:

  • Nearest-key suggestionclosestKnownKey now widens the distance bound in stages (load.ts:169-182) instead of taking the first candidate within the maximum bound. Traced prefx: ref is eliminated by the length filter, prefix wins at bound 1. Correct.
  • Per-sink dedupe — the process-global Set<string> is now a WeakMap<ConfigWarningSink, Set<string>> (load.ts:1293-1317). The default sink keys off logger.warn, which is a stable arrow property on a const object literal (internal/logger.ts), so the long-lived watch-loop path still dedupes as before. The new test discriminates — under the old global set the third resolve would leave second empty, not at length 1.
  • <dir>=<url-prefix> parsingnormalizeDocsSourceInput / parseDocsSourceInput are extracted to internal/docs-source.ts byte-identically to the block removed from generate.ts, and both doctor and nav now route through them. A documented invocation like --docs-dir guides=/guides no longer exits 1.

All three prior threads were already marked resolved by the author; nothing left to retire there.

ℹ️ resolveProject's public contract now has a throwing input grammar

resolveProject is publicly exported (src/index.ts:24-28) and its module JSDoc states it is "non-throwing for environmental problems," reserving throws for genuinely malformed config. The eager parseDocsSourceInput call at project.ts:338-344 extends that throwing surface to a new place: the shape of a caller-supplied option value. That is a defensible reading of "malformed," and both CLI callers catch it — but ResolveProjectOptions.docsDirs' own JSDoc (project.ts:139-143, outside this diff) still describes the field as plain directory paths:

Docs directories relative to cwd, for a single-source project whose content root the config does not state.

Worth a sentence there noting the <dir>[=<url-prefix>] grammar and that a malformed value throws, so external callers of the exported function know to guard it.

Nitpick

docs/reference/doctor.mdx:156 — this PR rewrites the --docs-dir row to explain the repeatable multi-mount behavior, which is the right call, but it still omits the =<url-prefix> form that doctor now accepts. docs/reference/cli.mdx:50 has the same gap (outside this diff).

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

Comment thread packages/leadtype/src/config/project.ts Outdated
Comment thread packages/leadtype/src/config/project.ts Outdated
@KayleeWilliams
KayleeWilliams force-pushed the dx/diagnostics-hardening branch from 6502c78 to 1955309 Compare August 12, 2026 13:38

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1955309481

ℹ️ 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".

Comment thread packages/leadtype/src/config/project.ts
Comment thread packages/leadtype/src/config/load.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bfd266cab4

ℹ️ 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".

Comment thread packages/leadtype/src/config/project.ts

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

ℹ️ No critical issues — one suggestion inline.

Reviewed changes since 6502c788:

  • First --docs-dir value's =<url-prefix> is now honored. The docsDirs.length > 1 gate is gone, and the primary collection's routePrefix is overridden from docsDirInputs[0].urlPrefix (project.ts:808-826). Checked that this clobbers nothing authored: normalize.ts:417 hardcodes /docs for single-source, so the value being replaced is always the default. leadtype doctor --docs-dir docs=/custom now agrees with what generate publishes. This retires the first thread from the previous review.
  • resolveConfiglessProject (project.ts:391-472) — new behavior, not a refactor. When discovery finds no config but a content root exists, resolveProject builds the collections generate would stage instead of returning emptyProject. I traced the blast radius rather than the flag: docsDirs defaults to ["docs"], so this fires for any configless project with a docs/ dir. Every ResolvedProject consumer already optional-chains config/resolvedcreateDocsProject still throws at project/index.ts:284, doctor reports config.mode: "none" next to populated collections, resolveCollectionNavigation is null-safe. And I diffed the derivation against generate's resolveDocsSources (generate.ts:841-870) case by case: index-0 vs later mount paths, explicit prefixes at every index, the collision message, the malformed-value message, and missing-dir handling all agree.
  • synthesizeExtraDirCollections (project.ts:294-381) extracted from the inline block, now shared by the config and configless paths. Same collision message and source.dir-missing error as before.
  • Three new regression tests, all with exact toEquals that fail with the bug present — the doctor primary-prefix test pins [["docs", "/manual"]], the configless test pins [["docs","/docs",1],["changelog","/docs/changelog",1]]. Fixtures use mkdtemp, so the path-keyed dedupe Sets can't collide across tests.

npx vitest run src/config/project.test.ts src/cli/nav.test.ts → 53 passed; tsgo --noEmit clean.

One carry-over: the origin: "explicit" provenance thread from the last review is still open, and the rework has spread it from one site to three — project.ts:821 (primary, config path), :369 (extras), :438 (configless primary). A CLI-supplied prefix reported as explicit still sends readers hunting through a config file for a value that isn't there, while the sibling dir provenance on the same collections correctly attributes the flag via inferredFrom.

ℹ️ Nitpick

nav.ts:274's collections.length === 0 → reportInferredTree fallback is now reachable only when the docs dir doesn't exist — the case where it prints the leadtype init hint (pinned by nav.test.ts:167). Not a break, but the branch no longer means what its surroundings suggest, and a reader will assume it still covers "configless project."

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

Comment thread packages/leadtype/src/config/project.ts Outdated
Six small diagnostics fixes from review of the resolver stack, each
re-verified before fixing:

Unknown config keys no longer vanish silently. Validation warns — never
errors, for forward compatibility — on unrecognized keys at the top
level, collection level, gitSource level, and inside navigation entry
objects, with a did-you-mean when the key is close to a known one
(shared bounded-Levenshtein helper extracted from search into
internal/edit-distance). Warnings ride the loaded config into
resolveProject diagnostics, so doctor reports them as config.unknown-key
findings with the owning field path. Deliberately open surfaces —
frontmatterSchema contents, mounts entries, llms sections, and the
separately validated organization/agents/openapi/lint objects — are
exempt.

The load-time deprecation warning (and the new unknown-key warning) now
route through a caller-provided sink. doctor and nav pass their injected
io, so warnings are capturable in tests and can never interleave with
--json stdout; entry points without injected io keep the process logger.

doctor's repeatable --docs-dir now honors every value the way generate's
legacy multi-dir path does: the first directory mounts at the docs root,
each further one under its folder name as a synthesized collection, and
colliding mount basenames fail with generate's exact message. Previously
only docsDirs[0] was read, so the report missed every page the build
actually stages.

resolveProject no longer fabricates `<root>/leadtype.config.ts` as the
path of a caller-supplied in-memory config. configPath is absent when
there is no file, and a new configOrigin ("file" | "caller") records how
the config arrived; doctor's report omits config.path rather than naming
a file that does not exist.

createDocsProject replaces the `collection.contentDir as string` cast
with an explicit invariant check (requireResolvedContentDir), so a
future warn-level directory diagnostic fails as a named
internal-invariant error instead of handing createDocsSource an
undefined path.

docs/reference/doctor.mdx now lists source.inherit-failed as error,
matching the code — createDocsProject and generate refuse to run on it,
so doctor exiting 0 would let CI pass a project the build then fails on
— and documents config.unknown-key. Lockfile entry regenerated for the
edited page only.
…n once per sink

- Lift parseDocsSourceInput/normalizeDocsSourceInput out of cli/generate into
  internal/docs-source and read the flag through it in resolveProject, so the
  documented <dir>=<url-prefix> form resolves the real directory and mounts at
  the explicit prefix instead of tripping source.dir-missing. Malformed values
  fail with generate's error; nav stops pre-resolving the raw string.
- closestKnownKey widens the edit-distance bound one step at a time so the
  nearest candidate wins, not the first within the fixed bound.
- Deprecation/unknown-key warning dedupe is keyed per sink (WeakMap) as well
  as per config path: a fresh caller-injected sink always hears the warning
  once, and the process logger keeps its once-per-file behavior.
generate applies an explicit =<url-prefix> to every parsed --docs-dir,
including the first, and stages every directory even when no config
exists. resolveProject skipped index 0 (reporting the primary at the
default /docs) and returned an empty project from the missing-config
branch, so doctor and nav described a project the build never ships —
and still exited 0. Apply the first value's prefix to the primary
collection and synthesize the same fallback collections generate stages
when there is no config but content exists.
- Run inherited source configs through the unknown-key collector.
  inheritConfig only extracts the fields it knows, so a `navigatoin` in
  the source repo's docs.config was silently inert and the collection
  quietly fell back to an inferred tree. Warnings name the source config
  file and collection, land in the project diagnostics, and reach the
  warn sink. The allowed set is the full authored key set — the default
  source config is the source repo's own docs.config, so product and
  friends are legitimate there.
- Key the once-per-sink warning dedupe on path plus rendered content,
  not path alone. generate --watch reloads the config through the same
  process-wide sink, so replacing one typo with a different one printed
  nothing until the process restarted. Identical reloads stay quiet;
  a changed warning set re-emits. Deprecations get the same key.
- Resolve an authored single-source tree over the union of every
  --docs-dir, the way generate's staged mirror does (extras mounted
  under their folder names): navigation: ["guides/setup"] with
  --docs-dir docs --docs-dir guides resolved only against docs and
  reported nav.unresolvable — doctor exit 1 for a build that exits 0
  (verified against the real command). The extra dirs fold into the
  primary's manifest instead of getting per-directory inferred trees
  generate never derives, and they now follow generate's derivation
  opt-outs: no inferred trees once a tree is authored or when i18n is
  set.
@KayleeWilliams
KayleeWilliams force-pushed the dx/diagnostics-hardening branch from c44f0cc to 123c43b Compare August 12, 2026 14:23

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 123c43ba8b

ℹ️ 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".

Comment on lines 358 to 361
try {
const imported = await importConfigModule(configPath);
onSourceConfig?.(imported, configPath, entry.key);
return validateSourceOwnedConfigFields(imported, configPath, entry.key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit inherited-config warnings during generation

When leadtype generate processes a collection with inheritConfig, this optional visitor is never supplied by executeGenerate (cli/generate.ts calls inheritCollectionSourceConfigs with only two arguments). A source-owned config containing navigatoin therefore still generates silently with inferred navigation, even though doctor/nav now warn. The prior fix only wires the visitor through resolveProject; this unchanged direct generate call is fresh evidence that the production generation path remains uncovered.

Useful? React with 👍 / 👎.

Comment on lines +623 to +625
const contentRoots = options.contentDir
? [path.resolve(options.contentDir)]
: docsDirs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve a relative contentDir from the project root

When a configless caller passes both cwd and a relative contentDir, such as resolveProject({ cwd: "/repo", contentDir: "manual" }), path.resolve(options.contentDir) resolves from the process working directory rather than /repo, contrary to the option's documented relative-to-cwd semantics. If /repo/manual is the only content directory, this existence check returns an empty project; if an unrelated manual directory exists under the process cwd, the resolver can inspect the wrong files. Resolve the override against rootDir here and in resolveConfiglessProject.

Useful? React with 👍 / 👎.

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

ℹ️ No critical issues — one suggestion inline.

Reviewed changes since f7a88a80 (the branch was force-pushed again, so SHAs churned while commit messages stayed stable — bfd266cafca3d728, c44f0ccb123c43ba. This covers both commits since the last review.)

  • All three prior Pullfrog threads are genuinely fixed, verified against the tree rather than the replies. The flag-supplied prefix sites now carry { origin: "default", inferredFrom: "url prefix (--docs-dir)" }, matching the sibling dir provenance, so a --docs-dir docs=/custom no longer reports as config-authored. The extras' infer now carries normalized.config.i18n === undefined && !primaryTreeAuthored (project.ts:907-910) — going further than the thread asked, and correctly so: generate skips derivation for an authored navigation/groups too, not just for i18n.
  • Source-owned config unknown keys (inherit.ts SourceConfigVisitorcollectSourceConfigUnknownKeys). I checked the obvious false-positive risk and it doesn't fire: inherit lives on the host config's collection.inheritConfig.inherit (read by sourceConfigInheritFields, inherit.ts:327-331), never as a source-config top-level key, and all five SourceOwnedConfigFields are in TOP_LEVEL_CONFIG_KEYS. Validating against the full key set rather than the inheritable subset is the right call — a source repo's own docs.config.* legitimately carries product. Diagnostics owning as <sourcePath>#<field> plus the new context parameter means the message names the source file, not the host.
  • Content-keyed dedupe (warnedKey(configPath, ...content)). The watch-mode bug it fixes is real and the rationale is in the comment at load.ts:1357-1362. Empty warning sets are never remembered, so a fixed-then-rebroken config still re-warns for a different typo.
  • Union navigation view for repeated --docs-dir (navigationExtraDirsmountedDocsDirs, plus the subsumedDirs filter that stops double-counting and a spurious "mixed" origin). I checked the mount-ordering hazard here — the array puts the catch-all { pathPrefix: "" } base mount before the extras — and it's safe: resolveDocsPathMount sorts by pathPrefix.length descending (internal/docs-url.ts:95) before matching, and the empty prefix is only reachable via the if (!mount.pathPrefix) fallback at :98. I also confirmed withPrefix lands exactly once, never twice: both llm.ts:1755 and :1774 recompute an unprefixed path from docsDir rather than reusing the prefixed one.
  • Tests assert URLs, not just presenceproject.test.ts:735-741 pins /docs/guides/setup in routedUrlPaths and doctor.test.ts:444-450 pins unrepresentedPages: ["/docs"], so a shadowing regression in mount resolution would fail them.

npx vitest run src/config/project.test.ts src/cli/doctor.test.ts src/cli/nav.test.ts → 94 passed; tsgo --noEmit clean.

The one inline finding is the remaining seam in the same mountedDocsDirs work: the prefix is applied to a path list whose only consumer expects unprefixed input.

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

const files = filterFile ? collected.filter(filterFile) : collected;
const relativePaths = files.map((filePath) =>
normalizeDocsPath(path.relative(docsDir, filePath))
withPrefix(normalizeDocsPath(path.relative(docsDir, filePath)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prefixing relativePaths disables the i18n ambiguous-layout guard for every mounted --docs-dir.

relativePaths has exactly one consumer — assertUnambiguousDefaultLocaleLayout at line 1739-1743. (It appears only at 1730 and 1740 in this function; the URL and logical paths are built independently at 1764-1770 and 1774, each recomputing from docsDir.) So the withPrefix here doesn't feed any output path, it only reshapes the assertion's input.

That assertion keys off the first path segment (llm.ts:1611-1617):

const first = normalizeDocsPath(relativePath).split("/")[0] ?? "";
return !localeCodes.has(first);                                   // hasRootDefault

normalizeDocsPath(relativePath).startsWith(`${defaultLocale}/`)   // hasDefaultFolder

With pathPrefix: "guides", every path now starts with guides/, so first is never a locale code (hasRootDefault unconditionally true) and nothing starts with en/ (hasDefaultFolder unconditionally false). hasRootDefault && hasDefaultFolder can never hold, so the guard cannot fire for a mounted directory regardless of its actual layout.

Dropping the prefix here can't under-prefix anything, since no output path reads this list.

Suggested change
withPrefix(normalizeDocsPath(path.relative(docsDir, filePath)))
normalizeDocsPath(path.relative(docsDir, filePath))
Reachability and symptom

Reachable on the config-bearing path: project.ts:919 attaches navigationExtraDirs under primaryTreeAuthored && collections[0] with no i18n exclusion — the normalized.config.i18n === undefined guard at :909 gates only the inferred synthesis, not the union attachment. navigation.ts:217-225 then puts mountedDocsDirs and i18n on the same resolveConfig, and the localized branch runs per extra locale.

So: a localized project with an authored top-level navigation (or groups) plus a second --docs-dir.

Symptom: a genuinely ambiguous layout inside the mounted dir — guides/setup.mdx alongside guides/en/other.mdx — no longer throws Ambiguous i18n default-locale layout. Use either root docs files or docs/en/ files…. It proceeds, and only surfaces later (if the paths literally collide) as the generic duplicate-route error from selectLocalizedFiles at llm.ts:1657-1661.

Not a wrong-output bug — the direction is a false negative, a guard that stops guarding — which is why this is a suggestion rather than a blocker.

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