@llm4ts/flow 2.36.0 → 2.38.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.
@@ -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
+ }
@@ -0,0 +1,293 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { PlanParseError } from "./FlowError.ts"
4
+ import { fingerprintOf } from "./ReviewCache.ts"
5
+ import type { CoverageRule } from "./SpecChecks.ts"
6
+ import type { JoinMatch } from "./Survey.ts"
7
+
8
+ /**
9
+ * The pack's graph vocabulary (ADR 0030). A `## Node:` rule names a sub-file
10
+ * node kind, an `## Edge:` rule a captured link, a `## Join:` rule a link by
11
+ * attribute equality across files, a `## Probe:` a flow that must be
12
+ * connected end to end. `## Survey:` rules are folded in as file-to-file
13
+ * Edge rules so a pre-0030 pack produces the same graph.
14
+ */
15
+ export class NodeRule extends Schema.Class<NodeRule>("NodeRule")({
16
+ kind: Schema.String,
17
+ files: Schema.String,
18
+ pattern: Schema.String,
19
+ descriptor: Schema.Boolean,
20
+ anchor: Schema.optionalKey(Schema.String),
21
+ /** `attr → named group` copies beyond the default "every group but name". */
22
+ attrs: Schema.Record(Schema.String, Schema.String)
23
+ }) {}
24
+
25
+ export class EdgeRule extends Schema.Class<EdgeRule>("EdgeRule")({
26
+ kind: Schema.String,
27
+ files: Schema.String,
28
+ pattern: Schema.String,
29
+ fromKind: Schema.String,
30
+ toKind: Schema.String,
31
+ /** A folded `## Survey:` rule: the target is the first positional group (or the whole match), as before ADR 0030. */
32
+ positional: Schema.optionalKey(Schema.Boolean)
33
+ }) {}
34
+
35
+ export const JoinMatchMode = Schema.Literals(["exact", "url"])
36
+ export type JoinMatchMode = typeof JoinMatchMode.Type
37
+ export const JoinScope = Schema.Literals(["estate", "app", "file"])
38
+ export type JoinScope = typeof JoinScope.Type
39
+
40
+ export class JoinRule extends Schema.Class<JoinRule>("JoinRule")({
41
+ kind: Schema.String,
42
+ fromKind: Schema.String,
43
+ fromAttr: Schema.String,
44
+ toKind: Schema.String,
45
+ toAttr: Schema.String,
46
+ match: JoinMatchMode,
47
+ scope: JoinScope
48
+ }) {}
49
+
50
+ export class ProbeRule extends Schema.Class<ProbeRule>("ProbeRule")({
51
+ name: Schema.String,
52
+ from: Schema.String,
53
+ to: Schema.String
54
+ }) {}
55
+
56
+ export interface GraphRules {
57
+ readonly nodes: ReadonlyArray<NodeRule>
58
+ readonly edges: ReadonlyArray<EdgeRule>
59
+ readonly joins: ReadonlyArray<JoinRule>
60
+ readonly probes: ReadonlyArray<ProbeRule>
61
+ readonly worklistMax: number
62
+ readonly batchSize: number
63
+ }
64
+
65
+ export const emptyGraphRules: GraphRules = {
66
+ nodes: [],
67
+ edges: [],
68
+ joins: [],
69
+ probes: [],
70
+ worklistMax: 200,
71
+ batchSize: 20
72
+ }
73
+
74
+ /**
75
+ * `## Survey:` is an Edge rule between files whose first positional capture
76
+ * (or the whole match) is the target. The pattern is kept verbatim: rewriting
77
+ * its first `(` would break `(?:…)`, lookbehinds and escaped parens.
78
+ */
79
+ export const edgeRuleOfSurvey = (rule: CoverageRule): EdgeRule =>
80
+ EdgeRule.make({
81
+ kind: rule.name,
82
+ files: rule.files,
83
+ pattern: rule.unit,
84
+ fromKind: "file",
85
+ toKind: "file",
86
+ positional: true
87
+ })
88
+
89
+ const fail = (message: string): Effect.Effect<never, PlanParseError> =>
90
+ Effect.fail(PlanParseError.make({ message }))
91
+
92
+ const isValidRegExp = (source: string): boolean => {
93
+ try {
94
+ new RegExp(source, "gm")
95
+ return true
96
+ } catch {
97
+ return false
98
+ }
99
+ }
100
+
101
+ const fieldsOf = (lines: ReadonlyArray<string>): Readonly<Record<string, string>> =>
102
+ Object.fromEntries(
103
+ lines.flatMap((line) => {
104
+ const trimmed = line.replace(/^- /, "")
105
+ const index = trimmed.indexOf(":")
106
+ return index < 0 ? [] : [[trimmed.slice(0, index).trim(), trimmed.slice(index + 1).trim()]]
107
+ })
108
+ )
109
+
110
+ const yes = (value: string | undefined): boolean =>
111
+ value !== undefined && ["yes", "true", "on"].includes(value.trim().toLowerCase())
112
+
113
+ const attrCopies = (value: string | undefined): Readonly<Record<string, string>> =>
114
+ Object.fromEntries(
115
+ (value ?? "")
116
+ .split(",")
117
+ .map((item) => item.trim())
118
+ .filter((item) => item.length > 0)
119
+ .flatMap((item) => {
120
+ const [attr, group] = item.split("=").map((part) => part.trim())
121
+ return attr !== undefined && group !== undefined && attr.length > 0 && group.length > 0
122
+ ? [[attr, group]]
123
+ : []
124
+ })
125
+ )
126
+
127
+ const kindAndAttr = (value: string | undefined): readonly [string, string] | undefined => {
128
+ const dot = (value ?? "").indexOf(".")
129
+ if (value === undefined || dot <= 0 || dot === value.length - 1) {
130
+ return undefined
131
+ }
132
+ return [value.slice(0, dot).trim(), value.slice(dot + 1).trim()]
133
+ }
134
+
135
+ export const parseGraphRules = (
136
+ sections: ReadonlyArray<string>,
137
+ survey: ReadonlyArray<CoverageRule>
138
+ ): Effect.Effect<GraphRules, PlanParseError> =>
139
+ Effect.gen(function* () {
140
+ const nodes: Array<NodeRule> = []
141
+ const edges: Array<EdgeRule> = survey.map(edgeRuleOfSurvey)
142
+ const joins: Array<JoinRule> = []
143
+ const probes: Array<ProbeRule> = []
144
+ let worklistMax = emptyGraphRules.worklistMax
145
+ let batchSize = emptyGraphRules.batchSize
146
+ for (const section of sections) {
147
+ const lines = section.split(/\r?\n/)
148
+ const heading = lines[0]?.trim() ?? ""
149
+ const fields = fieldsOf(lines.slice(1))
150
+ if (heading.startsWith("## Node: ")) {
151
+ const kind = heading.slice("## Node: ".length).trim()
152
+ if (fields.files === undefined || fields.pattern === undefined) {
153
+ return yield* fail(`'${heading}' needs 'files:' and 'pattern:'`)
154
+ }
155
+ if (!isValidRegExp(fields.pattern)) {
156
+ return yield* fail(`'${heading}' pattern is not a valid regex: ${fields.pattern}`)
157
+ }
158
+ if (!fields.pattern.includes("(?<name>")) {
159
+ return yield* fail(`'${heading}' pattern needs a (?<name>…) group`)
160
+ }
161
+ nodes.push(
162
+ NodeRule.make({
163
+ kind,
164
+ files: fields.files,
165
+ pattern: fields.pattern,
166
+ descriptor: yes(fields.descriptor),
167
+ ...(fields.anchor === undefined ? {} : { anchor: fields.anchor }),
168
+ attrs: attrCopies(fields.attrs)
169
+ })
170
+ )
171
+ } else if (heading.startsWith("## Edge: ")) {
172
+ const kind = heading.slice("## Edge: ".length).trim()
173
+ if (fields.files === undefined || fields.pattern === undefined) {
174
+ return yield* fail(`'${heading}' needs 'files:' and 'pattern:'`)
175
+ }
176
+ if (!isValidRegExp(fields.pattern)) {
177
+ return yield* fail(`'${heading}' pattern is not a valid regex: ${fields.pattern}`)
178
+ }
179
+ if (!fields.pattern.includes("(?<to>")) {
180
+ return yield* fail(`'${heading}' pattern needs a (?<to>…) group`)
181
+ }
182
+ edges.push(
183
+ EdgeRule.make({
184
+ kind,
185
+ files: fields.files,
186
+ pattern: fields.pattern,
187
+ fromKind: fields.from ?? "file",
188
+ toKind: fields.to ?? "file"
189
+ })
190
+ )
191
+ } else if (heading.startsWith("## Join: ")) {
192
+ const kind = heading.slice("## Join: ".length).trim()
193
+ const from = kindAndAttr(fields.from)
194
+ const to = kindAndAttr(fields.to)
195
+ if (from === undefined || to === undefined) {
196
+ return yield* fail(`'${heading}' needs 'from: <kind>.<attr>' and 'to: <kind>.<attr>'`)
197
+ }
198
+ const match: JoinMatchMode = fields.match === "url" ? "url" : "exact"
199
+ const scope: JoinScope =
200
+ fields.scope === "file" || fields.scope === "app" || fields.scope === "estate"
201
+ ? fields.scope
202
+ : match === "url"
203
+ ? "app"
204
+ : "estate"
205
+ joins.push(
206
+ JoinRule.make({
207
+ kind,
208
+ fromKind: from[0],
209
+ fromAttr: from[1],
210
+ toKind: to[0],
211
+ toAttr: to[1],
212
+ match,
213
+ scope
214
+ })
215
+ )
216
+ } else if (heading.startsWith("## Probe: ")) {
217
+ const name = heading.slice("## Probe: ".length).trim()
218
+ if (fields.from === undefined || fields.to === undefined) {
219
+ return yield* fail(`'${heading}' needs 'from:' and 'to:'`)
220
+ }
221
+ probes.push(ProbeRule.make({ name, from: fields.from, to: fields.to }))
222
+ } else if (heading === "## Graph") {
223
+ const max = Number.parseInt(fields["worklist-max"] ?? "", 10)
224
+ const batch = Number.parseInt(fields["batch-size"] ?? "", 10)
225
+ worklistMax = Number.isInteger(max) && max > 0 ? max : worklistMax
226
+ batchSize = Number.isInteger(batch) && batch > 0 ? batch : batchSize
227
+ }
228
+ }
229
+ const nodeKinds = new Set(["file", ...nodes.map((rule) => rule.kind)])
230
+ for (const rule of edges) {
231
+ for (const kind of [rule.fromKind, rule.toKind]) {
232
+ if (!nodeKinds.has(kind)) {
233
+ return yield* fail(
234
+ `'## Edge: ${rule.kind}' names node kind '${kind}' no '## Node:' declares`
235
+ )
236
+ }
237
+ }
238
+ }
239
+ for (const rule of joins) {
240
+ for (const kind of [rule.fromKind, rule.toKind]) {
241
+ if (!nodeKinds.has(kind)) {
242
+ return yield* fail(
243
+ `'## Join: ${rule.kind}' names node kind '${kind}' no '## Node:' declares`
244
+ )
245
+ }
246
+ }
247
+ }
248
+ return { nodes, edges, joins, probes, worklistMax, batchSize }
249
+ })
250
+
251
+ /** Every edge kind the rules can produce: captured kinds plus join kinds. */
252
+ export const edgeKindsOf = (rules: GraphRules): ReadonlySet<string> =>
253
+ new Set([...rules.edges.map((rule) => rule.kind), ...rules.joins.map((rule) => rule.kind)])
254
+
255
+ /** Changes when any rule that shapes the graph changes; probes and budgets do not. */
256
+ export const graphRulesHash = (rules: GraphRules): string =>
257
+ fingerprintOf([JSON.stringify({ n: rules.nodes, e: rules.edges, j: rules.joins })])
258
+
259
+ /** `${ctx}/a/b?x=1` → `/a/b`; `salva.do` → `/salva.do`; scheme, host and fragment dropped. */
260
+ export const normalizeUrl = (raw: string): string => {
261
+ let url = raw.trim()
262
+ url = url.replace(/^(\$\{[^}]*\}|<%=[^%]*%>)/, "")
263
+ url = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, "")
264
+ url = url.replace(/[?#].*$/, "")
265
+ if (!url.startsWith("/")) {
266
+ url = `/${url}`
267
+ }
268
+ if (url.length > 1 && url.endsWith("/")) {
269
+ url = url.slice(0, -1)
270
+ }
271
+ return url
272
+ }
273
+
274
+ /**
275
+ * Servlet-spec matching of a normalised request against a `url-pattern`:
276
+ * exact, then a `/*` prefix, then a `*.ext` extension. The extension case
277
+ * reads the raw pattern, since normalising `*.do` would prefix a slash.
278
+ */
279
+ export const matchUrl = (request: string, pattern: string): JoinMatch | undefined => {
280
+ const trimmed = pattern.trim()
281
+ if (trimmed.startsWith("*.")) {
282
+ return request.endsWith(trimmed.slice(1)) ? "extension" : undefined
283
+ }
284
+ const target = normalizeUrl(trimmed)
285
+ if (request === target) {
286
+ return "exact"
287
+ }
288
+ if (target.endsWith("/*")) {
289
+ const prefix = target.slice(0, -2)
290
+ return request === prefix || request.startsWith(`${prefix}/`) ? "prefix" : undefined
291
+ }
292
+ return undefined
293
+ }