@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.
- 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/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/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/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/Survey.ts +846 -52
|
@@ -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
|
+
}
|