Blocking (review id 2514): - [security] Forbid symlinks in vaults. ensureServable now sets core.symlinks=false in each vault's local git config (a pushed symlink is checked out as a plain file, never a real link), and the engine cycle wraps every read/write/mkdir in an lstat/realpath guard (new path-guard.ts) that refuses a path that is — or traverses — a symlink, or whose realpath escapes the vault root. Prevents a writer from publishing /etc/passwd or the server .env, or writing outside the vault. Adds unit tests (path-guard.test.ts) + a read-guard integration test (cycle.test.ts) + real lstat/realpath in the roundtrip integration test. - [simplification] Delete dead lib/diff.ts + test/diff.test.ts and drop the now-unused @fellow/prosemirror-recreate-transform dependency. - [documentation] Add a CHANGELOG [Unreleased] → Added entry for git-sync. Warnings: - [test-coverage] Cover the CREATE-branch conflict-markers guard (a new .md with markers and no gitmost_id is recorded as a create failure, never created). Suggestions: - [stability] Bound each `git config` in ensureServable with a timeout. - [authz] Trigger endpoint resolves spaceId workspace-scoped and 404s a foreign space before any vault directory is created. - [stability] Attribute git-initiated moves to the service account (lastUpdatedById), via an optional actor param on PageService.movePage. - [documentation] Document the per-space autoMergeConflicts toggle in AGENTS.md. - [test-coverage] Cover the unterminated `:::` callout fence fallback. - [simplification] Move test-only roundtrip-helpers.ts out of src/ into test/. Architecture: - Move the Yjs/ProseMirror merge primitives (yjs-body-merge, three-way-merge, lcs + specs) into collaboration/merge/, breaking the collaboration → integrations/git-sync dependency cycle this PR introduced. - Port the schema-surface drift gate to packages/mcp (the mcp schema mirror had none); pins 52 entries. Deferred (with rationale in the review thread): the incremental-pull perf warning (correctness-neutral; needs a high-water-mark design + its own tests on the data-loss-critical path) and the redis-sync rolling-deploy mixed-version edge (the deficient behavior is in already-released old-instance code; the new code is correct on both sides; impact is a transient rollout-window artifact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
76 lines
2.1 KiB
TypeScript
76 lines
2.1 KiB
TypeScript
/**
|
|
* Pure, IO-free comparison helpers for the idempotency round-trip checks. The
|
|
* round-trip harness that drives these lives in the package's tests, not in the
|
|
* engine.
|
|
*/
|
|
|
|
/**
|
|
* Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids
|
|
* are regenerated by `markdownToProseMirror` (SPEC §11), so they must be
|
|
* ignored when comparing the semantic shape of two documents. Returns a NEW
|
|
* tree; the input is not mutated.
|
|
*/
|
|
export function stripBlockIds(node: any): any {
|
|
if (Array.isArray(node)) {
|
|
return node.map(stripBlockIds);
|
|
}
|
|
if (node && typeof node === "object") {
|
|
const out: any = {};
|
|
for (const key of Object.keys(node)) {
|
|
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
|
|
// Drop the `id` attr; keep every other attribute.
|
|
const { id, ...rest } = node.attrs as Record<string, unknown>;
|
|
void id;
|
|
out.attrs = stripBlockIds(rest);
|
|
} else {
|
|
out[key] = stripBlockIds(node[key]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Find the first divergence between two values via a recursive deep compare.
|
|
* Returns a short path + the two differing values, or null if they are equal.
|
|
*/
|
|
export function firstDivergence(
|
|
a: any,
|
|
b: any,
|
|
path = "$",
|
|
): { path: string; a: any; b: any } | null {
|
|
if (a === b) return null;
|
|
|
|
const ta = typeof a;
|
|
const tb = typeof b;
|
|
if (ta !== tb || a === null || b === null) {
|
|
return { path, a, b };
|
|
}
|
|
if (ta !== "object") {
|
|
return { path, a, b };
|
|
}
|
|
|
|
const aIsArr = Array.isArray(a);
|
|
const bIsArr = Array.isArray(b);
|
|
if (aIsArr !== bIsArr) return { path, a, b };
|
|
|
|
if (aIsArr) {
|
|
if (a.length !== b.length) {
|
|
return { path: `${path}.length`, a: a.length, b: b.length };
|
|
}
|
|
for (let i = 0; i < a.length; i++) {
|
|
const d = firstDivergence(a[i], b[i], `${path}[${i}]`);
|
|
if (d) return d;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
for (const k of keys) {
|
|
const d = firstDivergence(a[k], b[k], `${path}.${k}`);
|
|
if (d) return d;
|
|
}
|
|
return null;
|
|
}
|