e90624a51c
Per maintainer directive (#119 comment): land the canonical converter on the git-sync branch so sync is tested on the real format, NOT a dead legacy copy. #119 itself stays FROZEN (не вливается) — this only merges develop in. Resolutions (all git-sync converter conflicts → develop; engine kept as-is): - Dropped the branch's legacy `packages/git-sync/src/lib/*` converter — the converter now lives solely in `@docmost/prosemirror-markdown` (#293); the engine (pull/push/stabilize/index) only switches its imports to the package (no logic change, verified by diff). - Removed the branch's orphaned converter tests + fixtures under `packages/git-sync/test/` (their coverage moved to the package's own test suite on develop); git-sync/test now holds engine tests only. - .gitignore / Dockerfile / test.yml / AGENTS.md: unioned — build/ ignored for every package; Dockerfile COPYs both prosemirror-markdown/build (mcp+git-sync runtime) and git-sync/build (git-sync's runtime consumer lands on this branch); CI builds prosemirror-markdown before git-sync/mcp. - pnpm-lock.yaml regenerated for the merged workspace. Branch adaptations to canon (server-side tests only — converter untouched, per the guardrail that converter fixes go to the package on develop, fixtures-first): - git-sync-converter-gate.spec.ts: heading textAlign and image width/height now round-trip via the canon trailing-comment forms (#9 `<!--attrs {...}-->`, #4 `<!--img {...}-->`) instead of the old HTML-tag forms — expectations flipped to the real canon output. RESIDUAL: canon #4 does not yet carry image `align` (documented as a known divergence; fix belongs in the package on develop). - schema-attribute-contract.spec.ts: the schema mirror moved from `@docmost/git-sync/lib/docmost-schema` to `@docmost/prosemirror-markdown`; import + jest source-mapper updated. Verified: prosemirror-markdown/git-sync/mcp build clean; git-sync corpus green; server `tsc --noEmit` 0; gate + schema-attribute-contract specs 32/32.
145 lines
6.7 KiB
TypeScript
145 lines
6.7 KiB
TypeScript
import { getSchema } from '@tiptap/core';
|
|
import { Schema } from '@tiptap/pm/model';
|
|
import { tiptapExtensions } from './collaboration.util';
|
|
// The canonical converter mirror's extension set. The schema mirror now lives in
|
|
// the single `@docmost/prosemirror-markdown` package (#293); the server jest
|
|
// config maps it to the package SOURCE (moduleNameMapper
|
|
// `^@docmost/prosemirror-markdown$`), so this reads the real mirror, not a build.
|
|
import { docmostExtensions as gitSyncExtensions } from '@docmost/prosemirror-markdown';
|
|
|
|
/**
|
|
* ATTRIBUTE-LEVEL SCHEMA CONTRACT (review #293, variant A).
|
|
*
|
|
* The document schema exists as three hand-synced copies (editor-ext =
|
|
* source-of-truth, plus the git-sync and mcp converter mirrors). The existing
|
|
* `schema-editor-ext-contract.test.ts` compares only node/mark TYPE NAMES, so a
|
|
* NEW ATTRIBUTE added to an existing node upstream slips through and its value is
|
|
* silently dropped on every git-sync round trip. That is a repeatedly-hit
|
|
* data-loss class (image caption #221, paragraph alignment #10, details `open`).
|
|
*
|
|
* This test closes the attribute gap MECHANICALLY: it builds the real canonical
|
|
* schema from the server's `tiptapExtensions` (the same set the collab write path
|
|
* uses) and the git-sync mirror schema, then asserts that for every node/mark the
|
|
* two schemas share, their ATTRIBUTE-KEY sets are equal — minus a committed
|
|
* allowlist of intentional, understood divergences. A forgotten attribute now
|
|
* fails CI loudly instead of losing data in production.
|
|
*
|
|
* WHY THIS ISN'T THE "fragile attribute compare" the sibling name-level contract
|
|
* (`packages/git-sync/test/schema-editor-ext-contract.test.ts`) deferred: that
|
|
* concern was about comparing raw extension CONFIGS, where editor-ext spreads
|
|
* global attributes (textAlign, id, …) across separate extensions and StarterKit
|
|
* contributes types the mirror gets elsewhere. We instead compare the RESOLVED
|
|
* ProseMirror `Schema` objects — `getSchema()` has already merged every
|
|
* addGlobalAttributes spread into concrete per-node attrs on both sides — so the
|
|
* compare is apples-to-apples (57 shared nodes/marks, only a handful of
|
|
* documented divergences) rather than config-shape noise.
|
|
*/
|
|
|
|
/**
|
|
* Intentional, understood attribute divergences between the canonical schema and
|
|
* the git-sync mirror. Each entry MUST carry a reason. The test asserts the
|
|
* allowlist is not stale (every listed attr is actually still divergent), so this
|
|
* cannot rot into a silent escape hatch.
|
|
*
|
|
* Shape: { [nodeOrMarkName]: { canonicalOnly?: string[]; mirrorOnly?: string[] } }
|
|
*/
|
|
const ALLOWED_DIVERGENCES: Record<
|
|
string,
|
|
{ canonicalOnly?: string[]; mirrorOnly?: string[] }
|
|
> = {
|
|
// mirrorOnly: the converter mirror carries `align` on table cells/headers so a
|
|
// GFM column-alignment marker (:--, :-:, --:) can be reconstructed on export;
|
|
// editor-ext expresses cell alignment differently. Intentional, round-trip-used.
|
|
tableCell: { mirrorOnly: ['align'] },
|
|
tableHeader: { mirrorOnly: ['align'] },
|
|
// youtube: the mirror adds `align` (media alignment it renders as data-align)
|
|
// and does NOT carry editor-ext's `start` (video start-time). `start` is a
|
|
// PRE-EXISTING gap (a youtube embed's start offset is not preserved across a
|
|
// markdown round trip) — documented here so the contract is green for the known
|
|
// state and RED for any NEW drift. Follow-up: carry `start` through the mirror.
|
|
youtube: { mirrorOnly: ['align'], canonicalOnly: ['start'] },
|
|
// image.title: the mirror carries a `title` attr (used to round-trip the
|
|
// markdown image title ``) that editor-ext does not declare
|
|
// on its image node. Mirror-only and round-trip-used, not data loss. Intentional.
|
|
image: { mirrorOnly: ['title'] },
|
|
// highlight.colorName (a named-color alias alongside the color value) is a
|
|
// PRE-EXISTING mirror gap; the color value itself round-trips. Documented.
|
|
highlight: { canonicalOnly: ['colorName'] },
|
|
};
|
|
|
|
function attrKeys(schema: Schema): Map<string, Set<string>> {
|
|
const out = new Map<string, Set<string>>();
|
|
for (const [name, type] of Object.entries(schema.nodes)) {
|
|
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
|
}
|
|
for (const [name, type] of Object.entries(schema.marks)) {
|
|
out.set(name, new Set(Object.keys((type.spec as any).attrs ?? {})));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function diff(a: Set<string>, b: Set<string>): string[] {
|
|
return [...a].filter((x) => !b.has(x)).sort();
|
|
}
|
|
|
|
describe('schema attribute contract: git-sync mirror vs canonical editor-ext', () => {
|
|
const canonical = attrKeys(getSchema(tiptapExtensions as never));
|
|
const mirror = attrKeys(getSchema(gitSyncExtensions as never));
|
|
|
|
it('builds meaningful schemas (guard against a vacuous pass)', () => {
|
|
expect(canonical.size).toBeGreaterThan(10);
|
|
expect(mirror.size).toBeGreaterThan(10);
|
|
});
|
|
|
|
it('every shared node/mark has matching attribute keys (modulo the allowlist)', () => {
|
|
const drift: string[] = [];
|
|
for (const [name, canonAttrs] of canonical) {
|
|
const mirrorAttrs = mirror.get(name);
|
|
if (!mirrorAttrs) continue; // name-level gaps are the other test's job
|
|
const allow = ALLOWED_DIVERGENCES[name] ?? {};
|
|
const canonicalOnly = diff(canonAttrs, mirrorAttrs).filter(
|
|
(k) => !(allow.canonicalOnly ?? []).includes(k),
|
|
);
|
|
const mirrorOnly = diff(mirrorAttrs, canonAttrs).filter(
|
|
(k) => !(allow.mirrorOnly ?? []).includes(k),
|
|
);
|
|
if (canonicalOnly.length) {
|
|
drift.push(
|
|
`${name}: attrs in editor-ext but MISSING from git-sync mirror ` +
|
|
`(silently dropped on round trip): ${canonicalOnly.join(', ')}`,
|
|
);
|
|
}
|
|
if (mirrorOnly.length) {
|
|
drift.push(
|
|
`${name}: attrs in git-sync mirror but NOT in editor-ext ` +
|
|
`(mirror invented an attribute): ${mirrorOnly.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
expect(drift).toEqual([]);
|
|
});
|
|
|
|
it('the allowlist is not stale (every listed divergence is still real)', () => {
|
|
const stale: string[] = [];
|
|
for (const [name, allow] of Object.entries(ALLOWED_DIVERGENCES)) {
|
|
const canonAttrs = canonical.get(name);
|
|
const mirrorAttrs = mirror.get(name);
|
|
if (!canonAttrs || !mirrorAttrs) {
|
|
stale.push(`${name}: no longer a shared node/mark`);
|
|
continue;
|
|
}
|
|
for (const k of allow.canonicalOnly ?? []) {
|
|
if (!(canonAttrs.has(k) && !mirrorAttrs.has(k))) {
|
|
stale.push(`${name}.canonicalOnly '${k}' is no longer divergent`);
|
|
}
|
|
}
|
|
for (const k of allow.mirrorOnly ?? []) {
|
|
if (!(mirrorAttrs.has(k) && !canonAttrs.has(k))) {
|
|
stale.push(`${name}.mirrorOnly '${k}' is no longer divergent`);
|
|
}
|
|
}
|
|
}
|
|
expect(stale).toEqual([]);
|
|
});
|
|
});
|