Extract binder package from citation-evidence (EBIND-WP-0001)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Extracts citation-evidence/src/binder/ into this repo as the standalone
@citation-evidence/evidence-binder package: headless binding service +
in-memory link repo, active-state machine, the SharedContracts §7
rect-registry contract (registry, change pumps, hooks, SVG overlay), and
the target-neutral reference FormRenderer.

- toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint);
  imports rewritten from @shared/@engine aliases to the engine's
  @citation-evidence/engine package specifiers
- dependency boundary (engine + anchor only; no source/work/umbrella)
  enforced via eslint no-restricted-imports
- docs: extraction inventory + contract deltas, ADR-0001 (reference UI
  kept as supported exports), refreshed README/SCOPE/INTENT, populated
  capabilities index
- typecheck + lint green, 37 tests passing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-09 01:36:33 +02:00
parent 87d4eb3006
commit a10f080f12
36 changed files with 6643 additions and 170 deletions

View file

@ -0,0 +1,180 @@
/**
* Binding service + in-memory link repo tests.
*
* Exercises every public surface plus the §4 events the service emits.
*/
import { describe, expect, it } from "vitest";
import type {
EvidenceLink,
EvidenceTarget,
} from "@citation-evidence/engine/shared";
import type {
EvidenceItemId,
EvidenceLinkId,
} from "@citation-evidence/engine/shared";
import { createEventBus } from "@citation-evidence/engine";
import type { EngineEvent } from "@citation-evidence/engine";
import { createInMemoryLinkRepo } from "../repos/in-memory-links";
import { createBindingService } from "./bindings";
function makeFixture() {
const bus = createEventBus();
const repo = createInMemoryLinkRepo();
const events: EngineEvent[] = [];
bus.onAny((e) => events.push(e));
let counter = 0;
const now = () => `2026-05-25T00:00:0${counter++}.000Z`;
const service = createBindingService(repo, bus, now);
return { bus, repo, events, service };
}
const FIELD_A: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
const FIELD_B: EvidenceTarget = { targetType: "form-field", targetId: "amount" };
const EV1 = "ev_test_one" as EvidenceItemId;
const EV2 = "ev_test_two" as EvidenceItemId;
describe("createBindingService", () => {
it("linkEvidenceToTarget creates a link, emits EvidenceLinkCreated, and persists it", () => {
const { service, repo, events } = makeFixture();
const link = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
});
expect(link.evidenceItemId).toBe(EV1);
expect(link.targetType).toBe("form-field");
expect(link.targetId).toBe("summary");
expect(link.relation).toBe("supports");
expect(link.status).toBe("candidate");
expect(link.createdAt).toBe(link.updatedAt);
expect(repo.get(link.id)).toEqual(link);
const created = events.filter((e) => e.type === "EvidenceLinkCreated");
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ linkId: link.id, link });
});
it("honours explicit relation/status/confidence", () => {
const { service } = makeFixture();
const link = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
relation: "contradicts",
status: "conflicting",
confidence: 0.42,
createdBy: "tegwick",
});
expect(link.relation).toBe("contradicts");
expect(link.status).toBe("conflicting");
expect(link.confidence).toBe(0.42);
expect(link.createdBy).toBe("tegwick");
});
it("listEvidenceForTarget returns only links for the requested target", () => {
const { service } = makeFixture();
const a1 = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_B });
const a2 = service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
const linksForA = service.listEvidenceForTarget(FIELD_A);
expect(linksForA.map((l) => l.id).sort()).toEqual([a1.id, a2.id].sort());
});
it("listTargetsForEvidence returns all targets an evidence item is linked to", () => {
const { service } = makeFixture();
const a = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
const b = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_B });
service.linkEvidenceToTarget({ evidenceItemId: EV2, target: FIELD_A });
const targets = service.listTargetsForEvidence(EV1);
expect(targets.map((l) => l.id).sort()).toEqual([a.id, b.id].sort());
});
it("unlinkEvidence removes the link and reports success/failure", () => {
const { service } = makeFixture();
const link = service.linkEvidenceToTarget({ evidenceItemId: EV1, target: FIELD_A });
expect(service.unlinkEvidence(link.id)).toBe(true);
expect(service.getLink(link.id)).toBeNull();
expect(service.unlinkEvidence(link.id)).toBe(false);
expect(service.unlinkEvidence("evlink_unknown" as EvidenceLinkId)).toBe(false);
});
it("updateLink merges patch, bumps updatedAt, and emits EvidenceLinkUpdated", () => {
const { service, events } = makeFixture();
const original = service.linkEvidenceToTarget({
evidenceItemId: EV1,
target: FIELD_A,
});
const updated = service.updateLink(original.id, {
status: "confirmed",
confidence: 0.9,
});
expect(updated.status).toBe("confirmed");
expect(updated.confidence).toBe(0.9);
expect(updated.relation).toBe(original.relation);
expect(updated.updatedAt).not.toBe(original.updatedAt);
const updatedEvents = events.filter((e) => e.type === "EvidenceLinkUpdated");
expect(updatedEvents).toHaveLength(1);
expect((updatedEvents[0] as Extract<EngineEvent, { type: "EvidenceLinkUpdated" }>).link).toEqual(updated);
});
it("updateLink throws on unknown id", () => {
const { service } = makeFixture();
expect(() =>
service.updateLink("evlink_unknown" as EvidenceLinkId, { status: "verified" }),
).toThrow(/unknown id/);
});
it("setActiveEvidence emits EvidenceItemActivated with source=form-field", () => {
const { service, events } = makeFixture();
service.setActiveEvidence(EV1);
const activated = events.filter((e) => e.type === "EvidenceItemActivated");
expect(activated).toHaveLength(1);
expect(activated[0]).toMatchObject({ evidenceItemId: EV1, source: "form-field" });
});
});
describe("EvidenceLinkRepository (in-memory)", () => {
it("rejects duplicate ids on create", () => {
const repo = createInMemoryLinkRepo();
const link: EvidenceLink = {
id: "evlink_x" as EvidenceLinkId,
evidenceItemId: EV1,
targetType: "form-field",
targetId: "f",
relation: "supports",
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
repo.create(link);
expect(() => repo.create(link)).toThrow(/duplicate/);
});
it("update throws on unknown id", () => {
const repo = createInMemoryLinkRepo();
const link: EvidenceLink = {
id: "evlink_unknown" as EvidenceLinkId,
evidenceItemId: EV1,
targetType: "form-field",
targetId: "f",
relation: "supports",
status: "candidate",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
};
expect(() => repo.update(link)).toThrow(/unknown/);
});
});

118
src/services/bindings.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* Binding service links EvidenceItems to structured targets.
*
* Implements `wiki/ArchitectureOverview.md` §4.6 + SharedContracts §2.4
* (status enum), §2.5 (relation enum). Emits §4 events:
* `EvidenceLinkCreated`, `EvidenceLinkUpdated`, `EvidenceItemActivated`.
*
* MVP semantics:
* - `linkEvidenceToTarget` defaults `relation="supports"`, `status="candidate"`.
* - `unlinkEvidence` is hard-delete; the rejected-status path is left to
* a later ADR.
* - `setActiveEvidence` emits an `EvidenceItemActivated` event with
* `source="form-field"` so the viewer/sidebar can react.
*/
import type {
EvidenceLink,
EvidenceLinkStoredStatus,
EvidenceRelation,
EvidenceTarget,
} from "@citation-evidence/engine/shared";
import type { EvidenceItemId, EvidenceLinkId } from "@citation-evidence/engine/shared";
import { newId } from "@citation-evidence/engine/shared";
import type { EventBus } from "@citation-evidence/engine";
import type { EvidenceLinkRepository } from "../repos/in-memory-links";
export interface LinkEvidenceToTargetInput {
readonly evidenceItemId: EvidenceItemId;
readonly target: EvidenceTarget;
readonly relation?: EvidenceRelation;
readonly status?: EvidenceLinkStoredStatus;
readonly confidence?: number;
readonly createdBy?: string;
}
export interface UpdateLinkStatusInput {
readonly status?: EvidenceLinkStoredStatus;
readonly relation?: EvidenceRelation;
readonly confidence?: number;
}
export interface BindingService {
linkEvidenceToTarget(input: LinkEvidenceToTargetInput): EvidenceLink;
unlinkEvidence(id: EvidenceLinkId): boolean;
updateLink(id: EvidenceLinkId, input: UpdateLinkStatusInput): EvidenceLink;
getLink(id: EvidenceLinkId): EvidenceLink | null;
listEvidenceForTarget(target: EvidenceTarget): readonly EvidenceLink[];
listTargetsForEvidence(evidenceItemId: EvidenceItemId): readonly EvidenceLink[];
setActiveEvidence(evidenceItemId: EvidenceItemId): void;
}
export function createBindingService(
links: EvidenceLinkRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): BindingService {
return {
linkEvidenceToTarget(input) {
const ts = now();
const link: EvidenceLink = {
id: newId("evidence-link"),
evidenceItemId: input.evidenceItemId,
targetType: input.target.targetType,
targetId: input.target.targetId,
relation: input.relation ?? "supports",
status: input.status ?? "candidate",
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = links.create(link);
bus.emit({ type: "EvidenceLinkCreated", linkId: stored.id, link: stored });
return stored;
},
unlinkEvidence(id) {
const removed = links.delete(id);
if (removed) {
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
}
return removed;
},
updateLink(id, input) {
const existing = links.get(id);
if (!existing) {
throw new Error(`BindingService.updateLink: unknown id ${id}`);
}
const next: EvidenceLink = {
...existing,
...(input.status !== undefined ? { status: input.status } : {}),
...(input.relation !== undefined ? { relation: input.relation } : {}),
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
updatedAt: now(),
};
const stored = links.update(next);
bus.emit({ type: "EvidenceLinkUpdated", linkId: stored.id, link: stored });
return stored;
},
getLink(id) {
return links.get(id);
},
listEvidenceForTarget(target) {
return links.listForTarget(target);
},
listTargetsForEvidence(evidenceItemId) {
return links.listForEvidenceItem(evidenceItemId);
},
setActiveEvidence(evidenceItemId) {
bus.emit({
type: "EvidenceItemActivated",
evidenceItemId,
source: "form-field",
});
},
};
}

1
src/services/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./bindings";