diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 30edd305..34cca8ab 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -31,7 +31,7 @@ "data": {"pass": 25, "fail": 0}, "wire": {"pass": 11, "fail": 0}, "validate": {"pass": 23, "fail": 0}, - "store": {"pass": 37, "fail": 0}, + "store": {"pass": 46, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "crdt-tree": {"pass": 21, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 763, + "total_pass": 772, "total_fail": 0, - "total": 763 + "total": 772 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 5ce5087f..b3e0a611 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -35,7 +35,7 @@ _Generated by `lib/content/conformance.sh`_ | data | 25 | 0 | 25 | | wire | 11 | 0 | 11 | | validate | 23 | 0 | 23 | -| store | 37 | 0 | 37 | +| store | 46 | 0 | 46 | | snapshot | 20 | 0 | 20 | | crdt | 34 | 0 | 34 | | crdt-tree | 21 | 0 | 21 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **763** | **0** | **763** | +| **Total** | **772** | **0** | **772** | diff --git a/lib/content/store.sx b/lib/content/store.sx index ef840c13..d59ddbb9 100644 --- a/lib/content/store.sx +++ b/lib/content/store.sx @@ -5,9 +5,10 @@ ;; replay of its op stream up to a sequence number; the materialised doc is a ;; cache, never primary state. ;; -;; Requires (loaded by the harness): block.sx, doc.sx, and persist -;; (event/backend/log/kv/api). The persist backend `b` is opened by the caller -;; via (persist/open) and injected — content knows nothing about which backend. +;; Requires (loaded by the harness): block.sx, doc.sx, section.sx (doc-deep-find +;; + doc-tree-ids, for the tree-wide diff), plus persist (event/backend/log/kv/ +;; api). The persist backend `b` is opened by the caller via (persist/open) and +;; injected — content knows nothing about which backend. (define content/-stream (fn (doc-id) (str "content:" doc-id))) @@ -69,11 +70,18 @@ (fn (b doc-id) (map (fn (ev) {:type (persist/event-type ev) :at (persist/event-at ev) :seq (persist/event-seq ev)}) (content/log b doc-id)))) ;; ── diff between two materialised document versions ── -;; Returns {:added (ids) :removed (ids) :changed (ids)} where changed = ids -;; present in both whose block content differs. -(define - content/-missing? - (fn (doc id) (= (ct-index-of (doc-blocks doc) id) -1))) +;; Tree-wide: ids are enumerated across the whole block tree (descending into +;; sections), so nested-block adds/removes/changes are detected, not just +;; top-level ones. Returns {:added :removed :changed} (lists of ids): +;; :added — ids present (anywhere) in `new` but not in `old` +;; :removed — ids present (anywhere) in `old` but not in `new` +;; :changed — content blocks present in both whose block value differs +;; Section containers never appear in :changed (they hold no own content — a +;; child change surfaces as that child's own entry); a whole section appearing +;; or disappearing shows up in :added / :removed by its id. +(define content/-all-ids (fn (doc) (doc-tree-ids doc))) + +(define content/-missing? (fn (doc id) (= (doc-deep-find doc id) nil))) (define content/-changed @@ -83,15 +91,16 @@ (fn (id) (let - ((bo (doc-find old id)) (bn (doc-find new id))) + ((bo (doc-deep-find old id)) (bn (doc-deep-find new id))) (cond ((= bo nil) false) ((= bn nil) false) + ((= (blk-type bo) "section") false) ((= bo bn) false) (else true)))) - (doc-ids old)))) + (content/-all-ids old)))) -(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (doc-ids old)) :added (filter (fn (id) (content/-missing? old id)) (doc-ids new))})) +(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (content/-all-ids old)) :added (filter (fn (id) (content/-missing? old id)) (content/-all-ids new))})) ;; convenience: diff two persisted versions by seq. (define diff --git a/lib/content/tests/store.sx b/lib/content/tests/store.sx index f452b66d..d8fb85cb 100644 --- a/lib/content/tests/store.sx +++ b/lib/content/tests/store.sx @@ -178,3 +178,31 @@ "op-log nested delete via content/at seq2" (doc-tree-ids (content/at B4 "nest" 2)) (list "sec" "n")) + +;; ── diff is TREE-WIDE: nested-block add/change/remove are detected, and +;; section containers never appear in :changed (a top-level-only diff would miss +;; "n" entirely and instead flag the section). ── +(define dn01 (content/diff-versions B4 "nest" 0 1)) +(content-test + "diff nested added (section + child)" + (get dn01 :added) + (list "sec" "n")) +(content-test "diff nested added removed empty" (get dn01 :removed) (list)) +(content-test "diff nested added changed empty" (get dn01 :changed) (list)) + +(define dn12 (content/diff-versions B4 "nest" 1 2)) +(content-test + "diff nested changed child only" + (get dn12 :changed) + (list "n")) +(content-test "diff nested changed no add" (get dn12 :added) (list)) +(content-test "diff nested changed no remove" (get dn12 :removed) (list)) + +(define dn23 (content/diff-versions B4 "nest" 2 3)) +(content-test "diff nested removed child" (get dn23 :removed) (list "n")) +(content-test "diff nested removed no change" (get dn23 :changed) (list)) + +(content-test + "diff nested no-op" + (get (content/diff-versions B4 "nest" 1 1) :changed) + (list)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c36178d4..c1f8aaa1 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **763/763** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) +`bash lib/content/conformance.sh` → **772/772** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) ## Ground rules @@ -113,6 +113,18 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening: `content/diff` (and `content/diff-versions`) are now + TREE-WIDE. They enumerated ids via `doc-ids`/`doc-find` (top-level only), so a + diff between two versions of a document containing sections silently missed + every nested-block add/remove/change — the same class of seam as the by-id + op-log bug. Now ids come from `doc-tree-ids` and lookups from `doc-deep-find`, + so nested changes surface precisely. Section containers are excluded from + `:changed` (they hold no own content; a child change reports as that child), + while whole-section add/remove still shows in `:added`/`:removed`. Flat-doc + diffs are unchanged (deep == top-level with no sections). +9 store tests + (nested add = section+child, nested change = child only, nested remove, + no-op). 772/772. + - 2026-06-07 — Feature: in-document prose search. `content/search-text` (and `content/search-text-ids`) return every content block, tree-wide, whose `(asText b)` contains a term — so search spans text/heading/code/quote/callout