@unlaxer/dve-toolkit 4.1.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.
package/config.ts ADDED
@@ -0,0 +1,64 @@
1
+ // DVE configuration — multi-project support
2
+
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export interface ProjectConfig {
7
+ name: string;
8
+ path: string; // absolute path to project root
9
+ sessionsDir: string; // relative to path, default "dge/sessions"
10
+ decisionsDir: string;
11
+ specsDir: string;
12
+ annotationsDir: string;
13
+ }
14
+
15
+ export interface DVEConfig {
16
+ projects: ProjectConfig[];
17
+ outputDir: string; // where graph files go
18
+ }
19
+
20
+ const DEFAULT_DIRS = {
21
+ sessionsDir: "dge/sessions",
22
+ decisionsDir: "dge/decisions",
23
+ specsDir: "dge/specs",
24
+ annotationsDir: "dve/annotations",
25
+ };
26
+
27
+ export function loadConfig(configPath: string): DVEConfig | null {
28
+ if (!existsSync(configPath)) return null;
29
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
30
+ return {
31
+ outputDir: raw.outputDir ?? "dve/dist",
32
+ projects: (raw.projects ?? []).map((p: any) => ({
33
+ name: p.name ?? path.basename(p.path),
34
+ path: path.resolve(path.dirname(configPath), p.path),
35
+ sessionsDir: p.sessionsDir ?? DEFAULT_DIRS.sessionsDir,
36
+ decisionsDir: p.decisionsDir ?? DEFAULT_DIRS.decisionsDir,
37
+ specsDir: p.specsDir ?? DEFAULT_DIRS.specsDir,
38
+ annotationsDir: p.annotationsDir ?? DEFAULT_DIRS.annotationsDir,
39
+ })),
40
+ };
41
+ }
42
+
43
+ export function singleProjectConfig(cwd: string): DVEConfig {
44
+ return {
45
+ outputDir: path.join(cwd, "dve", "dist"),
46
+ projects: [
47
+ {
48
+ name: path.basename(cwd),
49
+ path: cwd,
50
+ ...DEFAULT_DIRS,
51
+ },
52
+ ],
53
+ };
54
+ }
55
+
56
+ export function resolveProjectDirs(project: ProjectConfig) {
57
+ return {
58
+ sessionsDir: path.join(project.path, project.sessionsDir),
59
+ decisionsDir: path.join(project.path, project.decisionsDir),
60
+ specsDir: path.join(project.path, project.specsDir),
61
+ annotationsDir: path.join(project.path, project.annotationsDir),
62
+ cwd: project.path,
63
+ };
64
+ }
@@ -0,0 +1,142 @@
1
+ // ContextBundle generator — DVE → DGE bridge
2
+
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import type { DVEGraph, GraphNode, ContextBundle, Gap, Decision, Session } from "../graph/schema.js";
6
+ import { traceDecision } from "../graph/query.js";
7
+
8
+ export interface BundleOptions {
9
+ graph: DVEGraph;
10
+ originId: string;
11
+ constraints?: string[];
12
+ outputDir: string;
13
+ }
14
+
15
+ function findNode(graph: DVEGraph, id: string): GraphNode | undefined {
16
+ return graph.nodes.find((n) => n.id === id);
17
+ }
18
+
19
+ function sessionGaps(graph: DVEGraph, sessionId: string): GraphNode[] {
20
+ const gapIds = graph.edges
21
+ .filter((e) => e.source === sessionId && e.type === "discovers")
22
+ .map((e) => e.target);
23
+ return graph.nodes.filter((n) => gapIds.includes(n.id));
24
+ }
25
+
26
+ function relatedDecisions(graph: DVEGraph, sessionId: string): GraphNode[] {
27
+ const gaps = sessionGaps(graph, sessionId);
28
+ const ddIds = new Set<string>();
29
+ for (const gap of gaps) {
30
+ const resolves = graph.edges.filter((e) => e.source === gap.id && e.type === "resolves");
31
+ for (const edge of resolves) ddIds.add(edge.target);
32
+ }
33
+ return graph.nodes.filter((n) => ddIds.has(n.id));
34
+ }
35
+
36
+ function relatedAnnotations(graph: DVEGraph, targetId: string): GraphNode[] {
37
+ const annIds = graph.edges
38
+ .filter((e) => e.target === targetId && e.type === "annotates")
39
+ .map((e) => e.source);
40
+ return graph.nodes.filter((n) => annIds.includes(n.id));
41
+ }
42
+
43
+ export function generateBundle(opts: BundleOptions): ContextBundle {
44
+ const { graph, originId, constraints = [], outputDir } = opts;
45
+ const origin = findNode(graph, originId);
46
+ if (!origin) throw new Error(`Node not found: ${originId}`);
47
+
48
+ // Find related session
49
+ let sessionNode: GraphNode | undefined;
50
+ let sessionData: Partial<Session> = {};
51
+
52
+ if (origin.type === "session") {
53
+ sessionNode = origin;
54
+ } else if (origin.type === "gap") {
55
+ const gapData = origin.data as Partial<Gap>;
56
+ sessionNode = findNode(graph, gapData.session_id ?? "");
57
+ } else if (origin.type === "decision") {
58
+ const ddData = origin.data as Partial<Decision>;
59
+ const sessionRef = ddData.session_refs?.[0];
60
+ if (sessionRef) sessionNode = findNode(graph, sessionRef);
61
+ }
62
+
63
+ if (sessionNode) sessionData = sessionNode.data as Partial<Session>;
64
+
65
+ // Gather context
66
+ const gaps = sessionNode ? sessionGaps(graph, sessionNode.id) : [];
67
+ const decisions = sessionNode ? relatedDecisions(graph, sessionNode.id) : [];
68
+ const annotations = relatedAnnotations(graph, originId);
69
+
70
+ // Determine action
71
+ let suggestedAction: ContextBundle["suggested_action"] = "revisit";
72
+ if (origin.type === "gap") suggestedAction = "deep_dive";
73
+ if (constraints.length > 0) suggestedAction = "new_angle";
74
+
75
+ // Sessions date range
76
+ const dates = [sessionData.date].filter(Boolean) as string[];
77
+ const dateRange = dates.length > 0 ? dates.join(" ~ ") : "unknown";
78
+
79
+ // Build prompt template
80
+ const originLabel =
81
+ origin.type === "decision"
82
+ ? `${origin.id} (${(origin.data as any).title})`
83
+ : origin.type === "gap"
84
+ ? `Gap "${(origin.data as any).summary?.slice(0, 60)}"`
85
+ : `Session "${sessionData.theme}"`;
86
+
87
+ const priorDDs = decisions.map((d) => `${d.id}: ${(d.data as any).title}`);
88
+ const priorGaps = gaps.slice(0, 5).map((g) => {
89
+ const gd = g.data as Partial<Gap>;
90
+ return `${g.id.split("#")[1]}: ${gd.summary?.slice(0, 50)}`;
91
+ });
92
+
93
+ let prompt = `${originLabel} を再検討。\n`;
94
+ if (priorDDs.length > 0) prompt += `前回の決定: ${priorDDs.join(", ")}。\n`;
95
+ if (priorGaps.length > 0) prompt += `前回の Gap: ${priorGaps.join("; ")}。\n`;
96
+ if (sessionData.characters?.length) {
97
+ prompt += `前回のキャラ: ${sessionData.characters.join(", ")}。\n`;
98
+ }
99
+ if (constraints.length > 0) {
100
+ prompt += `追加制約: ${constraints.join("; ")}。\n`;
101
+ }
102
+ prompt += `この文脈を踏まえて DGE して。`;
103
+
104
+ const bundle: ContextBundle = {
105
+ type: "dve-context-bundle",
106
+ version: "1.0.0",
107
+ origin: {
108
+ node_type: origin.type as any,
109
+ node_id: origin.id,
110
+ file: (origin.data as any).file_path ?? "",
111
+ },
112
+ summary: {
113
+ theme: sessionData.theme ?? "unknown",
114
+ date_range: dateRange,
115
+ prior_decisions: priorDDs,
116
+ prior_gaps: gaps.map((g) => ({
117
+ id: g.id,
118
+ summary: (g.data as any).summary ?? "",
119
+ status: (g.data as any).status ?? "Active",
120
+ })),
121
+ characters_used: sessionData.characters ?? [],
122
+ session_count: 1,
123
+ },
124
+ new_constraints: constraints,
125
+ annotations: annotations.map((a) => ({
126
+ date: (a.data as any).date ?? "",
127
+ action: (a.data as any).action ?? "comment",
128
+ body: (a.data as any).body ?? "",
129
+ })),
130
+ suggested_action: suggestedAction,
131
+ prompt_template: prompt,
132
+ };
133
+
134
+ // Save to file
135
+ mkdirSync(outputDir, { recursive: true });
136
+ const slug = originId.replace(/[^a-zA-Z0-9-]/g, "_");
137
+ const filename = `ctx-${new Date().toISOString().split("T")[0]}-${slug}.json`;
138
+ const outPath = path.join(outputDir, filename);
139
+ writeFileSync(outPath, JSON.stringify(bundle, null, 2));
140
+
141
+ return bundle;
142
+ }
@@ -0,0 +1,254 @@
1
+ // Graph builder — assemble nodes + edges from parse results
2
+
3
+ import { readdirSync, existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { parseSession } from "../parser/session-parser.js";
6
+ import { parseDecision } from "../parser/decision-parser.js";
7
+ import { parseAnnotation } from "../parser/annotation-parser.js";
8
+ import { parseSpec } from "../parser/spec-parser.js";
9
+ import { gitLinkerEdges } from "../parser/git-linker.js";
10
+ import { buildGlossary } from "../parser/glossary-builder.js";
11
+ import type { DVEGraph, GraphNode, Edge } from "./schema.js";
12
+
13
+ export interface BuildOptions {
14
+ sessionsDir: string;
15
+ decisionsDir: string;
16
+ specsDir: string;
17
+ annotationsDir: string;
18
+ cwd: string;
19
+ enableGitLinker?: boolean;
20
+ }
21
+
22
+ function scanMd(dir: string): string[] {
23
+ if (!existsSync(dir)) return [];
24
+ return readdirSync(dir)
25
+ .filter((f) => f.endsWith(".md") && f !== "index.md")
26
+ .map((f) => path.join(dir, f))
27
+ .sort();
28
+ }
29
+
30
+ export function buildGraph(opts: BuildOptions): DVEGraph {
31
+ const nodes: GraphNode[] = [];
32
+ const edges: Edge[] = [];
33
+ const warnings: { file: string; message: string }[] = [];
34
+
35
+ // 1. Parse sessions
36
+ const sessionFiles = scanMd(opts.sessionsDir);
37
+ for (const file of sessionFiles) {
38
+ const { session, gaps } = parseSession(file);
39
+ if (session.node.id) {
40
+ nodes.push({
41
+ type: "session",
42
+ id: session.node.id!,
43
+ data: session.node as any,
44
+ confidence: session.confidence,
45
+ warnings: session.warnings,
46
+ });
47
+ for (const w of session.warnings) {
48
+ warnings.push({ file, message: w });
49
+ }
50
+ }
51
+
52
+ // Dialogue node — sits between Session and Gaps
53
+ const dialogueId = `${session.node.id!}#dialogue`;
54
+ const hasDialogue = !!(session.node as any).content &&
55
+ /Scene|先輩|ナレーション|☕|👤|🎩|😰|⚔|🎨|📊/.test((session.node as any).content ?? "");
56
+ nodes.push({
57
+ type: "dialogue" as any,
58
+ id: dialogueId,
59
+ data: {
60
+ session_id: session.node.id!,
61
+ has_content: hasDialogue,
62
+ scene_count: ((session.node as any).content?.match(/##.*Scene/g) ?? []).length,
63
+ char_count: ((session.node as any).content?.match(/☕|👤|🎩|😰|⚔|🎨|📊|🏥|😈|🧑‍💼/g) ?? []).length,
64
+ } as any,
65
+ confidence: hasDialogue ? 1.0 : 0.3,
66
+ warnings: hasDialogue ? [] : ["会話劇テキスト未保存"],
67
+ });
68
+ // Session → Dialogue
69
+ edges.push({
70
+ source: session.node.id!,
71
+ target: dialogueId,
72
+ type: "contains",
73
+ confidence: "explicit",
74
+ });
75
+
76
+ for (const gap of gaps) {
77
+ if (gap.node.id) {
78
+ nodes.push({
79
+ type: "gap",
80
+ id: gap.node.id!,
81
+ data: gap.node as any,
82
+ confidence: gap.confidence,
83
+ warnings: gap.warnings,
84
+ });
85
+ // Dialogue → Gap (instead of Session → Gap)
86
+ edges.push({
87
+ source: dialogueId,
88
+ target: gap.node.id!,
89
+ type: "discovers",
90
+ confidence: "explicit",
91
+ });
92
+ for (const w of gap.warnings) {
93
+ warnings.push({ file, message: `${gap.node.id}: ${w}` });
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ // 2. Parse decisions
100
+ const ddFiles = scanMd(opts.decisionsDir);
101
+ for (const file of ddFiles) {
102
+ const dd = parseDecision(file);
103
+ if (dd.node.id) {
104
+ nodes.push({
105
+ type: "decision",
106
+ id: dd.node.id!,
107
+ data: dd.node as any,
108
+ confidence: dd.confidence,
109
+ warnings: dd.warnings,
110
+ });
111
+ for (const w of dd.warnings) {
112
+ warnings.push({ file, message: `${dd.node.id}: ${w}` });
113
+ }
114
+
115
+ // resolves edges: find gaps that this DD references
116
+ // Match session_refs to gaps in those sessions
117
+ for (const sessionRef of dd.node.session_refs ?? []) {
118
+ const sessionGaps = nodes.filter(
119
+ (n) => n.type === "gap" && (n.data as any).session_id === sessionRef
120
+ );
121
+ // If DD has specific gap_refs (#N), match them
122
+ if (dd.node.gap_refs && dd.node.gap_refs.length > 0) {
123
+ for (const gapRef of dd.node.gap_refs) {
124
+ const gapNum = gapRef.replace("#", "");
125
+ const matchingGap = sessionGaps.find((g) =>
126
+ g.id.endsWith(`#G-${gapNum.padStart(3, "0")}`)
127
+ );
128
+ if (matchingGap) {
129
+ edges.push({
130
+ source: matchingGap.id,
131
+ target: dd.node.id!,
132
+ type: "resolves",
133
+ confidence: "explicit",
134
+ evidence: `DD references Gap ${gapRef}`,
135
+ });
136
+ }
137
+ }
138
+ } else {
139
+ // No specific gap refs — link DD to session (inferred)
140
+ edges.push({
141
+ source: sessionRef,
142
+ target: dd.node.id!,
143
+ type: "resolves",
144
+ confidence: "inferred",
145
+ evidence: "DD references session without specific gap numbers",
146
+ });
147
+ }
148
+ }
149
+
150
+ // supersedes edges
151
+ for (const sup of dd.node.supersedes ?? []) {
152
+ edges.push({
153
+ source: dd.node.id!,
154
+ target: sup,
155
+ type: "supersedes",
156
+ confidence: "explicit",
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ // 3. Parse annotations
163
+ const annFiles = scanMd(opts.annotationsDir);
164
+ for (const file of annFiles) {
165
+ const ann = parseAnnotation(file);
166
+ if (ann.node.id) {
167
+ nodes.push({
168
+ type: "annotation",
169
+ id: ann.node.id!,
170
+ data: ann.node as any,
171
+ confidence: ann.confidence,
172
+ warnings: ann.warnings,
173
+ });
174
+ // annotates edge
175
+ if (ann.node.target?.id) {
176
+ edges.push({
177
+ source: ann.node.id!,
178
+ target: ann.node.target.id,
179
+ type: "annotates",
180
+ confidence: "explicit",
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ // 4. Parse specs
187
+ const specFiles = scanMd(opts.specsDir);
188
+ for (const file of specFiles) {
189
+ const spec = parseSpec(file);
190
+ if (spec.node.id) {
191
+ nodes.push({
192
+ type: "spec",
193
+ id: spec.node.id!,
194
+ data: spec.node as any,
195
+ confidence: spec.confidence,
196
+ warnings: spec.warnings,
197
+ });
198
+ // produces edges: Decision → Spec
199
+ for (const ddRef of spec.node.decision_refs ?? []) {
200
+ if (nodes.some((n) => n.id === ddRef)) {
201
+ edges.push({
202
+ source: ddRef,
203
+ target: spec.node.id!,
204
+ type: "produces",
205
+ confidence: "inferred",
206
+ evidence: `Spec references ${ddRef}`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ // 5. Git linker
214
+ if (opts.enableGitLinker !== false) {
215
+ const ddIds = new Set(nodes.filter((n) => n.type === "decision").map((n) => n.id));
216
+ const gitEdges = gitLinkerEdges(opts.cwd, ddIds);
217
+ for (const edge of gitEdges) {
218
+ // Add commit as external ref node if not exists
219
+ if (!nodes.some((n) => n.id === edge.target)) {
220
+ nodes.push({
221
+ type: "annotation" as any, // reuse for external refs
222
+ id: edge.target,
223
+ data: { type: "commit", ref: edge.target, evidence: edge.evidence } as any,
224
+ confidence: 0.8,
225
+ warnings: [],
226
+ });
227
+ }
228
+ edges.push(edge);
229
+ }
230
+ }
231
+
232
+ // Stats
233
+ const stats = {
234
+ sessions: nodes.filter((n) => n.type === "session").length,
235
+ gaps: nodes.filter((n) => n.type === "gap").length,
236
+ decisions: nodes.filter((n) => n.type === "decision").length,
237
+ annotations: nodes.filter((n) => n.type === "annotation").length,
238
+ specs: nodes.filter((n) => n.type === "spec").length,
239
+ };
240
+
241
+ // Build glossary from completed graph
242
+ const partialGraph = { version: "1.0.0", generated_at: "", stats, nodes, edges, warnings } as DVEGraph;
243
+ const glossary = buildGlossary(partialGraph, opts.cwd);
244
+
245
+ return {
246
+ version: "1.0.0",
247
+ generated_at: new Date().toISOString(),
248
+ stats,
249
+ nodes,
250
+ edges,
251
+ warnings,
252
+ glossary: glossary.entries,
253
+ };
254
+ }
@@ -0,0 +1,105 @@
1
+ // Clustering — group decisions by supersedes chains and theme similarity
2
+
3
+ import type { DVEGraph, GraphNode, Decision } from "./schema.js";
4
+
5
+ export interface Cluster {
6
+ id: string;
7
+ label: string;
8
+ ddIds: string[];
9
+ gapCount: number;
10
+ }
11
+
12
+ // Group DDs by supersedes chains (connected components)
13
+ export function clusterBySupersedes(graph: DVEGraph): Cluster[] {
14
+ const ddNodes = graph.nodes.filter((n) => n.type === "decision");
15
+ const supersedesEdges = graph.edges.filter((e) => e.type === "supersedes");
16
+
17
+ // Union-Find
18
+ const parent: Record<string, string> = {};
19
+ for (const dd of ddNodes) parent[dd.id] = dd.id;
20
+
21
+ function find(x: string): string {
22
+ if (parent[x] !== x) parent[x] = find(parent[x]);
23
+ return parent[x];
24
+ }
25
+ function union(a: string, b: string) {
26
+ const ra = find(a), rb = find(b);
27
+ if (ra !== rb) parent[ra] = rb;
28
+ }
29
+
30
+ for (const edge of supersedesEdges) {
31
+ if (parent[edge.source] !== undefined && parent[edge.target] !== undefined) {
32
+ union(edge.source, edge.target);
33
+ }
34
+ }
35
+
36
+ // Group by root
37
+ const groups: Record<string, string[]> = {};
38
+ for (const dd of ddNodes) {
39
+ const root = find(dd.id);
40
+ if (!groups[root]) groups[root] = [];
41
+ groups[root].push(dd.id);
42
+ }
43
+
44
+ // Build clusters
45
+ const clusters: Cluster[] = [];
46
+ for (const [root, ddIds] of Object.entries(groups)) {
47
+ // Find the latest DD's title as cluster label
48
+ const latestDD = ddIds
49
+ .map((id) => graph.nodes.find((n) => n.id === id))
50
+ .filter(Boolean)
51
+ .sort((a, b) => ((b!.data as any).date ?? "").localeCompare((a!.data as any).date ?? ""))
52
+ [0];
53
+
54
+ const label = (latestDD?.data as any)?.title ?? root;
55
+
56
+ // Count related gaps
57
+ const gapCount = ddIds.reduce((sum, ddId) => {
58
+ return sum + graph.edges.filter((e) => e.target === ddId && e.type === "resolves").length;
59
+ }, 0);
60
+
61
+ clusters.push({
62
+ id: `cluster-${root}`,
63
+ label: label.slice(0, 60),
64
+ ddIds,
65
+ gapCount,
66
+ });
67
+ }
68
+
69
+ return clusters.sort((a, b) => b.gapCount - a.gapCount);
70
+ }
71
+
72
+ // Simple keyword-based theme clustering for DDs not in supersedes chains
73
+ export function clusterByTheme(graph: DVEGraph): Cluster[] {
74
+ const ddNodes = graph.nodes.filter((n) => n.type === "decision");
75
+ const keywords: Record<string, string[]> = {};
76
+
77
+ for (const dd of ddNodes) {
78
+ const title = ((dd.data as any).title ?? "").toLowerCase();
79
+ // Extract significant words (3+ chars, not common)
80
+ const words = title.split(/[\s\-_\/]+/).filter((w: string) => w.length >= 3);
81
+ for (const word of words) {
82
+ if (!keywords[word]) keywords[word] = [];
83
+ keywords[word].push(dd.id);
84
+ }
85
+ }
86
+
87
+ // Find keyword groups with 2+ DDs
88
+ const clusters: Cluster[] = [];
89
+ const used = new Set<string>();
90
+ for (const [word, ids] of Object.entries(keywords)) {
91
+ if (ids.length < 2) continue;
92
+ const uniqueIds = ids.filter((id) => !used.has(id));
93
+ if (uniqueIds.length < 2) continue;
94
+
95
+ for (const id of uniqueIds) used.add(id);
96
+ clusters.push({
97
+ id: `theme-${word}`,
98
+ label: word,
99
+ ddIds: uniqueIds,
100
+ gapCount: 0,
101
+ });
102
+ }
103
+
104
+ return clusters;
105
+ }
package/graph/query.ts ADDED
@@ -0,0 +1,82 @@
1
+ // Graph queries — traceDecision, impactOf, orphanGaps, overturned
2
+
3
+ import type { DVEGraph, GraphNode, Edge, Gap, Decision } from "./schema.js";
4
+
5
+ // Trace a decision back to its originating sessions and gaps
6
+ export function traceDecision(graph: DVEGraph, ddId: string): GraphNode[] {
7
+ const chain: GraphNode[] = [];
8
+ const visited = new Set<string>();
9
+
10
+ function walkBack(nodeId: string) {
11
+ if (visited.has(nodeId)) return;
12
+ visited.add(nodeId);
13
+
14
+ const node = graph.nodes.find((n) => n.id === nodeId);
15
+ if (node) chain.push(node);
16
+
17
+ // Find edges where this node is the target
18
+ const incoming = graph.edges.filter((e) => e.target === nodeId);
19
+ for (const edge of incoming) {
20
+ walkBack(edge.source);
21
+ }
22
+ }
23
+
24
+ walkBack(ddId);
25
+ return chain;
26
+ }
27
+
28
+ // Forward traversal — find all nodes affected by a change to this node
29
+ export function impactOf(graph: DVEGraph, nodeId: string): GraphNode[] {
30
+ const impacted: GraphNode[] = [];
31
+ const visited = new Set<string>();
32
+
33
+ function walkForward(nid: string) {
34
+ if (visited.has(nid)) return;
35
+ visited.add(nid);
36
+
37
+ const outgoing = graph.edges.filter((e) => e.source === nid);
38
+ for (const edge of outgoing) {
39
+ const target = graph.nodes.find((n) => n.id === edge.target);
40
+ if (target && !visited.has(target.id)) {
41
+ impacted.push(target);
42
+ walkForward(target.id);
43
+ }
44
+ }
45
+ }
46
+
47
+ walkForward(nodeId);
48
+ return impacted;
49
+ }
50
+
51
+ // Orphan gaps — gaps not linked to any decision
52
+ export function orphanGaps(graph: DVEGraph): GraphNode[] {
53
+ const gapNodes = graph.nodes.filter((n) => n.type === "gap");
54
+ const resolvedGapIds = new Set(
55
+ graph.edges.filter((e) => e.type === "resolves").map((e) => e.source)
56
+ );
57
+ return gapNodes.filter((g) => !resolvedGapIds.has(g.id));
58
+ }
59
+
60
+ // Overturned decisions + their impact
61
+ export function overturned(graph: DVEGraph): { decision: GraphNode; impact: GraphNode[] }[] {
62
+ const overturnedDDs = graph.nodes.filter(
63
+ (n) => n.type === "decision" && (n.data as Decision).status === "overturned"
64
+ );
65
+ return overturnedDDs.map((dd) => ({
66
+ decision: dd,
67
+ impact: impactOf(graph, dd.id),
68
+ }));
69
+ }
70
+
71
+ // Search nodes by keyword
72
+ export function search(graph: DVEGraph, keyword: string): GraphNode[] {
73
+ const lower = keyword.toLowerCase();
74
+ return graph.nodes.filter((n) => {
75
+ const data = n.data as any;
76
+ const searchFields = [
77
+ data.title, data.theme, data.summary, data.body, data.rationale,
78
+ n.id,
79
+ ].filter(Boolean);
80
+ return searchFields.some((f: string) => f.toLowerCase().includes(lower));
81
+ });
82
+ }