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
  ![](src). 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:
claude code agent 227
2026-06-26 01:29:02 +03:00
parent b536a41ad3
commit d5079aa1d8
20 changed files with 1621 additions and 135 deletions

View File

@@ -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

View File

@@ -0,0 +1,159 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { applyPushActions } from '../src/engine/push';
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
const SPACE_ID = 'sp-test';
/** A recording client fake; listSpaceTree/createPage configurable per test. */
function makeClient() {
return {
listSpaceTree: vi.fn(async () => ({
pages: [] as { id: string; parentPageId?: string | null; title?: string }[],
complete: true,
})),
importPageMarkdown: vi.fn(async () => ({ success: true })),
createPage: vi.fn(
async (
title: string,
_content: string,
_spaceId: string,
_parentPageId?: string,
) => ({ data: { id: 'assigned-id', title }, success: true }),
),
deletePage: vi.fn(async () => ({ success: true })),
movePage: vi.fn(async () => ({ success: true })),
renamePage: vi.fn(async () => ({ success: true })),
};
}
function makeGit() {
return {
updateRef: vi.fn(async () => {}),
fastForwardBranch: vi.fn(async () => ({ ok: true })),
showFileAtRef: vi.fn(async () => null),
};
}
/** A recording fs fake over a path->text store (writes are read back). */
function makeFs(initial: Record<string, string> = {}) {
const store: Record<string, string> = { ...initial };
const fs = {
readFile: vi.fn(async (path: string) => {
if (!(path in store)) throw new Error(`no such file: ${path}`);
return store[path];
}),
writeFile: vi.fn(async (path: string, text: string) => {
store[path] = text;
}),
};
return { fs, store };
}
function deps(client: any, git: any, fs: ReturnType<typeof makeFs>): ApplyPushDeps {
return {
client,
git: git as any,
readFile: fs.fs.readFile,
writeFile: fs.fs.writeFile,
spaceId: SPACE_ID,
};
}
function actions(partial: Partial<PushActions>): PushActions {
return {
creates: [],
updates: [],
deletes: [],
renamesMoves: [],
skipped: [],
...partial,
};
}
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
// === Finding #6 — adopt must NOT clobber an arbitrary duplicate-title sibling ===
// The retry-adopt map keys pages by (parentPageId|root, title). When TWO root
// siblings share the title 'Foo', the key collides and the map keeps the FIRST
// (p1). A brand-new untracked 'Foo/Foo.md' (no gitmost_id) then "adopts" p1 and
// pushes its body over it via importPageMarkdown — silently overwriting an
// arbitrary, possibly unrelated, existing page. Desired: a fresh createPage, or
// an ambiguity skip — NEVER a silent overwrite of an existing sibling.
describe('redteam #6 — adopt clobbers wrong duplicate-title sibling', () => {
it('does NOT overwrite an arbitrary duplicate-title sibling (p1) via importPageMarkdown', async () => {
const client = makeClient();
client.listSpaceTree.mockResolvedValue({
pages: [
{ id: 'p1', parentPageId: null, title: 'Foo' },
{ id: 'p2', parentPageId: null, title: 'Foo' },
],
complete: true,
});
const git = makeGit();
// A brand-new local file with NO gitmost_id frontmatter.
const fs = makeFs({ 'Foo/Foo.md': '# Foo\n\nfresh foo body\n' });
await applyPushActions(
deps(client, git, fs),
actions({ creates: [{ path: 'Foo/Foo.md' }] }),
);
// The wrong sibling must never be overwritten with our body.
const clobberedP1 = client.importPageMarkdown.mock.calls.some(
(c: any[]) => c[0] === 'p1',
);
expect(clobberedP1).toBe(false);
});
});
// === Finding #12 — new child under new parent must be parented, not put at ROOT ===
// creates are applied in path order: 'Proj/Apple.md' (Apple < Proj) BEFORE
// 'Proj/Proj.md'. When Apple is created first, its parent folder-note
// 'Proj/Proj.md' has no gitmost_id yet, so the parent resolves to null and Apple
// is created at the SPACE ROOT instead of under Proj. Desired: the parent page is
// created before its child, so Apple's createPage receives Proj's assigned id.
describe('redteam #12 — new child under new parent placed at ROOT', () => {
it('createPage for Apple receives parentPageId === the id assigned to Proj', async () => {
let seq = 0;
const client = makeClient();
client.createPage.mockImplementation(
async (title: string) => ({
data: { id: `id-${++seq}`, title },
success: true,
}),
);
const git = makeGit();
// Both brand-new local files, neither carrying a gitmost_id yet. writeFile
// updates the store so readFile reads back any pageId written during the run.
const fs = makeFs({
'Proj/Apple.md': '# Apple\n\napple body\n',
'Proj/Proj.md': '# Proj\n\nproj body\n',
});
await applyPushActions(
deps(client, git, fs),
actions({
creates: [{ path: 'Proj/Apple.md' }, { path: 'Proj/Proj.md' }],
}),
);
const calls = client.createPage.mock.calls;
const results = client.createPage.mock.results;
const projIdx = calls.findIndex((c: any[]) => c[0] === 'Proj');
const appleIdx = calls.findIndex((c: any[]) => c[0] === 'Apple');
expect(projIdx).toBeGreaterThanOrEqual(0);
expect(appleIdx).toBeGreaterThanOrEqual(0);
const projId = ((await results[projIdx].value) as any).data.id;
const appleParentPageId = calls[appleIdx][3];
// Apple is a child of Proj -> it must be created under Proj, not at ROOT.
expect(appleParentPageId).toBe(projId);
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
// Import the converter DIRECTLY from src (NOT the docmost-client barrel, which
// pulls in collaboration.ts and mutates the global DOM at import time), matching
// the other converter unit tests. markdownToProseMirror is imported for the
// round-trip cases; loading it mutates the global DOM via jsdom (required for
// @tiptap/html's generateJSON under Node) — this is expected.
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
// ---------------------------------------------------------------------------
// #1 editor-ext atoms dropped: the `default` branch (markdown-converter.ts
// ~584-586) collapses unknown atoms to "" by mapping their (empty) children.
// ---------------------------------------------------------------------------
describe('#1 editor-ext atoms dropped', () => {
it('preserves an inline status atom text', () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'status', attrs: { text: 'Done' } }],
});
expect(convertProseMirrorToMarkdown(d)).toContain('Done');
});
it('preserves a block htmlEmbed atom', () => {
const d = doc({ type: 'htmlEmbed', attrs: { source: '<b>hi</b>' } });
expect(convertProseMirrorToMarkdown(d)).not.toBe('');
});
it('preserves a footnoteReference atom', () => {
const d = doc({
type: 'paragraph',
content: [{ type: 'footnoteReference', attrs: { id: 'fn1', referenceNumber: 1 } }],
});
expect(convertProseMirrorToMarkdown(d)).not.toBe('');
});
});
// ---------------------------------------------------------------------------
// #2 top-level image attrs lost: a top-level image emits markdown ![](src),
// which carries no width/height/align/attachmentId.
// ---------------------------------------------------------------------------
describe('#2 top-level image attrs lost', () => {
it('keeps width through export and re-import', async () => {
const d = doc({
type: 'image',
attrs: { src: '/files/x.png', width: '320', height: '200', align: 'right', attachmentId: 'a1' },
});
const md = convertProseMirrorToMarkdown(d);
expect(md).toContain('320');
const back = await markdownToProseMirror(md);
expect(back.content[0].attrs.width).toBe('320');
});
});
// ---------------------------------------------------------------------------
// #3 code-fence corruption: a code block whose TEXT contains a ``` fence must
// be emitted with a wider outer fence so the inner fence survives.
// ---------------------------------------------------------------------------
describe('#3 code-fence corruption', () => {
it('round-trips a code block containing an inner fence', async () => {
const code = '```js\nfoo()\n```';
const d = doc({
type: 'codeBlock',
attrs: { language: '' },
content: [{ type: 'text', text: code }],
});
const md1 = convertProseMirrorToMarkdown(d);
const back = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(back);
expect(md2).toBe(md1);
});
});
// ---------------------------------------------------------------------------
// #16 depth guard: deep recursion in processNode overflows the stack (today a
// RangeError) instead of being guarded.
// ---------------------------------------------------------------------------
describe('#16 depth guard', () => {
it('does not throw on a deeply nested blockquote doc', () => {
const DEPTH = 50000;
let node: any = { type: 'paragraph', content: [{ type: 'text', text: 'x' }] };
for (let i = 0; i < DEPTH; i++) {
node = { type: 'blockquote', content: [node] };
}
const d = doc(node);
expect(() => convertProseMirrorToMarkdown(d)).not.toThrow();
});
});

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { buildVaultLayout, type PageNode } from '../src/engine/layout.js';
import { classifyRenameMoves } from '../src/engine/push.js';
import type {
ClassifyRenameMovesDeps,
MetaSide,
RenameMoveAction,
} from '../src/engine/push.js';
import type { DocmostMdMeta } from '../src/lib/index.js';
// RED-TEAM finding #4 (two facets):
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
// equally-titled root pages keeps the bare stem (and which gets the
// ` ~slugId` suffix) depends purely on input array order. The layout is
// supposed to be a deterministic function of the page SET, so reordering
// the input must not move the suffix onto a different page.
// (b) The page title derived from a DISAMBIGUATED filename ('Report ~a1.md')
// never strips the cosmetic ` ~slugId` suffix, so a pure disambiguation
// file-rename is mis-classified as a real title RENAME that would push the
// suffix ('Report ~a1') back into Docmost as the page's actual title.
describe('redteam #4a — buildVaultLayout is stable under input reorder', () => {
it('keeps the same stem for page A regardless of input order', () => {
const A: PageNode = { id: 'A', title: 'Report', slugId: 'a1', parentPageId: null };
const B: PageNode = { id: 'B', title: 'Report', slugId: 'b2', parentPageId: null };
const l1 = buildVaultLayout([A, B]);
const l2 = buildVaultLayout([B, A]);
// Identity (pageId A) must resolve to the same file stem no matter how the
// flat page list happened to be ordered.
expect(l2.get('A')?.stem).toBe(l1.get('A')?.stem);
});
});
describe('redteam #4b — disambiguation suffix is not a title change', () => {
// Mirror production push.ts `titleFromPath` EXACTLY: the synthetic native meta
// sets `title = baseName(path) without ".md"`. This is the real derivation the
// injected `metaAt` carries in `main`.
function titleFromPath(path: string): string {
const slash = path.lastIndexOf('/');
const base = slash < 0 ? path : path.slice(slash + 1);
return base.endsWith('.md') ? base.slice(0, -3) : base;
}
function deps(): ClassifyRenameMovesDeps {
const metaAt = (path: string, _side: MetaSide): DocmostMdMeta | null => ({
version: 1,
title: titleFromPath(path),
pageId: 'p1',
});
// Same enclosing folder (root) on both sides -> no reparent.
const resolveParentPageId = (_path: string, _side: MetaSide): string | null => null;
return { metaAt, resolveParentPageId };
}
it('does NOT emit a rename when only a ~slugId suffix was appended', () => {
// A sibling collision appeared, so the file 'Report.md' was relocated to the
// disambiguated 'Report ~a1.md'. The page TITLE in Docmost is still 'Report'.
const rms: RenameMoveAction[] = [
{ pageId: 'p1', oldPath: 'Report.md', newPath: 'Report ~a1.md' },
];
const [classified] = classifyRenameMoves(rms, deps());
// Desired behaviour: a pure disambiguation file-rename is cosmetic/local and
// must NOT be pushed as a title change. (If any rename WERE emitted it must
// carry the real title 'Report', never the suffixed 'Report ~a1'.)
expect(classified.rename).toBeUndefined();
});
});

