fix(git-sync,converter): 3 HIGH round-trip/data-loss fixes found by QA (#359)

Implements the 3 HIGH fixes agent_qa found via the RALPH test cycle (analysis theirs,
implementation here), fixtures-first.

- T2E-1 [converter]: a multi-paragraph callout collapsed to one paragraph on round-trip.
  markdown-converter `case "callout"` now joins block children with "\n>\n" (a blank ">"
  separator), byte-identical to the blockquote serializer, so two paragraphs survive
  re-import. Fixture 12-callout-multiblock; two existing tests updated (their old
  expectations encoded the collapse bug).
- T6-listnest [converter]: a callout/blockquote nested in a list item corrupted on
  round-trip (callout type lost, `[!type]` leaked into text — confirmed DB corruption).
  Add bridgeNestedCallouts: a post-marked, nesting-agnostic JSDOM pass that reconstructs
  callouts from `[!type]`-opening blockquotes at ANY depth (the indented `  > [!type]`
  form the column-0-anchored preprocessor misses), emitting the same callout div as the
  top-level path (disjoint inputs, no double-processing). Strict `^[!type]` guard, so a
  real blockquote — or one with `[!` mid-line — is NOT converted. Fixture 13-callout-in-list
  + a no-lead control + false-positive controls (proven non-vacuous).
- D-P3-1 [git-sync]: a "ghost" file (a tracked id that was never a page) was silently
  absence-deleted on pull-reconcile -> data loss. Add GitSyncClient.pageIdsExist (datasource:
  workspace-scoped SELECT over any space incl. trashed/moved, non-UUID ids filtered first);
  cycle.ts computes the candidate-delete set and passes existing ids as `deletableIds` into
  planReconciliation, which now absence-deletes ONLY real page rows — a ghost is preserved.
  A cycle-level e2e test proves the ghost survives runCycle end-to-end.

Suites green: prosemirror-markdown 687, git-sync 272 (no type errors), datasource 36,
orchestrator 26; both packages tsc clean. Scope: the 3 fixes + tests/fixtures only, no
develop re-merge. T4E-esc (LOW escaping) left for the maintainer, per agent_qa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 21:27:21 +03:00
parent 6956563961
commit 5ac08b8f4b
17 changed files with 657 additions and 23 deletions
@@ -65,6 +65,16 @@ describe('GitSyncClient contract (type-level)', () => {
expect(true).toBe(true);
});
it('pageIdsExist(ids) -> ids subset (D-P3-1 ghost guard seam)', () => {
expectTypeOf<GitSyncClient['pageIdsExist']>().parameters.toEqualTypeOf<
[string[]]
>();
expectTypeOf<
Awaited<ReturnType<GitSyncClient['pageIdsExist']>>
>().toEqualTypeOf<string[]>();
expect(true).toBe(true);
});
it('a structurally-correct adapter satisfies GitSyncClient (drift => compile error)', () => {
// A minimal dummy adapter mirroring the EXACT result shapes the engine reads.
// The `satisfies GitSyncClient` clause is the contract guard: any drift in a
@@ -74,6 +84,7 @@ describe('GitSyncClient contract (type-level)', () => {
pages: [] as GitSyncPageNodeLite[],
complete: true,
}),
pageIdsExist: async (_pageIds: string[]) => [] as string[],
getPageJson: async (pageId: string) => ({
id: pageId,
slugId: 'slug',
@@ -130,6 +141,7 @@ describe('GitSyncClient contract (type-level)', () => {
// in BOTH directions).
const bad = {
listSpaceTree: async () => ({ pages: [] as GitSyncPageNodeLite[], complete: true }),
pageIdsExist: async () => [] as string[],
getPageJson: async (pageId: string) => ({
id: pageId,
slugId: 's',