@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
package/src/Survey.ts
CHANGED
|
@@ -1,34 +1,301 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
1
2
|
import * as Effect from "effect/Effect"
|
|
2
3
|
import * as Schema from "effect/Schema"
|
|
4
|
+
import { PlanParseError } from "./FlowError.ts"
|
|
5
|
+
import {
|
|
6
|
+
edgeRuleOfSurvey,
|
|
7
|
+
emptyGraphRules,
|
|
8
|
+
matchUrl,
|
|
9
|
+
normalizeUrl,
|
|
10
|
+
type EdgeRule,
|
|
11
|
+
type GraphRules,
|
|
12
|
+
type JoinRule,
|
|
13
|
+
type NodeRule
|
|
14
|
+
} from "./GraphRules.ts"
|
|
3
15
|
import type { CoverageRule } from "./SpecChecks.ts"
|
|
4
16
|
import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
|
|
5
17
|
|
|
18
|
+
export const GraphOrigin = Schema.Literals(["scanner", "llm", "external"])
|
|
19
|
+
export type GraphOrigin = typeof GraphOrigin.Type
|
|
20
|
+
export const GraphConfidence = Schema.Literals(["exact", "inferred"])
|
|
21
|
+
export type GraphConfidence = typeof GraphConfidence.Type
|
|
22
|
+
export const GraphMechanism = Schema.Literals([
|
|
23
|
+
"capture",
|
|
24
|
+
"join:exact",
|
|
25
|
+
"join:url",
|
|
26
|
+
"contraction",
|
|
27
|
+
"llm",
|
|
28
|
+
"codegraph"
|
|
29
|
+
])
|
|
30
|
+
export type GraphMechanism = typeof GraphMechanism.Type
|
|
31
|
+
export const JoinMatch = Schema.Literals(["exact", "prefix", "extension"])
|
|
32
|
+
export type JoinMatch = typeof JoinMatch.Type
|
|
33
|
+
|
|
34
|
+
export class GraphEvidence extends Schema.Class<GraphEvidence>("GraphEvidence")({
|
|
35
|
+
file: Schema.String,
|
|
36
|
+
line: Schema.Int,
|
|
37
|
+
snippet: Schema.String
|
|
38
|
+
}) {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A node of the estate graph. The four original fields describe a FILE node;
|
|
42
|
+
* the ADR 0030 fields are optional on the wire so a pre-0030 `graph.json`
|
|
43
|
+
* decodes and the four-field constructor keeps compiling — read them through
|
|
44
|
+
* `nodeId`, `nodeKind` and the other helpers below, never directly.
|
|
45
|
+
*/
|
|
6
46
|
export class SurveyNode extends Schema.Class<SurveyNode>("SurveyNode")({
|
|
7
47
|
path: Schema.String,
|
|
8
48
|
name: Schema.String,
|
|
9
49
|
lines: Schema.Int,
|
|
10
|
-
units: Schema.Int
|
|
50
|
+
units: Schema.Int,
|
|
51
|
+
id: Schema.optionalKey(Schema.String),
|
|
52
|
+
kind: Schema.optionalKey(Schema.String),
|
|
53
|
+
label: Schema.optionalKey(Schema.String),
|
|
54
|
+
lineStart: Schema.optionalKey(Schema.Int),
|
|
55
|
+
lineEnd: Schema.optionalKey(Schema.Int),
|
|
56
|
+
attrs: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
57
|
+
origin: Schema.optionalKey(GraphOrigin),
|
|
58
|
+
/** A wiring record (a `web.xml` mapping), not a unit: contracted by the file projection. */
|
|
59
|
+
descriptor: Schema.optionalKey(Schema.Boolean),
|
|
60
|
+
/** The attr naming the unit this node stands for (a declaration's class). */
|
|
61
|
+
anchor: Schema.optionalKey(Schema.String)
|
|
11
62
|
}) {}
|
|
12
63
|
|
|
13
64
|
export class SurveyEdge extends Schema.Class<SurveyEdge>("SurveyEdge")({
|
|
14
65
|
from: Schema.String,
|
|
15
66
|
to: Schema.String,
|
|
16
|
-
kind: Schema.String
|
|
67
|
+
kind: Schema.String,
|
|
68
|
+
origin: Schema.optionalKey(GraphOrigin),
|
|
69
|
+
confidence: Schema.optionalKey(GraphConfidence),
|
|
70
|
+
/** The pack section that produced the edge. */
|
|
71
|
+
rule: Schema.optionalKey(Schema.String),
|
|
72
|
+
mechanism: Schema.optionalKey(GraphMechanism),
|
|
73
|
+
join: Schema.optionalKey(JoinMatch),
|
|
74
|
+
evidence: Schema.optionalKey(GraphEvidence)
|
|
75
|
+
}) {}
|
|
76
|
+
|
|
77
|
+
export const UnresolvedReason = Schema.Literals([
|
|
78
|
+
"edge-target",
|
|
79
|
+
"missing-attr",
|
|
80
|
+
"join-from",
|
|
81
|
+
"join-to",
|
|
82
|
+
"isolated"
|
|
83
|
+
])
|
|
84
|
+
export type UnresolvedReason = typeof UnresolvedReason.Type
|
|
85
|
+
|
|
86
|
+
export class Unresolved extends Schema.Class<Unresolved>("Unresolved")({
|
|
87
|
+
reason: UnresolvedReason,
|
|
88
|
+
rule: Schema.String,
|
|
89
|
+
node: Schema.String,
|
|
90
|
+
reference: Schema.optionalKey(Schema.String),
|
|
91
|
+
file: Schema.String,
|
|
92
|
+
line: Schema.Int
|
|
93
|
+
}) {}
|
|
94
|
+
|
|
95
|
+
/** An attr the LLM pass filled on an existing node, with the line that justifies it. */
|
|
96
|
+
export class Fill extends Schema.Class<Fill>("Fill")({
|
|
97
|
+
node: Schema.String,
|
|
98
|
+
key: Schema.String,
|
|
99
|
+
value: Schema.String,
|
|
100
|
+
evidence: GraphEvidence
|
|
17
101
|
}) {}
|
|
18
102
|
|
|
19
103
|
export class SurveyGraph extends Schema.Class<SurveyGraph>("SurveyGraph")({
|
|
20
104
|
nodes: Schema.Array(SurveyNode),
|
|
21
|
-
edges: Schema.Array(SurveyEdge)
|
|
105
|
+
edges: Schema.Array(SurveyEdge),
|
|
106
|
+
unresolved: Schema.Array(Unresolved).pipe(
|
|
107
|
+
Schema.withDecodingDefaultKey(Effect.succeed([])),
|
|
108
|
+
Schema.withConstructorDefault(Effect.succeed([]))
|
|
109
|
+
),
|
|
110
|
+
fills: Schema.Array(Fill).pipe(
|
|
111
|
+
Schema.withDecodingDefaultKey(Effect.succeed([])),
|
|
112
|
+
Schema.withConstructorDefault(Effect.succeed([]))
|
|
113
|
+
)
|
|
22
114
|
}) {
|
|
23
|
-
incoming(
|
|
24
|
-
return this.
|
|
115
|
+
incoming(id: string): ReadonlyArray<SurveyEdge> {
|
|
116
|
+
return indexOf(this).incoming.get(id) ?? []
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
outgoing(id: string): ReadonlyArray<SurveyEdge> {
|
|
120
|
+
return indexOf(this).outgoing.get(id) ?? []
|
|
25
121
|
}
|
|
26
122
|
|
|
27
|
-
|
|
28
|
-
return this.
|
|
123
|
+
node(id: string): SurveyNode | undefined {
|
|
124
|
+
return indexOf(this).byId.get(id)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface GraphIndex {
|
|
129
|
+
readonly byId: ReadonlyMap<string, SurveyNode>
|
|
130
|
+
readonly incoming: ReadonlyMap<string, ReadonlyArray<SurveyEdge>>
|
|
131
|
+
readonly outgoing: ReadonlyMap<string, ReadonlyArray<SurveyEdge>>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Adjacency, built once per graph instance and kept in a WeakMap: a graph is
|
|
136
|
+
* immutable (every change constructs a new one), so the index never goes
|
|
137
|
+
* stale, and walks over an estate-sized graph stay linear instead of V × E.
|
|
138
|
+
*/
|
|
139
|
+
const indexes = new WeakMap<SurveyGraph, GraphIndex>()
|
|
140
|
+
|
|
141
|
+
const indexOf = (graph: SurveyGraph): GraphIndex => {
|
|
142
|
+
const cached = indexes.get(graph)
|
|
143
|
+
if (cached !== undefined) {
|
|
144
|
+
return cached
|
|
145
|
+
}
|
|
146
|
+
const byId = new Map<string, SurveyNode>()
|
|
147
|
+
for (const node of graph.nodes) {
|
|
148
|
+
const id = nodeId(node)
|
|
149
|
+
if (!byId.has(id)) {
|
|
150
|
+
byId.set(id, node)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const incoming = new Map<string, Array<SurveyEdge>>()
|
|
154
|
+
const outgoing = new Map<string, Array<SurveyEdge>>()
|
|
155
|
+
for (const edge of graph.edges) {
|
|
156
|
+
const into = incoming.get(edge.to)
|
|
157
|
+
if (into === undefined) {
|
|
158
|
+
incoming.set(edge.to, [edge])
|
|
159
|
+
} else {
|
|
160
|
+
into.push(edge)
|
|
161
|
+
}
|
|
162
|
+
const from = outgoing.get(edge.from)
|
|
163
|
+
if (from === undefined) {
|
|
164
|
+
outgoing.set(edge.from, [edge])
|
|
165
|
+
} else {
|
|
166
|
+
from.push(edge)
|
|
167
|
+
}
|
|
29
168
|
}
|
|
169
|
+
const index: GraphIndex = { byId, incoming, outgoing }
|
|
170
|
+
indexes.set(graph, index)
|
|
171
|
+
return index
|
|
30
172
|
}
|
|
31
173
|
|
|
174
|
+
export const nodeId = (node: SurveyNode): string => node.id ?? node.name
|
|
175
|
+
export const nodeKind = (node: SurveyNode): string => node.kind ?? "file"
|
|
176
|
+
export const nodeLabel = (node: SurveyNode): string => node.label ?? node.name
|
|
177
|
+
export const nodeAttrs = (node: SurveyNode): Readonly<Record<string, string>> => node.attrs ?? {}
|
|
178
|
+
export const nodeLineStart = (node: SurveyNode): number => node.lineStart ?? 1
|
|
179
|
+
export const nodeLineEnd = (node: SurveyNode): number => node.lineEnd ?? node.lines
|
|
180
|
+
export const isDescriptor = (node: SurveyNode): boolean => node.descriptor === true
|
|
181
|
+
export const edgeOrigin = (edge: SurveyEdge): GraphOrigin => edge.origin ?? "scanner"
|
|
182
|
+
export const edgeConfidence = (edge: SurveyEdge): GraphConfidence => edge.confidence ?? "exact"
|
|
183
|
+
|
|
184
|
+
const inferred: GraphConfidence = "inferred"
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The file-level view every pre-ADR-0030 consumer expects. Each node folds onto
|
|
188
|
+
* a unit (its `anchor` attr when that names a known unit, else its file's
|
|
189
|
+
* unit); descriptor nodes with outgoing edges are contracted (`a → d → b`
|
|
190
|
+
* becomes `a → b` under `a → d`'s kind); self loops drop; duplicates by
|
|
191
|
+
* (from, to, kind) drop. On a file-only graph this is the identity.
|
|
192
|
+
*/
|
|
193
|
+
export const projectToFiles = (graph: SurveyGraph): SurveyGraph => {
|
|
194
|
+
const files = graph.nodes.filter((node) => nodeKind(node) === "file")
|
|
195
|
+
const known = new Set(files.map((node) => node.name))
|
|
196
|
+
const byId = new Map(graph.nodes.map((node) => [nodeId(node), node]))
|
|
197
|
+
const unitOf = (id: string): string => {
|
|
198
|
+
const node = byId.get(id)
|
|
199
|
+
if (node === undefined) {
|
|
200
|
+
return id
|
|
201
|
+
}
|
|
202
|
+
if (nodeKind(node) === "file") {
|
|
203
|
+
return node.name
|
|
204
|
+
}
|
|
205
|
+
const anchored = node.anchor === undefined ? undefined : nodeAttrs(node)[node.anchor]
|
|
206
|
+
if (anchored !== undefined) {
|
|
207
|
+
const resolved = resolveUnit(anchored, known)
|
|
208
|
+
if (known.has(resolved)) {
|
|
209
|
+
return resolved
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return unitName(node.path)
|
|
213
|
+
}
|
|
214
|
+
const contractible = new Set(
|
|
215
|
+
graph.nodes
|
|
216
|
+
.filter((node) => isDescriptor(node) && graph.outgoing(nodeId(node)).length > 0)
|
|
217
|
+
.map(nodeId)
|
|
218
|
+
)
|
|
219
|
+
const edgeKey = (edge: SurveyEdge): string =>
|
|
220
|
+
`${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${edgeConfidence(edge)}`
|
|
221
|
+
let edges: ReadonlyArray<SurveyEdge> = graph.edges
|
|
222
|
+
for (let round = 0; round <= graph.nodes.length; round += 1) {
|
|
223
|
+
if (!edges.some((edge) => contractible.has(edge.to))) {
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
const next: Array<SurveyEdge> = []
|
|
227
|
+
const keys = new Set<string>()
|
|
228
|
+
const push = (edge: SurveyEdge): void => {
|
|
229
|
+
const key = edgeKey(edge)
|
|
230
|
+
if (!keys.has(key)) {
|
|
231
|
+
keys.add(key)
|
|
232
|
+
next.push(edge)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const edge of edges) {
|
|
236
|
+
if (!contractible.has(edge.to)) {
|
|
237
|
+
push(edge)
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
for (const hop of graph.outgoing(edge.to)) {
|
|
241
|
+
push(
|
|
242
|
+
new SurveyEdge({
|
|
243
|
+
...edge,
|
|
244
|
+
to: hop.to,
|
|
245
|
+
mechanism: "contraction",
|
|
246
|
+
...(edgeConfidence(edge) === "inferred" || edgeConfidence(hop) === "inferred"
|
|
247
|
+
? { confidence: inferred }
|
|
248
|
+
: {})
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// A descriptor cycle re-derives the same edges forever: stop when a round
|
|
254
|
+
// adds nothing new; what still ends on a descriptor is cycle residue.
|
|
255
|
+
if (
|
|
256
|
+
next.length === edges.length &&
|
|
257
|
+
next.every((edge, index) => edgeKey(edge) === edgeKey(edges[index]!))
|
|
258
|
+
) {
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
edges = next
|
|
262
|
+
}
|
|
263
|
+
const seen = new Set<string>()
|
|
264
|
+
const projected = edges.flatMap((edge) => {
|
|
265
|
+
if (contractible.has(edge.from) || contractible.has(edge.to)) {
|
|
266
|
+
return []
|
|
267
|
+
}
|
|
268
|
+
const from = unitOf(edge.from)
|
|
269
|
+
const to = unitOf(edge.to)
|
|
270
|
+
const key = `${from}\u0000${to}\u0000${edge.kind}`
|
|
271
|
+
if (from === to || seen.has(key)) {
|
|
272
|
+
return []
|
|
273
|
+
}
|
|
274
|
+
seen.add(key)
|
|
275
|
+
return [new SurveyEdge({ ...edge, from, to })]
|
|
276
|
+
})
|
|
277
|
+
return new SurveyGraph({
|
|
278
|
+
nodes: files,
|
|
279
|
+
edges: projected,
|
|
280
|
+
unresolved: graph.unresolved,
|
|
281
|
+
fills: graph.fills
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const graphFromJson = (text: string): Effect.Effect<SurveyGraph, PlanParseError> =>
|
|
286
|
+
Effect.try({
|
|
287
|
+
try: (): unknown => JSON.parse(text),
|
|
288
|
+
catch: (error) => PlanParseError.make({ message: `graph.json is not JSON: ${String(error)}` })
|
|
289
|
+
}).pipe(
|
|
290
|
+
Effect.flatMap((json) =>
|
|
291
|
+
Schema.decodeUnknownEffect(SurveyGraph)(json).pipe(
|
|
292
|
+
Effect.mapError((error) =>
|
|
293
|
+
PlanParseError.make({ message: `graph.json does not decode: ${String(error)}` })
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
32
299
|
/**
|
|
33
300
|
* The transitive dependency closure of `program` as repo-relative paths,
|
|
34
301
|
* breadth-first, excluding the program itself. The `seen` set is required, not
|
|
@@ -43,7 +310,8 @@ export const closureFor = (
|
|
|
43
310
|
program: string,
|
|
44
311
|
maxFiles: number
|
|
45
312
|
): ReadonlyArray<string> => {
|
|
46
|
-
const
|
|
313
|
+
const projected = projectToFiles(graph)
|
|
314
|
+
const pathOf = new Map(projected.nodes.map((node) => [node.name, node.path]))
|
|
47
315
|
const walk = (
|
|
48
316
|
frontier: ReadonlyArray<string>,
|
|
49
317
|
seen: ReadonlySet<string>,
|
|
@@ -53,7 +321,7 @@ export const closureFor = (
|
|
|
53
321
|
return acc.slice(0, maxFiles)
|
|
54
322
|
}
|
|
55
323
|
const next = [
|
|
56
|
-
...new Set(frontier.flatMap((from) =>
|
|
324
|
+
...new Set(frontier.flatMap((from) => projected.outgoing(from).map((edge) => edge.to)))
|
|
57
325
|
].filter((name) => !seen.has(name))
|
|
58
326
|
return walk(next, new Set([...seen, ...next]), [
|
|
59
327
|
...acc,
|
|
@@ -93,71 +361,592 @@ export const resolveUnit = (reference: string, known: ReadonlySet<string>): stri
|
|
|
93
361
|
return known.has(folded) ? folded : reference
|
|
94
362
|
}
|
|
95
363
|
|
|
96
|
-
const matches = (regex: string, contents: string): ReadonlyArray<string> => {
|
|
97
|
-
const expression = new RegExp(regex, "g")
|
|
98
|
-
return [...contents.matchAll(expression)].map((match) => match[1] ?? match[0])
|
|
99
|
-
}
|
|
100
|
-
|
|
101
364
|
export interface SurveyGraphOptions {
|
|
102
365
|
/** Regex over repo-relative paths to leave out even when `sources` matches. */
|
|
103
366
|
readonly exclude?: string
|
|
104
367
|
}
|
|
105
368
|
|
|
106
|
-
export
|
|
107
|
-
|
|
108
|
-
|
|
369
|
+
export interface CodeGraphOptions {
|
|
370
|
+
readonly sources: string
|
|
371
|
+
readonly exclude?: string
|
|
372
|
+
readonly coverage: ReadonlyArray<CoverageRule>
|
|
373
|
+
readonly rules: GraphRules
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface CodeGraphBuild {
|
|
377
|
+
readonly graph: SurveyGraph
|
|
378
|
+
/** path → sha256 of the scanned contents; the cache's staleness key. */
|
|
379
|
+
readonly files: Readonly<Record<string, string>>
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex")
|
|
383
|
+
|
|
384
|
+
/** 0-based offset → 1-based line, via a precomputed table of line starts. */
|
|
385
|
+
const lineLocator = (text: string): ((offset: number) => number) => {
|
|
386
|
+
const starts = [0]
|
|
387
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
388
|
+
if (text.charCodeAt(index) === 10) {
|
|
389
|
+
starts.push(index + 1)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return (offset) => {
|
|
393
|
+
let low = 0
|
|
394
|
+
let high = starts.length - 1
|
|
395
|
+
while (low < high) {
|
|
396
|
+
const mid = (low + high + 1) >> 1
|
|
397
|
+
if ((starts[mid] ?? 0) <= offset) {
|
|
398
|
+
low = mid
|
|
399
|
+
} else {
|
|
400
|
+
high = mid - 1
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return low + 1
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
interface Capture {
|
|
408
|
+
readonly line: number
|
|
409
|
+
readonly groups: Readonly<Record<string, string>>
|
|
410
|
+
/** The first positional group, else the whole match: what a `## Survey:` rule targets. */
|
|
411
|
+
readonly first: string
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Every match of `pattern` (flags `gm`) with its line and defined named groups. */
|
|
415
|
+
const captures = (
|
|
416
|
+
pattern: string,
|
|
417
|
+
text: string,
|
|
418
|
+
locate: (offset: number) => number
|
|
419
|
+
): ReadonlyArray<Capture> =>
|
|
420
|
+
[...text.matchAll(new RegExp(pattern, "gm"))].map((match) => ({
|
|
421
|
+
line: locate(match.index ?? 0),
|
|
422
|
+
groups: Object.fromEntries(
|
|
423
|
+
Object.entries(match.groups ?? {}).flatMap(([key, value]) =>
|
|
424
|
+
value === undefined ? [] : [[key, value]]
|
|
425
|
+
)
|
|
426
|
+
),
|
|
427
|
+
first: match[1] ?? match[0]
|
|
428
|
+
}))
|
|
429
|
+
|
|
430
|
+
const scanner: GraphOrigin = "scanner"
|
|
431
|
+
const exact: GraphConfidence = "exact"
|
|
432
|
+
|
|
433
|
+
/** Node ids: `<kind>:<path>#<name>`, `~n` for a repeated name within one file. */
|
|
434
|
+
const scanNodes = (
|
|
435
|
+
paths: ReadonlyArray<string>,
|
|
436
|
+
contents: ReadonlyMap<string, string>,
|
|
109
437
|
units: ReadonlyArray<CoverageRule>,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
): Effect.fn.Return<SurveyGraph, WorkspaceError> {
|
|
113
|
-
// The source regex narrows discovery itself, so the workspace's result cap
|
|
114
|
-
// counts candidate units rather than every jar, image, and generated file
|
|
115
|
-
// sharing the tree with them.
|
|
116
|
-
const paths = [
|
|
117
|
-
...(yield* workspace.discover("**/*", {
|
|
118
|
-
matching: new RegExp(sources),
|
|
119
|
-
...(options.exclude === undefined ? {} : { excluding: new RegExp(options.exclude) })
|
|
120
|
-
}))
|
|
121
|
-
].sort()
|
|
438
|
+
rules: ReadonlyArray<NodeRule>
|
|
439
|
+
): ReadonlyArray<SurveyNode> => {
|
|
122
440
|
const nodes: Array<SurveyNode> = []
|
|
123
|
-
const contents = new Map<string, string>()
|
|
124
441
|
for (const path of paths) {
|
|
125
|
-
const text =
|
|
126
|
-
|
|
442
|
+
const text = contents.get(path) ?? ""
|
|
443
|
+
const lines = text.split(/\r?\n/).length
|
|
444
|
+
const locate = lineLocator(text)
|
|
127
445
|
nodes.push(
|
|
128
446
|
SurveyNode.make({
|
|
129
447
|
path,
|
|
130
448
|
name: unitName(path),
|
|
131
|
-
lines
|
|
449
|
+
lines,
|
|
132
450
|
units: units
|
|
133
451
|
.filter((rule) => new RegExp(rule.files).test(path))
|
|
134
|
-
.reduce((count, rule) => count +
|
|
452
|
+
.reduce((count, rule) => count + captures(rule.unit, text, locate).length, 0),
|
|
453
|
+
id: unitName(path),
|
|
454
|
+
kind: "file",
|
|
455
|
+
lineStart: 1,
|
|
456
|
+
lineEnd: lines
|
|
135
457
|
})
|
|
136
458
|
)
|
|
459
|
+
for (const rule of rules.filter((rule) => new RegExp(rule.files).test(path))) {
|
|
460
|
+
const seen = new Map<string, number>()
|
|
461
|
+
const own: Array<SurveyNode> = []
|
|
462
|
+
for (const hit of captures(rule.pattern, text, locate)) {
|
|
463
|
+
const name = hit.groups.name ?? `L${hit.line}`
|
|
464
|
+
const count = (seen.get(name) ?? 0) + 1
|
|
465
|
+
seen.set(name, count)
|
|
466
|
+
const attrs: Record<string, string> = Object.fromEntries(
|
|
467
|
+
Object.entries(hit.groups).filter(([key]) => key !== "name")
|
|
468
|
+
)
|
|
469
|
+
for (const [attr, group] of Object.entries(rule.attrs)) {
|
|
470
|
+
const value = hit.groups[group]
|
|
471
|
+
if (value !== undefined) {
|
|
472
|
+
attrs[attr] = value
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
own.push(
|
|
476
|
+
SurveyNode.make({
|
|
477
|
+
path,
|
|
478
|
+
name,
|
|
479
|
+
lines: 0,
|
|
480
|
+
units: 0,
|
|
481
|
+
id: `${rule.kind}:${path}#${name}${count > 1 ? `~${count}` : ""}`,
|
|
482
|
+
kind: rule.kind,
|
|
483
|
+
label: name,
|
|
484
|
+
lineStart: hit.line,
|
|
485
|
+
lineEnd: lines,
|
|
486
|
+
attrs,
|
|
487
|
+
origin: scanner,
|
|
488
|
+
descriptor: rule.descriptor,
|
|
489
|
+
...(rule.anchor === undefined ? {} : { anchor: rule.anchor })
|
|
490
|
+
})
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
// A node spans to the line before the next node of the same kind in the file.
|
|
494
|
+
own.forEach((node, index) => {
|
|
495
|
+
const next = own[index + 1]
|
|
496
|
+
nodes.push(
|
|
497
|
+
next === undefined
|
|
498
|
+
? node
|
|
499
|
+
: new SurveyNode({
|
|
500
|
+
...node,
|
|
501
|
+
lineEnd: Math.max(nodeLineStart(node), nodeLineStart(next) - 1)
|
|
502
|
+
})
|
|
503
|
+
)
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return nodes
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Lookups the edge pass needs per capture, built once per scan instead of scanning every node. */
|
|
511
|
+
interface NodeLookup {
|
|
512
|
+
/** path → kind → nodes of that kind in the file, in line order. */
|
|
513
|
+
readonly inFile: ReadonlyMap<string, ReadonlyMap<string, ReadonlyArray<SurveyNode>>>
|
|
514
|
+
/** `kind\0name` → first node anywhere. */
|
|
515
|
+
readonly byKindName: ReadonlyMap<string, SurveyNode>
|
|
516
|
+
/** `path\0kind\0name` → node. */
|
|
517
|
+
readonly byPathKindName: ReadonlyMap<string, SurveyNode>
|
|
518
|
+
readonly known: ReadonlySet<string>
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const lookupOf = (nodes: ReadonlyArray<SurveyNode>): NodeLookup => {
|
|
522
|
+
const inFile = new Map<string, Map<string, Array<SurveyNode>>>()
|
|
523
|
+
const byKindName = new Map<string, SurveyNode>()
|
|
524
|
+
const byPathKindName = new Map<string, SurveyNode>()
|
|
525
|
+
const known = new Set<string>()
|
|
526
|
+
for (const node of nodes) {
|
|
527
|
+
const kind = nodeKind(node)
|
|
528
|
+
if (kind === "file") {
|
|
529
|
+
known.add(node.name)
|
|
530
|
+
}
|
|
531
|
+
const kinds = inFile.get(node.path) ?? new Map<string, Array<SurveyNode>>()
|
|
532
|
+
const list = kinds.get(kind) ?? []
|
|
533
|
+
list.push(node)
|
|
534
|
+
kinds.set(kind, list)
|
|
535
|
+
inFile.set(node.path, kinds)
|
|
536
|
+
const kindKey = `${kind}\u0000${node.name}`
|
|
537
|
+
if (!byKindName.has(kindKey)) {
|
|
538
|
+
byKindName.set(kindKey, node)
|
|
539
|
+
}
|
|
540
|
+
const pathKey = `${node.path}\u0000${kind}\u0000${node.name}`
|
|
541
|
+
if (!byPathKindName.has(pathKey)) {
|
|
542
|
+
byPathKindName.set(pathKey, node)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const kinds of inFile.values()) {
|
|
546
|
+
for (const list of kinds.values()) {
|
|
547
|
+
list.sort((left, right) => nodeLineStart(left) - nodeLineStart(right))
|
|
548
|
+
}
|
|
137
549
|
}
|
|
138
|
-
|
|
550
|
+
return { inFile, byKindName, byPathKindName, known }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The last node of `kind` in `path` starting at or before `line` (binary search over the file's list). */
|
|
554
|
+
const enclosing = (
|
|
555
|
+
lookup: NodeLookup,
|
|
556
|
+
path: string,
|
|
557
|
+
kind: string,
|
|
558
|
+
line: number
|
|
559
|
+
): SurveyNode | undefined => {
|
|
560
|
+
const list = lookup.inFile.get(path)?.get(kind) ?? []
|
|
561
|
+
let low = 0
|
|
562
|
+
let high = list.length - 1
|
|
563
|
+
let found: SurveyNode | undefined
|
|
564
|
+
while (low <= high) {
|
|
565
|
+
const mid = (low + high) >> 1
|
|
566
|
+
const candidate = list[mid]!
|
|
567
|
+
if (nodeLineStart(candidate) <= line) {
|
|
568
|
+
found = candidate
|
|
569
|
+
low = mid + 1
|
|
570
|
+
} else {
|
|
571
|
+
high = mid - 1
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return found
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const resolveTarget = (
|
|
578
|
+
lookup: NodeLookup,
|
|
579
|
+
path: string,
|
|
580
|
+
kind: string,
|
|
581
|
+
reference: string
|
|
582
|
+
): { readonly id: string; readonly found: boolean } => {
|
|
583
|
+
if (kind === "file") {
|
|
584
|
+
const unit = resolveUnit(reference, lookup.known)
|
|
585
|
+
return { id: unit, found: lookup.known.has(unit) }
|
|
586
|
+
}
|
|
587
|
+
const node =
|
|
588
|
+
lookup.byPathKindName.get(`${path}\u0000${kind}\u0000${reference}`) ??
|
|
589
|
+
lookup.byKindName.get(`${kind}\u0000${reference}`)
|
|
590
|
+
return node === undefined ? { id: reference, found: false } : { id: nodeId(node), found: true }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const scanEdges = (
|
|
594
|
+
paths: ReadonlyArray<string>,
|
|
595
|
+
contents: ReadonlyMap<string, string>,
|
|
596
|
+
nodes: ReadonlyArray<SurveyNode>,
|
|
597
|
+
rules: ReadonlyArray<EdgeRule>
|
|
598
|
+
): {
|
|
599
|
+
readonly edges: ReadonlyArray<SurveyEdge>
|
|
600
|
+
readonly unresolved: ReadonlyArray<Unresolved>
|
|
601
|
+
} => {
|
|
602
|
+
const lookup = lookupOf(nodes)
|
|
603
|
+
const lineOf = new Map(nodes.map((node) => [nodeId(node), nodeLineStart(node)]))
|
|
139
604
|
const edges: Array<SurveyEdge> = []
|
|
140
|
-
|
|
605
|
+
const unresolved: Array<Unresolved> = []
|
|
606
|
+
const seen = new Set<string>()
|
|
607
|
+
const push = (edge: SurveyEdge): void => {
|
|
608
|
+
const key = `${edge.from}\u0000${edge.to}\u0000${edge.kind}`
|
|
609
|
+
if (!seen.has(key)) {
|
|
610
|
+
seen.add(key)
|
|
611
|
+
edges.push(edge)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const rule of rules) {
|
|
141
615
|
const filePattern = new RegExp(rule.files)
|
|
142
616
|
for (const path of paths.filter((path) => filePattern.test(path))) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
617
|
+
const text = contents.get(path) ?? ""
|
|
618
|
+
const locate = lineLocator(text)
|
|
619
|
+
const lines = text.split(/\r?\n/)
|
|
620
|
+
for (const hit of captures(rule.pattern, text, locate)) {
|
|
621
|
+
const reference = rule.positional === true ? hit.first : hit.groups.to
|
|
622
|
+
if (reference === undefined) {
|
|
623
|
+
continue
|
|
624
|
+
}
|
|
625
|
+
const fromNode =
|
|
626
|
+
rule.fromKind === "file" ? undefined : enclosing(lookup, path, rule.fromKind, hit.line)
|
|
627
|
+
const from = fromNode === undefined ? unitName(path) : nodeId(fromNode)
|
|
628
|
+
const evidence = GraphEvidence.make({
|
|
629
|
+
file: path,
|
|
630
|
+
line: hit.line,
|
|
631
|
+
snippet: (lines[hit.line - 1] ?? "").trim()
|
|
148
632
|
})
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
633
|
+
const target = resolveTarget(lookup, path, rule.toKind, reference)
|
|
634
|
+
const targets: Array<string> = []
|
|
635
|
+
if (target.found && hit.groups.thru !== undefined && rule.toKind !== "file") {
|
|
636
|
+
const end = resolveTarget(lookup, path, rule.toKind, hit.groups.thru)
|
|
637
|
+
const startLine = lineOf.get(target.id) ?? 0
|
|
638
|
+
const endLine = end.found ? (lineOf.get(end.id) ?? startLine) : startLine
|
|
639
|
+
targets.push(
|
|
640
|
+
...(lookup.inFile.get(path)?.get(rule.toKind) ?? [])
|
|
641
|
+
.filter((node) => nodeLineStart(node) >= startLine && nodeLineStart(node) <= endLine)
|
|
642
|
+
.map(nodeId)
|
|
643
|
+
)
|
|
644
|
+
} else if (target.found || rule.toKind === "file") {
|
|
645
|
+
targets.push(target.id)
|
|
646
|
+
}
|
|
647
|
+
if (!target.found) {
|
|
648
|
+
unresolved.push(
|
|
649
|
+
Unresolved.make({
|
|
650
|
+
reason: "edge-target",
|
|
651
|
+
rule: rule.kind,
|
|
652
|
+
node: from,
|
|
653
|
+
reference,
|
|
654
|
+
file: path,
|
|
655
|
+
line: hit.line
|
|
656
|
+
})
|
|
153
657
|
)
|
|
154
|
-
) {
|
|
155
|
-
edges.push(edge)
|
|
156
658
|
}
|
|
659
|
+
for (const to of targets) {
|
|
660
|
+
push(
|
|
661
|
+
SurveyEdge.make({
|
|
662
|
+
from,
|
|
663
|
+
to,
|
|
664
|
+
kind: rule.kind,
|
|
665
|
+
origin: scanner,
|
|
666
|
+
confidence: exact,
|
|
667
|
+
rule: rule.kind,
|
|
668
|
+
mechanism: "capture",
|
|
669
|
+
evidence
|
|
670
|
+
})
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return { edges, unresolved }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Nodes and captured edges only; joins and the unresolved summary come from `applyFillsAndJoins`. */
|
|
680
|
+
export const scanGraph = (
|
|
681
|
+
paths: ReadonlyArray<string>,
|
|
682
|
+
contents: ReadonlyMap<string, string>,
|
|
683
|
+
options: CodeGraphOptions
|
|
684
|
+
): SurveyGraph => {
|
|
685
|
+
const nodes = scanNodes(paths, contents, options.coverage, options.rules.nodes)
|
|
686
|
+
const { edges, unresolved } = scanEdges(paths, contents, nodes, options.rules.edges)
|
|
687
|
+
return SurveyGraph.make({ nodes, edges, unresolved, fills: [] })
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Directories `D` with some scanned path under `D/WEB-INF/`, sorted; the per-app scope of url joins. */
|
|
691
|
+
export const appRoots = (paths: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
692
|
+
[
|
|
693
|
+
...new Set(
|
|
694
|
+
paths.flatMap((path) => {
|
|
695
|
+
const index = path.indexOf("/WEB-INF/")
|
|
696
|
+
return index < 0 ? [] : [path.slice(0, index)]
|
|
697
|
+
})
|
|
698
|
+
)
|
|
699
|
+
].sort()
|
|
700
|
+
|
|
701
|
+
/** The longest app root that prefixes `path`, or "" for the estate scope. */
|
|
702
|
+
export const appOf = (path: string, roots: ReadonlyArray<string>): string =>
|
|
703
|
+
[...roots]
|
|
704
|
+
.filter((root) => path === root || path.startsWith(`${root}/`))
|
|
705
|
+
.sort((left, right) => right.length - left.length)[0] ?? ""
|
|
706
|
+
|
|
707
|
+
const withFills = (
|
|
708
|
+
nodes: ReadonlyArray<SurveyNode>,
|
|
709
|
+
fills: ReadonlyArray<Fill>
|
|
710
|
+
): ReadonlyArray<SurveyNode> =>
|
|
711
|
+
nodes.map((node) => {
|
|
712
|
+
const own = fills.filter((fill) => fill.node === nodeId(node))
|
|
713
|
+
return own.length === 0
|
|
714
|
+
? node
|
|
715
|
+
: new SurveyNode({
|
|
716
|
+
...node,
|
|
717
|
+
attrs: {
|
|
718
|
+
...nodeAttrs(node),
|
|
719
|
+
...Object.fromEntries(own.map((fill) => [fill.key, fill.value]))
|
|
720
|
+
}
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
const joinRank: Readonly<Record<JoinMatch, number>> = { exact: 0, prefix: 1, extension: 2 }
|
|
725
|
+
|
|
726
|
+
/** A join attr: a named group copied into attrs, or the node's own name for `name`. */
|
|
727
|
+
const attrOf = (node: SurveyNode, attr: string): string | undefined =>
|
|
728
|
+
attr === "name" ? node.name : nodeAttrs(node)[attr]
|
|
729
|
+
const llm: GraphOrigin = "llm"
|
|
730
|
+
|
|
731
|
+
const joinEdges = (
|
|
732
|
+
nodes: ReadonlyArray<SurveyNode>,
|
|
733
|
+
fills: ReadonlyArray<Fill>,
|
|
734
|
+
rules: ReadonlyArray<JoinRule>,
|
|
735
|
+
roots: ReadonlyArray<string>
|
|
736
|
+
): {
|
|
737
|
+
readonly edges: ReadonlyArray<SurveyEdge>
|
|
738
|
+
readonly unresolved: ReadonlyArray<Unresolved>
|
|
739
|
+
} => {
|
|
740
|
+
const filled = new Set(fills.map((fill) => `${fill.node}\u0000${fill.key}`))
|
|
741
|
+
const edges: Array<SurveyEdge> = []
|
|
742
|
+
const unresolved: Array<Unresolved> = []
|
|
743
|
+
const seen = new Set<string>()
|
|
744
|
+
for (const rule of rules) {
|
|
745
|
+
const sources = nodes.filter((node) => nodeKind(node) === rule.fromKind)
|
|
746
|
+
const targets = nodes.filter((node) => nodeKind(node) === rule.toKind)
|
|
747
|
+
for (const source of sources) {
|
|
748
|
+
const raw = attrOf(source, rule.fromAttr)
|
|
749
|
+
if (raw === undefined) {
|
|
750
|
+
unresolved.push(
|
|
751
|
+
Unresolved.make({
|
|
752
|
+
reason: "missing-attr",
|
|
753
|
+
rule: rule.kind,
|
|
754
|
+
node: nodeId(source),
|
|
755
|
+
file: source.path,
|
|
756
|
+
line: nodeLineStart(source)
|
|
757
|
+
})
|
|
758
|
+
)
|
|
759
|
+
continue
|
|
760
|
+
}
|
|
761
|
+
const request = rule.match === "url" ? normalizeUrl(raw) : raw.trim()
|
|
762
|
+
const inScope = targets.filter((target) =>
|
|
763
|
+
rule.scope === "file"
|
|
764
|
+
? target.path === source.path
|
|
765
|
+
: rule.scope === "app"
|
|
766
|
+
? appOf(target.path, roots) === appOf(source.path, roots)
|
|
767
|
+
: true
|
|
768
|
+
)
|
|
769
|
+
const matched = inScope.flatMap((target) => {
|
|
770
|
+
const value = attrOf(target, rule.toAttr)
|
|
771
|
+
if (value === undefined) {
|
|
772
|
+
return []
|
|
773
|
+
}
|
|
774
|
+
const how: JoinMatch | undefined =
|
|
775
|
+
rule.match === "url"
|
|
776
|
+
? matchUrl(request, value)
|
|
777
|
+
: value.trim() === request
|
|
778
|
+
? "exact"
|
|
779
|
+
: undefined
|
|
780
|
+
return how === undefined ? [] : [{ target, how, value }]
|
|
781
|
+
})
|
|
782
|
+
// Servlet-spec order: exact beats the longest prefix beats an extension.
|
|
783
|
+
const best = matched.sort(
|
|
784
|
+
(left, right) =>
|
|
785
|
+
joinRank[left.how] - joinRank[right.how] || right.value.length - left.value.length
|
|
786
|
+
)[0]
|
|
787
|
+
if (best === undefined) {
|
|
788
|
+
unresolved.push(
|
|
789
|
+
Unresolved.make({
|
|
790
|
+
reason: "join-from",
|
|
791
|
+
rule: rule.kind,
|
|
792
|
+
node: nodeId(source),
|
|
793
|
+
reference: raw,
|
|
794
|
+
file: source.path,
|
|
795
|
+
line: nodeLineStart(source)
|
|
796
|
+
})
|
|
797
|
+
)
|
|
798
|
+
continue
|
|
799
|
+
}
|
|
800
|
+
const inferred =
|
|
801
|
+
filled.has(`${nodeId(source)}\u0000${rule.fromAttr}`) ||
|
|
802
|
+
filled.has(`${nodeId(best.target)}\u0000${rule.toAttr}`)
|
|
803
|
+
const key = `${nodeId(source)}\u0000${nodeId(best.target)}\u0000${rule.kind}`
|
|
804
|
+
if (!seen.has(key)) {
|
|
805
|
+
seen.add(key)
|
|
806
|
+
edges.push(
|
|
807
|
+
SurveyEdge.make({
|
|
808
|
+
from: nodeId(source),
|
|
809
|
+
to: nodeId(best.target),
|
|
810
|
+
kind: rule.kind,
|
|
811
|
+
origin: inferred ? llm : scanner,
|
|
812
|
+
confidence: inferred ? "inferred" : exact,
|
|
813
|
+
rule: rule.kind,
|
|
814
|
+
mechanism: rule.match === "url" ? "join:url" : "join:exact",
|
|
815
|
+
join: best.how,
|
|
816
|
+
evidence: GraphEvidence.make({
|
|
817
|
+
file: source.path,
|
|
818
|
+
line: nodeLineStart(source),
|
|
819
|
+
snippet: raw
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// A join target nothing reaches, reported once per node whatever the kind:
|
|
827
|
+
// a mapping hit by a form but by no ajax call is wired, not a hole.
|
|
828
|
+
const reached = new Set(edges.map((edge) => edge.to))
|
|
829
|
+
const reportedKinds = new Set<string>()
|
|
830
|
+
for (const rule of rules) {
|
|
831
|
+
if (reportedKinds.has(rule.toKind)) {
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
reportedKinds.add(rule.toKind)
|
|
835
|
+
for (const target of nodes.filter((node) => nodeKind(node) === rule.toKind)) {
|
|
836
|
+
if (!reached.has(nodeId(target))) {
|
|
837
|
+
unresolved.push(
|
|
838
|
+
Unresolved.make({
|
|
839
|
+
reason: "join-to",
|
|
840
|
+
rule: rule.kind,
|
|
841
|
+
node: nodeId(target),
|
|
842
|
+
file: target.path,
|
|
843
|
+
line: nodeLineStart(target)
|
|
844
|
+
})
|
|
845
|
+
)
|
|
157
846
|
}
|
|
158
847
|
}
|
|
159
848
|
}
|
|
160
|
-
return
|
|
849
|
+
return { edges, unresolved }
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Pure re-derivation: every `join:*` edge is dropped and rebuilt from the
|
|
854
|
+
* current attrs plus `fills`; `unresolved` is recomputed from scratch except
|
|
855
|
+
* for `edge-target` entries, which only the scanner knows. `paths` defaults
|
|
856
|
+
* to the node paths; pass the scanned list to compute app roots precisely.
|
|
857
|
+
*/
|
|
858
|
+
export const applyFillsAndJoins = (
|
|
859
|
+
graph: SurveyGraph,
|
|
860
|
+
fills: ReadonlyArray<Fill>,
|
|
861
|
+
rules: GraphRules,
|
|
862
|
+
paths: ReadonlyArray<string> = graph.nodes.map((node) => node.path)
|
|
863
|
+
): SurveyGraph => {
|
|
864
|
+
const nodes = withFills(graph.nodes, fills)
|
|
865
|
+
const kept = graph.edges.filter((edge) => !(edge.mechanism ?? "").startsWith("join:"))
|
|
866
|
+
const joined = joinEdges(nodes, fills, rules.joins, appRoots(paths))
|
|
867
|
+
const edges = [...kept, ...joined.edges]
|
|
868
|
+
const projected = projectToFiles(SurveyGraph.make({ nodes, edges, unresolved: [], fills }))
|
|
869
|
+
// A file that only holds wiring records (web.xml) is a descriptor file, not
|
|
870
|
+
// a unit nothing references: it never counts as isolated.
|
|
871
|
+
const wiringFiles = new Set(nodes.filter(isDescriptor).map((node) => node.path))
|
|
872
|
+
const isolated = projected.nodes
|
|
873
|
+
.filter(
|
|
874
|
+
(node) =>
|
|
875
|
+
!wiringFiles.has(node.path) &&
|
|
876
|
+
projected.incoming(node.name).length === 0 &&
|
|
877
|
+
projected.outgoing(node.name).length === 0
|
|
878
|
+
)
|
|
879
|
+
.map((node) =>
|
|
880
|
+
Unresolved.make({
|
|
881
|
+
reason: "isolated",
|
|
882
|
+
rule: "file",
|
|
883
|
+
node: node.name,
|
|
884
|
+
file: node.path,
|
|
885
|
+
line: 1
|
|
886
|
+
})
|
|
887
|
+
)
|
|
888
|
+
// An edge-target hole retires once a non-scanner edge leaves the same node
|
|
889
|
+
// from the same line: the seat (or an external index) answered it.
|
|
890
|
+
const answered = new Set(
|
|
891
|
+
edges
|
|
892
|
+
.filter((edge) => edgeOrigin(edge) !== "scanner" && edge.evidence !== undefined)
|
|
893
|
+
.map((edge) => `${edge.from}\u0000${edge.evidence?.file}\u0000${edge.evidence?.line}`)
|
|
894
|
+
)
|
|
895
|
+
return SurveyGraph.make({
|
|
896
|
+
nodes,
|
|
897
|
+
edges,
|
|
898
|
+
unresolved: [
|
|
899
|
+
...graph.unresolved.filter(
|
|
900
|
+
(item) =>
|
|
901
|
+
item.reason === "edge-target" &&
|
|
902
|
+
!answered.has(`${item.node}\u0000${item.file}\u0000${item.line}`)
|
|
903
|
+
),
|
|
904
|
+
...joined.unresolved,
|
|
905
|
+
...isolated
|
|
906
|
+
],
|
|
907
|
+
fills
|
|
908
|
+
})
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export const buildCodeGraph = Effect.fn("@llm4ts/flow/Survey.buildCodeGraph")(function* (
|
|
912
|
+
workspace: WorkspaceShape,
|
|
913
|
+
options: CodeGraphOptions
|
|
914
|
+
): Effect.fn.Return<CodeGraphBuild, WorkspaceError> {
|
|
915
|
+
// The source regex narrows discovery itself, so the workspace's result cap
|
|
916
|
+
// counts candidate units rather than every jar, image, and generated file
|
|
917
|
+
// sharing the tree with them.
|
|
918
|
+
const paths = [
|
|
919
|
+
...(yield* workspace.discover("**/*", {
|
|
920
|
+
matching: new RegExp(options.sources),
|
|
921
|
+
...(options.exclude === undefined ? {} : { excluding: new RegExp(options.exclude) })
|
|
922
|
+
}))
|
|
923
|
+
].sort()
|
|
924
|
+
const contents = new Map<string, string>()
|
|
925
|
+
const files: Record<string, string> = {}
|
|
926
|
+
for (const path of paths) {
|
|
927
|
+
const text = yield* workspace.read(path)
|
|
928
|
+
contents.set(path, text)
|
|
929
|
+
files[path] = sha256(text)
|
|
930
|
+
}
|
|
931
|
+
const scanned = scanGraph(paths, contents, options)
|
|
932
|
+
return { graph: applyFillsAndJoins(scanned, [], options.rules, paths), files }
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
/** The pre-ADR-0030 entry: Survey rules only, file nodes only. Delegates to `buildCodeGraph`. */
|
|
936
|
+
export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
|
|
937
|
+
workspace: WorkspaceShape,
|
|
938
|
+
sources: string,
|
|
939
|
+
units: ReadonlyArray<CoverageRule>,
|
|
940
|
+
edgeRules: ReadonlyArray<CoverageRule>,
|
|
941
|
+
options: SurveyGraphOptions = {}
|
|
942
|
+
): Effect.fn.Return<SurveyGraph, WorkspaceError> {
|
|
943
|
+
const build = yield* buildCodeGraph(workspace, {
|
|
944
|
+
sources,
|
|
945
|
+
...(options.exclude === undefined ? {} : { exclude: options.exclude }),
|
|
946
|
+
coverage: units,
|
|
947
|
+
rules: { ...emptyGraphRules, edges: edgeRules.map(edgeRuleOfSurvey) }
|
|
948
|
+
})
|
|
949
|
+
return build.graph
|
|
161
950
|
})
|
|
162
951
|
|
|
163
952
|
export const mergeSurveyEdges = (
|
|
@@ -192,7 +981,10 @@ export const mergeSurveyEdges = (
|
|
|
192
981
|
})
|
|
193
982
|
}
|
|
194
983
|
|
|
195
|
-
export const renderSurveyInventory = (
|
|
984
|
+
export const renderSurveyInventory = (full: SurveyGraph): string => {
|
|
985
|
+
// The inventory is a table of UNITS (files); sub-file nodes and descriptors
|
|
986
|
+
// fold onto their files first (ADR 0030).
|
|
987
|
+
const graph = projectToFiles(full)
|
|
196
988
|
const rows = [...graph.nodes]
|
|
197
989
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
198
990
|
.map((node) => {
|
|
@@ -201,7 +993,9 @@ export const renderSurveyInventory = (graph: SurveyGraph): string => {
|
|
|
201
993
|
const flags = incoming === 0 && outgoing === 0 ? "unreferenced — retire candidate?" : ""
|
|
202
994
|
return `| ${node.name} | ${node.path} | ${node.lines} | ${node.units} | ${incoming} | ${outgoing} | ${flags} |`
|
|
203
995
|
})
|
|
204
|
-
const refined =
|
|
996
|
+
const refined = full.edges.filter(
|
|
997
|
+
(edge) => edge.kind.startsWith("llm-") || edgeOrigin(edge) === "llm"
|
|
998
|
+
).length
|
|
205
999
|
return [
|
|
206
1000
|
"# Estate inventory",
|
|
207
1001
|
"",
|
|
@@ -272,8 +1066,8 @@ export const surveyRefinePrompt = (graph: SurveyGraph, context: SurveyPromptCont
|
|
|
272
1066
|
'- "notes": references you could not resolve to a unit — indirect targets whose value you',
|
|
273
1067
|
" could not trace, external systems. Empty if none.",
|
|
274
1068
|
"",
|
|
275
|
-
`Units: ${graph
|
|
276
|
-
.map((node) => node.name)
|
|
1069
|
+
`Units: ${projectToFiles(graph)
|
|
1070
|
+
.nodes.map((node) => node.name)
|
|
277
1071
|
.sort()
|
|
278
1072
|
.join(", ")}`,
|
|
279
1073
|
"",
|