From d5beb619aa7ea2b8ec1e8331c25dba7aab259cb3 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:02:48 -0700 Subject: [PATCH] F4 SLICE-1: anchor prune counts proposal anchorIds (closes store.ts F4 TODO) (#12) Co-Authored-By: Claude Opus 4.8 --- src/store.ts | 5 +++-- test/proposalModel.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/store.ts b/src/store.ts index 53c9a21..e8c467a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -61,8 +61,8 @@ export class CoauthorStore { * Read-modify-write for sidecar co-ownership (spec F3 §6.2): each controller * mutates only its own section against a FRESH load, so ThreadController and * AttributionController never clobber each other. After the mutation, anchors - * referenced by neither threads nor attributions are pruned (proposals are - * still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the + * referenced by no thread, attribution, OR proposal are pruned (threads, + * attributions, and proposals all keep their anchors — F4). `mutate` MUST be synchronous: the * read-modify-write (and the self-write mark) completes within this call. */ update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { @@ -71,6 +71,7 @@ export class CoauthorStore { const referenced = new Set([ ...artifact.threads.map((t) => t.anchorId), ...artifact.attributions.map((a) => a.anchorId), + ...artifact.proposals.map((p) => p.anchorId), ]); for (const id of Object.keys(artifact.anchors)) { if (!referenced.has(id)) delete artifact.anchors[id]; diff --git a/test/proposalModel.test.ts b/test/proposalModel.test.ts index ad8d3a5..43eedbc 100644 --- a/test/proposalModel.test.ts +++ b/test/proposalModel.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { emptyArtifact, @@ -5,6 +8,7 @@ import { type Artifact, type Proposal, } from "../src/model"; +import { CoauthorStore } from "../src/store"; const agent = { kind: "agent" as const, @@ -44,3 +48,28 @@ describe("Proposal model (spec §6.3, INV-13)", () => { expect(serializeArtifact(again)).toBe(serializeArtifact(a)); }); }); + +describe("CoauthorStore prune with proposals (spec §6.3)", () => { + it("retains anchors referenced only by proposals; prunes them after removal", () => { + const dir = mkdtempSync(join(tmpdir(), "cowriting-prune-")); + try { + const store = new CoauthorStore(dir); + store.update("docs/x.md", (a) => { + a.anchors["a_p"] = { + fingerprint: { text: "t", before: "", after: "", lineHint: 0 }, + }; + a.proposals.push({ + id: "pr_1", anchorId: "a_p", replacement: "r", author: agent, + createdAt: "2026-06-10T00:00:00.000Z", + }); + }); + expect(store.load("docs/x.md")!.anchors["a_p"]).toBeDefined(); + store.update("docs/x.md", (a) => { + a.proposals = a.proposals.filter((p) => p.id !== "pr_1"); + }); + expect(store.load("docs/x.md")!.anchors["a_p"]).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});