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>
118 lines
4 KiB
TypeScript
118 lines
4 KiB
TypeScript
/**
|
|
* 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",
|
|
});
|
|
},
|
|
};
|
|
}
|