From dd2f2115bd081b5c68d84a584585404a83033ed1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:45:11 +0200 Subject: [PATCH] Implement CE-WP-0009: wire umbrella to @citation-evidence/engine Add link: dependency on citation-engine, retarget @shared/@engine aliases, remove in-repo shared/engine copies. ADR-0002 accepted (option B). 172 tests, typecheck, and lint pass. --- README.md | 15 +- .../ADR-0002-monorepo-vs-polyrepo.md | 40 +- docs/mvp-workplans-index.md | 4 +- eslint.config.js | 21 +- package.json | 1 + pnpm-lock.yaml | 3 + src/anchor/scroll-job.test.ts | 6 +- src/app/App.tsx | 12 +- src/app/forms/capture-persistence.test.ts | 11 +- src/binder/FormRenderer.tsx | 6 +- src/engine/README.md | 7 - src/engine/engine.test.ts | 168 -------- src/engine/events/bus.test.ts | 64 ---- src/engine/events/bus.ts | 79 ---- src/engine/events/index.ts | 8 - src/engine/events/types.ts | 159 -------- src/engine/index.ts | 62 --- src/engine/persistence.test.ts | 209 ---------- src/engine/persistence.ts | 145 ------- src/engine/rendering/html.test.ts | 172 --------- src/engine/rendering/html.ts | 92 ----- src/engine/rendering/index.ts | 8 - src/engine/rendering/markdown.test.ts | 194 ---------- src/engine/rendering/markdown.ts | 68 ---- src/engine/repos/in-memory-sessions.ts | 47 --- src/engine/repos/in-memory.ts | 166 -------- src/engine/repos/index.ts | 12 - src/engine/services/annotations.ts | 129 ------- src/engine/services/documents.ts | 74 ---- src/engine/services/evidence.ts | 142 ------- src/engine/services/index.ts | 29 -- src/engine/services/sessions.test.ts | 250 ------------ src/engine/services/sessions.ts | 362 ------------------ src/engine/session-snapshot.test.ts | 98 ----- src/shared/README.md | 8 - src/shared/annotation.ts | 45 --- src/shared/citation-card-source.test.ts | 75 ---- src/shared/citation-card-source.ts | 26 -- src/shared/citation-card.ts | 35 -- src/shared/document.ts | 91 ----- src/shared/evidence-link.test.ts | 62 --- src/shared/evidence-link.ts | 107 ------ src/shared/evidence-set.ts | 36 -- src/shared/evidence.ts | 37 -- src/shared/ids.test.ts | 21 - src/shared/ids.ts | 58 --- src/shared/index.ts | 13 - src/shared/open-context-url.test.ts | 32 -- src/shared/open-context-url.ts | 36 -- src/shared/selector.ts | 79 ---- src/shared/session-archive.test.ts | 88 ----- src/shared/session-archive.ts | 150 -------- src/shared/session.ts | 26 -- src/shared/text/normalize.test.ts | 56 --- src/shared/text/normalize.ts | 49 --- .../capture-session-persist.dom.test.tsx | 11 +- .../forms-field-values.dom.test.tsx | 11 +- tsconfig.json | 4 +- vite.config.ts | 6 +- .../CE-WP-0009-engine-workspace-wireup.md | 10 +- 60 files changed, 93 insertions(+), 3942 deletions(-) delete mode 100644 src/engine/README.md delete mode 100644 src/engine/engine.test.ts delete mode 100644 src/engine/events/bus.test.ts delete mode 100644 src/engine/events/bus.ts delete mode 100644 src/engine/events/index.ts delete mode 100644 src/engine/events/types.ts delete mode 100644 src/engine/index.ts delete mode 100644 src/engine/persistence.test.ts delete mode 100644 src/engine/persistence.ts delete mode 100644 src/engine/rendering/html.test.ts delete mode 100644 src/engine/rendering/html.ts delete mode 100644 src/engine/rendering/index.ts delete mode 100644 src/engine/rendering/markdown.test.ts delete mode 100644 src/engine/rendering/markdown.ts delete mode 100644 src/engine/repos/in-memory-sessions.ts delete mode 100644 src/engine/repos/in-memory.ts delete mode 100644 src/engine/repos/index.ts delete mode 100644 src/engine/services/annotations.ts delete mode 100644 src/engine/services/documents.ts delete mode 100644 src/engine/services/evidence.ts delete mode 100644 src/engine/services/index.ts delete mode 100644 src/engine/services/sessions.test.ts delete mode 100644 src/engine/services/sessions.ts delete mode 100644 src/engine/session-snapshot.test.ts delete mode 100644 src/shared/README.md delete mode 100644 src/shared/annotation.ts delete mode 100644 src/shared/citation-card-source.test.ts delete mode 100644 src/shared/citation-card-source.ts delete mode 100644 src/shared/citation-card.ts delete mode 100644 src/shared/document.ts delete mode 100644 src/shared/evidence-link.test.ts delete mode 100644 src/shared/evidence-link.ts delete mode 100644 src/shared/evidence-set.ts delete mode 100644 src/shared/evidence.ts delete mode 100644 src/shared/ids.test.ts delete mode 100644 src/shared/ids.ts delete mode 100644 src/shared/index.ts delete mode 100644 src/shared/open-context-url.test.ts delete mode 100644 src/shared/open-context-url.ts delete mode 100644 src/shared/selector.ts delete mode 100644 src/shared/session-archive.test.ts delete mode 100644 src/shared/session-archive.ts delete mode 100644 src/shared/session.ts delete mode 100644 src/shared/text/normalize.test.ts delete mode 100644 src/shared/text/normalize.ts diff --git a/README.md b/README.md index a554429..5bc2eb9 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A document-centered evidence workspace for capturing, managing, presenting, and re-opening citations. The umbrella over the six-package design described in `INTENT.md` and `wiki/ArchitectureOverview.md`. -During the MVP all code lives here under `src/` (see "Repository layout" -below). Sister repos hold INTENT only — code migrates outward when each -subsystem stabilises. +Shared types and engine services live in the extracted +[`@citation-evidence/engine`](../citation-engine/) package (`link:../citation-engine`). +Remaining partitions stay under `src/` until each subsystem extracts. ## Documentation @@ -24,10 +24,10 @@ Both are referenced from every workplan and from each sister repo's INTENT.md. ## Repository layout +Requires sibling checkout: `../citation-engine` (see `package.json` `link:` dep). + ``` src/ - shared/ # vocabulary, types, pure helpers → becomes part of citation-engine - engine/ # services, repositories, event bus → becomes part of citation-engine anchor/ # selector creation/resolution, viewer adapter contract → becomes evidence-anchor source/ # ingest, fingerprint, extraction, recovery → becomes evidence-source binder/ # evidence-to-target binding, visual guide → becomes evidence-binder @@ -41,9 +41,9 @@ repo is intended to be a `git mv` plus a `package.json` cut — nothing more. ## Sister repos -Peers under `~/`; each holds INTENT.md only during MVP: +Peers under `~/`: -- [`~/citation-engine`](../citation-engine/) — shared model + engine services +- [`~/citation-engine`](../citation-engine/) — **extracted** shared model + engine (`@citation-evidence/engine`) - [`~/evidence-anchor`](../evidence-anchor/) — selectors + adapter contract - [`~/evidence-source`](../evidence-source/) — ingest, representation, recovery - [`~/evidence-binder`](../evidence-binder/) — binding, visual guide, rect registry @@ -54,6 +54,7 @@ Peers under `~/`; each holds INTENT.md only during MVP: Requirements: Node 20 LTS (see `.nvmrc`) and `pnpm` 9. ```bash +# citation-engine must be checked out next to this repo (../citation-engine) pnpm install pnpm dev # vite dev server (once src/app/ has a real entry) pnpm test # vitest one-shot diff --git a/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md b/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md index 8de60e3..5c208e8 100644 --- a/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md +++ b/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md @@ -1,16 +1,19 @@ # ADR-0002 — Monorepo vs polyrepo for the six subsystems -- Status: proposed +- Status: accepted - Date: 2026-05-24 -- Workplan: CE-WP-0001-T07 (stub) +- Decided: 2026-06-22 +- Workplan: CENG-WP-0002-T01 ## Context The umbrella-first MVP lives entirely in `citation-evidence/` under -`src/{shared,engine,anchor,source,binder,work,app}/`. Each folder is named -after its eventual extracted package. At some point — driven by an external -consumer needing one subsystem, or by independent release cadence — code -will move out into its sister repo. +`src/{anchor,source,binder,work,app}/` with shared types and engine services +in the extracted `@citation-evidence/engine` package (`citation-engine` repo). + +Each remaining folder is named after its eventual extracted package. At some +point — driven by an external consumer needing one subsystem, or by independent +release cadence — code will move out into its sister repo. We need a written answer to: when that moment comes, do we (a) keep one repository with pnpm workspaces, (b) split into six independent repos with @@ -43,8 +46,29 @@ across the boundary. ## Decision -(blank — to be answered before the first subsystem extraction lands.) +**B — six independent repos with published packages**, using **`link:` sibling +dependencies during local development** until a registry is configured. + +Rationale: + +1. The ecosystem is already organized as six sister repos plus the umbrella; + independent repos match the documented architecture. +2. `citation-engine` extraction (`CENG-WP-0001`) and umbrella wireup + (`CE-WP-0009`) prove the `link:../citation-engine` dev workflow. +3. Publishing can be deferred — no registry is configured yet — without + blocking extraction of the remaining subsystems. +4. Option C adds tooling overhead before any external consumer exists. ## Consequences -(blank) +- **Local dev:** sister repos sit as siblings under `~/` (or equivalent). + Consumers declare `"@citation-evidence/engine": "link:../citation-engine"`. +- **Publishing:** when a registry is chosen, bump `@citation-evidence/engine` + semver and replace `link:` with the registry version in consumer repos. +- **Contracts:** `citation-evidence/wiki/SharedContracts.md` stays authoritative; + `citation-engine/wiki/SharedContracts.md` is a conformance copy (see + `citation-engine/wiki/README.md`). +- **Versioning:** engine package semver tracks API/contract changes; umbrella + and sister repos pin or range-pin on publish. +- **CI:** each repo runs its own test/lint pipeline; cross-repo integration + tests remain in `citation-evidence` until subsystems extract fully. \ No newline at end of file diff --git a/docs/mvp-workplans-index.md b/docs/mvp-workplans-index.md index 9f04f26..0046627 100644 --- a/docs/mvp-workplans-index.md +++ b/docs/mvp-workplans-index.md @@ -21,8 +21,8 @@ CE-WP-0008 fixes capture field value persistence and viewport scroll reliability | Workplan | Repo | Title | Status | |----------|------|-------|--------| | `CENG-WP-0001` | citation-engine | Extract engine from umbrella | done | -| `CE-WP-0009` | citation-evidence | Wire umbrella to `@citation-evidence/engine` | active | -| `CENG-WP-0002` | citation-engine | Package distribution (ADR-0002, publish prep) | active | +| `CE-WP-0009` | citation-evidence | Wire umbrella to `@citation-evidence/engine` | done | +| `CENG-WP-0002` | citation-engine | Package distribution (ADR-0002, publish prep) | done | `CE-WP-0009` depends on `CENG-WP-0001`. `CENG-WP-0002` can run in parallel; publish tasks wait on ADR-0002 resolution. diff --git a/eslint.config.js b/eslint.config.js index f0b68a3..2aef68e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,23 +1,28 @@ // ESLint flat config (ESLint 9+). // Enforces the partition dependency map in wiki/DependencyMap.md §4. // +// shared/ and engine/ live in the linked @citation-evidence/engine package; +// boundary rules for those partitions are enforced in citation-engine. +// // Element types (folders) and allowed importers: -// shared : importable by every other element (no internal imports of its own). -// engine : imports shared. +// shared : importable by every other element (package: citation-engine). +// engine : imports shared (package: citation-engine). // anchor : imports shared, engine. // source : imports shared, engine. // binder : imports shared, engine, anchor. // work : imports shared, engine, anchor, source. (NOT binder.) // app : imports anything. -// -// Path aliases (@shared/*, @engine/*, etc.) come from tsconfig.json paths and -// are resolved by eslint-import-resolver-typescript. import js from "@eslint/js"; import tseslint from "typescript-eslint"; import boundaries from "eslint-plugin-boundaries"; import importPlugin from "eslint-plugin-import"; import globals from "globals"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const engineSrc = resolve(__dirname, "../citation-engine/src"); export default tseslint.config( { @@ -41,8 +46,8 @@ export default tseslint.config( typescript: { project: "./tsconfig.json" }, }, "boundaries/elements": [ - { type: "shared", pattern: "src/shared/**" }, - { type: "engine", pattern: "src/engine/**" }, + { type: "shared", pattern: `${engineSrc}/shared/**` }, + { type: "engine", pattern: `${engineSrc}/engine/**` }, { type: "anchor", pattern: "src/anchor/**" }, { type: "source", pattern: "src/source/**" }, { type: "binder", pattern: "src/binder/**" }, @@ -68,4 +73,4 @@ export default tseslint.config( ], }, }, -); +); \ No newline at end of file diff --git a/package.json b/package.json index fbfcd3e..405be48 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@citation-evidence/engine": "link:../citation-engine", "jszip": "^3.10.1", "pdfjs-dist": "^4.4.168", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ec548d..4460989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@citation-evidence/engine': + specifier: link:../citation-engine + version: link:../citation-engine jszip: specifier: ^3.10.1 version: 3.10.1 diff --git a/src/anchor/scroll-job.test.ts b/src/anchor/scroll-job.test.ts index 74842b3..5e9c0bb 100644 --- a/src/anchor/scroll-job.test.ts +++ b/src/anchor/scroll-job.test.ts @@ -31,7 +31,7 @@ describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => { const scrollToHighlight = vi.fn(); const centerHighlight = vi.fn(); let utils: PdfHighlighterUtils | null = null; - let highlight: Highlight | undefined; + const highlightRef: { current: Highlight | undefined } = { current: undefined }; const state = { lastCompletedKey: null as string | null }; @@ -39,7 +39,7 @@ describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => { { requestKey: "ann_test:1", annotationId: "ann_test" }, { getUtils: () => utils, - findHighlight: (id) => (id === "ann_test" ? highlight : undefined), + findHighlight: (id) => (id === "ann_test" ? highlightRef.current : undefined), scrollToHighlight: (_u, target) => scrollToHighlight(target), centerHighlight, scheduleFrame: (fn) => { @@ -59,7 +59,7 @@ describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => { expect(scrollToHighlight).not.toHaveBeenCalled(); utils = { scrollToHighlight: vi.fn() } as unknown as PdfHighlighterUtils; - highlight = TARGET; + highlightRef.current = TARGET; frames.shift()?.(); expect(scrollToHighlight).toHaveBeenCalledWith(TARGET); diff --git a/src/app/App.tsx b/src/app/App.tsx index b6d54db..0873466 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -172,14 +172,20 @@ function SessionScopedTree({ mode }: { mode: AppMode }) { return ( {mode === "forms" ? ( ) : ( } /> diff --git a/src/app/forms/capture-persistence.test.ts b/src/app/forms/capture-persistence.test.ts index e9ab808..b56257b 100644 --- a/src/app/forms/capture-persistence.test.ts +++ b/src/app/forms/capture-persistence.test.ts @@ -4,7 +4,8 @@ import { describe, expect, it } from "vitest"; -import type { SessionId } from "@shared/ids"; +import type { EvidenceLink } from "@shared/evidence-link"; +import type { EvidenceItemId, EvidenceLinkId, SessionId } from "@shared/ids"; import { CAPTURE_STATE_VERSION, @@ -47,17 +48,17 @@ describe("capture-persistence", () => { fieldValues: { summary: "Tenant owes arrears", deadline: "2026-12-15" }, evidenceLinks: [ { - id: "evlink_1", - evidenceItemId: "evi_1", + id: "evlink_1" as EvidenceLinkId, + evidenceItemId: "evi_1" as EvidenceItemId, targetType: "form-field", targetId: "summary", relation: "supports", status: "candidate", createdAt: "2026-06-08T00:00:00.000Z", updatedAt: "2026-06-08T00:00:00.000Z", - }, + } satisfies EvidenceLink, ], - } as const; + }; saveCaptureState(SESSION, withData, storage); const loaded = loadCaptureState(SESSION, storage); diff --git a/src/binder/FormRenderer.tsx b/src/binder/FormRenderer.tsx index 920045a..b4b561a 100644 --- a/src/binder/FormRenderer.tsx +++ b/src/binder/FormRenderer.tsx @@ -9,7 +9,7 @@ * CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm. */ -import { useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode } from "react"; +import { useRef, useState, type ChangeEvent, type CSSProperties } from "react"; import type { EvidenceTarget } from "@shared/evidence-link"; @@ -298,7 +298,9 @@ export function FormRenderer({ field={field} value={values?.[field.id] ?? ""} linkCount={linkCounts?.[field.id] ?? 0} - linkHint={linkHints?.[field.id]} + {...(linkHints?.[field.id] != null + ? { linkHint: linkHints[field.id] } + : {})} isActive={isFieldActive(state, field.id)} isEditing={editingFieldId === field.id} editLabel={editLabel} diff --git a/src/engine/README.md b/src/engine/README.md deleted file mode 100644 index d64e23c..0000000 --- a/src/engine/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# `src/engine/` — services, repositories, event bus - -Future home: `citation-engine` (the services half). -Owns: repositories for `Document`/`Annotation`/`EvidenceItem`/`EvidenceLink`, -ID generation orchestration, the event bus, and pure orchestration services. - -May import from: `shared/` only (`wiki/DependencyMap.md` §4). diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts deleted file mode 100644 index 30745d2..0000000 --- a/src/engine/engine.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import type { Document, DocumentRepresentation } from "@shared/document"; -import type { DocumentId, RepresentationId } from "@shared/ids"; -import type { Selector } from "@shared/selector"; -import { createEngine, type Engine, type EngineEvent } from "./index"; - -function fakeDocAndRep(): { document: Document; representation: DocumentRepresentation } { - const docId = "doc_fake" as DocumentId; - const repId = "rep_fake" as RepresentationId; - return { - document: { - id: docId, - mediaType: "application/pdf", - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", - }, - representation: { - id: repId, - documentId: docId, - representationType: "pdf-text", - contentHash: "h", - canonicalText: "The quick brown fox.", - pageMap: [{ page: 1, width: 100, height: 100 }], - offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }], - generatedAt: "2026-05-25T00:00:00.000Z", - }, - }; -} - -describe("Engine integration", () => { - let engine: Engine; - let events: EngineEvent[]; - - beforeEach(() => { - engine = createEngine(); - events = []; - engine.bus.onAny((e) => events.push(e)); - }); - - it("documentService.register stores both and emits DocumentImported + DocumentRepresentationGenerated", () => { - const { document, representation } = fakeDocAndRep(); - const result = engine.documents.register({ document, representation }); - expect(result.document).toBe(document); - expect(result.representation).toBe(representation); - expect(engine.documents.get(document.id)).toBe(document); - expect(engine.documents.getRepresentation(representation.id)).toBe(representation); - expect(events.map((e) => e.type)).toEqual(["DocumentImported", "DocumentRepresentationGenerated"]); - }); - - it("annotationService.create stamps an ID + normalize version + timestamps, then emits AnnotationCreated", () => { - const { document, representation } = fakeDocAndRep(); - engine.documents.register({ document, representation }); - const selectors: Selector[] = [{ type: "TextQuoteSelector", exact: "brown fox" }]; - const ann = engine.annotations.create({ - documentId: document.id, - representationId: representation.id, - selectors, - quote: "brown fox", - note: "a quick mark", - }); - expect(ann.id).toMatch(/^ann_/); - expect(ann.normalizeVersion).toBeGreaterThan(0); - expect(ann.createdAt).toBe(ann.updatedAt); - expect(engine.annotations.get(ann.id)).toBe(ann); - const created = events.find((e) => e.type === "AnnotationCreated"); - expect(created?.type).toBe("AnnotationCreated"); - }); - - it("setResolutionStatus emits AnnotationResolved for resolved/ambiguous and AnnotationResolutionFailed for unresolved/stale", () => { - const { document, representation } = fakeDocAndRep(); - engine.documents.register({ document, representation }); - const ann = engine.annotations.create({ - documentId: document.id, - representationId: representation.id, - selectors: [{ type: "TextQuoteSelector", exact: "x" }], - }); - events.length = 0; - engine.annotations.setResolutionStatus(ann.id, "resolved", { confidence: 0.95 }); - expect(events.map((e) => e.type)).toEqual(["AnnotationResolved"]); - engine.annotations.setResolutionStatus(ann.id, "unresolved", { confidence: 0, reason: "no quote match" }); - expect(events.map((e) => e.type)).toEqual(["AnnotationResolved", "AnnotationResolutionFailed"]); - }); - - it("evidenceService.create requires at least one annotation and emits EvidenceItemCreated", () => { - const { document, representation } = fakeDocAndRep(); - engine.documents.register({ document, representation }); - const ann = engine.annotations.create({ - documentId: document.id, - representationId: representation.id, - selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }], - }); - expect(() => engine.evidence.create({ annotationIds: [] })).toThrow(); - const item = engine.evidence.create({ - annotationIds: [ann.id], - commentary: "good quote", - }); - expect(item.status).toBe("candidate"); - expect(item.annotationIds).toEqual([ann.id]); - expect(events.find((e) => e.type === "EvidenceItemCreated")).toBeDefined(); - }); - - it("setStatus emits EvidenceItemUpdated only on real change and carries previousStatus", () => { - const { document, representation } = fakeDocAndRep(); - engine.documents.register({ document, representation }); - const ann = engine.annotations.create({ - documentId: document.id, - representationId: representation.id, - selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }], - }); - const item = engine.evidence.create({ annotationIds: [ann.id] }); - events.length = 0; - const same = engine.evidence.setStatus(item.id, "candidate"); - expect(same).toBe(item); - expect(events).toEqual([]); - engine.evidence.setStatus(item.id, "confirmed"); - const updated = events.find((e) => e.type === "EvidenceItemUpdated"); - expect(updated).toBeDefined(); - if (updated?.type === "EvidenceItemUpdated") { - expect(updated.previousStatus).toBe("candidate"); - } - }); - - it("listByDocument scopes evidence items to a single document via annotation lookup", () => { - const a = fakeDocAndRep(); - engine.documents.register(a); - const annA = engine.annotations.create({ - documentId: a.document.id, - representationId: a.representation.id, - selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }], - }); - engine.evidence.create({ annotationIds: [annA.id], commentary: "a" }); - - // Second, distinct document. - const otherDocId = "doc_other" as DocumentId; - const otherRepId = "rep_other" as RepresentationId; - engine.documents.register({ - document: { ...a.document, id: otherDocId }, - representation: { ...a.representation, id: otherRepId, documentId: otherDocId }, - }); - const annB = engine.annotations.create({ - documentId: otherDocId, - representationId: otherRepId, - selectors: [{ type: "TextQuoteSelector", exact: "z" }], - }); - engine.evidence.create({ annotationIds: [annB.id], commentary: "b" }); - - expect(engine.evidence.listByDocument(a.document.id)).toHaveLength(1); - expect(engine.evidence.listByDocument(otherDocId)).toHaveLength(1); - }); - - it("activate emits EvidenceItemActivated without mutating the item", () => { - const { document, representation } = fakeDocAndRep(); - engine.documents.register({ document, representation }); - const ann = engine.annotations.create({ - documentId: document.id, - representationId: representation.id, - selectors: [{ type: "TextQuoteSelector", exact: "x" }], - }); - const item = engine.evidence.create({ annotationIds: [ann.id] }); - events.length = 0; - engine.evidence.activate(item.id, "sidebar"); - const activated = events.find((e) => e.type === "EvidenceItemActivated"); - expect(activated).toBeDefined(); - if (activated?.type === "EvidenceItemActivated") { - expect(activated.source).toBe("sidebar"); - } - }); -}); diff --git a/src/engine/events/bus.test.ts b/src/engine/events/bus.test.ts deleted file mode 100644 index e0ce4c3..0000000 --- a/src/engine/events/bus.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { DocumentId } from "@shared/ids"; -import { createEventBus } from "./bus"; - -const docId = "doc_test" as DocumentId; -const minimalDoc = { - id: docId, - mediaType: "application/pdf", - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", -}; - -describe("EventBus", () => { - it("delivers typed events to the registered listener", () => { - const bus = createEventBus(); - const spy = vi.fn(); - bus.on("DocumentImported", spy); - const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc }); - expect(spy).toHaveBeenCalledOnce(); - expect(spy.mock.calls[0]![0]).toMatchObject({ type: "DocumentImported", documentId: docId }); - expect(result.listenerCount).toBe(1); - expect(result.errors).toEqual([]); - }); - - it("does not deliver an event to listeners of a different type", () => { - const bus = createEventBus(); - const spy = vi.fn(); - bus.on("AnnotationCreated", spy); - bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc }); - expect(spy).not.toHaveBeenCalled(); - }); - - it("delivers every event to onAny listeners", () => { - const bus = createEventBus(); - const spy = vi.fn(); - bus.onAny(spy); - bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc }); - bus.emit({ type: "EvidenceItemActivated", evidenceItemId: "ev_x" as never }); - expect(spy).toHaveBeenCalledTimes(2); - }); - - it("returns an unsubscribe function from on()", () => { - const bus = createEventBus(); - const spy = vi.fn(); - const off = bus.on("DocumentImported", spy); - off(); - bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc }); - expect(spy).not.toHaveBeenCalled(); - }); - - it("captures listener errors and still calls subsequent listeners", () => { - const bus = createEventBus(); - const boom = new Error("listener exploded"); - const a = vi.fn(() => { throw boom; }); - const b = vi.fn(); - bus.on("DocumentImported", a); - bus.on("DocumentImported", b); - const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc }); - expect(a).toHaveBeenCalledOnce(); - expect(b).toHaveBeenCalledOnce(); - expect(result.errors).toEqual([boom]); - expect(result.listenerCount).toBe(2); - }); -}); diff --git a/src/engine/events/bus.ts b/src/engine/events/bus.ts deleted file mode 100644 index 0d844f3..0000000 --- a/src/engine/events/bus.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Synchronous in-process event bus. - * - * Listeners fire in registration order on the calling stack; `emit` returns - * after every listener has run. A listener throwing does not stop later - * listeners — its error surfaces through the returned `errors` array so - * callers can decide whether to log, rethrow, or ignore. - * - * MVP-sufficient. ADR-0005 (persistence) will decide whether to upgrade to - * an async/queued bus when storage becomes durable. - */ - -import type { EngineEvent, EngineEventOf, EngineEventType } from "./types"; - -export type EngineEventListener = ( - event: EngineEventOf, -) => void; - -export type AnyEngineEventListener = (event: EngineEvent) => void; - -export interface EmitResult { - readonly listenerCount: number; - readonly errors: readonly unknown[]; -} - -export interface EventBus { - on(type: T, listener: EngineEventListener): () => void; - onAny(listener: AnyEngineEventListener): () => void; - emit(event: EngineEventOf): EmitResult; -} - -export function createEventBus(): EventBus { - const typedListeners = new Map>(); - const anyListeners = new Set(); - - return { - on(type, listener) { - let set = typedListeners.get(type); - if (!set) { - set = new Set(); - typedListeners.set(type, set); - } - set.add(listener as unknown as EngineEventListener); - return () => { - set!.delete(listener as unknown as EngineEventListener); - }; - }, - onAny(listener) { - anyListeners.add(listener); - return () => { - anyListeners.delete(listener); - }; - }, - emit(event) { - const errors: unknown[] = []; - let count = 0; - const typedSet = typedListeners.get(event.type); - if (typedSet) { - for (const l of typedSet) { - count++; - try { - (l as AnyEngineEventListener)(event); - } catch (err) { - errors.push(err); - } - } - } - for (const l of anyListeners) { - count++; - try { - l(event); - } catch (err) { - errors.push(err); - } - } - return { listenerCount: count, errors }; - }, - }; -} diff --git a/src/engine/events/index.ts b/src/engine/events/index.ts deleted file mode 100644 index daf22cc..0000000 --- a/src/engine/events/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./types"; -export { - createEventBus, - type EventBus, - type EngineEventListener, - type AnyEngineEventListener, - type EmitResult, -} from "./bus"; diff --git a/src/engine/events/types.ts b/src/engine/events/types.ts deleted file mode 100644 index dd4a4d2..0000000 --- a/src/engine/events/types.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Engine event vocabulary. - * - * Implements `wiki/SharedContracts.md` §4 (closed event list). Each event - * carries the *minimum* identifying payload needed by downstream listeners; - * services hand back the full domain object to the caller separately. - * - * Adding an event requires updating SharedContracts.md first. - */ - -import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation"; -import type { Document, DocumentRepresentation } from "@shared/document"; -import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence"; -import type { - EvidenceLink, - EvidenceTarget, -} from "@shared/evidence-link"; -import type { - AnnotationId, - DocumentId, - EvidenceItemId, - EvidenceLinkId, - RepresentationId, - SessionId, -} from "@shared/ids"; -import type { Session } from "@shared/session"; - -export interface DocumentImportedEvent { - readonly type: "DocumentImported"; - readonly documentId: DocumentId; - readonly document: Document; -} - -export interface DocumentRepresentationGeneratedEvent { - readonly type: "DocumentRepresentationGenerated"; - readonly documentId: DocumentId; - readonly representationId: RepresentationId; - readonly representation: DocumentRepresentation; -} - -export interface DocumentRemovedEvent { - readonly type: "DocumentRemoved"; - readonly documentId: DocumentId; -} - -export interface AnnotationCreatedEvent { - readonly type: "AnnotationCreated"; - readonly annotationId: AnnotationId; - readonly annotation: Annotation; -} - -export interface AnnotationResolvedEvent { - readonly type: "AnnotationResolved"; - readonly annotationId: AnnotationId; - readonly status: AnnotationResolutionStatus; - readonly confidence: number; -} - -export interface AnnotationResolutionFailedEvent { - readonly type: "AnnotationResolutionFailed"; - readonly annotationId: AnnotationId; - readonly reason: string; -} - -export interface AnnotationUpdatedEvent { - readonly type: "AnnotationUpdated"; - readonly annotationId: AnnotationId; - readonly annotation: Annotation; -} - -export interface EvidenceItemCreatedEvent { - readonly type: "EvidenceItemCreated"; - readonly evidenceItemId: EvidenceItemId; - readonly evidenceItem: EvidenceItem; -} - -export interface EvidenceItemUpdatedEvent { - readonly type: "EvidenceItemUpdated"; - readonly evidenceItemId: EvidenceItemId; - readonly evidenceItem: EvidenceItem; - readonly previousStatus: EvidenceItemStatus; -} - -export interface EvidenceItemActivatedEvent { - readonly type: "EvidenceItemActivated"; - readonly evidenceItemId: EvidenceItemId; - readonly source?: "sidebar" | "form-field" | "citation-card"; -} - -export interface EvidenceLinkCreatedEvent { - readonly type: "EvidenceLinkCreated"; - readonly linkId: EvidenceLinkId; - readonly link: EvidenceLink; -} - -export interface EvidenceLinkUpdatedEvent { - readonly type: "EvidenceLinkUpdated"; - readonly linkId: EvidenceLinkId; - readonly link: EvidenceLink; -} - -export interface EvidenceLinkRemovedEvent { - readonly type: "EvidenceLinkRemoved"; - readonly linkId: EvidenceLinkId; -} - -export interface FormFieldActivatedEvent { - readonly type: "FormFieldActivated"; - readonly target: EvidenceTarget; - readonly previousTarget?: EvidenceTarget; -} - -export interface SessionCreatedEvent { - readonly type: "SessionCreated"; - readonly sessionId: SessionId; - readonly session: Session; -} - -export interface SessionRenamedEvent { - readonly type: "SessionRenamed"; - readonly sessionId: SessionId; - readonly session: Session; - readonly previousName: string; -} - -export interface SessionDeletedEvent { - readonly type: "SessionDeleted"; - readonly sessionId: SessionId; -} - -export interface SessionActivatedEvent { - readonly type: "SessionActivated"; - readonly sessionId: SessionId | null; - readonly previousSessionId: SessionId | null; -} - -export type EngineEvent = - | DocumentImportedEvent - | DocumentRepresentationGeneratedEvent - | DocumentRemovedEvent - | AnnotationCreatedEvent - | AnnotationUpdatedEvent - | AnnotationResolvedEvent - | AnnotationResolutionFailedEvent - | EvidenceItemCreatedEvent - | EvidenceItemUpdatedEvent - | EvidenceItemActivatedEvent - | EvidenceLinkCreatedEvent - | EvidenceLinkUpdatedEvent - | EvidenceLinkRemovedEvent - | FormFieldActivatedEvent - | SessionCreatedEvent - | SessionRenamedEvent - | SessionDeletedEvent - | SessionActivatedEvent; - -export type EngineEventType = EngineEvent["type"]; - -export type EngineEventOf = Extract; diff --git a/src/engine/index.ts b/src/engine/index.ts deleted file mode 100644 index 9240954..0000000 --- a/src/engine/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Engine composition root. - * - * `createEngine()` wires in-memory repos to the services and shares a single - * event bus. The app layer holds the returned `Engine` instance and passes - * its services into the UI. - * - * Swapping the repository implementation later (ADR-0005) is a matter of - * replacing `createInMemoryRepos()` here. The service signatures don't - * change. - */ - -import { createEventBus, type EventBus } from "./events"; -import { - createInMemoryRepos, - type InMemoryRepos, -} from "./repos"; -import { - createAnnotationService, - createDocumentService, - createEvidenceService, - type AnnotationService, - type DocumentService, - type EvidenceService, -} from "./services"; - -export * from "./events"; -export * from "./repos"; -export * from "./services"; -export * from "./rendering"; -export { - SNAPSHOT_VERSION, - attachPersister, - captureSnapshot, - documentIdsIn, - restoreFromStorage, - restoreSnapshot, - sanitizeDocumentForPersistence, - type EngineSnapshot, - type PersisterOptions, -} from "./persistence"; - -export interface Engine { - readonly bus: EventBus; - readonly repos: InMemoryRepos; - readonly documents: DocumentService; - readonly annotations: AnnotationService; - readonly evidence: EvidenceService; -} - -export function createEngine(): Engine { - const bus = createEventBus(); - const repos = createInMemoryRepos(); - const documents = createDocumentService(repos.documents, repos.representations, bus); - const annotations = createAnnotationService(repos.annotations, bus); - const evidence = createEvidenceService( - repos.evidenceItems, - (id) => repos.annotations.get(id), - bus, - ); - return { bus, repos, documents, annotations, evidence }; -} diff --git a/src/engine/persistence.test.ts b/src/engine/persistence.test.ts deleted file mode 100644 index 874ef83..0000000 --- a/src/engine/persistence.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Document, DocumentRepresentation } from "@shared/document"; -import type { DocumentId, RepresentationId } from "@shared/ids"; -import { - attachPersister, - captureSnapshot, - createEngine, - restoreFromStorage, - restoreSnapshot, - sanitizeDocumentForPersistence, - type Engine, - type EngineEvent, - type EngineSnapshot, -} from "./index"; - -function fakeDocAndRep(suffix: string): { - document: Document; - representation: DocumentRepresentation; -} { - const docId = `doc_${suffix}` as DocumentId; - const repId = `rep_${suffix}` as RepresentationId; - return { - document: { - id: docId, - mediaType: "application/pdf", - title: `Doc ${suffix}`, - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", - }, - representation: { - id: repId, - documentId: docId, - representationType: "pdf-text", - contentHash: `hash-${suffix}`, - canonicalText: "The quick brown fox.", - pageMap: [{ page: 1, width: 100, height: 100 }], - offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }], - generatedAt: "2026-05-25T00:00:00.000Z", - }, - }; -} - -function memoryStorage(): Pick { - const map = new Map(); - return { - getItem: (k) => map.get(k) ?? null, - setItem: (k, v) => void map.set(k, v), - removeItem: (k) => void map.delete(k), - }; -} - -function seed(engine: Engine, suffix: string) { - const { document, representation } = fakeDocAndRep(suffix); - engine.documents.register({ document, representation }); - const ann = engine.annotations.create({ - documentId: document.id, - representationId: representation.id, - selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }], - quote: "brown fox", - }); - const item = engine.evidence.create({ - annotationIds: [ann.id], - commentary: `commentary-${suffix}`, - }); - return { document, representation, ann, item }; -} - -describe("captureSnapshot + restoreSnapshot", () => { - it("round-trips documents, representations, annotations and evidence items", () => { - const src = createEngine(); - const a = seed(src, "a"); - const b = seed(src, "b"); - const snap = captureSnapshot(src); - expect(snap.documents).toHaveLength(2); - expect(snap.representations).toHaveLength(2); - expect(snap.annotations).toHaveLength(2); - expect(snap.evidenceItems).toHaveLength(2); - - const dst = createEngine(); - restoreSnapshot(dst, snap); - expect(dst.documents.get(a.document.id)?.title).toBe("Doc a"); - expect(dst.documents.get(b.document.id)?.title).toBe("Doc b"); - expect(dst.annotations.get(a.ann.id)?.quote).toBe("brown fox"); - expect(dst.evidence.get(a.item.id)?.commentary).toBe("commentary-a"); - }); - - it("restoreSnapshot does NOT emit *Created events (events would loop the persister)", () => { - const src = createEngine(); - seed(src, "x"); - const snap = captureSnapshot(src); - - const dst = createEngine(); - const seen: EngineEvent["type"][] = []; - dst.bus.onAny((e) => seen.push(e.type)); - restoreSnapshot(dst, snap); - expect(seen).toEqual([]); - }); - - it("rejects a snapshot with a mismatching version", () => { - const dst = createEngine(); - expect(() => - restoreSnapshot(dst, { - version: 999, - documents: [], - representations: [], - annotations: [], - evidenceItems: [], - } as EngineSnapshot), - ).toThrow(/version/); - }); -}); - -describe("attachPersister", () => { - let storage: ReturnType; - let engine: Engine; - const KEY = "ce-test-snap"; - - beforeEach(() => { - storage = memoryStorage(); - engine = createEngine(); - }); - - it("writes a snapshot to storage on every mutating event", () => { - const off = attachPersister(engine, { key: KEY, storage }); - expect(storage.getItem(KEY)).toBeNull(); - seed(engine, "z"); - const raw = storage.getItem(KEY); - expect(raw).not.toBeNull(); - const snap = JSON.parse(raw!) as EngineSnapshot; - expect(snap.documents).toHaveLength(1); - expect(snap.evidenceItems).toHaveLength(1); - off(); - }); - - it("stops writing after the unsubscribe is called", () => { - const off = attachPersister(engine, { key: KEY, storage }); - seed(engine, "q"); - const after = storage.getItem(KEY); - off(); - seed(engine, "r"); - expect(storage.getItem(KEY)).toBe(after); - }); - - it("survives a JSON.stringify failure without throwing into the caller", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const failing = { ...memoryStorage(), setItem: () => { throw new Error("boom"); } }; - attachPersister(engine, { key: KEY, storage: failing }); - expect(() => seed(engine, "k")).not.toThrow(); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); - }); -}); - -describe("restoreFromStorage", () => { - it("returns {restored: false} when the key is empty", () => { - const storage = memoryStorage(); - const engine = createEngine(); - const result = restoreFromStorage(engine, { key: "missing", storage }); - expect(result.restored).toBe(false); - }); - - it("hydrates the engine when storage holds a valid snapshot", () => { - const src = createEngine(); - seed(src, "rs"); - const storage = memoryStorage(); - storage.setItem("snap", JSON.stringify(captureSnapshot(src))); - - const dst = createEngine(); - const result = restoreFromStorage(dst, { key: "snap", storage }); - expect(result.restored).toBe(true); - expect(dst.documents.list()).toHaveLength(1); - }); - - it("strips blob: URIs from persisted documents", () => { - const engine = createEngine(); - const docId = "doc_blob" as DocumentId; - engine.documents.register({ - document: { - id: docId, - mediaType: "application/pdf", - title: "upload.pdf", - uri: "blob:http://localhost/dead", - createdAt: "2026-06-07T00:00:00.000Z", - updatedAt: "2026-06-07T00:00:00.000Z", - }, - representation: fakeDocAndRep("blob").representation, - }); - const snap = captureSnapshot(engine); - expect(snap.documents[0]?.uri).toBeUndefined(); - expect(sanitizeDocumentForPersistence({ - id: docId, - mediaType: "application/pdf", - uri: "blob:x", - createdAt: "x", - updatedAt: "x", - }).uri).toBeUndefined(); - }); - - it("ignores malformed JSON without throwing", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const storage = memoryStorage(); - storage.setItem("snap", "not-json"); - const engine = createEngine(); - const result = restoreFromStorage(engine, { key: "snap", storage }); - expect(result.restored).toBe(false); - expect(warn).toHaveBeenCalled(); - warn.mockRestore(); - }); -}); diff --git a/src/engine/persistence.ts b/src/engine/persistence.ts deleted file mode 100644 index 1dcff98..0000000 --- a/src/engine/persistence.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Engine snapshot + restore. - * - * MVP "persistence" — capture the engine's in-memory state into a JSON blob - * and restore it later. Used by the SPA to survive page reloads via - * `localStorage` until ADR-0005 lands a real store. - * - * Restore deliberately bypasses the service layer: it writes directly to - * the repos so no `*Created` events fire. Without that, restoring would - * trigger the persister to re-write the same snapshot — and if the user - * has another tab open, it would also broadcast spurious "this annotation - * just appeared" events to UI listeners. - */ - -import type { Annotation } from "@shared/annotation"; -import type { Document, DocumentRepresentation } from "@shared/document"; -import type { EvidenceItem } from "@shared/evidence"; -import type { DocumentId } from "@shared/ids"; - -import type { Engine } from "./index"; - -export const SNAPSHOT_VERSION = 1; - -export interface EngineSnapshot { - readonly version: number; - readonly documents: readonly Document[]; - readonly representations: readonly DocumentRepresentation[]; - readonly annotations: readonly Annotation[]; - readonly evidenceItems: readonly EvidenceItem[]; -} - -/** Strip ephemeral blob URLs — they cannot survive reload without bytes. */ -export function sanitizeDocumentForPersistence(document: Document): Document { - if (!document.uri || !document.uri.startsWith("blob:")) return document; - const { uri: _uri, ...rest } = document; - return rest; -} - -export function captureSnapshot(engine: Engine): EngineSnapshot { - const documents = engine.documents.list().map(sanitizeDocumentForPersistence); - // Gather representations per known document. - const representations: DocumentRepresentation[] = []; - const annotations: Annotation[] = []; - const evidenceItems: EvidenceItem[] = []; - const seenItemIds = new Set(); - for (const doc of documents) { - representations.push(...engine.documents.listRepresentations(doc.id)); - annotations.push(...engine.annotations.listByDocument(doc.id)); - for (const item of engine.evidence.listByDocument(doc.id)) { - // listByDocument keys off annotation lookup; an item that shares - // annotations across two documents would surface twice. De-dupe. - if (!seenItemIds.has(item.id)) { - seenItemIds.add(item.id); - evidenceItems.push(item); - } - } - } - return { - version: SNAPSHOT_VERSION, - documents: [...documents], - representations, - annotations, - evidenceItems, - }; -} - -export function restoreSnapshot(engine: Engine, snapshot: EngineSnapshot): void { - if (snapshot.version !== SNAPSHOT_VERSION) { - throw new Error( - `restoreSnapshot: snapshot version ${snapshot.version} does not match current ${SNAPSHOT_VERSION}`, - ); - } - for (const d of snapshot.documents) engine.repos.documents.create(d); - for (const r of snapshot.representations) engine.repos.representations.create(r); - for (const a of snapshot.annotations) engine.repos.annotations.create(a); - for (const i of snapshot.evidenceItems) engine.repos.evidenceItems.create(i); -} - -export interface PersisterOptions { - /** Storage key. */ - readonly key: string; - /** Storage shim — defaults to globalThis.localStorage. */ - readonly storage?: Pick; -} - -/** - * Subscribe to engine events and write a fresh snapshot on every mutation. - * Returns the unsubscribe function. - * - * Initial snapshot is NOT written — call `captureSnapshot` + `storage.setItem` - * yourself if you want a baseline. - */ -export function attachPersister(engine: Engine, options: PersisterOptions): () => void { - const storage = options.storage ?? globalThis.localStorage; - const write = () => { - const snap = captureSnapshot(engine); - try { - storage.setItem(options.key, JSON.stringify(snap)); - } catch (err) { - // localStorage quota / serialization errors shouldn't crash the app. - // Surface to the console; ADR-0005 owns the durable fix. - console.warn("attachPersister: write failed", err); - } - }; - const offs = [ - engine.bus.on("DocumentImported", write), - engine.bus.on("DocumentRepresentationGenerated", write), - engine.bus.on("AnnotationCreated", write), - engine.bus.on("AnnotationResolved", write), - engine.bus.on("AnnotationResolutionFailed", write), - engine.bus.on("EvidenceItemCreated", write), - engine.bus.on("EvidenceItemUpdated", write), - ]; - return () => { - for (const off of offs) off(); - }; -} - -export type RestoreFromStorageOptions = PersisterOptions; - -export function restoreFromStorage( - engine: Engine, - options: RestoreFromStorageOptions, -): { readonly restored: boolean; readonly snapshot?: EngineSnapshot } { - const storage = options.storage ?? globalThis.localStorage; - const raw = storage.getItem(options.key); - if (!raw) return { restored: false }; - try { - const parsed = JSON.parse(raw) as EngineSnapshot; - if (typeof parsed !== "object" || parsed === null) return { restored: false }; - restoreSnapshot(engine, parsed); - return { restored: true, snapshot: parsed }; - } catch (err) { - console.warn("restoreFromStorage: parse failed, ignoring stored snapshot", err); - return { restored: false }; - } -} - -/** - * Narrow helper: get the set of document ids restored from a snapshot. - * Useful for the SPA's "show me what was open last time" logic. - */ -export function documentIdsIn(snapshot: EngineSnapshot): readonly DocumentId[] { - return snapshot.documents.map((d) => d.id); -} diff --git a/src/engine/rendering/html.test.ts b/src/engine/rendering/html.test.ts deleted file mode 100644 index 33b23d0..0000000 --- a/src/engine/rendering/html.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * HTML citation card renderer tests (CE-WP-0004-T03). - * - * Snapshots lock the output format defined in - * `docs/decisions/ADR-0007-citation-card-format.md`. Class names are - * part of the public contract — renaming any of them requires updating - * both this test and the ADR. - */ - -import { describe, expect, it } from "vitest"; - -import type { Annotation } from "@shared/annotation"; -import type { Document } from "@shared/document"; -import type { EvidenceItem } from "@shared/evidence"; -import type { - AnnotationId, - DocumentId, - EvidenceItemId, - RepresentationId, -} from "@shared/ids"; - -import { renderCitationCardHtml } from "./html"; - -const DOC_ID = "doc_2024-order" as DocumentId; -const REP_ID = "rep_2024-order" as RepresentationId; -const ANN_ID = "ann_para3" as AnnotationId; -const EV_ID = "ev_para3" as EvidenceItemId; - -function makeDoc(overrides: Partial = {}): Document { - return { - id: DOC_ID, - title: "Order from 14 Mar 2024", - mediaType: "application/pdf", - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", - ...overrides, - }; -} - -function makeAnn(overrides: Partial = {}): Annotation { - return { - id: ANN_ID, - documentId: DOC_ID, - representationId: REP_ID, - selectors: [], - quote: "Die Frist endet am 31. März 2024.", - normalizeVersion: 1, - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", - ...overrides, - }; -} - -function makeEv(overrides: Partial = {}): EvidenceItem { - return { - id: EV_ID, - annotationIds: [ANN_ID], - status: "candidate", - createdAt: "2026-05-25T00:00:00.000Z", - updatedAt: "2026-05-25T00:00:00.000Z", - ...overrides, - }; -} - -describe("renderCitationCardHtml()", () => { - it("renders the full aside with quote, attribution, and commentary", () => { - const out = renderCitationCardHtml({ - evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }), - document: makeDoc(), - annotation: makeAnn(), - }); - expect(out).toMatchInlineSnapshot(` - " - " - `); - }); - - it("omits the commentary div when none is set", () => { - const out = renderCitationCardHtml({ - evidenceItem: makeEv(), - document: makeDoc(), - annotation: makeAnn(), - }); - expect(out).toMatchInlineSnapshot(` - " - " - `); - }); - - it("converts \\n inside the quote to
for rich-text paste", () => { - const out = renderCitationCardHtml({ - evidenceItem: makeEv(), - document: makeDoc(), - annotation: makeAnn({ quote: "Line one.\nLine two." }), - }); - expect(out).toContain( - '
Line one.
Line two.
', - ); - }); - - it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => { - const out = renderCitationCardHtml({ - evidenceItem: makeEv({ - commentary: `Notes: & 'untrusted'`, - }), - document: makeDoc({ title: `Order "draft" & more` }), - annotation: makeAnn({ quote: "<> & 'inner'" }), - }); - // Quote escaping - expect(out).toContain( - "
<<value>> & 'inner'
", - ); - // Source label escaping - expect(out).toContain( - "Order "draft" & more", - ); - // Commentary escaping — and especially: no raw