fix(git-sync): close #119 blockers — dead edit-revert guard, cross-space guard, red suite (F5/S2/G1/A1/F7)

F5 (HIGH data-loss): guard #2 (GS-EDIT-REVERT) called a local key-sorting equality that
never matched a real page (block ids + materialized defaults differ), so the guard was
dead and a web edit on a git-sync space was silently reverted within one poll cycle. Use
the package's authoritative docsCanonicallyEqual (strips block id + normalizes
KNOWN_DEFAULTS), wired through the git-sync loader like sanitizeTitle; delete the dead
local canonicalize/canonicalJsonEqual.
S2 (security): importPageMarkdown targeted a page by the vault-file id without a spaceId
check (deletePage had one) — a space-A vault file carrying space-B's page id could
resurrect/overwrite/clear B's page. Mirror deletePage's guard: skip when the loaded page
lives in a different space than ctx.spaceId.
G1 (jest green): add sanitizeTitle + docsCanonicallyEqual to the loadGitSync mock; update
the converter-gate + package golden expectations to the genuinely-fixed output (paragraph
textAlign now round-trips, multi-block table cells emit HTML tables); fix the orchestrator
spec's stale mock so the per-space enabled gate (added later) is satisfied.
A1: the converter dropped heading textAlign on export (bare '## text'); emit a styled
<hN> when aligned, symmetric to paragraphs — round-trips losslessly (level + align), no
churn for unaligned headings.
F7 (docs): reword the false 'single choke point' title-strip comment; correct push.ts
docstrings that still described the removed standalone-CLI/daemon model.

