Skip to content

fix(ui): smart resolution + existence-validation for code-file paths#654

Merged
backnotprop merged 7 commits intomainfrom
fix/path-detection-again
May 4, 2026
Merged

fix(ui): smart resolution + existence-validation for code-file paths#654
backnotprop merged 7 commits intomainfrom
fix/path-detection-again

Conversation

@backnotprop
Copy link
Copy Markdown
Owner

Summary

  • Smart resolution for code-file paths in /api/doc: a literal miss now falls back to a case-insensitive suffix match against a cached project walk, mirroring what already works for markdown. Abbreviated paths from prose (editor/App.tsxpackages/editor/App.tsx) resolve correctly.
  • Existence pre-validation via a new POST /api/doc/exists endpoint + frontend useValidatedCodePaths hook. The renderer demotes paths the project doesn't contain (e.g. files the plan proposes creating) to plain code instead of leaving broken-link buttons that 404 on click.
  • Picker popover for ambiguous matches: when a name like App.tsx exists in multiple packages, clicking opens a small dropdown of all matches; selecting one opens that file in the popout.

The server walk is pre-warmed at plan/annotate load and on every /api/doc request, stored as a Promise (race-safe), with a 30s TTL so newly-created files resolve mid-review. The frontend renders optimistically (every detected path is a link on first paint) and demotes missing ones once the POST returns — first paint is unchanged.

A shape filter (isPlausibleCodeFilePath) hard-rejects shell brace expansion ({a,b}.ts), glob wildcards, and whitespace, while still allowing [ / ] so Next.js dynamic routes survive. Pi extension mirrors all server changes. The popout now surfaces "File not found in repo: <path>" instead of silently swallowing 404s.