View File

@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from 'vitest';
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
import type { PushDeps } from '../src/engine/push';
import type { Settings } from '../src/engine/settings';
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
import { serializePageFile } from '../src/lib/page-file';
// Red-team confirmations for PR #119 (git-sync). Each test asserts the DESIRED
// behavior, so it FAILS today iff the bug is real.
function makeSettings(): Settings {
return {
docmostApiUrl: 'https://docmost.example.com',
docmostEmail: 'you@example.com',
docmostPassword: 'secret',
docmostSpaceId: 'space-1',
vaultPath: '/vault',
pollIntervalMs: 15000,
debounceMs: 2000,
logLevel: 'info',
} as Settings;
}
// ---------------------------------------------------------------------------
// #13 — conflict markers must never reach Docmost (SPEC §9), even when there is
// NO in-progress merge (markers committed on `main` by some other path). The
// push apply reads the body and hands it to importPageMarkdown verbatim; the
// DESIRED behavior is a content scan that prevents a `<<<<<<<` body from being
// pushed. Assert the pushed body does NOT contain a conflict marker.
// ---------------------------------------------------------------------------
function makePushGit(opts: {
changes: { status: 'A' | 'M' | 'D' | 'R' | 'C'; path: string; oldPath?: string }[];
lastPushed?: string | null;
}) {
const calls = { updateRef: [] as { ref: string; target: string }[] };
const git: PushDeps['git'] = {
assertGitAvailable: vi.fn(async () => {}),
ensureRepo: vi.fn(async () => {}),
isMergeInProgress: vi.fn(async () => false), // NO merge in progress
checkout: vi.fn(async () => {}),
stageAll: vi.fn(async () => {}),
commit: vi.fn(async () => false),
readRef: vi.fn(async (ref: string) =>
ref === LAST_PUSHED_REF ? (opts.lastPushed ?? 'base-sha') : null,
),
revParse: vi.fn(async (ref: string) => {
if (ref === DOCMOST_BRANCH) return 'doc-sha';
if (ref === 'main') return 'main-sha';
return null;
}),
diffNameStatus: vi.fn(async () => opts.changes),
showFileAtRef: vi.fn(async () => null),
updateRef: vi.fn(async (ref: string, target: string) => {
calls.updateRef.push({ ref, target });
}),
fastForwardBranch: vi.fn(async () => ({ ok: true })),
listTrackedFiles: vi.fn(async () => [] as string[]),
};
return { git, calls };
}
describe('#13 conflict markers reach Docmost', () => {
it('does NOT push a body containing a `<<<<<<< HEAD` conflict marker', async () => {
const conflictBody =
'<<<<<<< HEAD\nmy line\n=======\ntheir line\n>>>>>>> feature\n';
const file = serializePageFile('p-1', conflictBody);
const { git } = makePushGit({ changes: [{ status: 'M', path: 'Doc.md' }] });
const importPageMarkdown = vi.fn(async () => ({ success: true }));
const client = {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
importPageMarkdown,
createPage: vi.fn(),
deletePage: vi.fn(),
movePage: vi.fn(),
renamePage: vi.fn(),
};
const deps: PushDeps = {
settings: makeSettings(),
git,
makeClient: () => client as any,
readFile: vi.fn(async (path: string) => {
if (path === 'Doc.md') return file;
throw new Error(`no such file: ${path}`);
}),
writeFile: vi.fn(async () => {}),
log: () => {},
};
const res = await runPush(deps, { dryRun: false });
expect(res.mode).toBe('apply');
// The body actually sent to Docmost (2nd positional arg is the markdown body).
expect(importPageMarkdown).toHaveBeenCalledTimes(1);
const pushedBody: string = importPageMarkdown.mock.calls[0][1] as any;
// DESIRED: a content scan gates conflict markers; the body must be clean.
expect(pushedBody).not.toContain('<<<<<<<');
expect(pushedBody).not.toContain('=======');
expect(pushedBody).not.toContain('>>>>>>>');
});
});
// ---------------------------------------------------------------------------
// #15 — a divergent `docmost` mirror (fastForwardBranch refuses) is escalated by
// runPush (`divergentDocmost: true`), but runCycle forwards only {mode, failures}
// — the divergence is DROPPED from RunCycleResult. DESIRED: the cycle result
// surfaces the divergence so the caller can act on it.
// ---------------------------------------------------------------------------
function fakeVault(overrides: Record<string, any> = {}) {
const order: string[] = [];
const rec =
(name: string, ret?: any) =>
async (...args: any[]) => {
order.push(args.length ? `${name}:${args.join(',')}` : name);
return ret;
};
const vault: any = {
order,
assertGitAvailable: rec('assertGitAvailable'),
ensureRepo: rec('ensureRepo'),
isMergeInProgress: vi.fn(async () => false),
ensureBranch: rec('ensureBranch'),
checkout: rec('checkout'),
listTrackedFiles: vi.fn(async () => [] as string[]),
stageAll: rec('stageAll'),
commit: rec('commit', false),
merge: rec('merge', { ok: true, conflict: false, output: '' }),
readRef: vi.fn(async () => null),
revParse: vi.fn(async () => 'main-commit-sha'),
diffNameStatus: vi.fn(async () => [] as any[]),
showFileAtRef: vi.fn(async () => ''),
updateRef: rec('updateRef'),
// The mirror diverged: the ff is REFUSED. runPush escalates this as
// divergentDocmost; the question is whether runCycle surfaces it.
fastForwardBranch: rec('fastForwardBranch', {
ok: false,
reason: 'not-fast-forward',
}),
...overrides,
};
return vault;
}
function baseDeps(vault: any, over: Partial<RunCycleDeps> = {}): RunCycleDeps {
return {
spaceId: 'space-1',
client: {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
getPageJson: vi.fn(),
importPageMarkdown: vi.fn(),
createPage: vi.fn(),
deletePage: vi.fn(),
movePage: vi.fn(),
renamePage: vi.fn(),
listRecentSince: vi.fn(),
listTrash: vi.fn(),
restorePage: vi.fn(),
} as any,
vault,
settings: { vaultPath: '/vault' } as any,
fs: {
readFile: vi.fn(async () => ''),
writeFile: vi.fn(async () => undefined),
mkdir: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
log: vi.fn(),
...over,
};
}
describe('#15 divergence dropped by runCycle', () => {
it('surfaces the divergent `docmost` mirror in RunCycleResult', async () => {
const vault = fakeVault();
const deps = baseDeps(vault);
const res = await runCycle(deps);
expect(res.ran).toBe(true);
// The push DID refuse to fast-forward the divergent mirror.
expect(vault.order).toContain(
'fastForwardBranch:docmost,main-commit-sha',
);
// DESIRED: the cycle result surfaces the divergence (some warning/flag), so a
// caller driving runCycle can see the §5 invariant breach without scraping
// logs. Today RunCycleResult.push is only {mode, failures}.
const divergence =
(res as any).divergentDocmost ??
(res.push as any)?.divergentDocmost ??
(res as any).warning;
expect(divergence).toBeTruthy();
});
});

View File

@@ -77,10 +77,14 @@ const expectedSurface: SurfaceEntry[] = [
{ name: "drawio", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "size", "src", "title", "width"] },
{ name: "embed", kind: "node", attrs: ["align", "height", "provider", "src", "width"] },
{ name: "excalidraw", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "size", "src", "title", "width"] },
{ name: "footnoteDefinition", kind: "node", attrs: ["id"] },
{ name: "footnoteReference", kind: "node", attrs: ["id"] },
{ name: "footnotesList", kind: "node", attrs: [] },
{ name: "hardBreak", kind: "node", attrs: [] },
{ name: "heading", kind: "node", attrs: ["id", "indent", "level", "textAlign"] },
{ name: "highlight", kind: "mark", attrs: ["color"] },
{ name: "horizontalRule", kind: "node", attrs: [] },
{ name: "htmlEmbed", kind: "node", attrs: ["height", "source"] },
{ name: "image", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "placeholder", "size", "src", "title", "width"] },
{ name: "italic", kind: "mark", attrs: [] },
{ name: "link", kind: "mark", attrs: ["class", "href", "internal", "rel", "target", "title"] },
@@ -90,8 +94,10 @@ const expectedSurface: SurfaceEntry[] = [
{ name: "mention", kind: "node", attrs: ["anchorId", "creatorId", "entityId", "entityType", "id", "label", "slugId"] },
{ name: "orderedList", kind: "node", attrs: ["start", "type"] },
{ name: "pageBreak", kind: "node", attrs: [] },
{ name: "pageEmbed", kind: "node", attrs: ["sourcePageId"] },
{ name: "paragraph", kind: "node", attrs: ["id", "indent", "textAlign"] },
{ name: "pdf", kind: "node", attrs: ["attachmentId", "height", "name", "placeholder", "size", "src", "width"] },
{ name: "status", kind: "node", attrs: ["color", "text"] },
{ name: "strike", kind: "mark", attrs: [] },
{ name: "subpages", kind: "node", attrs: [] },
{ name: "subscript", kind: "mark", attrs: [] },
@@ -104,6 +110,8 @@ const expectedSurface: SurfaceEntry[] = [
{ name: "taskList", kind: "node", attrs: [] },
{ name: "text", kind: "node", attrs: [] },
{ name: "textStyle", kind: "mark", attrs: ["color"] },
{ name: "transclusionReference", kind: "node", attrs: ["sourcePageId", "transclusionId"] },
{ name: "transclusionSource", kind: "node", attrs: ["id"] },
{ name: "underline", kind: "mark", attrs: [] },
{ name: "video", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "placeholder", "size", "src", "width"] },
{ name: "youtube", kind: "node", attrs: ["align", "height", "src", "width"] },