fix(git-sync): red-team hardening — 12 confirmed sync-breaking bugs + regression tests
A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked findings (9 others triaged out as already-defended). Wrote a reproduction test per finding (each asserts the CORRECT behavior, so it fails on the bug), then fixed the production code so every repro goes green. All confirmed bugs: Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror): - #1 editor-ext node types silently dropped on export — ported the 8 missing canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed, status, pageEmbed, transclusionSource/Reference) into the git-sync schema mirror and added converter cases that emit their schema-matching HTML instead of flattening unknown nodes to '' (this was the critical data-loss flagged in review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated. - #2 top-level image lost width/height/align/attachmentId — now emits an HTML <img> (like video/diagrams) when it carries layout attrs; bare images stay . Image node parses width/height as strings so they re-import. - #3 code block containing a ``` fence corrupted on round-trip — outer fence is now widened to (longest-inner-backtick-run + 1). - #16 deep nesting threw RangeError (page never synced) — added a depth guard (MAX_NODE_DEPTH=400) so the converter never overflows the stack. Push/layout/cycle (engine): - #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent layout — deterministic, order-independent sibling disambiguation; suffix is stripped from a path-derived title ONLY when the new name is exactly the old title plus the suffix (never a genuine retitle ending in ' ~token'). - #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling — ambiguous (parent,title) is no longer adopted (falls back to fresh create). - #12 a new child under a new parent was created at ROOT — creates are ordered parent-before-child with an in-memory created-id map for parent resolution. - #13 git conflict markers could reach Docmost — bodies are scanned and the marker lines stripped (a '=======' line is only treated as a conflict separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe). - #15 a divergent `docmost` mirror was escalated by runPush but dropped by runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator. Server (merge / lock / provenance): - #9 3-way merge lost a human's block edit when git inserted an adjacent block — finer-grained diff3 region merge (via lcs) preserves non-overlapping human edits; genuine same-block conflicts still resolve git-wins. - #10 single-writer race — module-static liveLocks closes the same-process TOCTOU window, and a heartbeat refresh that cannot confirm the lock now aborts the cycle at its next write checkpoint (cooperative AbortSignal threaded through runCycle). Cross-process fencing tokens remain a follow-up. - #14 sticky-agent provenance overrode an explicit actor='git-sync' write, blinding the listener loop-guard — resolveSource now lets an explicit actor win over the sticky-agent fallback (explicit agent still wins). Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541 pass, server tsc clean. A review pass over the fixes caught and corrected a title-suffix over-strip, an inert abort signal, a document-wide conflict-marker strip, and two leaf-atom content-holes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -419,33 +419,33 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
});
|
||||
|
||||
describe('converter gap coverage — documented round-trip data loss (specs 12–14)', () => {
|
||||
// 12. A 3-backtick fence inside a codeBlock body is NOT lengthened: the inner
|
||||
// fence prematurely terminates the block, splitting it into three nodes.
|
||||
it('a triple-backtick fence inside a codeBlock body is lossy (fence collision)', async () => {
|
||||
// 12. A 3-backtick fence inside a codeBlock body is now lengthened: the outer
|
||||
// fence widens to (longest inner run + 1) backticks per CommonMark, so the
|
||||
// inner ``` is treated as content and the block survives as ONE node.
|
||||
it('a triple-backtick fence inside a codeBlock body round-trips via a widened fence', async () => {
|
||||
const d = doc({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'js' },
|
||||
content: [{ type: 'text', text: '```\ninner\n```' }],
|
||||
});
|
||||
const md1 = convertProseMirrorToMarkdown(d);
|
||||
expect(md1).toBe('```js\n```\ninner\n```\n```');
|
||||
// Outer fence widened to 4 backticks; the inner 3-backtick fence is content.
|
||||
expect(md1).toBe('````js\n```\ninner\n```\n````');
|
||||
|
||||
const doc2 = await markdownToProseMirror(md1);
|
||||
// The inner fence split the block into THREE top-level nodes.
|
||||
// The block survives as a SINGLE code block (no premature split).
|
||||
const top = doc2.content || [];
|
||||
expect(top).toHaveLength(3);
|
||||
expect(top).toHaveLength(1);
|
||||
expect(top[0].type).toBe('codeBlock');
|
||||
expect(top[0].attrs?.language).toBe('js');
|
||||
expect(top[0].content?.[0]).toMatchObject({ type: 'text', text: '\n' });
|
||||
expect(top[1].type).toBe('paragraph');
|
||||
expect(top[1].content?.[0]).toMatchObject({ type: 'text', text: 'inner' });
|
||||
expect(top[2].type).toBe('codeBlock');
|
||||
expect(top[2].attrs?.language).toBeNull();
|
||||
expect(top[2].content?.[0]).toMatchObject({ type: 'text', text: '\n' });
|
||||
expect(top[0].content?.[0]?.text).toContain('```\ninner\n```');
|
||||
|
||||
const md2 = convertProseMirrorToMarkdown(doc2);
|
||||
expect(md2).not.toBe(md1); // not byte-stable
|
||||
expect(docsCanonicallyEqual(d, doc2)).toBe(false); // documented data loss
|
||||
expect(md2).toBe(md1); // byte-stable
|
||||
// Canonically the re-imported code text gains a single trailing newline
|
||||
// (marked re-adds it; the exporter strips it back, hence byte stability).
|
||||
// The fence is no longer lossy: the inner fence and content fully survive.
|
||||
expect(docsCanonicallyEqual(d, doc2)).toBe(false);
|
||||
});
|
||||
|
||||
// 13. A leading ordered-list marker in paragraph text is NOT escaped, so a
|
||||
|
||||
Reference in New Issue
Block a user