@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.
Files changed (56) hide show
  1. package/dist/Domains.d.ts +1 -1
  2. package/dist/Domains.d.ts.map +1 -1
  3. package/dist/Domains.js +7 -3
  4. package/dist/Domains.js.map +1 -1
  5. package/dist/Flow.d.ts +3 -1
  6. package/dist/Flow.d.ts.map +1 -1
  7. package/dist/Flow.js +1 -0
  8. package/dist/Flow.js.map +1 -1
  9. package/dist/GraphCache.d.ts +70 -0
  10. package/dist/GraphCache.d.ts.map +1 -0
  11. package/dist/GraphCache.js +111 -0
  12. package/dist/GraphCache.js.map +1 -0
  13. package/dist/GraphQuery.d.ts +67 -0
  14. package/dist/GraphQuery.d.ts.map +1 -0
  15. package/dist/GraphQuery.js +212 -0
  16. package/dist/GraphQuery.js.map +1 -0
  17. package/dist/GraphRender.d.ts +26 -0
  18. package/dist/GraphRender.d.ts.map +1 -0
  19. package/dist/GraphRender.js +154 -0
  20. package/dist/GraphRender.js.map +1 -0
  21. package/dist/GraphRules.d.ts +86 -0
  22. package/dist/GraphRules.d.ts.map +1 -0
  23. package/dist/GraphRules.js +246 -0
  24. package/dist/GraphRules.js.map +1 -0
  25. package/dist/GraphWorklist.d.ts +64 -0
  26. package/dist/GraphWorklist.d.ts.map +1 -0
  27. package/dist/GraphWorklist.js +283 -0
  28. package/dist/GraphWorklist.js.map +1 -0
  29. package/dist/Pack.d.ts +7 -0
  30. package/dist/Pack.d.ts.map +1 -1
  31. package/dist/Pack.js +9 -5
  32. package/dist/Pack.js.map +1 -1
  33. package/dist/Review.d.ts +12 -0
  34. package/dist/Review.d.ts.map +1 -1
  35. package/dist/Review.js +11 -1
  36. package/dist/Review.js.map +1 -1
  37. package/dist/Stories.d.ts +16 -0
  38. package/dist/Stories.d.ts.map +1 -1
  39. package/dist/Stories.js +42 -7
  40. package/dist/Stories.js.map +1 -1
  41. package/dist/Survey.d.ts +112 -3
  42. package/dist/Survey.d.ts.map +1 -1
  43. package/dist/Survey.js +644 -41
  44. package/dist/Survey.js.map +1 -1
  45. package/package.json +7 -2
  46. package/src/Domains.ts +7 -4
  47. package/src/Flow.ts +5 -1
  48. package/src/GraphCache.ts +191 -0
  49. package/src/GraphQuery.ts +315 -0
  50. package/src/GraphRender.ts +208 -0
  51. package/src/GraphRules.ts +293 -0
  52. package/src/GraphWorklist.ts +405 -0
  53. package/src/Pack.ts +15 -5
  54. package/src/Review.ts +25 -1
  55. package/src/Stories.ts +66 -12
  56. 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
+ }