whygraph 0.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/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/cli/commands/config.d.ts +14 -0
- package/dist/cli/commands/config.js +123 -0
- package/dist/cli/commands/down.d.ts +9 -0
- package/dist/cli/commands/down.js +46 -0
- package/dist/cli/commands/init.d.ts +17 -0
- package/dist/cli/commands/init.js +144 -0
- package/dist/cli/commands/issues.d.ts +10 -0
- package/dist/cli/commands/issues.js +376 -0
- package/dist/cli/commands/mcp.d.ts +2 -0
- package/dist/cli/commands/mcp.js +9 -0
- package/dist/cli/commands/restart.d.ts +11 -0
- package/dist/cli/commands/restart.js +43 -0
- package/dist/cli/commands/serve.d.ts +14 -0
- package/dist/cli/commands/serve.js +132 -0
- package/dist/cli/commands/server-utils.d.ts +6 -0
- package/dist/cli/commands/server-utils.js +94 -0
- package/dist/cli/commands/status.d.ts +11 -0
- package/dist/cli/commands/status.js +97 -0
- package/dist/cli/commands/up.d.ts +13 -0
- package/dist/cli/commands/up.js +62 -0
- package/dist/cli/commands/validate.d.ts +14 -0
- package/dist/cli/commands/validate.js +88 -0
- package/dist/cli/commands/viz.d.ts +7 -0
- package/dist/cli/commands/viz.js +97 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +33 -0
- package/dist/entity/id.d.ts +8 -0
- package/dist/entity/id.js +48 -0
- package/dist/entity/issues.d.ts +12 -0
- package/dist/entity/issues.js +68 -0
- package/dist/entity/parser.d.ts +6 -0
- package/dist/entity/parser.js +166 -0
- package/dist/entity/types.d.ts +54 -0
- package/dist/entity/types.js +21 -0
- package/dist/entity/validate.d.ts +12 -0
- package/dist/entity/validate.js +136 -0
- package/dist/entity/writer.d.ts +16 -0
- package/dist/entity/writer.js +142 -0
- package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
- package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
- package/dist/frontend/index.html +14 -0
- package/dist/graph/cascade.d.ts +10 -0
- package/dist/graph/cascade.js +49 -0
- package/dist/graph/decisions.d.ts +11 -0
- package/dist/graph/decisions.js +27 -0
- package/dist/graph/gaps.d.ts +10 -0
- package/dist/graph/gaps.js +58 -0
- package/dist/graph/nodes.d.ts +20 -0
- package/dist/graph/nodes.js +33 -0
- package/dist/graph/projection.d.ts +6 -0
- package/dist/graph/projection.js +44 -0
- package/dist/graph/query.d.ts +15 -0
- package/dist/graph/query.js +82 -0
- package/dist/graph/search.d.ts +2 -0
- package/dist/graph/search.js +23 -0
- package/dist/graph/supersede.d.ts +7 -0
- package/dist/graph/supersede.js +48 -0
- package/dist/graph/temporal.d.ts +13 -0
- package/dist/graph/temporal.js +28 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +10 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +340 -0
- package/dist/onboarding/interview.d.ts +22 -0
- package/dist/onboarding/interview.js +92 -0
- package/dist/onboarding/scan.d.ts +17 -0
- package/dist/onboarding/scan.js +106 -0
- package/dist/platform/rules.d.ts +8 -0
- package/dist/platform/rules.js +229 -0
- package/dist/server/core.d.ts +26 -0
- package/dist/server/core.js +111 -0
- package/dist/server/derived.d.ts +8 -0
- package/dist/server/derived.js +13 -0
- package/dist/server/etag.d.ts +9 -0
- package/dist/server/etag.js +25 -0
- package/dist/server/http.d.ts +13 -0
- package/dist/server/http.js +131 -0
- package/dist/server/pubsub.d.ts +12 -0
- package/dist/server/pubsub.js +19 -0
- package/dist/server/schema.d.ts +2 -0
- package/dist/server/schema.js +362 -0
- package/dist/server/stale-refs.d.ts +7 -0
- package/dist/server/stale-refs.js +23 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +98 -0
- package/dist/server/worktree-watcher.d.ts +20 -0
- package/dist/server/worktree-watcher.js +79 -0
- package/dist/server/worktree.d.ts +22 -0
- package/dist/server/worktree.js +84 -0
- package/package.json +73 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<title>whygraph</title>
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-F9dxfzD_.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ByZzPwVe.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Entity } from "../entity/types.js";
|
|
2
|
+
export interface CascadeResult {
|
|
3
|
+
removedIds: string[];
|
|
4
|
+
orphanedDecisionIds: string[];
|
|
5
|
+
partialDecisionPatches: Array<{
|
|
6
|
+
id: string;
|
|
7
|
+
remainingAffects: string[];
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export declare function cascadeRemoval(entities: Map<string, Entity>, nodeId: string, timestamp: string): CascadeResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import { buildGraph } from "./projection.js";
|
|
3
|
+
import { isDecisionNode } from "../entity/types.js";
|
|
4
|
+
const MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
5
|
+
export function cascadeRemoval(entities, nodeId, timestamp) {
|
|
6
|
+
const entityArray = Array.from(entities.values());
|
|
7
|
+
const graph = buildGraph(entityArray);
|
|
8
|
+
// Collect all descendants via COMPOSES edges (recursive)
|
|
9
|
+
const removalSet = new Set();
|
|
10
|
+
const queue = [nodeId];
|
|
11
|
+
while (queue.length > 0) {
|
|
12
|
+
const current = queue.pop();
|
|
13
|
+
/* v8 ignore next 1 */
|
|
14
|
+
if (removalSet.has(current))
|
|
15
|
+
continue;
|
|
16
|
+
if (!graph.hasNode(current))
|
|
17
|
+
continue;
|
|
18
|
+
removalSet.add(current);
|
|
19
|
+
// Find COMPOSES children: edges where current is source with label COMPOSES
|
|
20
|
+
graph.forEachOutEdge(current, (_edge, attrs, _source, target) => {
|
|
21
|
+
if (attrs.label === "COMPOSES" && !removalSet.has(target)) {
|
|
22
|
+
queue.push(target);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const removedIds = Array.from(removalSet);
|
|
27
|
+
const orphanedDecisionIds = [];
|
|
28
|
+
const partialDecisionPatches = [];
|
|
29
|
+
// Check all decisions
|
|
30
|
+
for (const entity of entityArray) {
|
|
31
|
+
if (!isDecisionNode(entity))
|
|
32
|
+
continue;
|
|
33
|
+
if (entity.removed_at !== undefined)
|
|
34
|
+
continue;
|
|
35
|
+
if (removalSet.has(entity.id))
|
|
36
|
+
continue;
|
|
37
|
+
const affectsInRemoval = entity.affects.filter((id) => removalSet.has(id));
|
|
38
|
+
if (affectsInRemoval.length === 0)
|
|
39
|
+
continue;
|
|
40
|
+
const remaining = entity.affects.filter((id) => !removalSet.has(id));
|
|
41
|
+
if (remaining.length === 0) {
|
|
42
|
+
orphanedDecisionIds.push(entity.id);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
partialDecisionPatches.push({ id: entity.id, remainingAffects: remaining });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { removedIds, orphanedDecisionIds, partialDecisionPatches };
|
|
49
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import type { DecisionNode, DecisionStatus, DecisionTag } from "../entity/types.js";
|
|
3
|
+
type MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
export interface DecisionFilters {
|
|
5
|
+
status?: DecisionStatus;
|
|
6
|
+
tags?: DecisionTag[];
|
|
7
|
+
dateFrom?: string;
|
|
8
|
+
dateTo?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getDecisions(graph: MultiDirectedGraph, filters?: DecisionFilters): DecisionNode[];
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function getDecisions(graph, filters) {
|
|
2
|
+
const results = [];
|
|
3
|
+
graph.forEachNode((nodeId, attributes) => {
|
|
4
|
+
if (attributes.label !== "Decision")
|
|
5
|
+
return;
|
|
6
|
+
// Exclude removed decisions
|
|
7
|
+
if (attributes.removed_at !== undefined)
|
|
8
|
+
return;
|
|
9
|
+
// Status filter (AND)
|
|
10
|
+
if (filters?.status !== undefined && attributes.status !== filters.status)
|
|
11
|
+
return;
|
|
12
|
+
// Tags filter (OR within tags)
|
|
13
|
+
if (filters?.tags !== undefined && filters.tags.length > 0) {
|
|
14
|
+
const nodeTags = attributes.tags;
|
|
15
|
+
const hasMatch = filters.tags.some((tag) => nodeTags.includes(tag));
|
|
16
|
+
if (!hasMatch)
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// Date range filters (AND)
|
|
20
|
+
if (filters?.dateFrom !== undefined && attributes.date < filters.dateFrom)
|
|
21
|
+
return;
|
|
22
|
+
if (filters?.dateTo !== undefined && attributes.date > filters.dateTo)
|
|
23
|
+
return;
|
|
24
|
+
results.push({ id: nodeId, ...attributes });
|
|
25
|
+
});
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import type { StructuralNode } from "../entity/types.js";
|
|
3
|
+
type MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
/**
|
|
5
|
+
* Find Feature/Component nodes with no inbound AFFECTS edges from any Decision.
|
|
6
|
+
* Order: Features first, then Components by COMPOSES depth (shallow first).
|
|
7
|
+
* Excludes removed nodes and App nodes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getGaps(graph: MultiDirectedGraph, limit?: number): StructuralNode[];
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find Feature/Component nodes with no inbound AFFECTS edges from any Decision.
|
|
3
|
+
* Order: Features first, then Components by COMPOSES depth (shallow first).
|
|
4
|
+
* Excludes removed nodes and App nodes.
|
|
5
|
+
*/
|
|
6
|
+
export function getGaps(graph, limit) {
|
|
7
|
+
const gaps = [];
|
|
8
|
+
graph.forEachNode((nodeId, attributes) => {
|
|
9
|
+
// Skip removed nodes
|
|
10
|
+
if (attributes.removed_at)
|
|
11
|
+
return;
|
|
12
|
+
// Only Feature and Component nodes
|
|
13
|
+
const label = attributes.label;
|
|
14
|
+
if (label !== "Feature" && label !== "Component")
|
|
15
|
+
return;
|
|
16
|
+
// Check for inbound AFFECTS edges
|
|
17
|
+
const hasAffects = graph.someInEdge(nodeId, (_edge, edgeAttrs) => {
|
|
18
|
+
return edgeAttrs.label === "AFFECTS";
|
|
19
|
+
});
|
|
20
|
+
if (!hasAffects) {
|
|
21
|
+
gaps.push({ id: nodeId, ...attributes });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
// Sort: Features first, then Components by COMPOSES depth (shallow first)
|
|
25
|
+
gaps.sort((a, b) => {
|
|
26
|
+
if (a.label !== b.label) {
|
|
27
|
+
return a.label === "Feature" ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
if (a.label === "Component" && b.label === "Component") {
|
|
30
|
+
const depthA = getComposesDepth(graph, a.id);
|
|
31
|
+
const depthB = getComposesDepth(graph, b.id);
|
|
32
|
+
return depthA - depthB;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
});
|
|
36
|
+
if (limit !== undefined) {
|
|
37
|
+
return gaps.slice(0, limit);
|
|
38
|
+
}
|
|
39
|
+
return gaps;
|
|
40
|
+
}
|
|
41
|
+
/** Count how many COMPOSES edges exist from root to this node. */
|
|
42
|
+
function getComposesDepth(graph, nodeId) {
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let current = nodeId;
|
|
45
|
+
while (true) {
|
|
46
|
+
let parentFound = null;
|
|
47
|
+
graph.forEachInEdge(current, (_edge, edgeAttrs, source) => {
|
|
48
|
+
if (edgeAttrs.label === "COMPOSES") {
|
|
49
|
+
parentFound = source;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (parentFound === null)
|
|
53
|
+
break;
|
|
54
|
+
depth++;
|
|
55
|
+
current = parentFound;
|
|
56
|
+
}
|
|
57
|
+
return depth;
|
|
58
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import type { NodeLabel } from "../entity/types.js";
|
|
3
|
+
type MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
export interface NodeFilters {
|
|
5
|
+
label?: NodeLabel;
|
|
6
|
+
parent?: string;
|
|
7
|
+
search?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface NodeSummary {
|
|
10
|
+
id: string;
|
|
11
|
+
label: NodeLabel;
|
|
12
|
+
name: string;
|
|
13
|
+
parent?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* List nodes with optional filtering. Returns lightweight summaries.
|
|
17
|
+
* Excludes removed nodes.
|
|
18
|
+
*/
|
|
19
|
+
export declare function listNodes(graph: MultiDirectedGraph, filters?: NodeFilters): NodeSummary[];
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List nodes with optional filtering. Returns lightweight summaries.
|
|
3
|
+
* Excludes removed nodes.
|
|
4
|
+
*/
|
|
5
|
+
export function listNodes(graph, filters) {
|
|
6
|
+
const results = [];
|
|
7
|
+
graph.forEachNode((nodeId, attributes) => {
|
|
8
|
+
// Exclude removed nodes
|
|
9
|
+
if (attributes.removed_at)
|
|
10
|
+
return;
|
|
11
|
+
const label = attributes.label;
|
|
12
|
+
const name = (attributes.name ?? attributes.title);
|
|
13
|
+
const parent = attributes.parent;
|
|
14
|
+
// Filter by label
|
|
15
|
+
if (filters?.label && label !== filters.label)
|
|
16
|
+
return;
|
|
17
|
+
// Filter by parent
|
|
18
|
+
if (filters?.parent && parent !== filters.parent)
|
|
19
|
+
return;
|
|
20
|
+
// Filter by search (case-insensitive substring on name/title)
|
|
21
|
+
if (filters?.search) {
|
|
22
|
+
const searchLower = filters.search.toLowerCase();
|
|
23
|
+
if (!name.toLowerCase().includes(searchLower))
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const summary = { id: nodeId, label, name };
|
|
27
|
+
if (parent !== undefined) {
|
|
28
|
+
summary.parent = parent;
|
|
29
|
+
}
|
|
30
|
+
results.push(summary);
|
|
31
|
+
});
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import type { Entity } from "../entity/types.js";
|
|
3
|
+
type MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
declare const MultiDirectedGraph: typeof graphology.MultiDirectedGraph;
|
|
5
|
+
export declare function buildGraph(entities: Entity[]): MultiDirectedGraph;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import { isStructuralNode, isDecisionNode } from "../entity/types.js";
|
|
3
|
+
const MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
export function buildGraph(entities) {
|
|
5
|
+
const graph = new MultiDirectedGraph();
|
|
6
|
+
// Phase 1: add all nodes
|
|
7
|
+
for (const entity of entities) {
|
|
8
|
+
const { id, ...attributes } = entity;
|
|
9
|
+
graph.addNode(id, attributes);
|
|
10
|
+
}
|
|
11
|
+
// Phase 2: derive edges
|
|
12
|
+
for (const entity of entities) {
|
|
13
|
+
// COMPOSES: parent → child
|
|
14
|
+
if (isStructuralNode(entity) && entity.parent !== undefined) {
|
|
15
|
+
if (graph.hasNode(entity.parent)) {
|
|
16
|
+
graph.addEdgeWithKey(`composes-${entity.parent}-${entity.id}`, entity.parent, entity.id, { label: "COMPOSES" });
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
console.warn(`Skipping COMPOSES edge: parent "${entity.parent}" not found for node "${entity.id}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// AFFECTS: decision → affected node
|
|
23
|
+
if (isDecisionNode(entity)) {
|
|
24
|
+
for (const targetId of entity.affects) {
|
|
25
|
+
if (graph.hasNode(targetId)) {
|
|
26
|
+
graph.addEdgeWithKey(`affects-${entity.id}-${targetId}`, entity.id, targetId, { label: "AFFECTS" });
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.warn(`Skipping AFFECTS edge: target "${targetId}" not found for decision "${entity.id}"`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// SUPERSEDES: new → old
|
|
33
|
+
if (entity.supersedes !== undefined) {
|
|
34
|
+
if (graph.hasNode(entity.supersedes)) {
|
|
35
|
+
graph.addEdgeWithKey(`supersedes-${entity.id}-${entity.supersedes}`, entity.id, entity.supersedes, { label: "SUPERSEDES" });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.warn(`Skipping SUPERSEDES edge: target "${entity.supersedes}" not found for decision "${entity.id}"`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return graph;
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import type { DecisionNode } from "../entity/types.js";
|
|
3
|
+
type MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
export interface ContextNodeResult {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
name: string;
|
|
8
|
+
parentChain: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface ContextResult {
|
|
11
|
+
nodes: ContextNodeResult[];
|
|
12
|
+
decisions: DecisionNode[];
|
|
13
|
+
}
|
|
14
|
+
export declare function getContext(graph: MultiDirectedGraph, file: string, symbol?: string): ContextResult;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
function refMatches(refs, file, symbol) {
|
|
2
|
+
for (const ref of refs) {
|
|
3
|
+
if (ref.file !== file)
|
|
4
|
+
continue;
|
|
5
|
+
// File-level ref (no symbol) always matches
|
|
6
|
+
if (ref.symbol === undefined)
|
|
7
|
+
return true;
|
|
8
|
+
// If no symbol filter requested, any file match counts
|
|
9
|
+
if (symbol === undefined)
|
|
10
|
+
return true;
|
|
11
|
+
// Symbol must match
|
|
12
|
+
if (ref.symbol === symbol)
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function getParentChain(graph, nodeId) {
|
|
18
|
+
const chain = [];
|
|
19
|
+
let current = nodeId;
|
|
20
|
+
while (true) {
|
|
21
|
+
// Find incoming COMPOSES edges (parent -> current)
|
|
22
|
+
const inEdges = graph.inEdges(current);
|
|
23
|
+
let foundParent = false;
|
|
24
|
+
for (const edgeKey of inEdges) {
|
|
25
|
+
if (graph.getEdgeAttribute(edgeKey, "label") === "COMPOSES") {
|
|
26
|
+
const parent = graph.source(edgeKey);
|
|
27
|
+
chain.push(parent);
|
|
28
|
+
current = parent;
|
|
29
|
+
foundParent = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!foundParent)
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
return chain;
|
|
37
|
+
}
|
|
38
|
+
function collectDecisions(graph, nodeIds) {
|
|
39
|
+
const decisions = new Map();
|
|
40
|
+
for (const nodeId of nodeIds) {
|
|
41
|
+
// Find incoming AFFECTS edges (decision -> node)
|
|
42
|
+
const inEdges = graph.inEdges(nodeId);
|
|
43
|
+
for (const edgeKey of inEdges) {
|
|
44
|
+
if (graph.getEdgeAttribute(edgeKey, "label") === "AFFECTS") {
|
|
45
|
+
const decisionId = graph.source(edgeKey);
|
|
46
|
+
/* v8 ignore next 1 */
|
|
47
|
+
if (!decisions.has(decisionId)) {
|
|
48
|
+
decisions.set(decisionId, {
|
|
49
|
+
id: decisionId,
|
|
50
|
+
...graph.getNodeAttributes(decisionId),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Array.from(decisions.values());
|
|
57
|
+
}
|
|
58
|
+
export function getContext(graph, file, symbol) {
|
|
59
|
+
const matchedNodes = [];
|
|
60
|
+
const allRelevantNodeIds = new Set();
|
|
61
|
+
graph.forEachNode((nodeId, attributes) => {
|
|
62
|
+
const refs = attributes.refs;
|
|
63
|
+
if (!refs || refs.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
if (!refMatches(refs, file, symbol))
|
|
66
|
+
return;
|
|
67
|
+
const parentChain = getParentChain(graph, nodeId);
|
|
68
|
+
matchedNodes.push({
|
|
69
|
+
id: nodeId,
|
|
70
|
+
label: attributes.label,
|
|
71
|
+
name: attributes.name,
|
|
72
|
+
parentChain,
|
|
73
|
+
});
|
|
74
|
+
// Collect this node and all ancestors for decision lookup
|
|
75
|
+
allRelevantNodeIds.add(nodeId);
|
|
76
|
+
for (const ancestorId of parentChain) {
|
|
77
|
+
allRelevantNodeIds.add(ancestorId);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const decisions = collectDecisions(graph, allRelevantNodeIds);
|
|
81
|
+
return { nodes: matchedNodes, decisions };
|
|
82
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isDecisionNode } from "../entity/types.js";
|
|
2
|
+
export function searchDecisions(entities, query) {
|
|
3
|
+
const lowerQuery = query.toLowerCase();
|
|
4
|
+
const results = [];
|
|
5
|
+
for (const entity of entities.values()) {
|
|
6
|
+
if (!isDecisionNode(entity))
|
|
7
|
+
continue;
|
|
8
|
+
if (entity.removed_at !== undefined)
|
|
9
|
+
continue;
|
|
10
|
+
const searchable = [
|
|
11
|
+
entity.title,
|
|
12
|
+
entity.context,
|
|
13
|
+
entity.decision,
|
|
14
|
+
entity.tradeoffs,
|
|
15
|
+
entity.alternatives,
|
|
16
|
+
];
|
|
17
|
+
const matches = searchable.some((field) => field.toLowerCase().includes(lowerQuery));
|
|
18
|
+
if (matches) {
|
|
19
|
+
results.push(entity);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Entity } from "../entity/types.js";
|
|
2
|
+
export interface SupersedeCandidate {
|
|
3
|
+
newDecisionId: string;
|
|
4
|
+
existingDecisionId: string;
|
|
5
|
+
sharedNodeIds: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function detectSupersedeCandidates(entities: Map<string, Entity>): SupersedeCandidate[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isDecisionNode } from "../entity/types.js";
|
|
2
|
+
export function detectSupersedeCandidates(entities) {
|
|
3
|
+
// Collect active, non-removed decisions
|
|
4
|
+
const activeDecisions = [];
|
|
5
|
+
for (const entity of entities.values()) {
|
|
6
|
+
if (!isDecisionNode(entity))
|
|
7
|
+
continue;
|
|
8
|
+
if (entity.removed_at !== undefined)
|
|
9
|
+
continue;
|
|
10
|
+
if (entity.status === "superseded")
|
|
11
|
+
continue;
|
|
12
|
+
activeDecisions.push(entity);
|
|
13
|
+
}
|
|
14
|
+
// Build a set of existing supersedes relationships for exclusion
|
|
15
|
+
const supersededPairs = new Set();
|
|
16
|
+
for (const entity of entities.values()) {
|
|
17
|
+
if (!isDecisionNode(entity))
|
|
18
|
+
continue;
|
|
19
|
+
if (entity.supersedes !== undefined) {
|
|
20
|
+
// Normalize as "newer->older"
|
|
21
|
+
supersededPairs.add(`${entity.id}->${entity.supersedes}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const candidates = [];
|
|
25
|
+
for (let i = 0; i < activeDecisions.length; i++) {
|
|
26
|
+
for (let j = i + 1; j < activeDecisions.length; j++) {
|
|
27
|
+
const a = activeDecisions[i];
|
|
28
|
+
const b = activeDecisions[j];
|
|
29
|
+
// Check if already in a supersedes relationship (either direction)
|
|
30
|
+
if (supersededPairs.has(`${a.id}->${b.id}`) ||
|
|
31
|
+
supersededPairs.has(`${b.id}->${a.id}`)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const aSet = new Set(a.affects);
|
|
35
|
+
const shared = b.affects.filter((id) => aSet.has(id));
|
|
36
|
+
if (shared.length > 0) {
|
|
37
|
+
// Newer decision (later date or later created_at) is "new", other is "existing"
|
|
38
|
+
const aIsNewer = a.created_at > b.created_at;
|
|
39
|
+
candidates.push({
|
|
40
|
+
newDecisionId: aIsNewer ? a.id : b.id,
|
|
41
|
+
existingDecisionId: aIsNewer ? b.id : a.id,
|
|
42
|
+
sharedNodeIds: shared,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return candidates;
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import graphology from "graphology";
|
|
2
|
+
import type { Entity } from "../entity/types.js";
|
|
3
|
+
type MultiDirectedGraph = graphology.MultiDirectedGraph;
|
|
4
|
+
/**
|
|
5
|
+
* Build a graph projection at a specific point in time.
|
|
6
|
+
* Includes entities where created_at <= timestamp AND (removed_at is null OR removed_at > timestamp).
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildGraphAt(entities: Entity[], timestamp: string): MultiDirectedGraph;
|
|
9
|
+
/**
|
|
10
|
+
* Collect all unique timestamps (created_at and removed_at) from entities, sorted chronologically.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getTimestamps(entities: Entity[]): string[];
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildGraph } from "./projection.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build a graph projection at a specific point in time.
|
|
4
|
+
* Includes entities where created_at <= timestamp AND (removed_at is null OR removed_at > timestamp).
|
|
5
|
+
*/
|
|
6
|
+
export function buildGraphAt(entities, timestamp) {
|
|
7
|
+
const filtered = entities.filter((entity) => {
|
|
8
|
+
if (entity.created_at > timestamp)
|
|
9
|
+
return false;
|
|
10
|
+
if (entity.removed_at !== undefined && entity.removed_at <= timestamp)
|
|
11
|
+
return false;
|
|
12
|
+
return true;
|
|
13
|
+
});
|
|
14
|
+
return buildGraph(filtered);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Collect all unique timestamps (created_at and removed_at) from entities, sorted chronologically.
|
|
18
|
+
*/
|
|
19
|
+
export function getTimestamps(entities) {
|
|
20
|
+
const set = new Set();
|
|
21
|
+
for (const entity of entities) {
|
|
22
|
+
set.add(entity.created_at);
|
|
23
|
+
if (entity.removed_at !== undefined) {
|
|
24
|
+
set.add(entity.removed_at);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...set].sort();
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP server entrypoint. When run directly, starts the stdio-based MCP server.
|
|
4
|
+
* For programmatic use, import from ./server.js instead.
|
|
5
|
+
*/
|
|
6
|
+
import { startMcpServer } from "./server.js";
|
|
7
|
+
startMcpServer().catch((err) => {
|
|
8
|
+
console.error("Failed to start MCP server:", err);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|