Adds regression tests: the F5 acceptance test (canonically-equal content with real uuids
=> writePageBody NOT called), the S2 cross-space import guard, and the A1 heading
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-03 00:13:08 +03:00
parent 320b200ac8
commit 5d45f5a85e
9 changed files with 362 additions and 111 deletions
+20 -17
View File
@@ -1373,11 +1373,12 @@ function extractUpdatedAt(result: unknown): { updatedAt?: string } {
// --- runnable push orchestration (`runPush`) ---------------------------------
//
// `runPush` is the FS->Docmost twin of `pull.ts`'s `main`: it wires the VaultGit
// `runPush` is the FS->Docmost twin of the pull direction: it wires the VaultGit
// diff/ref primitives + the PURE `computePushActions` planner + the THIN
// `applyPushActions` applier into one runnable cycle. SAFE BY DEFAULT — the
// engine's FIRST write path to Docmost defaults to DRY-RUN (plan only, NO
// Docmost writes, NO ref advance); an explicit `--apply` is the ONLY path that
// `applyPushActions` applier into one runnable cycle. It is driven IN-PROCESS by
// the NestJS server (there is no standalone CLI). SAFE BY DEFAULT — a cycle
// defaults to DRY-RUN (plan only, NO Docmost writes, NO ref advance) unless the
// caller passes `opts.dryRun === false`; that apply path is the ONLY one that
// builds a client and mutates Docmost.
//
// Every external effect is injected (`PushDeps`): production wires the live
@@ -1388,7 +1389,7 @@ function extractUpdatedAt(result: unknown): { updatedAt?: string } {
* push direction (SPEC §7.3). The provenance is carried by the trailer (below),
* which the loop-guard keys on; the identity is for history readability only.
* When the vault repo already has a configured `user.name`/`user.email`, git
* uses that for the working-tree commit; this is the fallback the daemon stamps.
* uses that for the working-tree commit; this is the fallback the engine stamps.
*/
export const LOCAL_AUTHOR_NAME = "Local";
export const LOCAL_AUTHOR_EMAIL = "local@local";
@@ -1400,7 +1401,7 @@ export const LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local";
* Injectable deps for `runPush` (mirrors `pull.ts`'s wiring; everything that
* touches the outside world is here so tests pass fakes). `makeClient` is a
* FACTORY, not a client — a dry-run must build NO client at all (it is never
* called), and only `--apply` invokes it.
* called), and only the apply path (`opts.dryRun === false`) invokes it.
*/
export interface PushDeps {
settings: Settings;
@@ -1420,7 +1421,7 @@ export interface PushDeps {
| "fastForwardBranch"
| "listTrackedFiles"
>;
/** Build a real client — called ONLY on `--apply`, never on dry-run. */
/** Build a real client — called ONLY on the apply path, never on dry-run. */
makeClient: (settings: Settings) => ApplyPushDeps["client"];
/** Read a file's full text by its vault-relative (forward-slash) path. */
readFile: (path: string) => Promise<string>;
@@ -1448,12 +1449,12 @@ export interface PushRunResult {
renamesMoves: number;
skipped: number;
};
/** The applier's structured result — ONLY present on the `--apply` path. */
/** The applier's structured result — ONLY present on the apply path. */
applied?: ApplyPushResult;
/**
* True when `applyPushActions` REFUSED to fast-forward a divergent `docmost`
* mirror (SPEC §5 invariant broken). Escalated (logged prominently) and folded
* into the CLI's non-zero exit.
* mirror (SPEC §5 invariant broken). Logged prominently and surfaced on this
* result so the caller (the server orchestrator) escalates it.
*/
divergentDocmost?: boolean;
/** Per-page failures from the applier (empty/absent on a clean run). */
@@ -1461,11 +1462,13 @@ export interface PushRunResult {
}
/**
* Run one FS->Docmost push cycle (SPEC §6 "FS -> Docmost"), DRY-RUN BY DEFAULT.
* Run one FS->Docmost push cycle (SPEC §6 "FS -> Docmost"), DRY-RUN BY DEFAULT
* (the apply path runs only when `opts.dryRun === false`).
*
* Steps (mirrors `pull.ts`):
* 1. Preflight git: `assertGitAvailable` + `ensureRepo`; ABORT (clear message +
* non-zero-ish result) if a merge is in progress — never push on top of an
* an `aborted: "merge-in-progress"` result) if a merge is in progress —
* never push on top of an
* unresolved conflict (SPEC §9/§12). Conflict markers must NEVER reach
* Docmost (SPEC §9).
* 2. Checkout `main` (the human-facing branch the push reads from).
@@ -1478,12 +1481,12 @@ export interface PushRunResult {
* the PURE `computePushActions`.
* 6. DRY-RUN (default): LOG the full plan and RETURN — NO client, NO Docmost
* calls, NO ref advance.
* 7. `--apply`: build the client, run `applyPushActions(..., pushedCommit=main)`,
* 7. Apply path (`opts.dryRun === false`): build the client, run `applyPushActions(..., pushedCommit=main)`,
* then (a) if any pageIds were written back (creates), commit them on `main`
* with the `local` trailer and RE-advance `refs/docmost/last-pushed` to the
* new commit so the recorded pageIds are persisted in what Docmost mirrors;
* (b) ESCALATE a divergent-`docmost` ff refusal (SPEC §5) with a prominent
* WARNING and a non-zero-ish flag. Then log a one-line summary.
* WARNING and the `divergentDocmost` result flag. Then log a one-line summary.
*/
export async function runPush(
deps: PushDeps,
@@ -1492,8 +1495,8 @@ export async function runPush(
const { git, settings, log } = deps;
const dryRun = opts.dryRun;
// 1. Preflight git. Fail fast (actionable message via main().catch) if the git
// binary is missing — the vault state store relies on it.
// 1. Preflight git. Fail fast (the thrown error propagates to the caller) if
// the git binary is missing — the vault state store relies on it.
await git.assertGitAvailable();
await git.ensureRepo();
@@ -1622,7 +1625,7 @@ export async function runPush(
return { mode: "dry-run", base, pushedCommit, planned };
}
// 7. --apply: build the REAL client and execute. This is the ONLY write path.
// 7. Apply path: build the REAL client and execute. This is the ONLY write path.
const client = deps.makeClient(settings);
const applied = await applyPushActions(
{
@@ -154,6 +154,21 @@ export function convertProseMirrorToMarkdown(content: any): string {
case "heading":
const level = node.attrs?.level || 1;
const headingText = nodeContent.map(processNode).join("");
const headingAlign = node.attrs?.textAlign;
if (headingAlign && headingAlign !== "left") {
// Emit alignment as a styled `<hN>` so it round-trips losslessly,
// symmetric to the paragraph case above (review F5/A1). The bare
// `## text` markdown form carries NO alignment, so an aligned heading
// would silently drop textAlign on export. A styled `<hN>` re-parses:
// the heading parse rule (tag:"h1".."h6") matches and the textAlign
// global-attribute parseHTML (docmost-schema) reads the style back,
// preserving BOTH level and textAlign. escapeAttr keeps the align
// value injection-safe, exactly like the paragraph arm.
return `<h${level} style="text-align:${escapeAttr(headingAlign)}">${headingText}</h${level}>`;
}
// No alignment (or the default "left"): keep the plain `## text`
// markdown form — HTML-ifying an unaligned heading would be needless
// churn, exactly as the paragraph case keeps plain text when unaligned.
return "#".repeat(level) + " " + headingText;
case "text":
@@ -256,9 +256,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
expect(out).toBe('> ```js\n> a\n> b\n> ```');
});
// 4. A GFM body cell with TWO block children (paragraph + bulletList): joined
// by a space, the list's newline collapsed so the row stays intact.
it('a GFM body cell with paragraph+list joins them by a space (no "p1- a")', () => {
// 4. A body cell with TWO block children (paragraph + bulletList) cannot be a
// GFM pipe row (inline-only). #8 emits the WHOLE table as HTML <table> so
// the paragraph and the list each survive as their own block instead of
// being lossily flattened into one "p1 - a" pipe cell.
it('a table cell with paragraph+list emits an HTML <table> (blocks preserved)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
@@ -285,7 +287,9 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
],
}),
);
expect(out).toBe('| h |\n| --- |\n| p1 - a |');
expect(out).toBe(
'<table><tbody><tr><th><p>h</p></th></tr><tr><td><p>p1</p><ul><li><p>a</p></li></ul></td></tr></tbody></table>',
);
});
// 5. code + link co-occur: the schema's `code` mark excludes all other marks
@@ -391,8 +395,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
expect(out).toBe('> - x\n> - y');
});
// 11. GFM (non-spanned) cell: multi-block space-join + pipe-escape + newline-collapse.
it('a GFM cell escapes a literal pipe and collapses newlines across two paragraphs', () => {
// 11. A non-spanned cell with TWO block paragraphs: #8 emits the whole table
// as HTML <table>, so each paragraph stays its own <p> and the literal
// pipe needs no escaping inside HTML text (the old GFM path space-joined
// the blocks into one line and escaped the pipe to \|).
it('a table cell with two paragraphs emits an HTML <table> (blocks kept, no pipe-escape)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
@@ -413,7 +420,9 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
],
}),
);
expect(out).toBe('| h |\n| --- |\n| a\\|b c |');
expect(out).toBe(
'<table><tbody><tr><th><p>h</p></th></tr><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>',
);
});
});
@@ -775,3 +784,62 @@ describe('converter gap coverage — raw-HTML container round-trips (specs 15–
expect(md1).not.toContain('$x_i$');
});
});
// ===========================================================================
// 30. heading.textAlign round-trip (A1). The paragraph case already exports a
// non-default alignment as a styled `<p style="text-align:…">` that re-parses
// losslessly; headings used to emit only the bare `## text` form, silently
// DROPPING textAlign on export. The heading case is now symmetric: an aligned
// heading exports as `<hN style="text-align:…">` and re-parses back to a heading
// carrying BOTH the level and the textAlign, so the round-trip is lossless; an
// UNaligned heading still emits the bare `## text` markdown form (no churn).
// ===========================================================================
const alignedHeading = (level: number, align: string, ...inline: any[]) => ({
type: 'heading',
attrs: { level, textAlign: align },
content: inline,
});
describe('heading.textAlign round-trip (A1)', () => {
it('an aligned heading exports as <hN style="text-align:…"> (not bare ##)', () => {
expect(convertProseMirrorToMarkdown(doc(alignedHeading(2, 'center', text('Title'))))).toBe(
'<h2 style="text-align:center">Title</h2>',
);
});
it('survives export -> import -> export losslessly (level AND textAlign preserved)', async () => {
const input = alignedHeading(2, 'center', text('Title'));
const { md1, doc2, md2 } = await roundTrip(input);
// Export direction: a styled <hN>, injection-safe via escapeAttr.
expect(md1).toBe('<h2 style="text-align:center">Title</h2>');
// Import direction: re-parses to a heading node with the level AND textAlign
// (the raw <hN style> HTML block flows through marked -> generateJSON, where
// the heading parse rule matches and the textAlign global attr reads the
// style back). Byte-stable second export closes the loop.
const h = doc2.content[0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(2);
expect(h.attrs.textAlign).toBe('center');
expect(md2).toBe(md1);
// Canonical equality of the re-parsed doc against the original input doc.
expect(docsCanonicallyEqual(doc2, doc(input))).toBe(true);
});
it('a right-aligned h3 round-trips its level and alignment', async () => {
const { doc2 } = await roundTrip(alignedHeading(3, 'right', text('Head')));
const h = doc2.content[0];
expect(h.type).toBe('heading');
expect(h.attrs.level).toBe(3);
expect(h.attrs.textAlign).toBe('right');
});
it('an UNaligned heading still emits the bare "## text" form (no HTML churn)', () => {
const bare = convertProseMirrorToMarkdown(doc(heading(2, text('Plain'))));
expect(bare).toBe('## Plain');
expect(bare).not.toContain('<h2');
// The default "left" alignment is likewise NOT wrapped.
expect(
convertProseMirrorToMarkdown(doc(alignedHeading(2, 'left', text('Plain')))),
).toBe('## Plain');
});
});
@@ -129,10 +129,16 @@ describe('inline-mark matrix (underline/sub/sup/highlight±color/textStyle/comme
});
});
describe('paragraph.textAlign -> <div align>', () => {
it('non-default alignment wraps the paragraph in <div align="...">', () => {
describe('paragraph.textAlign -> <p style="text-align:...">', () => {
it('non-default alignment emits an HTML <p style="text-align:...">', () => {
// #7 fix: a non-default paragraph alignment now round-trips. It is exported
// as an HTML `<p style="text-align:center">` (the schema's paragraph
// parseHTML reads `style="text-align"` back onto `textAlign` on import), so
// the alignment survives instead of collapsing to bare text. (The old
// `<div align="center">` form was NOT re-parsed onto the paragraph and was
// therefore lossy.)
expect(c({ type: 'paragraph', attrs: { textAlign: 'center' }, content: [text('x')] })).toBe(
'<div align="center">x</div>',
'<p style="text-align:center">x</p>',
);
});
@@ -190,10 +196,14 @@ describe('escaping idempotence (SPEC §11 phantom-diff guard)', () => {
});
});
describe('table-cell sanitization (| and newline must not corrupt the GFM row)', () => {
it('escapes a literal pipe and collapses an inter-block newline in a cell', () => {
// A cell with a pipe in one paragraph and a second block paragraph: the pipe
// is escaped to \| and the block join (a space) keeps the row intact.
describe('multi-block table cell -> HTML <table> (#8: GFM pipes cannot hold block content)', () => {
it('emits the whole table as HTML <table> so a multi-paragraph cell survives', () => {
// A cell holding TWO block paragraphs cannot be represented by a GFM pipe
// row (one inline line only) — the old GFM path collapsed the two blocks
// into one line ("a\|b c"), losing the block boundary and forcing a fragile
// pipe-escape. #8 emits the WHOLE table as raw HTML <table> instead: the
// schema's table-family parseHTML round-trips it, each paragraph stays its
// own <p>, and the literal pipe needs no escaping inside HTML text.
const out = c({
type: 'table',
content: [
@@ -205,7 +215,9 @@ describe('table-cell sanitization (| and newline must not corrupt the GFM row)',
]},
],
});
expect(out).toBe('| H |\n| --- |\n| a\\|b c |');
expect(out).toBe(
'<table><tbody><tr><th><p>H</p></th></tr><tr><td><p>a|b</p><p>c</p></td></tr></tbody></table>',
);
});
});