@llm4ts/flow 2.37.0 → 2.39.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/dist/Domains.d.ts +1 -1
- package/dist/Domains.d.ts.map +1 -1
- package/dist/Domains.js +7 -3
- package/dist/Domains.js.map +1 -1
- package/dist/Flow.d.ts +3 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +1 -0
- package/dist/Flow.js.map +1 -1
- package/dist/GraphCache.d.ts +70 -0
- package/dist/GraphCache.d.ts.map +1 -0
- package/dist/GraphCache.js +111 -0
- package/dist/GraphCache.js.map +1 -0
- package/dist/GraphQuery.d.ts +67 -0
- package/dist/GraphQuery.d.ts.map +1 -0
- package/dist/GraphQuery.js +212 -0
- package/dist/GraphQuery.js.map +1 -0
- package/dist/GraphRender.d.ts +26 -0
- package/dist/GraphRender.d.ts.map +1 -0
- package/dist/GraphRender.js +154 -0
- package/dist/GraphRender.js.map +1 -0
- package/dist/GraphRules.d.ts +86 -0
- package/dist/GraphRules.d.ts.map +1 -0
- package/dist/GraphRules.js +246 -0
- package/dist/GraphRules.js.map +1 -0
- package/dist/GraphWorklist.d.ts +64 -0
- package/dist/GraphWorklist.d.ts.map +1 -0
- package/dist/GraphWorklist.js +283 -0
- package/dist/GraphWorklist.js.map +1 -0
- package/dist/Pack.d.ts +7 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +9 -5
- package/dist/Pack.js.map +1 -1
- package/dist/Review.d.ts +12 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +11 -1
- package/dist/Review.js.map +1 -1
- package/dist/Stories.d.ts +16 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +42 -7
- package/dist/Stories.js.map +1 -1
- package/dist/Survey.d.ts +112 -3
- package/dist/Survey.d.ts.map +1 -1
- package/dist/Survey.js +644 -41
- package/dist/Survey.js.map +1 -1
- package/package.json +7 -2
- package/src/Domains.ts +7 -4
- package/src/Flow.ts +5 -1
- package/src/GraphCache.ts +191 -0
- package/src/GraphQuery.ts +315 -0
- package/src/GraphRender.ts +208 -0
- package/src/GraphRules.ts +293 -0
- package/src/GraphWorklist.ts +405 -0
- package/src/Pack.ts +15 -5
- package/src/Review.ts +25 -1
- package/src/Stories.ts +66 -12
- package/src/Survey.ts +846 -52
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type { ProbeRule } from "./GraphRules.ts"
|
|
2
|
+
import {
|
|
3
|
+
SurveyEdge,
|
|
4
|
+
SurveyGraph,
|
|
5
|
+
closureFor,
|
|
6
|
+
edgeConfidence,
|
|
7
|
+
edgeOrigin,
|
|
8
|
+
nodeAttrs,
|
|
9
|
+
nodeId,
|
|
10
|
+
nodeKind,
|
|
11
|
+
nodeLabel,
|
|
12
|
+
projectToFiles,
|
|
13
|
+
unitName,
|
|
14
|
+
type SurveyNode
|
|
15
|
+
} from "./Survey.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pure queries over the code graph (ADR 0030 decision 7): what the `llm4ts
|
|
19
|
+
* graph` verb renders today and a seat tool would wrap tomorrow. Nothing here
|
|
20
|
+
* reads a file or calls a model.
|
|
21
|
+
*/
|
|
22
|
+
export interface GraphView {
|
|
23
|
+
readonly nodes: ReadonlyArray<SurveyNode>
|
|
24
|
+
readonly edges: ReadonlyArray<SurveyEdge>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchHit {
|
|
28
|
+
readonly node: SurveyNode
|
|
29
|
+
readonly score: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const lower = (text: string): string => text.toLowerCase()
|
|
33
|
+
|
|
34
|
+
/** Substring search over id, name, label, path and attr values; file-mates of a strong hit get a small boost. */
|
|
35
|
+
export const searchNodes = (
|
|
36
|
+
graph: SurveyGraph,
|
|
37
|
+
text: string,
|
|
38
|
+
options: { readonly kind?: string; readonly limit?: number } = {}
|
|
39
|
+
): ReadonlyArray<SearchHit> => {
|
|
40
|
+
const needle = lower(text.trim())
|
|
41
|
+
if (needle.length === 0) {
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
const base = graph.nodes.map((node) => {
|
|
45
|
+
let score = 0
|
|
46
|
+
if (lower(node.name) === needle) {
|
|
47
|
+
score += 6
|
|
48
|
+
} else if (lower(node.name).includes(needle)) {
|
|
49
|
+
score += 3
|
|
50
|
+
}
|
|
51
|
+
if (lower(nodeLabel(node)).includes(needle)) {
|
|
52
|
+
score += 2
|
|
53
|
+
}
|
|
54
|
+
if (Object.values(nodeAttrs(node)).some((value) => lower(value).includes(needle))) {
|
|
55
|
+
score += 2
|
|
56
|
+
}
|
|
57
|
+
if (lower(nodeId(node)).includes(needle) || lower(node.path).includes(needle)) {
|
|
58
|
+
score += 1
|
|
59
|
+
}
|
|
60
|
+
return { node, score }
|
|
61
|
+
})
|
|
62
|
+
const strongFiles = new Set(base.filter((hit) => hit.score >= 2).map((hit) => hit.node.path))
|
|
63
|
+
return (
|
|
64
|
+
base
|
|
65
|
+
.map((hit) =>
|
|
66
|
+
hit.score === 0 && strongFiles.has(hit.node.path) ? { ...hit, score: 0.5 } : hit
|
|
67
|
+
)
|
|
68
|
+
// A co-located node (score 0.5) rides along an open search; an explicit
|
|
69
|
+
// kind asks for real matches of that kind only.
|
|
70
|
+
.filter((hit) =>
|
|
71
|
+
options.kind === undefined
|
|
72
|
+
? hit.score > 0
|
|
73
|
+
: hit.score >= 1 && nodeKind(hit.node) === options.kind
|
|
74
|
+
)
|
|
75
|
+
.sort(
|
|
76
|
+
(left, right) =>
|
|
77
|
+
right.score - left.score || nodeId(left.node).localeCompare(nodeId(right.node))
|
|
78
|
+
)
|
|
79
|
+
.slice(0, options.limit ?? 20)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const viewOf = (graph: SurveyGraph, ids: ReadonlySet<string>): GraphView => ({
|
|
84
|
+
nodes: graph.nodes.filter((node) => ids.has(nodeId(node))),
|
|
85
|
+
edges: graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const neighborhood = (
|
|
89
|
+
graph: SurveyGraph,
|
|
90
|
+
ids: ReadonlyArray<string>,
|
|
91
|
+
hops: number
|
|
92
|
+
): GraphView => {
|
|
93
|
+
const seen = new Set(ids)
|
|
94
|
+
let frontier: ReadonlyArray<string> = ids
|
|
95
|
+
for (let hop = 0; hop < hops && frontier.length > 0; hop += 1) {
|
|
96
|
+
const next = frontier
|
|
97
|
+
.flatMap((id) => [
|
|
98
|
+
...graph.outgoing(id).map((edge) => edge.to),
|
|
99
|
+
...graph.incoming(id).map((edge) => edge.from)
|
|
100
|
+
])
|
|
101
|
+
.filter((id) => !seen.has(id))
|
|
102
|
+
next.forEach((id) => seen.add(id))
|
|
103
|
+
frontier = [...new Set(next)]
|
|
104
|
+
}
|
|
105
|
+
return viewOf(graph, seen)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Raw edges + projected file edges + file→sub-node containment: the graph
|
|
110
|
+
* probes and `--projected` paths walk, so a page reaches the servlet its form
|
|
111
|
+
* posts to and the servlet reaches the ESB call inside it.
|
|
112
|
+
*/
|
|
113
|
+
export const probeGraph = (graph: SurveyGraph): SurveyGraph => {
|
|
114
|
+
const projected = projectToFiles(graph)
|
|
115
|
+
const containment = graph.nodes
|
|
116
|
+
.filter((node) => nodeKind(node) !== "file")
|
|
117
|
+
.map((node) =>
|
|
118
|
+
SurveyEdge.make({
|
|
119
|
+
from: unitName(node.path),
|
|
120
|
+
to: nodeId(node),
|
|
121
|
+
kind: "contains",
|
|
122
|
+
mechanism: "contraction"
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
return SurveyGraph.make({
|
|
126
|
+
nodes: graph.nodes,
|
|
127
|
+
edges: [...graph.edges, ...projected.edges, ...containment],
|
|
128
|
+
unresolved: graph.unresolved,
|
|
129
|
+
fills: graph.fills
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Bidirectional BFS over directed edges; the path as edges from `from` to `to`. */
|
|
134
|
+
export const shortestPath = (
|
|
135
|
+
graph: SurveyGraph,
|
|
136
|
+
from: string,
|
|
137
|
+
to: string,
|
|
138
|
+
maxHops = 12,
|
|
139
|
+
options: { readonly projected?: boolean } = {}
|
|
140
|
+
): ReadonlyArray<SurveyEdge> | undefined => {
|
|
141
|
+
const walk = options.projected === true ? probeGraph(graph) : graph
|
|
142
|
+
if (from === to) {
|
|
143
|
+
return []
|
|
144
|
+
}
|
|
145
|
+
const forward = new Map<string, SurveyEdge | undefined>([[from, undefined]])
|
|
146
|
+
const backward = new Map<string, SurveyEdge | undefined>([[to, undefined]])
|
|
147
|
+
let frontF: ReadonlyArray<string> = [from]
|
|
148
|
+
let frontB: ReadonlyArray<string> = [to]
|
|
149
|
+
const join = (meet: string): ReadonlyArray<SurveyEdge> => {
|
|
150
|
+
const head: Array<SurveyEdge> = []
|
|
151
|
+
for (let edge = forward.get(meet); edge !== undefined; edge = forward.get(edge.from)) {
|
|
152
|
+
head.unshift(edge)
|
|
153
|
+
}
|
|
154
|
+
const tail: Array<SurveyEdge> = []
|
|
155
|
+
for (let edge = backward.get(meet); edge !== undefined; edge = backward.get(edge.to)) {
|
|
156
|
+
tail.push(edge)
|
|
157
|
+
}
|
|
158
|
+
return [...head, ...tail]
|
|
159
|
+
}
|
|
160
|
+
for (let hop = 0; hop < maxHops; hop += 1) {
|
|
161
|
+
if (frontF.length === 0 && frontB.length === 0) {
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
if (frontF.length > 0 && (frontF.length <= frontB.length || frontB.length === 0)) {
|
|
165
|
+
const next: Array<string> = []
|
|
166
|
+
for (const id of frontF) {
|
|
167
|
+
for (const edge of walk.outgoing(id)) {
|
|
168
|
+
if (!forward.has(edge.to)) {
|
|
169
|
+
forward.set(edge.to, edge)
|
|
170
|
+
next.push(edge.to)
|
|
171
|
+
}
|
|
172
|
+
if (backward.has(edge.to)) {
|
|
173
|
+
return join(edge.to)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
frontF = next
|
|
178
|
+
} else {
|
|
179
|
+
const next: Array<string> = []
|
|
180
|
+
for (const id of frontB) {
|
|
181
|
+
for (const edge of walk.incoming(id)) {
|
|
182
|
+
if (!backward.has(edge.from)) {
|
|
183
|
+
backward.set(edge.from, edge)
|
|
184
|
+
next.push(edge.from)
|
|
185
|
+
}
|
|
186
|
+
if (forward.has(edge.from)) {
|
|
187
|
+
return join(edge.from)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
frontB = next
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const pathBetween = (
|
|
198
|
+
graph: SurveyGraph,
|
|
199
|
+
a: string,
|
|
200
|
+
b: string,
|
|
201
|
+
maxHops = 12,
|
|
202
|
+
options: { readonly projected?: boolean } = {}
|
|
203
|
+
): {
|
|
204
|
+
readonly forward?: ReadonlyArray<SurveyEdge>
|
|
205
|
+
readonly backward?: ReadonlyArray<SurveyEdge>
|
|
206
|
+
} => {
|
|
207
|
+
const forward = shortestPath(graph, a, b, maxHops, options)
|
|
208
|
+
const backward = shortestPath(graph, b, a, maxHops, options)
|
|
209
|
+
return {
|
|
210
|
+
...(forward === undefined ? {} : { forward }),
|
|
211
|
+
...(backward === undefined ? {} : { backward })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const viewOfPath = (graph: SurveyGraph, path: ReadonlyArray<SurveyEdge>): GraphView => {
|
|
216
|
+
const ids = new Set(path.flatMap((edge) => [edge.from, edge.to]))
|
|
217
|
+
return { nodes: graph.nodes.filter((node) => ids.has(nodeId(node))), edges: path }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The program and its bounded closure, on the file projection. */
|
|
221
|
+
export const closureView = (graph: SurveyGraph, program: string, maxFiles: number): GraphView => {
|
|
222
|
+
const projected = projectToFiles(graph)
|
|
223
|
+
const paths = new Set(closureFor(graph, program, maxFiles))
|
|
224
|
+
const ids = new Set(
|
|
225
|
+
projected.nodes.filter((node) => node.name === program || paths.has(node.path)).map(nodeId)
|
|
226
|
+
)
|
|
227
|
+
return viewOf(projected, ids)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const wholeView = (graph: SurveyGraph): GraphView => ({
|
|
231
|
+
nodes: graph.nodes,
|
|
232
|
+
edges: graph.edges
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
export interface GraphStats {
|
|
236
|
+
readonly nodes: Readonly<Record<string, number>>
|
|
237
|
+
readonly edges: Readonly<Record<string, number>>
|
|
238
|
+
readonly origin: Readonly<Record<string, number>>
|
|
239
|
+
readonly mechanism: Readonly<Record<string, number>>
|
|
240
|
+
readonly confidence: Readonly<Record<string, number>>
|
|
241
|
+
readonly unresolved: Readonly<Record<string, number>>
|
|
242
|
+
readonly total: {
|
|
243
|
+
readonly nodes: number
|
|
244
|
+
readonly edges: number
|
|
245
|
+
readonly unresolved: number
|
|
246
|
+
readonly fills: number
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const tally = (keys: ReadonlyArray<string>): Readonly<Record<string, number>> => {
|
|
251
|
+
const counts: Record<string, number> = {}
|
|
252
|
+
for (const key of keys) {
|
|
253
|
+
counts[key] = (counts[key] ?? 0) + 1
|
|
254
|
+
}
|
|
255
|
+
return counts
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const graphStats = (graph: SurveyGraph): GraphStats => ({
|
|
259
|
+
nodes: tally(graph.nodes.map(nodeKind)),
|
|
260
|
+
edges: tally(graph.edges.map((edge) => edge.kind)),
|
|
261
|
+
origin: tally(graph.edges.map(edgeOrigin)),
|
|
262
|
+
mechanism: tally(graph.edges.map((edge) => edge.mechanism ?? "capture")),
|
|
263
|
+
confidence: tally(graph.edges.map(edgeConfidence)),
|
|
264
|
+
unresolved: tally(graph.unresolved.map((item) => item.reason)),
|
|
265
|
+
total: {
|
|
266
|
+
nodes: graph.nodes.length,
|
|
267
|
+
edges: graph.edges.length,
|
|
268
|
+
unresolved: graph.unresolved.length,
|
|
269
|
+
fills: graph.fills.length
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
/** An id, `<kind>:<name>` (first node of that kind with that name), or a bare file unit name. */
|
|
274
|
+
export const resolveNodeRef = (graph: SurveyGraph, ref: string): SurveyNode | undefined => {
|
|
275
|
+
const exact = graph.node(ref)
|
|
276
|
+
if (exact !== undefined) {
|
|
277
|
+
return exact
|
|
278
|
+
}
|
|
279
|
+
const colon = ref.indexOf(":")
|
|
280
|
+
if (colon > 0 && !ref.includes("#")) {
|
|
281
|
+
const kind = ref.slice(0, colon)
|
|
282
|
+
const name = ref.slice(colon + 1)
|
|
283
|
+
return graph.nodes.find((node) => nodeKind(node) === kind && node.name === name)
|
|
284
|
+
}
|
|
285
|
+
return graph.nodes.find((node) => nodeKind(node) === "file" && node.name === ref)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type ProbeStatus = "ok" | "broken" | "unknown-from" | "unknown-to"
|
|
289
|
+
|
|
290
|
+
export interface ProbeResult {
|
|
291
|
+
readonly probe: ProbeRule
|
|
292
|
+
readonly status: ProbeStatus
|
|
293
|
+
readonly path?: ReadonlyArray<SurveyEdge>
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Each probe's verdict on the union graph: connected, broken, or an endpoint nothing matches. */
|
|
297
|
+
export const probeResults = (
|
|
298
|
+
graph: SurveyGraph,
|
|
299
|
+
probes: ReadonlyArray<ProbeRule>,
|
|
300
|
+
maxHops = 12
|
|
301
|
+
): ReadonlyArray<ProbeResult> => {
|
|
302
|
+
const walk = probeGraph(graph)
|
|
303
|
+
return probes.map((probe) => {
|
|
304
|
+
const from = resolveNodeRef(graph, probe.from)
|
|
305
|
+
const to = resolveNodeRef(graph, probe.to)
|
|
306
|
+
if (from === undefined) {
|
|
307
|
+
return { probe, status: "unknown-from" }
|
|
308
|
+
}
|
|
309
|
+
if (to === undefined) {
|
|
310
|
+
return { probe, status: "unknown-to" }
|
|
311
|
+
}
|
|
312
|
+
const path = shortestPath(walk, nodeId(from), nodeId(to), maxHops)
|
|
313
|
+
return path === undefined ? { probe, status: "broken" } : { probe, status: "ok", path }
|
|
314
|
+
})
|
|
315
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { clusterPrograms, type ConsolidateRules } from "./Domains.ts"
|
|
2
|
+
import { neighborhood, type GraphView } from "./GraphQuery.ts"
|
|
3
|
+
import {
|
|
4
|
+
edgeConfidence,
|
|
5
|
+
nodeId,
|
|
6
|
+
nodeKind,
|
|
7
|
+
nodeLabel,
|
|
8
|
+
projectToFiles,
|
|
9
|
+
type SurveyGraph,
|
|
10
|
+
type SurveyNode
|
|
11
|
+
} from "./Survey.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders of a graph view (ADR 0030 decision 7): one view model, three
|
|
15
|
+
* emitters. Nodes group by file, edges carry their kind, inferred edges are
|
|
16
|
+
* dotted, unresolved nodes are red, and every node line ends with its id so a
|
|
17
|
+
* reader can paste it into `llm4ts graph path`.
|
|
18
|
+
*/
|
|
19
|
+
export const mermaidNodeCap = 150
|
|
20
|
+
|
|
21
|
+
interface KindStyle {
|
|
22
|
+
readonly mermaid: string
|
|
23
|
+
readonly shape: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reserved kinds get a fixed look; any pack-defined kind falls back to `neutral`. */
|
|
27
|
+
const styles: Readonly<Record<string, KindStyle>> = {
|
|
28
|
+
file: { mermaid: "fill:#eef,stroke:#446,color:#000", shape: "box" },
|
|
29
|
+
page: { mermaid: "fill:#efe,stroke:#464,color:#000", shape: "note" },
|
|
30
|
+
"ajax-call": { mermaid: "fill:#ffe,stroke:#664,color:#000", shape: "ellipse" },
|
|
31
|
+
form: { mermaid: "fill:#ffe,stroke:#664,color:#000", shape: "ellipse" },
|
|
32
|
+
"servlet-mapping": { mermaid: "fill:#fee,stroke:#644,color:#000", shape: "hexagon" },
|
|
33
|
+
"servlet-decl": { mermaid: "fill:#fee,stroke:#644,color:#000", shape: "hexagon" },
|
|
34
|
+
"esb-call": { mermaid: "fill:#fdf,stroke:#646,color:#000", shape: "component" },
|
|
35
|
+
"cobol-section": { mermaid: "fill:#eff,stroke:#466,color:#000", shape: "folder" },
|
|
36
|
+
"cobol-paragraph": { mermaid: "fill:#eff,stroke:#466,color:#000", shape: "box" }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const neutral: KindStyle = { mermaid: "fill:#f4f4f4,stroke:#888,color:#000", shape: "box" }
|
|
40
|
+
|
|
41
|
+
const styleOf = (kind: string): KindStyle => styles[kind] ?? neutral
|
|
42
|
+
|
|
43
|
+
const escapeMermaid = (text: string): string =>
|
|
44
|
+
text.replaceAll('"', "'").replaceAll("$", "$").replaceAll("{", "(").replaceAll("}", ")")
|
|
45
|
+
|
|
46
|
+
const escapeDot = (text: string): string => text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
|
|
47
|
+
|
|
48
|
+
/** Mermaid class names may not contain `:` or `/`; kinds are kebab-case already. */
|
|
49
|
+
const mermaidClass = (kind: string): string => kind.replace(/[^A-Za-z0-9_-]/g, "_")
|
|
50
|
+
|
|
51
|
+
const unresolvedIds = (graph: SurveyGraph): ReadonlySet<string> =>
|
|
52
|
+
new Set(graph.unresolved.map((item) => item.node))
|
|
53
|
+
|
|
54
|
+
const groupedByFile = (
|
|
55
|
+
view: GraphView
|
|
56
|
+
): ReadonlyArray<readonly [string, ReadonlyArray<SurveyNode>]> => {
|
|
57
|
+
const groups = new Map<string, Array<SurveyNode>>()
|
|
58
|
+
for (const node of [...view.nodes].sort((a, b) => nodeId(a).localeCompare(nodeId(b)))) {
|
|
59
|
+
groups.set(node.path, [...(groups.get(node.path) ?? []), node])
|
|
60
|
+
}
|
|
61
|
+
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const renderGraphText = (view: GraphView): string =>
|
|
65
|
+
[
|
|
66
|
+
...groupedByFile(view).flatMap(([path, nodes]) => [
|
|
67
|
+
path,
|
|
68
|
+
...nodes.map((node) => ` ${nodeId(node)} [${nodeKind(node)}]`)
|
|
69
|
+
]),
|
|
70
|
+
"",
|
|
71
|
+
...view.edges.map(
|
|
72
|
+
(edge) =>
|
|
73
|
+
`${edge.from} -> ${edge.to} [${edge.kind}]${edgeConfidence(edge) === "inferred" ? " (inferred)" : ""}`
|
|
74
|
+
)
|
|
75
|
+
].join("\n")
|
|
76
|
+
|
|
77
|
+
export const renderGraphMermaid = (
|
|
78
|
+
view: GraphView,
|
|
79
|
+
graph: SurveyGraph,
|
|
80
|
+
options: { readonly cap?: number; readonly force?: boolean } = {}
|
|
81
|
+
): string => {
|
|
82
|
+
const cap = options.cap ?? mermaidNodeCap
|
|
83
|
+
if (view.nodes.length > cap && options.force !== true) {
|
|
84
|
+
return [
|
|
85
|
+
`${view.nodes.length} nodes exceed the mermaid cap of ${cap}; pass --force to render anyway. Text view:`,
|
|
86
|
+
"",
|
|
87
|
+
renderGraphText(view)
|
|
88
|
+
].join("\n")
|
|
89
|
+
}
|
|
90
|
+
const ids = new Map(view.nodes.map((node, index) => [nodeId(node), `n${index}`]))
|
|
91
|
+
const flagged = unresolvedIds(graph)
|
|
92
|
+
const kinds = [...new Set(view.nodes.map(nodeKind))]
|
|
93
|
+
const lines = ["flowchart LR"]
|
|
94
|
+
groupedByFile(view).forEach(([path, nodes], index) => {
|
|
95
|
+
lines.push(` subgraph f${index}["${escapeMermaid(path)}"]`)
|
|
96
|
+
for (const node of nodes) {
|
|
97
|
+
const id = nodeId(node)
|
|
98
|
+
const cls = flagged.has(id) ? ":::unresolved" : `:::${mermaidClass(nodeKind(node))}`
|
|
99
|
+
lines.push(` ${ids.get(id)}["${escapeMermaid(nodeLabel(node))}"]${cls} %% ${id}`)
|
|
100
|
+
}
|
|
101
|
+
lines.push(" end")
|
|
102
|
+
})
|
|
103
|
+
for (const edge of view.edges) {
|
|
104
|
+
const arrow = edgeConfidence(edge) === "inferred" ? "-.->" : "-->"
|
|
105
|
+
lines.push(` ${ids.get(edge.from)} ${arrow}|${escapeMermaid(edge.kind)}| ${ids.get(edge.to)}`)
|
|
106
|
+
}
|
|
107
|
+
for (const kind of kinds) {
|
|
108
|
+
lines.push(` classDef ${mermaidClass(kind)} ${styleOf(kind).mermaid};`)
|
|
109
|
+
}
|
|
110
|
+
lines.push(" classDef unresolved fill:#fdd,stroke:#c00,color:#900;")
|
|
111
|
+
return lines.join("\n")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const renderGraphDot = (view: GraphView, graph: SurveyGraph): string => {
|
|
115
|
+
const ids = new Map(view.nodes.map((node, index) => [nodeId(node), `n${index}`]))
|
|
116
|
+
const flagged = unresolvedIds(graph)
|
|
117
|
+
const lines = ["digraph llm4ts {", " rankdir=LR;", " node [fontname=Helvetica];"]
|
|
118
|
+
groupedByFile(view).forEach(([path, nodes], index) => {
|
|
119
|
+
lines.push(` subgraph "cluster_${index}" {`, ` label="${escapeDot(path)}";`)
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
const id = nodeId(node)
|
|
122
|
+
const color = flagged.has(id) ? " color=red fontcolor=red" : ""
|
|
123
|
+
lines.push(
|
|
124
|
+
` ${ids.get(id)} [label="${escapeDot(nodeLabel(node))}" shape=${styleOf(nodeKind(node)).shape} tooltip="${escapeDot(id)}"${color}];`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
lines.push(" }")
|
|
128
|
+
})
|
|
129
|
+
for (const edge of view.edges) {
|
|
130
|
+
const style = edgeConfidence(edge) === "inferred" ? " style=dashed" : ""
|
|
131
|
+
lines.push(
|
|
132
|
+
` ${ids.get(edge.from)} -> ${ids.get(edge.to)} [label="${escapeDot(edge.kind)}"${style}];`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
lines.push("}")
|
|
136
|
+
return lines.join("\n")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** From every projected node nothing points at, the chains it starts, depth-first, bounded. */
|
|
140
|
+
export const renderEntryPaths = (
|
|
141
|
+
graph: SurveyGraph,
|
|
142
|
+
options: { readonly maxDepth?: number; readonly maxLines?: number } = {}
|
|
143
|
+
): string => {
|
|
144
|
+
const maxDepth = options.maxDepth ?? 4
|
|
145
|
+
const maxLines = options.maxLines ?? 200
|
|
146
|
+
const projected = projectToFiles(graph)
|
|
147
|
+
const entries = projected.nodes.filter(
|
|
148
|
+
(node) => projected.incoming(node.name).length === 0 && projected.outgoing(node.name).length > 0
|
|
149
|
+
)
|
|
150
|
+
const lines: Array<string> = []
|
|
151
|
+
const walk = (id: string, trail: string, seen: ReadonlySet<string>, depth: number): void => {
|
|
152
|
+
if (lines.length >= maxLines) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
const out = projected.outgoing(id)
|
|
156
|
+
if (out.length === 0 || depth === maxDepth) {
|
|
157
|
+
lines.push(trail)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
for (const edge of out) {
|
|
161
|
+
if (seen.has(edge.to)) {
|
|
162
|
+
lines.push(`${trail} → ${edge.to} (${edge.kind}) ↺`)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
walk(edge.to, `${trail} → ${edge.to} (${edge.kind})`, new Set([...seen, edge.to]), depth + 1)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
walk(entry.name, entry.name, new Set([entry.name]), 0)
|
|
170
|
+
}
|
|
171
|
+
return [
|
|
172
|
+
"## Entry paths",
|
|
173
|
+
"",
|
|
174
|
+
...(lines.length === 0 ? ["(none)"] : lines.map((line) => `- ${line}`)),
|
|
175
|
+
...(lines.length >= maxLines ? [`- … truncated at ${maxLines} lines`] : []),
|
|
176
|
+
""
|
|
177
|
+
].join("\n")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One mermaid block per multi-program consolidate cluster, on the file projection. */
|
|
181
|
+
export const renderClusterDiagrams = (
|
|
182
|
+
graph: SurveyGraph,
|
|
183
|
+
consolidate: ConsolidateRules,
|
|
184
|
+
programs: ReadonlyArray<string>,
|
|
185
|
+
options: { readonly cap?: number } = {}
|
|
186
|
+
): string => {
|
|
187
|
+
const projected = projectToFiles(graph)
|
|
188
|
+
const clusters = clusterPrograms(graph, programs, consolidate).filter(
|
|
189
|
+
(cluster) => cluster.programs.length > 1
|
|
190
|
+
)
|
|
191
|
+
if (clusters.length === 0) {
|
|
192
|
+
return "## Clusters\n\n(no multi-program cluster)\n"
|
|
193
|
+
}
|
|
194
|
+
return [
|
|
195
|
+
"## Clusters",
|
|
196
|
+
"",
|
|
197
|
+
...clusters.flatMap((cluster, index) => [
|
|
198
|
+
`### Cluster ${index + 1}: ${[...cluster.programs].sort().join(", ")}`,
|
|
199
|
+
"",
|
|
200
|
+
"```mermaid",
|
|
201
|
+
renderGraphMermaid(neighborhood(projected, cluster.programs, 1), projected, {
|
|
202
|
+
cap: options.cap ?? mermaidNodeCap
|
|
203
|
+
}),
|
|
204
|
+
"```",
|
|
205
|
+
""
|
|
206
|
+
])
|
|
207
|
+
].join("\n")
|
|
208
|
+
}
|