From c9a8f05244c28481e2594b2437d4032386369380 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 14:49:15 +0000 Subject: [PATCH] content: tree-wide content/find + has? (778/778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facade read-by-id was top-level only while content/edit's update/delete are tree-wide — could not read back a nested block content/edit just modified. Added generic ct-find-id (doc.sx) + doc-find-deep/doc-has-deep?; content/find + has? now descend into sections. content/find-top/has-top? keep top-level lookup. Audit: remaining doc-find/ct-index-of callers are positional insert/move (top-level by design). +6 api tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/api.sx | 9 +++++++-- lib/content/doc.sx | 37 +++++++++++++++++++++++++++++++++---- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/tests/api.sx | 34 ++++++++++++++++++++++++++++++++++ plans/content-on-sx.md | 15 ++++++++++++++- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/lib/content/api.sx b/lib/content/api.sx index 6c9535b4..d4eef25f 100644 --- a/lib/content/api.sx +++ b/lib/content/api.sx @@ -25,8 +25,13 @@ (define content/append doc-append) (define content/blocks doc-blocks) (define content/count doc-count) -(define content/find doc-find) -(define content/has? doc-has?) +;; find / has? are TREE-WIDE by id (descend into sections) — so the facade reads +;; back any block content/edit can update or delete. content/find-top / has-top? +;; keep the top-level-only lookup for callers that mean the ordered sequence. +(define content/find doc-find-deep) +(define content/has? doc-has-deep?) +(define content/find-top doc-find) +(define content/has-top? doc-has?) (define content/ids doc-ids) (define content/types doc-types) diff --git a/lib/content/doc.sx b/lib/content/doc.sx index a12c45fe..92130cbb 100644 --- a/lib/content/doc.sx +++ b/lib/content/doc.sx @@ -5,9 +5,10 @@ ;; and returns a NEW document — the input is never mutated, so any version is the ;; head of an op stream (replay-friendly for persist + CRDT merge). ;; -;; By-id ops (update/delete) are TREE-WIDE: they descend into any block carrying -;; a `children` list (i.e. sections), since ids are unique across the tree. This -;; keeps the persist op-log and content/edit correct for nested documents. +;; By-id ops (update/delete) and by-id lookup (doc-find-deep/doc-has-deep?) are +;; TREE-WIDE: they descend into any block carrying a `children` list (i.e. +;; sections), since ids are unique across the tree. This keeps the persist +;; op-log, content/edit and content/find correct for nested documents. ;; insert/move are positional and act at the top level. ;; ;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx. @@ -113,6 +114,26 @@ b)))) blocks))) +;; tree-wide find by id: first block matching id anywhere in the tree, or nil. +;; Descends into any `children` list, mirroring ct-replace-id/ct-remove-id. +(define + ct-find-id + (fn + (blocks id) + (if + (= (len blocks) 0) + nil + (let + ((b (first blocks))) + (if + (= (blk-id b) id) + b + (let + ((ch (st-iv-get b "children"))) + (let + ((nested (if (list? ch) (ct-find-id ch id) nil))) + (if (= nested nil) (ct-find-id (rest blocks) id) nested)))))))) + ;; ── query ── (define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id))) @@ -128,6 +149,14 @@ doc-has? (fn (doc id) (if (= (doc-index-of doc id) -1) false true))) +;; tree-wide lookup by id — reads a nested block by the same id content/edit can +;; update/delete (no section.sx dependency; uses the generic children descent). +(define doc-find-deep (fn (doc id) (ct-find-id (doc-blocks doc) id))) + +(define + doc-has-deep? + (fn (doc id) (if (= (doc-find-deep doc id) nil) false true))) + ;; ── structural edits (each returns a new document) ── (define doc-with-blocks (fn (doc blocks) (st-iv-set! doc "blocks" blocks))) @@ -189,7 +218,7 @@ ;; ── op constructors (data payload, reused by persist op log) ── (define op-insert (fn (block after) {:after after :op "insert" :block block})) -(define op-update (fn (id field value) {:id id :field field :op "update" :value value})) +(define op-update (fn (id field value) {:field field :id id :op "update" :value value})) (define op-move (fn (id index) {:id id :op "move" :index index})) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 34cca8ab..7bb424af 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -3,7 +3,7 @@ "block": {"pass": 38, "fail": 0}, "doc": {"pass": 40, "fail": 0}, "render": {"pass": 42, "fail": 0}, - "api": {"pass": 26, "fail": 0}, + "api": {"pass": 32, "fail": 0}, "meta": {"pass": 27, "fail": 0}, "page": {"pass": 7, "fail": 0}, "page-full": {"pass": 4, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 772, + "total_pass": 778, "total_fail": 0, - "total": 772 + "total": 778 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index b3e0a611..ca91ab02 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -7,7 +7,7 @@ _Generated by `lib/content/conformance.sh`_ | block | 38 | 0 | 38 | | doc | 40 | 0 | 40 | | render | 42 | 0 | 42 | -| api | 26 | 0 | 26 | +| api | 32 | 0 | 32 | | meta | 27 | 0 | 27 | | page | 7 | 0 | 7 | | page-full | 4 | 0 | 4 | @@ -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** | **772** | **0** | **772** | +| **Total** | **778** | **0** | **778** | diff --git a/lib/content/tests/api.sx b/lib/content/tests/api.sx index 4c3fa3ea..47ca4160 100644 --- a/lib/content/tests/api.sx +++ b/lib/content/tests/api.sx @@ -97,3 +97,37 @@ "render original unchanged" (content/render d1 "html") "

Hi

World

") + +;; ── facade find/has? are TREE-WIDE (reach into sections); find-top/has-top? +;; keep the top-level-only lookup. This makes the read-by-id surface consistent +;; with content/edit, whose update/delete are already tree-wide. ── +(content-bootstrap-section!) +(define + nd + (content/append + (content/empty "nested") + (mk-section + "sec" + (list (content/block "text" "inner" (list (list "text" "deep"))))))) +(content-test + "find nested (deep)" + (blk-id (content/find nd "inner")) + "inner") +(content-test "has? nested (deep)" (content/has? nd "inner") true) +(content-test "find-top misses nested" (content/find-top nd "inner") nil) +(content-test "has-top? misses nested" (content/has-top? nd "inner") false) +(content-test + "find-top sees top-level" + (blk-id (content/find-top nd "sec")) + "sec") +;; a nested block updated by id via content/edit is now readable by id via +;; content/find (was impossible when find was top-level-only). +(content-test + "edit-then-find nested round-trip" + (str + (blk-send + (content/find + (content/edit nd (content/update "inner" "text" "edited")) + "inner") + "text")) + "edited") diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c1f8aaa1..bbb65212 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` → **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) +`bash lib/content/conformance.sh` → **778/778** (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,19 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening (tree-wide audit): the public facade `content/find` / + `content/has?` were top-level-only (`doc-find`/`doc-has?`), so you could + `content/edit` an update/delete to a nested block by id (those ops are + tree-wide) but couldn't read that same block back by id through the facade — a + concrete read/write asymmetry. Added a generic `ct-find-id` to doc.sx (descends + into any `children` list, mirroring ct-replace-id/ct-remove-id, no section.sx + dependency) plus `doc-find-deep`/`doc-has-deep?`; `content/find`/`content/has?` + now point at them. Kept `content/find-top`/`content/has-top?` for the + top-level-only lookup. Audited all `doc-find`/`doc-ids`/`ct-index-of` callers: + the remaining ones are insert/move (positional, top-level by design) — no other + seams. +6 api tests (nested deep find/has, top variants miss nested, + edit-then-find round-trip). 778/778. + - 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