Original failure case: ~/.plannotator/plans/salvage-pr-380-shortcut-regist-2026-05-03-approved.md produced 404s for editor/App.tsx, review-editor/App.tsx, and packages/ui/shortcuts/{core,runtime,index}.ts. After: the first two resolve to their packages/... real paths; the shortcuts files render as plain code (they don't exist yet — the plan proposes creating them).

Test plan

  • bun test packages/shared/ — 218 pass
  • New unit tests pin the two regressions caught in self-review (URL-with-parens leaking path-shaped substrings; leading ./ breaking suffix match)
  • bun run --cwd apps/review build and bun run build:hook both succeed
  • Open ~/.plannotator/plans/salvage-pr-380-shortcut-regist-2026-05-03-approved.md in dev:hook and verify:
    • editor/App.tsx (line 7) is clickable, opens packages/editor/App.tsx
    • review-editor/App.tsx (line 7) is clickable, opens packages/review-editor/App.tsx
    • packages/ui/shortcuts/core.ts etc. render as plain <code>, no shimmer, no click handler
    • packages/editor/App.tsx (full path, lines 81/82/109/110) still clickable — regression check
    • Network tab shows exactly one POST /api/doc/exists after the initial render
  • Verify Next.js-style app/[slug]/page.tsx paths still linkify in a test plan
  • Verify a backtick App.tsx in a test plan opens the picker showing both packages/editor/App.tsx and packages/review-editor/App.tsx

The bare-prose / backtick path detector linkifies anything that looks
like a code path. Two failure modes regularly produce dead links: prose
abbreviations like `editor/App.tsx` (real file is
`packages/editor/App.tsx`) and references to files the plan proposes
but hasn't created yet. Both 404 on click with no UX cue.

Resolves abbreviated paths via a case-insensitive suffix-match against
a cached project walk (`resolveCodeFile` in `packages/shared/resolve-file.ts`),
mirroring what `resolveMarkdownFile` already does for markdown. The walk
is pre-warmed when the plan/annotate server boots and on every
`/api/doc` request, with a 30s TTL so newly-created files can resolve
mid-review. Storing the walk as a Promise makes the cache race-safe —
concurrent callers piggyback rather than starting a second walk.

A new `POST /api/doc/exists` endpoint takes a batch of candidate paths
and reports `found` / `ambiguous` / `missing` / `unavailable` per path.
On the frontend, `useValidatedCodePaths` extracts candidates from the
markdown on load and POSTs once. The renderer reads the result via
`CodePathValidationContext`: `found` opens directly with the resolved
absolute path, `ambiguous` opens a `CodeFilePicker` popover listing all
matches (common in monorepos where `App.tsx` exists in several
packages), `missing` demotes the link to plain code, and `unavailable`
falls back to the optimistic linkification we have today. While
validation is in flight, every detected path renders as a link, so
first paint is unchanged.

The detection itself gets a shape filter (`isPlausibleCodeFilePath`)
that hard-rejects shell brace expansion (`{a,b}`), glob wildcards, and
whitespace, while explicitly allowing `[` / `]` so Next.js dynamic
routes (`app/[slug]/page.tsx`) still resolve. The bare-prose regex moves
out of `InlineMarkdown.tsx` into `code-file.ts` so the renderer and the
new server-side extractor use the same source of truth, and the
extractor strips fenced code blocks, HTML comments, and URL ranges
before scanning so it only emits candidates the renderer would actually
paint.

Pi extension mirrors the Bun changes (handler upgrade, pre-warm,
`/api/doc/exists` route). When the popout's `/api/doc` request 404s the
dialog now surfaces "File not found in repo: <path>" instead of
silently swallowing the error.

Tests: `code-file.test.ts` extended with shape-filter and Next.js-route
cases; new `extract-code-paths.test.ts` covers extraction, dedup,
fenced/HTML/URL exclusion, and the URL-with-parens regression; new
`resolve-file.test.ts` covers the suffix-match strategy, leading `./`
handling, ambiguous results, and ignored-dir behavior.
Out-of-tree linked docs (and annotate-mode files outside cwd) reference
files relative to themselves. The validator was resolving against cwd
only, so those paths got marked missing and the renderer demoted them
to plain text — even though clicks still resolved correctly via base.

Also tightens the suffix-match's leading-segment strip so `../foo.ts`
no longer silently misresolves to an unrelated `foo.ts` in cwd.

Cleanup: delete unused extract-code-paths import in reference-handlers,
add the export entry to packages/shared so consumers don't rely on
Bun's lenient subpath resolution. Add TODO(security) comments at both
handleDocExists sites flagging that absolute paths bypass project-root
containment.

223 tests pass (3 new resolver cases for baseDir + ../ regression).
Self-review fallout:

1. The doc-base expression `linkedDocHook.filepath ? dirname(...) :
   imageBaseDir` lived in two places (click-time URL builder and Viewer
   prop). If they drift, validator and click resolve against different
   bases and we silently re-introduce the demote-correct-link bug.
   Extract to a single useMemo.

2. The handleDocExists security TODO mentioned absolute paths in
   `paths[]` but I just added `base` acceptance, which has the same
   shape of leak (hostile sender supplies base=/secret/dir + relative
   path). Both vectors flagged in one TODO, mirrored Bun + Pi.

223 tests pass; both builds clean.
Review fallout:

- `CodeFilePopout` hardcoded "File not found in repo" regardless of
  cause. The hook already captures the server's error string, so an
  ambiguous-path 400 (which can happen if a user clicks an optimistic
  link before validation completes) was surfacing as a misleading
  not-found message. Render the actual `error` and only show the
  planned/future-file caveat when the error matches "file not found".
- `InlineMarkdown` emitted demoted bare-prose paths as raw strings
  while every other plain-text branch in `emitPlainTextWithBareUrls`
  routes through `transformPlainText`. Cosmetic-only today since
  paths rarely contain transformable content, but the divergence
  invites copy-paste rot. Routed through the same helper.
- CLAUDE.md missed the new POST /api/doc/exists endpoint in both
  Plan Server and Annotate Server tables. Added.

223 tests pass; both builds clean.
When the validator is ready but a candidate path has no entry in the
validated map, the extractor intentionally excluded it — e.g. inside
an HTML comment or fenced code block. The renderer was optimistically
linking these because gateCodePath returned 'link' for missing entries.

Found during manual testing: `<!-- packages/editor/App.tsx -->` inside
a paragraph (parser doesn't recognize HTML comments as block-level)
was rendered as a clickable link. The extractor correctly stripped the
comment, but the renderer's optimistic fallback overrode that.

Also adds manual test harness: tests/manual/path-detection/ with
sandbox setup + three launcher scripts (plan mode, annotate in-tree,
annotate out-of-tree) covering ~30 test cases.

223 tests pass; both builds clean.
The parser doesn't recognize <!-- --> as block-level HTML, so comments
inside paragraphs fall through to InlineMarkdown. The scanner then
finds paths inside the comment text and linkifies them.

The previous gateCodePath fix (demote when not in validated map) didn't
help here because the same path appeared elsewhere in the document —
the map had an entry from the non-comment occurrence.

Fix: match <!-- ... --> at the top of the scanner loop and skip the
entire comment. HTML comments should be invisible per CommonMark spec.
@backnotprop backnotprop force-pushed the fix/path-detection-again branch from 98d6ab2 to 13a2de1 Compare May 4, 2026 21:10
The shared ResolveResult type gained an `unavailable` variant for code
files. The markdown resolver never returns it, but TS can't narrow
past it without an explicit guard. Both Bun and Pi handlers now guard
`not_found || unavailable` before accessing `result.path`.
@backnotprop backnotprop merged commit 84a0b43 into main May 4, 2026
10 checks passed
@backnotprop backnotprop deleted the fix/path-detection-again branch May 4, 2026 21:21
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