@cyber-dash-tech/revela 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ import {
2
+ applyEvidenceCandidates,
3
+ hasDecksState,
4
+ readDecksState,
5
+ reviewDeckState,
6
+ writeDecksState,
7
+ type AppliedEvidenceCandidate,
8
+ type DecksState,
9
+ type EvidenceBindingCandidate,
10
+ type EvidenceCandidateSearchDiagnostic,
11
+ type EvidenceRef,
12
+ type ReadinessIssue,
13
+ type SkippedEvidenceCandidate,
14
+ } from "../decks-state"
15
+ import { compileInspectionContext, type InspectionEvidenceTrace, type InspectionGap } from "../inspection-context/compile"
16
+ import { matchInspectionElement, type InspectionElementMatch, type InspectionElementSnapshot, type InspectionMatchConfidence } from "../inspection-context/match"
17
+ import { recordWorkspaceAction } from "./actions"
18
+ import type { WorkspaceAction } from "./types"
19
+
20
+ export interface EvidenceStatusForSelection {
21
+ version: 1
22
+ selection: EvidenceStatusSelection
23
+ match: EvidenceStatusMatch
24
+ boundEvidence: EvidenceStatusEvidence[]
25
+ gaps: EvidenceStatusGap[]
26
+ candidateEvidence: EvidenceStatusCandidate[]
27
+ searchDiagnostics: EvidenceCandidateSearchDiagnostic[]
28
+ actionTrace: EvidenceStatusActionTrace[]
29
+ }
30
+
31
+ export interface EvidenceStatusSelection {
32
+ slideIndex?: number
33
+ selectedText?: string
34
+ scope?: InspectionElementSnapshot["scope"]
35
+ }
36
+
37
+ export interface EvidenceStatusMatch {
38
+ confidence: InspectionMatchConfidence
39
+ reason: string
40
+ slideIndex?: number
41
+ slideTitle?: string
42
+ claimId?: string
43
+ claimText?: string
44
+ claimEvidenceSensitive?: boolean
45
+ claimEvidenceSupport?: string
46
+ }
47
+
48
+ export interface EvidenceStatusEvidence extends EvidenceRef {
49
+ slideIndex: number
50
+ slideTitle: string
51
+ hasDetail: boolean
52
+ }
53
+
54
+ export interface EvidenceStatusGap {
55
+ type: InspectionGap["type"]
56
+ slideIndex: number
57
+ slideTitle: string
58
+ claimText: string
59
+ message: string
60
+ }
61
+
62
+ export interface EvidenceStatusCandidate extends EvidenceBindingCandidate {
63
+ relevant: boolean
64
+ }
65
+
66
+ export interface EvidenceStatusActionTrace {
67
+ id: string
68
+ type: WorkspaceAction["type"]
69
+ timestamp: string
70
+ actor?: string
71
+ status: WorkspaceAction["status"]
72
+ summary?: string
73
+ inputs?: Record<string, unknown>
74
+ outputs?: Record<string, unknown>
75
+ nodeIds?: string[]
76
+ }
77
+
78
+ export interface ApplyEvidenceBindingsResult {
79
+ applied: AppliedEvidenceCandidate[]
80
+ skipped: SkippedEvidenceCandidate[]
81
+ nextReviewNeeded: boolean
82
+ }
83
+
84
+ export function getEvidenceStatusForSelection(workspaceRoot: string, snapshot: InspectionElementSnapshot, options: { slug?: string } = {}): EvidenceStatusForSelection {
85
+ if (!hasDecksState(workspaceRoot)) throw new Error("DECKS.json is required before checking evidence status. Run /revela init first.")
86
+ const state = readDecksState(workspaceRoot)
87
+ return getEvidenceStatusInState(state, snapshot, { ...options, workspaceRoot })
88
+ }
89
+
90
+ export function getEvidenceStatusInState(
91
+ state: DecksState,
92
+ snapshot: InspectionElementSnapshot,
93
+ options: { workspaceRoot?: string; slug?: string } = {},
94
+ ): EvidenceStatusForSelection {
95
+ const normalizedSnapshot = normalizeEvidenceSnapshot(snapshot)
96
+ const context = compileInspectionContext(state, options.slug)
97
+ const match = matchInspectionElement(context, normalizedSnapshot)
98
+ const reviewed = reviewDeckState(state, context.slug, { workspaceRoot: options.workspaceRoot })
99
+ const candidates = relevantCandidates(reviewed.result.evidenceCandidates ?? [], match)
100
+ const diagnostics = relevantSearchDiagnostics(reviewed.result.issues, match)
101
+
102
+ return {
103
+ version: 1,
104
+ selection: {
105
+ slideIndex: normalizedSnapshot.slideIndex,
106
+ selectedText: normalizedSnapshot.selectedText || normalizedSnapshot.text,
107
+ scope: normalizedSnapshot.scope,
108
+ },
109
+ match: projectMatch(match),
110
+ boundEvidence: match.evidence.map(projectEvidence),
111
+ gaps: evidenceStatusGaps(match, reviewed.result.issues),
112
+ candidateEvidence: candidates,
113
+ searchDiagnostics: diagnostics,
114
+ actionTrace: actionTraceForMatch(state.actions ?? [], match),
115
+ }
116
+ }
117
+
118
+ export function applyEvidenceBindings(workspaceRoot: string, candidateIds: string[]): ApplyEvidenceBindingsResult {
119
+ if (!hasDecksState(workspaceRoot)) throw new Error("DECKS.json is required before applying evidence bindings. Run /revela init first.")
120
+ const ids = [...new Set(candidateIds.map((id) => id.trim()).filter(Boolean))]
121
+ if (ids.length === 0) throw new Error("candidateIds are required for evidence binding application.")
122
+
123
+ const state = readDecksState(workspaceRoot)
124
+ const applied = applyEvidenceCandidates(state, ids, { workspaceRoot })
125
+ recordEvidenceBindingAction(applied.state, ids, applied.result)
126
+ writeDecksState(workspaceRoot, applied.state)
127
+ return applied.result
128
+ }
129
+
130
+ export function recordEvidenceBindingAction(state: DecksState, candidateIds: string[], result: ApplyEvidenceBindingsResult): DecksState {
131
+ return recordWorkspaceAction(state, {
132
+ type: "evidence.binding_applied",
133
+ actor: "revela-decks",
134
+ inputs: { candidateIds },
135
+ outputs: {
136
+ applied: result.applied.map((item) => ({ candidateId: item.candidateId, slideIndex: item.slideIndex, evidence: item.evidence })),
137
+ skipped: result.skipped,
138
+ nextReviewNeeded: result.nextReviewNeeded,
139
+ },
140
+ status: result.applied.length > 0 ? "success" : "skipped",
141
+ summary: `Applied ${result.applied.length} evidence candidate${result.applied.length === 1 ? "" : "s"}.`,
142
+ nodeIds: result.applied.map((item) => `slide:${item.slideIndex}`),
143
+ })
144
+ }
145
+
146
+ function normalizeEvidenceSnapshot(snapshot: InspectionElementSnapshot): InspectionElementSnapshot {
147
+ const selectedText = snapshot.selectedText || snapshot.text
148
+ return {
149
+ ...snapshot,
150
+ text: snapshot.text || selectedText,
151
+ selectedText,
152
+ }
153
+ }
154
+
155
+ function projectMatch(match: InspectionElementMatch): EvidenceStatusMatch {
156
+ return {
157
+ confidence: match.confidence,
158
+ reason: match.reason,
159
+ slideIndex: match.slide?.index,
160
+ slideTitle: match.slide?.title,
161
+ claimId: match.claim?.id,
162
+ claimText: match.claim?.text,
163
+ claimEvidenceSensitive: match.claim?.evidenceSensitive,
164
+ claimEvidenceSupport: match.claim?.evidenceSupport,
165
+ }
166
+ }
167
+
168
+ function projectEvidence(trace: InspectionEvidenceTrace): EvidenceStatusEvidence {
169
+ return {
170
+ ...trace,
171
+ slideIndex: trace.slideIndex,
172
+ slideTitle: trace.slideTitle,
173
+ hasDetail: trace.hasDetail,
174
+ }
175
+ }
176
+
177
+ function projectGap(gap: InspectionGap): EvidenceStatusGap {
178
+ return {
179
+ type: gap.type,
180
+ slideIndex: gap.slideIndex,
181
+ slideTitle: gap.slideTitle,
182
+ claimText: gap.claimText,
183
+ message: gap.message,
184
+ }
185
+ }
186
+
187
+ function evidenceStatusGaps(match: InspectionElementMatch, issues: ReadinessIssue[]): EvidenceStatusGap[] {
188
+ const gaps = match.gaps.map(projectGap)
189
+ const slideIndex = match.slide?.index
190
+ for (const issue of issues) {
191
+ if (issue.slideIndex !== slideIndex) continue
192
+ if (typeof issue.slideIndex !== "number") continue
193
+ if (issue.type !== "missing_evidence" && issue.type !== "weak_evidence") continue
194
+ if (gaps.some((gap) => gap.type === issue.type && gap.claimText === issue.claimText)) continue
195
+ gaps.push({
196
+ type: issue.type,
197
+ slideIndex: issue.slideIndex,
198
+ slideTitle: issue.slideTitle ?? match.slide?.title ?? "",
199
+ claimText: issue.claimText ?? match.claim?.text ?? "",
200
+ message: issue.message,
201
+ })
202
+ }
203
+ return gaps
204
+ }
205
+
206
+ function relevantCandidates(candidates: EvidenceBindingCandidate[], match: InspectionElementMatch): EvidenceStatusCandidate[] {
207
+ const slideIndex = match.slide?.index
208
+ return candidates
209
+ .filter((candidate) => candidate.slideIndex === slideIndex)
210
+ .map((candidate) => ({
211
+ ...candidate,
212
+ relevant: candidateRelevantToMatch(candidate, match),
213
+ }))
214
+ }
215
+
216
+ function candidateRelevantToMatch(candidate: EvidenceBindingCandidate, match: InspectionElementMatch): boolean {
217
+ const claimText = normalizeText(match.claim?.text)
218
+ if (!claimText || !candidate.claimText) return true
219
+ const candidateClaim = normalizeText(candidate.claimText)
220
+ if (candidateClaim === claimText || candidateClaim.includes(claimText) || claimText.includes(candidateClaim)) return true
221
+ const quote = normalizeText(candidate.quote)
222
+ if (quote.includes(claimText)) return true
223
+ return (candidate.supportScope ?? []).some((scope) => {
224
+ const normalizedScope = normalizeText(scope)
225
+ return normalizedScope === claimText || normalizedScope.includes(claimText) || claimText.includes(normalizedScope)
226
+ })
227
+ }
228
+
229
+ function relevantSearchDiagnostics(issues: ReadinessIssue[], match: InspectionElementMatch): EvidenceCandidateSearchDiagnostic[] {
230
+ const slideIndex = match.slide?.index
231
+ return issues
232
+ .filter((issue) => issue.slideIndex === slideIndex && issue.evidenceCandidateSearch)
233
+ .map((issue) => issue.evidenceCandidateSearch!)
234
+ }
235
+
236
+ function actionTraceForMatch(actions: WorkspaceAction[], match: InspectionElementMatch): EvidenceStatusActionTrace[] {
237
+ const slideNodeId = match.slide ? `slide:${match.slide.index}` : undefined
238
+ const evidenceKeys = new Set(match.evidence.flatMap((item) => [item.source, item.sourcePath, item.findingsFile].filter((value): value is string => Boolean(value))))
239
+ return actions
240
+ .filter((action) => actionRelevantToMatch(action, slideNodeId, evidenceKeys))
241
+ .slice(-12)
242
+ .map((action) => ({
243
+ id: action.id,
244
+ type: action.type,
245
+ timestamp: action.timestamp,
246
+ actor: action.actor,
247
+ status: action.status,
248
+ summary: action.summary,
249
+ inputs: action.inputs,
250
+ outputs: action.outputs,
251
+ nodeIds: action.nodeIds,
252
+ }))
253
+ }
254
+
255
+ function actionRelevantToMatch(action: WorkspaceAction, slideNodeId: string | undefined, evidenceKeys: Set<string>): boolean {
256
+ if (slideNodeId && action.nodeIds?.includes(slideNodeId)) return true
257
+ if (action.type === "evidence.binding_applied" || action.type === "evidence.candidate_generated") return true
258
+ const text = JSON.stringify({ inputs: action.inputs, outputs: action.outputs, nodeIds: action.nodeIds })
259
+ for (const key of evidenceKeys) {
260
+ if (key && text.includes(key)) return true
261
+ }
262
+ return false
263
+ }
264
+
265
+ function normalizeText(value: string | undefined): string {
266
+ return String(value ?? "").replace(/\s+/g, " ").trim().toLowerCase()
267
+ }
@@ -0,0 +1,426 @@
1
+ import { createHash } from "crypto"
2
+ import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, ResearchAxis, SlideSpec, SourceMaterial } from "../decks-state"
3
+ import { renderTargetId } from "./render-targets"
4
+ import type { GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, RenderTarget, WorkspaceGraph } from "./types"
5
+
6
+ export interface ProjectWorkspaceGraphOptions {
7
+ slug?: string
8
+ }
9
+
10
+ interface GraphBuilder {
11
+ nodes: Map<string, GraphNode>
12
+ edges: Map<string, GraphEdge>
13
+ }
14
+
15
+ export function projectWorkspaceGraph(state: DecksState, options: ProjectWorkspaceGraphOptions = {}): WorkspaceGraph {
16
+ const deck = activeDeck(state, options.slug)
17
+ const builder: GraphBuilder = { nodes: new Map(), edges: new Map() }
18
+
19
+ for (const material of state.workspace.sourceMaterials ?? []) addSourceMaterial(builder, material)
20
+ for (const axis of deck.researchPlan ?? []) addResearchFinding(builder, axis)
21
+
22
+ const narrativeId = addNarrative(builder, deck)
23
+ for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlide(builder, slide)
24
+ for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlideClaimsAndEvidence(builder, slide)
25
+ const targets = renderTargetsForDeck(state, deck)
26
+ for (const target of targets) addArtifact(builder, deck, target, narrativeId, targets)
27
+
28
+ return normalizeGraph(builder)
29
+ }
30
+
31
+ function activeDeck(state: DecksState, slug?: string): DeckSpec {
32
+ const key = slug || state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
33
+ if (!key || !state.decks[key]) throw new Error("No active deck is available for workspace graph projection.")
34
+ return state.decks[key]
35
+ }
36
+
37
+ function addSourceMaterial(builder: GraphBuilder, material: SourceMaterial): void {
38
+ const sourceId = sourceNodeId(material.path || material.fingerprint || "unknown-source")
39
+ addNode(builder, {
40
+ id: sourceId,
41
+ type: "source",
42
+ label: material.path,
43
+ data: compactData({
44
+ path: material.path,
45
+ type: material.type,
46
+ size: material.size,
47
+ fingerprint: material.fingerprint,
48
+ status: material.status,
49
+ summary: material.summary,
50
+ bestUsedFor: material.bestUsedFor,
51
+ }),
52
+ })
53
+
54
+ const extraction = material.extraction
55
+ if (!extraction) return
56
+ const extractionKey = extraction.manifestPath || extraction.textPath || extraction.cacheDir
57
+ if (!extractionKey) return
58
+
59
+ const extractionId = extractionNodeId(extractionKey)
60
+ addNode(builder, {
61
+ id: extractionId,
62
+ type: "extraction",
63
+ label: extractionKey,
64
+ data: compactData(extraction),
65
+ })
66
+ addEdge(builder, "extracted_as", sourceId, extractionId)
67
+ }
68
+
69
+ function addResearchFinding(builder: GraphBuilder, axis: ResearchAxis): void {
70
+ if (!axis.findingsFile?.trim()) return
71
+ const id = findingNodeId(axis.findingsFile)
72
+ addNode(builder, {
73
+ id,
74
+ type: "finding",
75
+ label: axis.findingsFile,
76
+ data: compactData({
77
+ axis: axis.axis,
78
+ needed: axis.needed,
79
+ status: axis.status,
80
+ findingsFile: axis.findingsFile,
81
+ notes: axis.notes,
82
+ sourceKind: "researchPlan",
83
+ }),
84
+ })
85
+ }
86
+
87
+ function addNarrative(builder: GraphBuilder, deck: DeckSpec): string | undefined {
88
+ const brief = deck.narrativeBrief
89
+ if (!hasNarrativeBrief(brief)) return undefined
90
+
91
+ const narrativeId = `narrative:${stableHash(deck.slug)}`
92
+ addNode(builder, {
93
+ id: narrativeId,
94
+ type: "narrativeIntent",
95
+ label: deck.goal || deck.slug,
96
+ data: compactData({
97
+ goal: deck.goal,
98
+ audience: deck.audience,
99
+ language: deck.language,
100
+ audienceBeliefBefore: brief?.audienceBeliefBefore,
101
+ audienceBeliefAfter: brief?.audienceBeliefAfter,
102
+ decisionOrAction: brief?.decisionOrAction,
103
+ narrativeArc: brief?.narrativeArc,
104
+ keyClaims: brief?.keyClaims,
105
+ }),
106
+ })
107
+
108
+ for (const objection of brief?.objections ?? []) {
109
+ const objectionId = `objection:${stableHash(objection)}`
110
+ addNode(builder, { id: objectionId, type: "objection", label: objection, data: { text: objection } })
111
+ addEdge(builder, "contains", narrativeId, objectionId)
112
+ addEdge(builder, "challenges", objectionId, narrativeId)
113
+ }
114
+
115
+ for (const risk of brief?.risks ?? []) {
116
+ const riskId = `risk:${stableHash(risk)}`
117
+ addNode(builder, { id: riskId, type: "risk", label: risk, data: { text: risk } })
118
+ addEdge(builder, "contains", narrativeId, riskId)
119
+ addEdge(builder, "constrained_by", narrativeId, riskId)
120
+ }
121
+
122
+ return narrativeId
123
+ }
124
+
125
+ function addSlide(builder: GraphBuilder, slide: SlideSpec): void {
126
+ addNode(builder, {
127
+ id: slideNodeId(slide.index),
128
+ type: "slide",
129
+ label: slide.title,
130
+ data: compactData({
131
+ index: slide.index,
132
+ title: slide.title,
133
+ purpose: slide.purpose,
134
+ narrativeRole: slide.narrativeRole,
135
+ layout: slide.layout,
136
+ components: slide.components,
137
+ status: slide.status,
138
+ }),
139
+ })
140
+ }
141
+
142
+ function addSlideClaimsAndEvidence(builder: GraphBuilder, slide: SlideSpec): void {
143
+ const slideId = slideNodeId(slide.index)
144
+ const claims = claimCandidates(slide)
145
+ const claimIds = claims.map((claim) => addClaim(builder, slide, claim))
146
+
147
+ for (const claimId of claimIds) {
148
+ addEdge(builder, "contains", slideId, claimId)
149
+ addEdge(builder, "appears_in", claimId, slideId)
150
+ }
151
+
152
+ for (const evidence of slide.evidence ?? []) {
153
+ const supportId = addEvidenceSupportNode(builder, evidence)
154
+ for (const claimId of claimIds) {
155
+ addEdge(builder, "supports", supportId, claimId, compactData({
156
+ slideIndex: slide.index,
157
+ detailLevel: hasEvidenceDetail(evidence) ? "detailed" : "weak",
158
+ source: evidence.source,
159
+ quote: evidence.quote,
160
+ page: evidence.page,
161
+ url: evidence.url,
162
+ sourcePath: evidence.sourcePath,
163
+ location: evidence.location,
164
+ findingsFile: evidence.findingsFile,
165
+ caveat: evidence.caveat,
166
+ extractedTextPath: evidence.extractedTextPath,
167
+ extractedManifestPath: evidence.extractedManifestPath,
168
+ }))
169
+ }
170
+ }
171
+ }
172
+
173
+ function addClaim(builder: GraphBuilder, slide: SlideSpec, claim: { origin: string; text: string }): string {
174
+ const id = claimNodeId(slide.index, claim.text)
175
+ addNode(builder, {
176
+ id,
177
+ type: "claim",
178
+ label: claim.text,
179
+ data: compactData({
180
+ slideIndex: slide.index,
181
+ slideTitle: slide.title,
182
+ origin: claim.origin,
183
+ text: claim.text,
184
+ }),
185
+ })
186
+ return id
187
+ }
188
+
189
+ function addEvidenceSupportNode(builder: GraphBuilder, evidence: EvidenceRef): string {
190
+ if (evidence.findingsFile?.trim()) {
191
+ const id = findingNodeId(evidence.findingsFile)
192
+ addNode(builder, {
193
+ id,
194
+ type: "finding",
195
+ label: evidence.findingsFile,
196
+ data: compactData({ findingsFile: evidence.findingsFile, source: evidence.source, quote: evidence.quote, location: evidence.location, caveat: evidence.caveat }),
197
+ })
198
+ return id
199
+ }
200
+
201
+ const sourceKey = evidence.sourcePath || evidence.source || evidence.url || "unknown-evidence-source"
202
+ const id = sourceNodeId(sourceKey)
203
+ addNode(builder, {
204
+ id,
205
+ type: "source",
206
+ label: sourceKey,
207
+ data: compactData({ source: evidence.source, sourcePath: evidence.sourcePath, url: evidence.url }),
208
+ })
209
+
210
+ if (evidence.extractedTextPath || evidence.extractedManifestPath) {
211
+ const extractionKey = evidence.extractedTextPath || evidence.extractedManifestPath
212
+ const extractionId = extractionNodeId(extractionKey ?? sourceKey)
213
+ addNode(builder, {
214
+ id: extractionId,
215
+ type: "extraction",
216
+ label: extractionKey,
217
+ data: compactData({ textPath: evidence.extractedTextPath, manifestPath: evidence.extractedManifestPath }),
218
+ })
219
+ addEdge(builder, "extracted_as", id, extractionId)
220
+ }
221
+
222
+ return id
223
+ }
224
+
225
+ function addArtifact(builder: GraphBuilder, deck: DeckSpec, target: RenderTarget, narrativeId: string | undefined, targets: RenderTarget[]): void {
226
+ const artifactId = artifactNodeId(target.outputPath ?? deck.outputPath)
227
+ addNode(builder, {
228
+ id: artifactId,
229
+ type: "artifact",
230
+ label: target.outputPath ?? deck.outputPath,
231
+ data: compactData({
232
+ renderTargetId: target.id,
233
+ type: target.type,
234
+ outputPath: target.outputPath ?? deck.outputPath,
235
+ slug: deck.slug,
236
+ status: deck.status,
237
+ artifactVersion: target.artifactVersion,
238
+ contractStatus: target.contractStatus,
239
+ }),
240
+ })
241
+
242
+ if (narrativeId) addEdge(builder, "renders_from", artifactId, narrativeId)
243
+ const sourceNodeIds = target.sourceNodeIds.length > 0 ? target.sourceNodeIds : deck.slides.map((slide) => slideNodeId(slide.index))
244
+ for (const sourceNodeId of sourceNodeIds) addEdge(builder, "renders_from", artifactId, resolveRenderSourceNodeId(sourceNodeId, targets))
245
+ }
246
+
247
+ function renderTargetsForDeck(state: DecksState, deck: DeckSpec): RenderTarget[] {
248
+ const deckOutputPath = normalizePath(deck.outputPath)
249
+ const htmlTargetId = renderTargetId("html_deck", deckOutputPath)
250
+ const htmlArtifactId = artifactNodeId(deckOutputPath)
251
+ const targets = (state.renderTargets ?? []).filter((target) => {
252
+ if (target.id === htmlTargetId) return true
253
+ if (target.type === "html_deck") return normalizePath(target.outputPath ?? "") === deckOutputPath
254
+ const data = target.data ?? {}
255
+ return data.sourceTargetId === htmlTargetId ||
256
+ data.sourceOutputPath === deckOutputPath ||
257
+ target.sourceNodeIds.includes(htmlTargetId) ||
258
+ target.sourceNodeIds.includes(htmlArtifactId)
259
+ })
260
+ const htmlTarget = targets.find((target) => target.id === htmlTargetId) ?? fallbackHtmlDeckRenderTarget(deck)
261
+ if (!targets.some((target) => target.id === htmlTarget.id)) targets.push(htmlTarget)
262
+ return targets.sort((a, b) => a.id.localeCompare(b.id))
263
+ }
264
+
265
+ function resolveRenderSourceNodeId(sourceNodeId: string, targets: RenderTarget[]): string {
266
+ if (!sourceNodeId.startsWith("target:")) return sourceNodeId
267
+ const target = targets.find((item) => item.id === sourceNodeId)
268
+ return target ? artifactNodeId(target.outputPath ?? target.id) : sourceNodeId
269
+ }
270
+
271
+ function fallbackHtmlDeckRenderTarget(deck: DeckSpec): RenderTarget {
272
+ return {
273
+ id: renderTargetId("html_deck", deck.outputPath),
274
+ type: "html_deck",
275
+ outputPath: deck.outputPath,
276
+ sourceNodeIds: deck.slides.map((slide) => slideNodeId(slide.index)),
277
+ contractStatus: "unknown",
278
+ data: { slug: deck.slug, compatibilityOutputPath: deck.outputPath },
279
+ }
280
+ }
281
+
282
+ function claimCandidates(slide: SlideSpec): Array<{ origin: string; text: string }> {
283
+ const claims: Array<{ origin: string; text: string }> = []
284
+ pushClaim(claims, "title", slide.title)
285
+ pushClaim(claims, "purpose", slide.purpose)
286
+ pushClaim(claims, "headline", slide.content?.headline)
287
+ for (const item of slide.content?.body ?? []) pushClaim(claims, "body", item)
288
+ for (const item of slide.content?.bullets ?? []) pushClaim(claims, "bullet", item)
289
+ return claims
290
+ }
291
+
292
+ function pushClaim(claims: Array<{ origin: string; text: string }>, origin: string, text: string | undefined): void {
293
+ const value = cleanOptionalText(text)
294
+ if (!value) return
295
+ if (claims.some((claim) => claim.text === value)) return
296
+ claims.push({ origin, text: value })
297
+ }
298
+
299
+ function hasNarrativeBrief(brief: NarrativeBrief | undefined): boolean {
300
+ return Boolean(
301
+ brief?.audienceBeliefBefore?.trim() ||
302
+ brief?.audienceBeliefAfter?.trim() ||
303
+ brief?.decisionOrAction?.trim() ||
304
+ brief?.narrativeArc?.trim() ||
305
+ brief?.keyClaims.length ||
306
+ brief?.objections.length ||
307
+ brief?.risks.length,
308
+ )
309
+ }
310
+
311
+ function hasEvidenceDetail(evidence: EvidenceRef): boolean {
312
+ return Boolean(
313
+ evidence.quote?.trim() ||
314
+ evidence.page?.trim() ||
315
+ evidence.location?.trim() ||
316
+ evidence.url?.trim() ||
317
+ evidence.findingsFile?.trim() ||
318
+ evidence.sourcePath?.trim() ||
319
+ evidence.extractedTextPath?.trim(),
320
+ )
321
+ }
322
+
323
+ function addNode(builder: GraphBuilder, node: GraphNode): void {
324
+ const existing = builder.nodes.get(node.id)
325
+ if (!existing) {
326
+ builder.nodes.set(node.id, cleanNode(node))
327
+ return
328
+ }
329
+ builder.nodes.set(node.id, cleanNode({
330
+ ...existing,
331
+ label: existing.label || node.label,
332
+ data: compactData({ ...(existing.data ?? {}), ...(node.data ?? {}) }),
333
+ }))
334
+ }
335
+
336
+ function addEdge(builder: GraphBuilder, type: GraphEdgeType, from: string, to: string, data?: Record<string, unknown>): void {
337
+ const cleanedData = compactData(data ?? {})
338
+ const edge: GraphEdge = {
339
+ id: edgeId(type, from, to, cleanedData),
340
+ type,
341
+ from,
342
+ to,
343
+ ...(Object.keys(cleanedData).length > 0 ? { data: cleanedData } : {}),
344
+ }
345
+ builder.edges.set(edge.id, edge)
346
+ }
347
+
348
+ function normalizeGraph(builder: GraphBuilder): WorkspaceGraph {
349
+ const nodes = [...builder.nodes.values()].sort((a, b) => a.id.localeCompare(b.id))
350
+ return {
351
+ nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
352
+ edges: [...builder.edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
353
+ }
354
+ }
355
+
356
+ function cleanNode(node: GraphNode): GraphNode {
357
+ const data = compactData(node.data ?? {})
358
+ return {
359
+ id: node.id,
360
+ type: node.type as GraphNodeType,
361
+ ...(node.label ? { label: node.label } : {}),
362
+ ...(Object.keys(data).length > 0 ? { data } : {}),
363
+ }
364
+ }
365
+
366
+ function compactData(input: Record<string, unknown>): Record<string, unknown> {
367
+ const output: Record<string, unknown> = {}
368
+ for (const [key, value] of Object.entries(input)) {
369
+ if (value === undefined || value === null) continue
370
+ if (typeof value === "string" && value.trim() === "") continue
371
+ if (Array.isArray(value) && value.length === 0) continue
372
+ output[key] = value
373
+ }
374
+ return output
375
+ }
376
+
377
+ function sourceNodeId(value: string): string {
378
+ return `source:${stablePathOrHash(value)}`
379
+ }
380
+
381
+ function extractionNodeId(value: string): string {
382
+ return `extraction:${stablePathOrHash(value)}`
383
+ }
384
+
385
+ function findingNodeId(value: string): string {
386
+ return `finding:${stablePathOrHash(value)}`
387
+ }
388
+
389
+ function claimNodeId(slideIndex: number, text: string): string {
390
+ return `claim:${slideIndex}:${stableHash(normalizeText(text))}`
391
+ }
392
+
393
+ function slideNodeId(index: number): string {
394
+ return `slide:${index}`
395
+ }
396
+
397
+ function artifactNodeId(outputPath: string): string {
398
+ return `artifact:${stablePathOrHash(outputPath)}`
399
+ }
400
+
401
+ function edgeId(type: GraphEdgeType, from: string, to: string, data: Record<string, unknown>): string {
402
+ return `edge:${type}:${stableHash(JSON.stringify({ from, to, data }))}`
403
+ }
404
+
405
+ function stablePathOrHash(value: string): string {
406
+ const normalized = normalizePath(value)
407
+ if (/^[a-z0-9._/-]+$/i.test(normalized) && normalized.length <= 80) return normalized
408
+ return stableHash(normalized)
409
+ }
410
+
411
+ function stableHash(value: string): string {
412
+ return createHash("sha1").update(value).digest("hex").slice(0, 12)
413
+ }
414
+
415
+ function normalizePath(value: string): string {
416
+ return value.trim().replace(/\\/g, "/").replace(/^\.\//, "")
417
+ }
418
+
419
+ function normalizeText(value: string): string {
420
+ return value.trim().replace(/\s+/g, " ").toLowerCase()
421
+ }
422
+
423
+ function cleanOptionalText(value: string | undefined): string | undefined {
424
+ const text = String(value ?? "").trim()
425
+ return text || undefined
426
+ }