@rahmantech/knl-view 0.0.0-stage → 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 +201 -0
- package/README.md +398 -2
- package/bin/knl-view.js +943 -0
- package/dist/assets/abnfDiagram-WEYOIA3C-B1gaBaSI.js +1 -0
- package/dist/assets/arc-NZ9uRaCl.js +1 -0
- package/dist/assets/architecture-WOLXFQ4H-8MalLwTd.js +1 -0
- package/dist/assets/architectureDiagram-ZHVYKT53-eZ8i5cl4.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-RJAE7QA3-BavOE8BM.js +140 -0
- package/dist/assets/c4Diagram-XDPPPE54-DqMaTAzx.js +38 -0
- package/dist/assets/channel-BZ2_TTTJ.js +1 -0
- package/dist/assets/chunk-2E7WHVMD-B1Hj2BjJ.js +88 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4EMNKR2T-HDscrCiq.js +1 -0
- package/dist/assets/chunk-55XCU76K-QR_ARj8I.js +1 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-7S6XSTFO-B0p3o547.js +2 -0
- package/dist/assets/chunk-AQDTSKZB-CCxwNyyA.js +72 -0
- package/dist/assets/chunk-DSNLSHJZ-BM5PBFmz.js +129 -0
- package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/assets/chunk-HAGFOTED-UCDVFuN_.js +62 -0
- package/dist/assets/chunk-HNE4Q36P-C_1sakp2.js +1 -0
- package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/assets/chunk-NQOKRVIF-b2D1Z4oK.js +213 -0
- package/dist/assets/chunk-UHW7PFBP-CJs0VwBY.js +1 -0
- package/dist/assets/chunk-VQ5W62QI-DQxjgE4_.js +1 -0
- package/dist/assets/chunk-WQQL4CDK-DcjPeAfQ.js +10 -0
- package/dist/assets/chunk-XXDRQBXY-glA0kB5s.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/chunk-YZO67HFG-DJqACL5V.js +1 -0
- package/dist/assets/chunk-ZAUX2LTX-CWfaYtwb.js +1 -0
- package/dist/assets/chunk-ZCHMQSOR-BGX73Hho.js +2 -0
- package/dist/assets/classDiagram-v2-4MYWR3UA-BpYXPz-e.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-e1quKm0Q.js +1 -0
- package/dist/assets/cynefin-EF2NZ3EQ-hb3oUmFO.js +1 -0
- package/dist/assets/cynefinDiagram-WF24UDRP-CnMQvLqi.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-NK4GIZVX-BH_Y8y3F.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-CBK3MWTC-Dh79VetZ.js +24 -0
- package/dist/assets/diagram-I5XIIV2D-D3u-o1RV.js +24 -0
- package/dist/assets/diagram-ODIL2UCW-Bfuo-OKS.js +30 -0
- package/dist/assets/diagram-PFBEJUXE-CwHasIbs.js +3 -0
- package/dist/assets/diagram-RJA3I4VV-D8ttmuE8.js +41 -0
- package/dist/assets/diagram-TYUNFSNR-CzC-8RZ0.js +200 -0
- package/dist/assets/dist-CUJYGnnI.js +1 -0
- package/dist/assets/ebnfDiagram-V4P3TTPT-9AAhU7In.js +1 -0
- package/dist/assets/elk-QONTBCTQ-BgvnD1v_.js +27 -0
- package/dist/assets/erDiagram-KYQIGQAA-RLtbiLlP.js +99 -0
- package/dist/assets/eventmodeling-K75KTNOO-DVY22vNZ.js +1 -0
- package/dist/assets/flowDiagram-IUGLLWBU-l7L5DTb5.js +1 -0
- package/dist/assets/ganttDiagram-R7GPNTJY-hHiBGa9W.js +292 -0
- package/dist/assets/gitGraph-VSP46ZUC-Cvxay1wh.js +1 -0
- package/dist/assets/gitGraphDiagram-PDYL7XHY-AOhlpSqg.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-CBLNQvK3.js +76 -0
- package/dist/assets/index-DLgMsUs2.css +1 -0
- package/dist/assets/info-OHQRW6UA-CJ8EUO1m.js +1 -0
- package/dist/assets/infoDiagram-MVDRKAX5-DOQu7oYI.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-HPEHV2GE-DcVMvGmx.js +70 -0
- package/dist/assets/journeyDiagram-BOSHW7O7-luMeqEqz.js +139 -0
- package/dist/assets/kanban-definition-FNSA4HC5-KTZbmrW2.js +89 -0
- package/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/assets/line-CmPHiOVm.js +1 -0
- package/dist/assets/linear-DHYlKXBA.js +1 -0
- package/dist/assets/mermaid-parser.core-BRDI-WhC.js +7 -0
- package/dist/assets/mermaid.core-DPTRaYLy.js +44 -0
- package/dist/assets/mindmap-definition-6OY57UG3-70EN5Kw_.js +98 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-JDAUHWVQ-CXWz83oS.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-ECIUJNBX-z5UXKmp4.js +1 -0
- package/dist/assets/pie-XZMESJXO-rWI1HIQ7.js +1 -0
- package/dist/assets/pieDiagram-GNMPY74T-Bh4Kv3NQ.js +39 -0
- package/dist/assets/quadrantDiagram-D57K4MC3-wGM4Ckig.js +7 -0
- package/dist/assets/radar-ABXABTNO-CIJ92p5N.js +1 -0
- package/dist/assets/railroad-I3PHUGI6-8bxua-Hf.js +1 -0
- package/dist/assets/railroad-abnf-LNEFI6M7-B9KVI10m.js +1 -0
- package/dist/assets/railroad-ebnf-DWYD2UWJ-Bt73rnyJ.js +1 -0
- package/dist/assets/railroad-peg-JR7BVNR7-Cie82UPT.js +1 -0
- package/dist/assets/railroadDiagram-Y72NRBL7-C1nmi_tl.js +1 -0
- package/dist/assets/requirementDiagram-EQYSW5RA-BSianW40.js +84 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-SY24JHO5-DIa11-h7.js +40 -0
- package/dist/assets/sequenceDiagram-BLEHNHQU-Df5rZKz2.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-dClg85lX.js +1 -0
- package/dist/assets/stateDiagram-v2-A6IESKWZ-B15PTSvX.js +291 -0
- package/dist/assets/swimlanes-B3ENRKFQ-C9mgRCMi.js +1 -0
- package/dist/assets/swimlanesDiagram-AYQRV4AK-BGFq4t7O.js +8 -0
- package/dist/assets/timeline-definition-JXYKGLGB-BDVf_mIv.js +120 -0
- package/dist/assets/treeView-D4JQ5SDB-BbBK-25J.js +1 -0
- package/dist/assets/treemap-SAJKECNS-CEDX8apk.js +1 -0
- package/dist/assets/usecaseDiagram-WPOLBNNX-D1QkwFF8.js +328 -0
- package/dist/assets/vennDiagram-Q3Y3IDCW-5PeGZvEO.js +34 -0
- package/dist/assets/wardley-7MLQ67FV-BatN94pO.js +1 -0
- package/dist/assets/wardleyDiagram-IVAIDKWA-DyCcDS3Y.js +78 -0
- package/dist/assets/xychartDiagram-4URNHDU4-CbCXcRlg.js +7 -0
- package/dist/index.html +22 -0
- package/package.json +46 -4
- package/server/config.js +263 -0
- package/server/consensus.js +175 -0
- package/server/graph/anchordb_client.js +253 -0
- package/server/graph/model.js +157 -0
- package/server/graph/parser.js +326 -0
- package/server/graph/sources.js +660 -0
- package/server/graph/syntax.js +257 -0
- package/server/index.js +679 -0
- package/server/install.js +97 -0
- package/server/map.js +126 -0
- package/server/models_registry.js +84 -0
- package/server/scaffold.js +376 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Directed graph (DAG) data model for knowledges-view: knowledge nodes and
|
|
2
|
+
// decision nodes.
|
|
3
|
+
|
|
4
|
+
export class GraphNode {
|
|
5
|
+
constructor({ id, title, type = 'knowledge', path = '', metadata = {} }) {
|
|
6
|
+
this.id = id
|
|
7
|
+
this.title = title || id
|
|
8
|
+
this.type = type // 'knowledge' | 'decision'
|
|
9
|
+
this.path = path
|
|
10
|
+
this.metadata = metadata
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
toJSON() {
|
|
14
|
+
return {
|
|
15
|
+
id: this.id,
|
|
16
|
+
title: this.title,
|
|
17
|
+
type: this.type,
|
|
18
|
+
path: this.path,
|
|
19
|
+
metadata: this.metadata
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class GraphEdge {
|
|
25
|
+
constructor({ from, to, type, metadata = {} }) {
|
|
26
|
+
this.from = from
|
|
27
|
+
this.to = to
|
|
28
|
+
this.type = type // 'depends_on' | 'references' | 'supersedes' | 'related_to' | 'affects_knowledge'
|
|
29
|
+
this.metadata = metadata
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
toJSON() {
|
|
33
|
+
return {
|
|
34
|
+
from: this.from,
|
|
35
|
+
to: this.to,
|
|
36
|
+
type: this.type,
|
|
37
|
+
metadata: this.metadata
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class DirectedGraph {
|
|
43
|
+
constructor() {
|
|
44
|
+
this.nodes = new Map()
|
|
45
|
+
this.edges = []
|
|
46
|
+
this.outgoing = new Map() // nodeId -> Array of edges
|
|
47
|
+
this.incoming = new Map() // nodeId -> Array of edges
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
addNode(node) {
|
|
51
|
+
if (!node || !node.id) return
|
|
52
|
+
this.nodes.set(node.id, node)
|
|
53
|
+
if (!this.outgoing.has(node.id)) this.outgoing.set(node.id, [])
|
|
54
|
+
if (!this.incoming.has(node.id)) this.incoming.set(node.id, [])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
addEdge(edge) {
|
|
58
|
+
if (!edge || !edge.from || !edge.to) return
|
|
59
|
+
// Make sure the nodes exist in the outgoing/incoming maps
|
|
60
|
+
if (!this.outgoing.has(edge.from)) this.outgoing.set(edge.from, [])
|
|
61
|
+
if (!this.incoming.has(edge.to)) this.incoming.set(edge.to, [])
|
|
62
|
+
|
|
63
|
+
this.edges.push(edge)
|
|
64
|
+
this.outgoing.get(edge.from).push(edge)
|
|
65
|
+
this.incoming.get(edge.to).push(edge)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getNode(id) {
|
|
69
|
+
return this.nodes.get(id) || null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getOutgoingEdges(id) {
|
|
73
|
+
return this.outgoing.get(id) || []
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getIncomingEdges(id) {
|
|
77
|
+
return this.incoming.get(id) || []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Radius-based N-hop sub-graph extraction (cheap enough to feed to an LLM).
|
|
81
|
+
//
|
|
82
|
+
// `direction` selects which edges the BFS may walk, mechanically relative
|
|
83
|
+
// to the edges themselves, never interpreted as cause and effect:
|
|
84
|
+
// 'out': edges leaving the target (what it supersedes / depends_on / affects)
|
|
85
|
+
// 'in': edges entering the target (what references or replaces it)
|
|
86
|
+
// 'both': the default; the full neighborhood as before
|
|
87
|
+
// Each returned node carries `via`: how it connects to the center
|
|
88
|
+
// ('center', 'in', 'out', or 'in+out' when reachable both ways).
|
|
89
|
+
extractSubGraph(startId, maxHops = 2, direction = 'both') {
|
|
90
|
+
const walkOut = direction !== 'in'
|
|
91
|
+
const walkIn = direction !== 'out'
|
|
92
|
+
const reachedVia = new Map() // id -> 'in' | 'out' | 'in+out' | 'center'
|
|
93
|
+
const setVia = (id, via) => {
|
|
94
|
+
const prev = reachedVia.get(id)
|
|
95
|
+
if (prev === undefined) reachedVia.set(id, via)
|
|
96
|
+
else if (prev !== via && prev !== 'center' && via !== 'center') reachedVia.set(id, 'in+out')
|
|
97
|
+
}
|
|
98
|
+
setVia(startId, 'center')
|
|
99
|
+
const collectedEdges = []
|
|
100
|
+
const seenEdges = new Set()
|
|
101
|
+
const queue = [{ id: startId, hop: 0 }]
|
|
102
|
+
|
|
103
|
+
while (queue.length > 0) {
|
|
104
|
+
const { id, hop } = queue.shift()
|
|
105
|
+
if (hop >= maxHops) continue
|
|
106
|
+
|
|
107
|
+
if (walkOut) {
|
|
108
|
+
for (const edge of this.getOutgoingEdges(id)) {
|
|
109
|
+
const edgeKey = `${edge.from}->${edge.to}:${edge.type}`
|
|
110
|
+
if (!seenEdges.has(edgeKey)) {
|
|
111
|
+
seenEdges.add(edgeKey)
|
|
112
|
+
collectedEdges.push(edge)
|
|
113
|
+
}
|
|
114
|
+
if (!reachedVia.has(edge.to)) {
|
|
115
|
+
queue.push({ id: edge.to, hop: hop + 1 })
|
|
116
|
+
}
|
|
117
|
+
setVia(edge.to, 'out')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (walkIn) {
|
|
122
|
+
for (const edge of this.getIncomingEdges(id)) {
|
|
123
|
+
const edgeKey = `${edge.from}->${edge.to}:${edge.type}`
|
|
124
|
+
if (!seenEdges.has(edgeKey)) {
|
|
125
|
+
seenEdges.add(edgeKey)
|
|
126
|
+
collectedEdges.push(edge)
|
|
127
|
+
}
|
|
128
|
+
if (!reachedVia.has(edge.from)) {
|
|
129
|
+
queue.push({ id: edge.from, hop: hop + 1 })
|
|
130
|
+
}
|
|
131
|
+
setVia(edge.from, 'in')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const subNodes = []
|
|
137
|
+
for (const [id, via] of reachedVia) {
|
|
138
|
+
const node = this.getNode(id)
|
|
139
|
+
if (node) subNodes.push({ ...node.toJSON(), via })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
center: startId,
|
|
144
|
+
maxHops,
|
|
145
|
+
direction,
|
|
146
|
+
nodes: subNodes,
|
|
147
|
+
edges: collectedEdges.map(e => e.toJSON())
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toJSON() {
|
|
152
|
+
return {
|
|
153
|
+
nodes: Array.from(this.nodes.values()).map(n => n.toJSON()),
|
|
154
|
+
edges: this.edges.map(e => e.toJSON())
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { GraphNode, GraphEdge, DirectedGraph } from './model.js'
|
|
4
|
+
|
|
5
|
+
const MD_EXTS = new Set(['.md', '.markdown', '.prd'])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simple (YAML-like) frontmatter extraction and the first heading.
|
|
9
|
+
*/
|
|
10
|
+
export function extractFrontmatterAndTitle(content) {
|
|
11
|
+
let metadata = {}
|
|
12
|
+
let body = content
|
|
13
|
+
let title = null
|
|
14
|
+
|
|
15
|
+
if (content.startsWith('---')) {
|
|
16
|
+
const endIdx = content.indexOf('\n---', 3)
|
|
17
|
+
if (endIdx !== -1) {
|
|
18
|
+
const rawFm = content.slice(3, endIdx).trim()
|
|
19
|
+
body = content.slice(endIdx + 4)
|
|
20
|
+
metadata = parseSimpleYaml(rawFm)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (metadata.title) {
|
|
25
|
+
title = String(metadata.title).trim()
|
|
26
|
+
} else {
|
|
27
|
+
// Take the first H1 as the title
|
|
28
|
+
const match = body.match(/^#\s+(.+)$/m)
|
|
29
|
+
if (match) {
|
|
30
|
+
title = match[1].trim()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { metadata, body, title }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseSimpleYaml(text) {
|
|
38
|
+
const result = {}
|
|
39
|
+
const lines = text.split('\n')
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const match = line.match(/^([a-zA-Z0-9_-]+)\s*:\s*(.*)$/)
|
|
42
|
+
if (!match) continue
|
|
43
|
+
const key = match[1].trim()
|
|
44
|
+
let val = match[2].trim()
|
|
45
|
+
|
|
46
|
+
// Handle array format [a, b, c]
|
|
47
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
48
|
+
const items = val.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
49
|
+
result[key] = items
|
|
50
|
+
} else {
|
|
51
|
+
// Strip quotes when present
|
|
52
|
+
val = val.replace(/^['"]|['"]$/g, '')
|
|
53
|
+
result[key] = val
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a knowledge Markdown file (e.g. docs/xxx.md or features/xxx.md).
|
|
61
|
+
*/
|
|
62
|
+
export function parseKnowledgeContent(relPath, content) {
|
|
63
|
+
const { metadata, body, title } = extractFrontmatterAndTitle(content)
|
|
64
|
+
const id = relPath.replace(/\\/g, '/')
|
|
65
|
+
const node = new GraphNode({
|
|
66
|
+
id,
|
|
67
|
+
title: title || path.basename(relPath),
|
|
68
|
+
type: 'knowledge',
|
|
69
|
+
path: id,
|
|
70
|
+
metadata
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const edges = []
|
|
74
|
+
|
|
75
|
+
// Parse dependencies from the frontmatter
|
|
76
|
+
const deps = Array.isArray(metadata.depends_on) ? metadata.depends_on : (metadata.depends_on ? [metadata.depends_on] : [])
|
|
77
|
+
for (const dep of deps) {
|
|
78
|
+
edges.push(new GraphEdge({
|
|
79
|
+
from: id,
|
|
80
|
+
to: String(dep).trim(),
|
|
81
|
+
type: 'depends_on'
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parse references from the frontmatter
|
|
86
|
+
const refs = Array.isArray(metadata.references) ? metadata.references : (metadata.references ? [metadata.references] : [])
|
|
87
|
+
for (const ref of refs) {
|
|
88
|
+
edges.push(new GraphEdge({
|
|
89
|
+
from: id,
|
|
90
|
+
to: String(ref).trim(),
|
|
91
|
+
type: 'references'
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse plain markdown links [text](target.md)
|
|
96
|
+
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
|
|
97
|
+
let linkMatch
|
|
98
|
+
while ((linkMatch = linkRegex.exec(body)) !== null) {
|
|
99
|
+
const targetUrl = linkMatch[2].trim()
|
|
100
|
+
// Ignore external web links (http/https/mailto)
|
|
101
|
+
if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://') && !targetUrl.startsWith('mailto:') && !targetUrl.startsWith('#')) {
|
|
102
|
+
const cleanTarget = targetUrl.split('#')[0].split('?')[0].replace(/^\.\//, '')
|
|
103
|
+
if (cleanTarget) {
|
|
104
|
+
const docDir = path.dirname(relPath).replace(/\\/g, '/')
|
|
105
|
+
const resolvedTarget = (docDir && docDir !== '.' && !cleanTarget.startsWith('/'))
|
|
106
|
+
? path.posix.normalize(`${docDir}/${cleanTarget}`)
|
|
107
|
+
: cleanTarget.replace(/^\//, '')
|
|
108
|
+
|
|
109
|
+
if (resolvedTarget && resolvedTarget !== id) {
|
|
110
|
+
edges.push(new GraphEdge({
|
|
111
|
+
from: id,
|
|
112
|
+
to: resolvedTarget,
|
|
113
|
+
type: 'references',
|
|
114
|
+
metadata: { text: linkMatch[1] }
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { node, edges }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse decision blocks from a decision file (decisionskills format: <!-- decision:start -->).
|
|
126
|
+
*/
|
|
127
|
+
export function parseDecisionBlocks(relPath, content) {
|
|
128
|
+
const nodes = []
|
|
129
|
+
const edges = []
|
|
130
|
+
|
|
131
|
+
const blockRegex = /<!--\s*decision:start\s*-->([\s\S]*?)<!--\s*decision:end\s*-->/g
|
|
132
|
+
let match
|
|
133
|
+
|
|
134
|
+
while ((match = blockRegex.exec(content)) !== null) {
|
|
135
|
+
const rawBlock = match[1]
|
|
136
|
+
const parsed = parseSingleDecisionBlock(relPath, rawBlock)
|
|
137
|
+
if (parsed) {
|
|
138
|
+
nodes.push(parsed.node)
|
|
139
|
+
edges.push(...parsed.edges)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { nodes, edges }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Helper for list fields that may repeat: the lines "- references: a" then
|
|
147
|
+
// "- references: b" produce [a, b], not just [b].
|
|
148
|
+
function appendList(existing, val) {
|
|
149
|
+
const items = val.split(',').map(s => s.trim().replace(/^`|`$/g, '')).filter(Boolean)
|
|
150
|
+
return existing.concat(items.filter((item) => !existing.includes(item)))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseSingleDecisionBlock(relPath, rawBlock) { const lines = rawBlock.split('\n')
|
|
154
|
+
let title = 'Untitled Decision'
|
|
155
|
+
let id = null
|
|
156
|
+
let decidedAt = null
|
|
157
|
+
let scope = null
|
|
158
|
+
let tags = []
|
|
159
|
+
let supersedes = []
|
|
160
|
+
let related = []
|
|
161
|
+
let references = []
|
|
162
|
+
let decisionText = ''
|
|
163
|
+
|
|
164
|
+
let currentSection = null
|
|
165
|
+
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
const trimmed = line.trim()
|
|
168
|
+
|
|
169
|
+
// Title heading (## Title)
|
|
170
|
+
if (trimmed.startsWith('## ')) {
|
|
171
|
+
title = trimmed.slice(3).trim()
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Section header (### Section)
|
|
176
|
+
if (trimmed.startsWith('### ')) {
|
|
177
|
+
currentSection = trimmed.slice(4).trim().toLowerCase()
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Metadata bullet items (- Field: Value)
|
|
182
|
+
const metaMatch = trimmed.match(/^-\s+([^:]+):\s*(.*)$/)
|
|
183
|
+
if (metaMatch && !currentSection) {
|
|
184
|
+
const field = metaMatch[1].trim().toLowerCase()
|
|
185
|
+
const val = metaMatch[2].trim().replace(/^`|`$/g, '')
|
|
186
|
+
|
|
187
|
+
if (val === 'none' || !val) continue
|
|
188
|
+
|
|
189
|
+
if (field === 'id') id = val
|
|
190
|
+
else if (field === 'decided at') decidedAt = val
|
|
191
|
+
else if (field === 'scope') scope = val
|
|
192
|
+
else if (field === 'tags') tags = val.split(',').map(s => s.trim().replace(/^`|`$/g, '')).filter(Boolean)
|
|
193
|
+
// List fields may repeat across lines; the second line appends,
|
|
194
|
+
// it does not overwrite the first.
|
|
195
|
+
else if (field === 'supersedes') supersedes = appendList(supersedes, val)
|
|
196
|
+
else if (field === 'related') related = appendList(related, val)
|
|
197
|
+
else if (field === 'references') references = appendList(references, val)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Body content per section
|
|
202
|
+
if (currentSection === 'decision' && trimmed) {
|
|
203
|
+
decisionText += (decisionText ? ' ' : '') + trimmed
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!id) return null
|
|
208
|
+
|
|
209
|
+
const node = new GraphNode({
|
|
210
|
+
id,
|
|
211
|
+
title,
|
|
212
|
+
type: 'decision',
|
|
213
|
+
path: relPath.replace(/\\/g, '/'),
|
|
214
|
+
metadata: {
|
|
215
|
+
decided_at: decidedAt,
|
|
216
|
+
scope,
|
|
217
|
+
tags,
|
|
218
|
+
decision: decisionText
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const edges = []
|
|
223
|
+
|
|
224
|
+
for (const sup of supersedes) {
|
|
225
|
+
edges.push(new GraphEdge({
|
|
226
|
+
from: id,
|
|
227
|
+
to: sup,
|
|
228
|
+
type: 'supersedes'
|
|
229
|
+
}))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const rel of related) {
|
|
233
|
+
edges.push(new GraphEdge({
|
|
234
|
+
from: id,
|
|
235
|
+
to: rel,
|
|
236
|
+
type: 'related_to'
|
|
237
|
+
}))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// References are resolved relative to the decision file's own folder,
|
|
241
|
+
// the same rule markdown links follow: a decision in decisions/2026-10.md
|
|
242
|
+
// writing `ref/DEC-x.md` points at decisions/ref/DEC-x.md. Absolute and
|
|
243
|
+
// URL-shaped values are kept as-is.
|
|
244
|
+
const docDir = path.posix.dirname(relPath.replace(/\\/g, '/'))
|
|
245
|
+
for (const ref of references) {
|
|
246
|
+
const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(ref)
|
|
247
|
+
const isDocRef = !isUrl && (ref.endsWith('.md') || ref.includes('/'))
|
|
248
|
+
let target = ref
|
|
249
|
+
if (isDocRef && !ref.startsWith('/')) {
|
|
250
|
+
target = (docDir && docDir !== '.')
|
|
251
|
+
? path.posix.normalize(`${docDir}/${ref}`)
|
|
252
|
+
: ref.replace(/^\//, '')
|
|
253
|
+
}
|
|
254
|
+
edges.push(new GraphEdge({
|
|
255
|
+
from: id,
|
|
256
|
+
to: target,
|
|
257
|
+
type: isDocRef ? 'affects_knowledge' : 'references'
|
|
258
|
+
}))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { node, edges }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build the full graph recursively from the knowledge base root folder (.knowledges).
|
|
266
|
+
*/
|
|
267
|
+
export async function buildGraphFromRoot(rootAbs) {
|
|
268
|
+
const graph = new DirectedGraph()
|
|
269
|
+
await walkDir(rootAbs, '', async (absPath, relPath) => {
|
|
270
|
+
const ext = path.extname(absPath).toLowerCase()
|
|
271
|
+
if (!MD_EXTS.has(ext)) return
|
|
272
|
+
|
|
273
|
+
let content
|
|
274
|
+
try {
|
|
275
|
+
content = await fs.readFile(absPath, 'utf8')
|
|
276
|
+
} catch {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const normRel = relPath.replace(/\\/g, '/')
|
|
281
|
+
|
|
282
|
+
// Check whether this is a decision file (under the decisions/ folder)
|
|
283
|
+
if (normRel.startsWith('decisions/')) {
|
|
284
|
+
const { nodes, edges: decisionEdges } = parseDecisionBlocks(normRel, content)
|
|
285
|
+
for (const n of nodes) graph.addNode(n)
|
|
286
|
+
for (const e of decisionEdges) graph.addEdge(e)
|
|
287
|
+
// A decision file with no blocks is a plain document (for example
|
|
288
|
+
// reference material under decisions/ref/): keep it in the graph as
|
|
289
|
+
// a knowledge node so decisions can point at it by path.
|
|
290
|
+
if (nodes.length === 0) {
|
|
291
|
+
const { node } = parseKnowledgeContent(normRel, content)
|
|
292
|
+
graph.addNode(node)
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
const { node, edges } = parseKnowledgeContent(normRel, content)
|
|
296
|
+
graph.addNode(node)
|
|
297
|
+
for (const e of edges) graph.addEdge(e)
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
return graph
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function walkDir(baseDir, currentRel, visitor) {
|
|
305
|
+
let entries
|
|
306
|
+
try {
|
|
307
|
+
entries = await fs.readdir(path.join(baseDir, currentRel), { withFileTypes: true })
|
|
308
|
+
} catch {
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const ent of entries) {
|
|
313
|
+
if (ent.name.startsWith('.')) {
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const nextRel = currentRel ? path.join(currentRel, ent.name) : ent.name
|
|
318
|
+
const fullPath = path.join(baseDir, nextRel)
|
|
319
|
+
|
|
320
|
+
if (ent.isDirectory()) {
|
|
321
|
+
await walkDir(baseDir, nextRel, visitor)
|
|
322
|
+
} else if (ent.isFile()) {
|
|
323
|
+
await visitor(fullPath, nextRel)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|