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
+87
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { runCycle, type RunCycleDeps } from "../src/engine/cycle";
import { serializePageFile } from "@docmost/prosemirror-markdown";
// A fake VaultGit recording the staging calls. An EMPTY vault/tree lets the real
// readExisting/computePullActions/applyPullActions/runPush run trivially (no
@@ -46,6 +47,8 @@ function baseDeps(vault: any, over: Partial<RunCycleDeps> = {}): RunCycleDeps {
spaceId: "space-1",
client: {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
// Default: every candidate id is a real page row (historical behavior).
pageIdsExist: vi.fn(async (ids: string[]) => ids),
getPageJson: vi.fn(),
importPageMarkdown: vi.fn(),
createPage: vi.fn(),
@@ -235,4 +238,88 @@ describe("runCycle (composition)", () => {
expect(deps.client.listSpaceTree).toHaveBeenCalledTimes(1);
expect(vault.diffNameStatus).not.toHaveBeenCalled();
});
// D-P3-1 ghost guard, end-to-end through runCycle: a tracked file whose id is
// absent from live AND is NOT returned by `pageIdsExist` (a ghost — never a
// page) must SURVIVE the whole cycle (no rm), while a sibling id that IS
// returned (a genuinely deleted page row) is absence-deleted. This proves the
// guard flows from pageIdsExist -> computePullActions -> applyPullActions,
// not just at the pure `planReconciliation` unit.
it("GHOST GUARD (e2e): a ghost tracked file SURVIVES runCycle; a real deleted-page file is removed", async () => {
const ghostId = "019f2500-dead-7000-8000-000000000009"; // never a page
const deletedId = "019f2500-0000-7000-8000-000000000001"; // a real (deleted) row
const liveId = "019f2500-0000-7000-8000-0000000000aa"; // keeps empty-live from firing
// Two tracked files, both ABSENT from the live tree (deletion candidates).
const vault = fakeVault({
listTrackedFiles: vi.fn(async () => ["Ghost.md", "Deleted.md"]),
});
// The datasource reports ONLY the deleted page as a real row; the ghost has
// no row (a PROPER SUBSET of the probed ids, not the identity shim).
const pageIdsExist = vi.fn(async (ids: string[]) =>
ids.filter((id) => id === deletedId),
);
const rm = vi.fn(async () => undefined);
const deps = baseDeps(vault, {
fs: {
readFile: vi.fn(async (absPath: string) => {
if (absPath.includes("Ghost.md"))
return serializePageFile(ghostId, "a body authored in git");
if (absPath.includes("Deleted.md"))
return serializePageFile(deletedId, "a deleted page body");
return "";
}),
writeFile: vi.fn(async () => undefined),
mkdir: vi.fn(async () => undefined),
rm,
lstat: vi.fn(async () => ({ isSymbolicLink: false })),
realpath: vi.fn(async (p: string) => p),
},
client: {
...baseDeps(vault).client,
// A single live page so the empty-live suppression does NOT fire (which
// would mask the guard by suppressing every delete this cycle).
listSpaceTree: vi.fn(async () => ({
pages: [
{
id: liveId,
slugId: "live",
title: "Live",
parentPageId: null,
position: "a0",
hasChildren: false,
},
],
complete: true,
})),
pageIdsExist,
getPageJson: vi.fn(async (pageId: string) => ({
id: pageId,
slugId: "live",
title: "Live",
parentPageId: null,
spaceId: "space-1",
updatedAt: "2026-06-20T00:00:00.000Z",
content: { type: "doc", content: [] },
})),
} as any,
});
const res = await runCycle(deps);
expect(res.ran).toBe(true);
// The guard probed the datasource for EXACTLY the absent candidate ids.
expect(pageIdsExist).toHaveBeenCalledTimes(1);
expect(new Set(pageIdsExist.mock.calls[0][0])).toEqual(
new Set([ghostId, deletedId]),
);
// The real deleted-page file is removed; the ghost file is PRESERVED.
const rmPaths = rm.mock.calls.map((c) => c[0] as string);
expect(rmPaths.some((p) => p.includes("Deleted.md"))).toBe(true);
expect(rmPaths.some((p) => p.includes("Ghost.md"))).toBe(false);
expect(res.pull?.deleted).toBe(1);
});
});