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>
125 lines
6.6 KiB
TypeScript
125 lines
6.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { getSchema } from "@tiptap/core";
|
|
|
|
import { docmostExtensions } from "../src/lib/docmost-schema.js";
|
|
|
|
// SCHEMA-DRIFT GUARD (must-review gate).
|
|
//
|
|
// `src/lib/docmost-schema.ts` is a VENDORED MIRROR of the canonical Docmost
|
|
// document schema defined in `@docmost/editor-ext`. git-sync uses it to convert
|
|
// pages to/from ProseMirror JSON; any node, mark, or attribute that exists in
|
|
// the canonical schema but is missing here is silently dropped on a round-trip
|
|
// (data loss). The reverse — a node/mark/attr here that no longer exists in the
|
|
// canonical schema — is dead surface that can mask drift.
|
|
//
|
|
// This test derives a stable, sorted "schema surface" (every node/mark name and
|
|
// its sorted attribute keys) and pins it against an INLINE expected constant.
|
|
// It is intentionally a LOUD must-review gate rather than an automatic
|
|
// editor-ext diff: editor-ext's Tiptap representation differs from this
|
|
// vendored copy, so a cross-representation compare would be fragile. We do NOT
|
|
// use toMatchSnapshot so the reference lives in this file and is reviewed in the
|
|
// diff of every change.
|
|
//
|
|
// WHEN THIS TEST FAILS: do NOT blindly update `expectedSurface`. First confirm
|
|
// the change matches `@docmost/editor-ext` (the canonical schema) so the
|
|
// markdown <-> ProseMirror round-trip stays lossless, THEN copy the new surface
|
|
// into the expected constant below.
|
|
|
|
interface SurfaceEntry {
|
|
name: string;
|
|
kind: "node" | "mark";
|
|
attrs: string[];
|
|
}
|
|
|
|
/** Derive the deterministic schema surface from the vendored extension set. */
|
|
function deriveSurface(): SurfaceEntry[] {
|
|
const schema = getSchema(docmostExtensions as never);
|
|
const surface: SurfaceEntry[] = [];
|
|
for (const [name, type] of Object.entries(schema.nodes)) {
|
|
surface.push({
|
|
name,
|
|
kind: "node",
|
|
attrs: Object.keys((type as { spec?: { attrs?: object } }).spec?.attrs ?? {}).sort(),
|
|
});
|
|
}
|
|
for (const [name, type] of Object.entries(schema.marks)) {
|
|
surface.push({
|
|
name,
|
|
kind: "mark",
|
|
attrs: Object.keys((type as { spec?: { attrs?: object } }).spec?.attrs ?? {}).sort(),
|
|
});
|
|
}
|
|
// Sort by name, then by kind, for a representation-independent ordering.
|
|
surface.sort((a, b) =>
|
|
a.name === b.name ? a.kind.localeCompare(b.kind) : a.name.localeCompare(b.name),
|
|
);
|
|
return surface;
|
|
}
|
|
|
|
// The committed reference surface. Built from the ACTUAL current schema; review
|
|
// every change to this constant against `@docmost/editor-ext`.
|
|
const expectedSurface: SurfaceEntry[] = [
|
|
{ name: "attachment", kind: "node", attrs: ["attachmentId", "mime", "name", "placeholder", "size", "url"] },
|
|
{ name: "audio", kind: "node", attrs: ["attachmentId", "placeholder", "size", "src"] },
|
|
{ name: "blockquote", kind: "node", attrs: [] },
|
|
{ name: "bold", kind: "mark", attrs: [] },
|
|
{ name: "bulletList", kind: "node", attrs: [] },
|
|
{ name: "callout", kind: "node", attrs: ["icon", "type"] },
|
|
{ name: "code", kind: "mark", attrs: [] },
|
|
{ name: "codeBlock", kind: "node", attrs: ["language"] },
|
|
{ name: "column", kind: "node", attrs: ["width"] },
|
|
{ name: "columns", kind: "node", attrs: ["layout", "widthMode"] },
|
|
{ name: "comment", kind: "mark", attrs: ["commentId", "resolved"] },
|
|
{ name: "details", kind: "node", attrs: ["open"] },
|
|
{ name: "detailsContent", kind: "node", attrs: [] },
|
|
{ name: "detailsSummary", kind: "node", attrs: [] },
|
|
{ name: "doc", kind: "node", attrs: [] },
|
|
{ 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"] },
|
|
{ name: "listItem", kind: "node", attrs: [] },
|
|
{ name: "mathBlock", kind: "node", attrs: ["text"] },
|
|
{ name: "mathInline", kind: "node", attrs: ["text"] },
|
|
{ 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: [] },
|
|
{ name: "superscript", kind: "mark", attrs: [] },
|
|
{ name: "table", kind: "node", attrs: [] },
|
|
{ name: "tableCell", kind: "node", attrs: ["align", "backgroundColor", "backgroundColorName", "colspan", "colwidth", "rowspan"] },
|
|
{ name: "tableHeader", kind: "node", attrs: ["align", "backgroundColor", "backgroundColorName", "colspan", "colwidth", "rowspan"] },
|
|
{ name: "tableRow", kind: "node", attrs: [] },
|
|
{ name: "taskItem", kind: "node", attrs: ["checked"] },
|
|
{ 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"] },
|
|
];
|
|
|
|
describe("docmost schema surface", () => {
|
|
it("matches the committed reference surface (re-verify against @docmost/editor-ext on change)", () => {
|
|
expect(deriveSurface()).toEqual(expectedSurface);
|
|
});
|
|
});
|