@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/dist/Survey.js
CHANGED
|
@@ -1,29 +1,247 @@
|
|
|
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.js";
|
|
5
|
+
import { edgeRuleOfSurvey, emptyGraphRules, matchUrl, normalizeUrl } from "./GraphRules.js";
|
|
6
|
+
export const GraphOrigin = Schema.Literals(["scanner", "llm", "external"]);
|
|
7
|
+
export const GraphConfidence = Schema.Literals(["exact", "inferred"]);
|
|
8
|
+
export const GraphMechanism = Schema.Literals([
|
|
9
|
+
"capture",
|
|
10
|
+
"join:exact",
|
|
11
|
+
"join:url",
|
|
12
|
+
"contraction",
|
|
13
|
+
"llm",
|
|
14
|
+
"codegraph"
|
|
15
|
+
]);
|
|
16
|
+
export const JoinMatch = Schema.Literals(["exact", "prefix", "extension"]);
|
|
17
|
+
export class GraphEvidence extends Schema.Class("GraphEvidence")({
|
|
18
|
+
file: Schema.String,
|
|
19
|
+
line: Schema.Int,
|
|
20
|
+
snippet: Schema.String
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A node of the estate graph. The four original fields describe a FILE node;
|
|
25
|
+
* the ADR 0030 fields are optional on the wire so a pre-0030 `graph.json`
|
|
26
|
+
* decodes and the four-field constructor keeps compiling — read them through
|
|
27
|
+
* `nodeId`, `nodeKind` and the other helpers below, never directly.
|
|
28
|
+
*/
|
|
3
29
|
export class SurveyNode extends Schema.Class("SurveyNode")({
|
|
4
30
|
path: Schema.String,
|
|
5
31
|
name: Schema.String,
|
|
6
32
|
lines: Schema.Int,
|
|
7
|
-
units: Schema.Int
|
|
33
|
+
units: Schema.Int,
|
|
34
|
+
id: Schema.optionalKey(Schema.String),
|
|
35
|
+
kind: Schema.optionalKey(Schema.String),
|
|
36
|
+
label: Schema.optionalKey(Schema.String),
|
|
37
|
+
lineStart: Schema.optionalKey(Schema.Int),
|
|
38
|
+
lineEnd: Schema.optionalKey(Schema.Int),
|
|
39
|
+
attrs: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
40
|
+
origin: Schema.optionalKey(GraphOrigin),
|
|
41
|
+
/** A wiring record (a `web.xml` mapping), not a unit: contracted by the file projection. */
|
|
42
|
+
descriptor: Schema.optionalKey(Schema.Boolean),
|
|
43
|
+
/** The attr naming the unit this node stands for (a declaration's class). */
|
|
44
|
+
anchor: Schema.optionalKey(Schema.String)
|
|
8
45
|
}) {
|
|
9
46
|
}
|
|
10
47
|
export class SurveyEdge extends Schema.Class("SurveyEdge")({
|
|
11
48
|
from: Schema.String,
|
|
12
49
|
to: Schema.String,
|
|
13
|
-
kind: Schema.String
|
|
50
|
+
kind: Schema.String,
|
|
51
|
+
origin: Schema.optionalKey(GraphOrigin),
|
|
52
|
+
confidence: Schema.optionalKey(GraphConfidence),
|
|
53
|
+
/** The pack section that produced the edge. */
|
|
54
|
+
rule: Schema.optionalKey(Schema.String),
|
|
55
|
+
mechanism: Schema.optionalKey(GraphMechanism),
|
|
56
|
+
join: Schema.optionalKey(JoinMatch),
|
|
57
|
+
evidence: Schema.optionalKey(GraphEvidence)
|
|
58
|
+
}) {
|
|
59
|
+
}
|
|
60
|
+
export const UnresolvedReason = Schema.Literals([
|
|
61
|
+
"edge-target",
|
|
62
|
+
"missing-attr",
|
|
63
|
+
"join-from",
|
|
64
|
+
"join-to",
|
|
65
|
+
"isolated"
|
|
66
|
+
]);
|
|
67
|
+
export class Unresolved extends Schema.Class("Unresolved")({
|
|
68
|
+
reason: UnresolvedReason,
|
|
69
|
+
rule: Schema.String,
|
|
70
|
+
node: Schema.String,
|
|
71
|
+
reference: Schema.optionalKey(Schema.String),
|
|
72
|
+
file: Schema.String,
|
|
73
|
+
line: Schema.Int
|
|
74
|
+
}) {
|
|
75
|
+
}
|
|
76
|
+
/** An attr the LLM pass filled on an existing node, with the line that justifies it. */
|
|
77
|
+
export class Fill extends Schema.Class("Fill")({
|
|
78
|
+
node: Schema.String,
|
|
79
|
+
key: Schema.String,
|
|
80
|
+
value: Schema.String,
|
|
81
|
+
evidence: GraphEvidence
|
|
14
82
|
}) {
|
|
15
83
|
}
|
|
16
84
|
export class SurveyGraph extends Schema.Class("SurveyGraph")({
|
|
17
85
|
nodes: Schema.Array(SurveyNode),
|
|
18
|
-
edges: Schema.Array(SurveyEdge)
|
|
86
|
+
edges: Schema.Array(SurveyEdge),
|
|
87
|
+
unresolved: Schema.Array(Unresolved).pipe(Schema.withDecodingDefaultKey(Effect.succeed([])), Schema.withConstructorDefault(Effect.succeed([]))),
|
|
88
|
+
fills: Schema.Array(Fill).pipe(Schema.withDecodingDefaultKey(Effect.succeed([])), Schema.withConstructorDefault(Effect.succeed([])))
|
|
19
89
|
}) {
|
|
20
|
-
incoming(
|
|
21
|
-
return this.
|
|
90
|
+
incoming(id) {
|
|
91
|
+
return indexOf(this).incoming.get(id) ?? [];
|
|
22
92
|
}
|
|
23
|
-
outgoing(
|
|
24
|
-
return this.
|
|
93
|
+
outgoing(id) {
|
|
94
|
+
return indexOf(this).outgoing.get(id) ?? [];
|
|
95
|
+
}
|
|
96
|
+
node(id) {
|
|
97
|
+
return indexOf(this).byId.get(id);
|
|
25
98
|
}
|
|
26
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Adjacency, built once per graph instance and kept in a WeakMap: a graph is
|
|
102
|
+
* immutable (every change constructs a new one), so the index never goes
|
|
103
|
+
* stale, and walks over an estate-sized graph stay linear instead of V × E.
|
|
104
|
+
*/
|
|
105
|
+
const indexes = new WeakMap();
|
|
106
|
+
const indexOf = (graph) => {
|
|
107
|
+
const cached = indexes.get(graph);
|
|
108
|
+
if (cached !== undefined) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
const byId = new Map();
|
|
112
|
+
for (const node of graph.nodes) {
|
|
113
|
+
const id = nodeId(node);
|
|
114
|
+
if (!byId.has(id)) {
|
|
115
|
+
byId.set(id, node);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const incoming = new Map();
|
|
119
|
+
const outgoing = new Map();
|
|
120
|
+
for (const edge of graph.edges) {
|
|
121
|
+
const into = incoming.get(edge.to);
|
|
122
|
+
if (into === undefined) {
|
|
123
|
+
incoming.set(edge.to, [edge]);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
into.push(edge);
|
|
127
|
+
}
|
|
128
|
+
const from = outgoing.get(edge.from);
|
|
129
|
+
if (from === undefined) {
|
|
130
|
+
outgoing.set(edge.from, [edge]);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
from.push(edge);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const index = { byId, incoming, outgoing };
|
|
137
|
+
indexes.set(graph, index);
|
|
138
|
+
return index;
|
|
139
|
+
};
|
|
140
|
+
export const nodeId = (node) => node.id ?? node.name;
|
|
141
|
+
export const nodeKind = (node) => node.kind ?? "file";
|
|
142
|
+
export const nodeLabel = (node) => node.label ?? node.name;
|
|
143
|
+
export const nodeAttrs = (node) => node.attrs ?? {};
|
|
144
|
+
export const nodeLineStart = (node) => node.lineStart ?? 1;
|
|
145
|
+
export const nodeLineEnd = (node) => node.lineEnd ?? node.lines;
|
|
146
|
+
export const isDescriptor = (node) => node.descriptor === true;
|
|
147
|
+
export const edgeOrigin = (edge) => edge.origin ?? "scanner";
|
|
148
|
+
export const edgeConfidence = (edge) => edge.confidence ?? "exact";
|
|
149
|
+
const inferred = "inferred";
|
|
150
|
+
/**
|
|
151
|
+
* The file-level view every pre-ADR-0030 consumer expects. Each node folds onto
|
|
152
|
+
* a unit (its `anchor` attr when that names a known unit, else its file's
|
|
153
|
+
* unit); descriptor nodes with outgoing edges are contracted (`a → d → b`
|
|
154
|
+
* becomes `a → b` under `a → d`'s kind); self loops drop; duplicates by
|
|
155
|
+
* (from, to, kind) drop. On a file-only graph this is the identity.
|
|
156
|
+
*/
|
|
157
|
+
export const projectToFiles = (graph) => {
|
|
158
|
+
const files = graph.nodes.filter((node) => nodeKind(node) === "file");
|
|
159
|
+
const known = new Set(files.map((node) => node.name));
|
|
160
|
+
const byId = new Map(graph.nodes.map((node) => [nodeId(node), node]));
|
|
161
|
+
const unitOf = (id) => {
|
|
162
|
+
const node = byId.get(id);
|
|
163
|
+
if (node === undefined) {
|
|
164
|
+
return id;
|
|
165
|
+
}
|
|
166
|
+
if (nodeKind(node) === "file") {
|
|
167
|
+
return node.name;
|
|
168
|
+
}
|
|
169
|
+
const anchored = node.anchor === undefined ? undefined : nodeAttrs(node)[node.anchor];
|
|
170
|
+
if (anchored !== undefined) {
|
|
171
|
+
const resolved = resolveUnit(anchored, known);
|
|
172
|
+
if (known.has(resolved)) {
|
|
173
|
+
return resolved;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return unitName(node.path);
|
|
177
|
+
};
|
|
178
|
+
const contractible = new Set(graph.nodes
|
|
179
|
+
.filter((node) => isDescriptor(node) && graph.outgoing(nodeId(node)).length > 0)
|
|
180
|
+
.map(nodeId));
|
|
181
|
+
const edgeKey = (edge) => `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${edgeConfidence(edge)}`;
|
|
182
|
+
let edges = graph.edges;
|
|
183
|
+
for (let round = 0; round <= graph.nodes.length; round += 1) {
|
|
184
|
+
if (!edges.some((edge) => contractible.has(edge.to))) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const next = [];
|
|
188
|
+
const keys = new Set();
|
|
189
|
+
const push = (edge) => {
|
|
190
|
+
const key = edgeKey(edge);
|
|
191
|
+
if (!keys.has(key)) {
|
|
192
|
+
keys.add(key);
|
|
193
|
+
next.push(edge);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
for (const edge of edges) {
|
|
197
|
+
if (!contractible.has(edge.to)) {
|
|
198
|
+
push(edge);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
for (const hop of graph.outgoing(edge.to)) {
|
|
202
|
+
push(new SurveyEdge({
|
|
203
|
+
...edge,
|
|
204
|
+
to: hop.to,
|
|
205
|
+
mechanism: "contraction",
|
|
206
|
+
...(edgeConfidence(edge) === "inferred" || edgeConfidence(hop) === "inferred"
|
|
207
|
+
? { confidence: inferred }
|
|
208
|
+
: {})
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// A descriptor cycle re-derives the same edges forever: stop when a round
|
|
213
|
+
// adds nothing new; what still ends on a descriptor is cycle residue.
|
|
214
|
+
if (next.length === edges.length &&
|
|
215
|
+
next.every((edge, index) => edgeKey(edge) === edgeKey(edges[index]))) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
edges = next;
|
|
219
|
+
}
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
const projected = edges.flatMap((edge) => {
|
|
222
|
+
if (contractible.has(edge.from) || contractible.has(edge.to)) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
const from = unitOf(edge.from);
|
|
226
|
+
const to = unitOf(edge.to);
|
|
227
|
+
const key = `${from}\u0000${to}\u0000${edge.kind}`;
|
|
228
|
+
if (from === to || seen.has(key)) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
seen.add(key);
|
|
232
|
+
return [new SurveyEdge({ ...edge, from, to })];
|
|
233
|
+
});
|
|
234
|
+
return new SurveyGraph({
|
|
235
|
+
nodes: files,
|
|
236
|
+
edges: projected,
|
|
237
|
+
unresolved: graph.unresolved,
|
|
238
|
+
fills: graph.fills
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
export const graphFromJson = (text) => Effect.try({
|
|
242
|
+
try: () => JSON.parse(text),
|
|
243
|
+
catch: (error) => PlanParseError.make({ message: `graph.json is not JSON: ${String(error)}` })
|
|
244
|
+
}).pipe(Effect.flatMap((json) => Schema.decodeUnknownEffect(SurveyGraph)(json).pipe(Effect.mapError((error) => PlanParseError.make({ message: `graph.json does not decode: ${String(error)}` })))));
|
|
27
245
|
/**
|
|
28
246
|
* The transitive dependency closure of `program` as repo-relative paths,
|
|
29
247
|
* breadth-first, excluding the program itself. The `seen` set is required, not
|
|
@@ -34,13 +252,14 @@ export class SurveyGraph extends Schema.Class("SurveyGraph")({
|
|
|
34
252
|
* instead of an unbounded read.
|
|
35
253
|
*/
|
|
36
254
|
export const closureFor = (graph, program, maxFiles) => {
|
|
37
|
-
const
|
|
255
|
+
const projected = projectToFiles(graph);
|
|
256
|
+
const pathOf = new Map(projected.nodes.map((node) => [node.name, node.path]));
|
|
38
257
|
const walk = (frontier, seen, acc) => {
|
|
39
258
|
if (frontier.length === 0 || acc.length >= maxFiles) {
|
|
40
259
|
return acc.slice(0, maxFiles);
|
|
41
260
|
}
|
|
42
261
|
const next = [
|
|
43
|
-
...new Set(frontier.flatMap((from) =>
|
|
262
|
+
...new Set(frontier.flatMap((from) => projected.outgoing(from).map((edge) => edge.to)))
|
|
44
263
|
].filter((name) => !seen.has(name));
|
|
45
264
|
return walk(next, new Set([...seen, ...next]), [
|
|
46
265
|
...acc,
|
|
@@ -77,52 +296,433 @@ export const resolveUnit = (reference, known) => {
|
|
|
77
296
|
const folded = unitName(reference);
|
|
78
297
|
return known.has(folded) ? folded : reference;
|
|
79
298
|
};
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
299
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
300
|
+
/** 0-based offset → 1-based line, via a precomputed table of line starts. */
|
|
301
|
+
const lineLocator = (text) => {
|
|
302
|
+
const starts = [0];
|
|
303
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
304
|
+
if (text.charCodeAt(index) === 10) {
|
|
305
|
+
starts.push(index + 1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return (offset) => {
|
|
309
|
+
let low = 0;
|
|
310
|
+
let high = starts.length - 1;
|
|
311
|
+
while (low < high) {
|
|
312
|
+
const mid = (low + high + 1) >> 1;
|
|
313
|
+
if ((starts[mid] ?? 0) <= offset) {
|
|
314
|
+
low = mid;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
high = mid - 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return low + 1;
|
|
321
|
+
};
|
|
83
322
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
323
|
+
/** Every match of `pattern` (flags `gm`) with its line and defined named groups. */
|
|
324
|
+
const captures = (pattern, text, locate) => [...text.matchAll(new RegExp(pattern, "gm"))].map((match) => ({
|
|
325
|
+
line: locate(match.index ?? 0),
|
|
326
|
+
groups: Object.fromEntries(Object.entries(match.groups ?? {}).flatMap(([key, value]) => value === undefined ? [] : [[key, value]])),
|
|
327
|
+
first: match[1] ?? match[0]
|
|
328
|
+
}));
|
|
329
|
+
const scanner = "scanner";
|
|
330
|
+
const exact = "exact";
|
|
331
|
+
/** Node ids: `<kind>:<path>#<name>`, `~n` for a repeated name within one file. */
|
|
332
|
+
const scanNodes = (paths, contents, units, rules) => {
|
|
94
333
|
const nodes = [];
|
|
95
|
-
const contents = new Map();
|
|
96
334
|
for (const path of paths) {
|
|
97
|
-
const text =
|
|
98
|
-
|
|
335
|
+
const text = contents.get(path) ?? "";
|
|
336
|
+
const lines = text.split(/\r?\n/).length;
|
|
337
|
+
const locate = lineLocator(text);
|
|
99
338
|
nodes.push(SurveyNode.make({
|
|
100
339
|
path,
|
|
101
340
|
name: unitName(path),
|
|
102
|
-
lines
|
|
341
|
+
lines,
|
|
103
342
|
units: units
|
|
104
343
|
.filter((rule) => new RegExp(rule.files).test(path))
|
|
105
|
-
.reduce((count, rule) => count +
|
|
344
|
+
.reduce((count, rule) => count + captures(rule.unit, text, locate).length, 0),
|
|
345
|
+
id: unitName(path),
|
|
346
|
+
kind: "file",
|
|
347
|
+
lineStart: 1,
|
|
348
|
+
lineEnd: lines
|
|
106
349
|
}));
|
|
350
|
+
for (const rule of rules.filter((rule) => new RegExp(rule.files).test(path))) {
|
|
351
|
+
const seen = new Map();
|
|
352
|
+
const own = [];
|
|
353
|
+
for (const hit of captures(rule.pattern, text, locate)) {
|
|
354
|
+
const name = hit.groups.name ?? `L${hit.line}`;
|
|
355
|
+
const count = (seen.get(name) ?? 0) + 1;
|
|
356
|
+
seen.set(name, count);
|
|
357
|
+
const attrs = Object.fromEntries(Object.entries(hit.groups).filter(([key]) => key !== "name"));
|
|
358
|
+
for (const [attr, group] of Object.entries(rule.attrs)) {
|
|
359
|
+
const value = hit.groups[group];
|
|
360
|
+
if (value !== undefined) {
|
|
361
|
+
attrs[attr] = value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
own.push(SurveyNode.make({
|
|
365
|
+
path,
|
|
366
|
+
name,
|
|
367
|
+
lines: 0,
|
|
368
|
+
units: 0,
|
|
369
|
+
id: `${rule.kind}:${path}#${name}${count > 1 ? `~${count}` : ""}`,
|
|
370
|
+
kind: rule.kind,
|
|
371
|
+
label: name,
|
|
372
|
+
lineStart: hit.line,
|
|
373
|
+
lineEnd: lines,
|
|
374
|
+
attrs,
|
|
375
|
+
origin: scanner,
|
|
376
|
+
descriptor: rule.descriptor,
|
|
377
|
+
...(rule.anchor === undefined ? {} : { anchor: rule.anchor })
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
// A node spans to the line before the next node of the same kind in the file.
|
|
381
|
+
own.forEach((node, index) => {
|
|
382
|
+
const next = own[index + 1];
|
|
383
|
+
nodes.push(next === undefined
|
|
384
|
+
? node
|
|
385
|
+
: new SurveyNode({
|
|
386
|
+
...node,
|
|
387
|
+
lineEnd: Math.max(nodeLineStart(node), nodeLineStart(next) - 1)
|
|
388
|
+
}));
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return nodes;
|
|
393
|
+
};
|
|
394
|
+
const lookupOf = (nodes) => {
|
|
395
|
+
const inFile = new Map();
|
|
396
|
+
const byKindName = new Map();
|
|
397
|
+
const byPathKindName = new Map();
|
|
398
|
+
const known = new Set();
|
|
399
|
+
for (const node of nodes) {
|
|
400
|
+
const kind = nodeKind(node);
|
|
401
|
+
if (kind === "file") {
|
|
402
|
+
known.add(node.name);
|
|
403
|
+
}
|
|
404
|
+
const kinds = inFile.get(node.path) ?? new Map();
|
|
405
|
+
const list = kinds.get(kind) ?? [];
|
|
406
|
+
list.push(node);
|
|
407
|
+
kinds.set(kind, list);
|
|
408
|
+
inFile.set(node.path, kinds);
|
|
409
|
+
const kindKey = `${kind}\u0000${node.name}`;
|
|
410
|
+
if (!byKindName.has(kindKey)) {
|
|
411
|
+
byKindName.set(kindKey, node);
|
|
412
|
+
}
|
|
413
|
+
const pathKey = `${node.path}\u0000${kind}\u0000${node.name}`;
|
|
414
|
+
if (!byPathKindName.has(pathKey)) {
|
|
415
|
+
byPathKindName.set(pathKey, node);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const kinds of inFile.values()) {
|
|
419
|
+
for (const list of kinds.values()) {
|
|
420
|
+
list.sort((left, right) => nodeLineStart(left) - nodeLineStart(right));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { inFile, byKindName, byPathKindName, known };
|
|
424
|
+
};
|
|
425
|
+
/** The last node of `kind` in `path` starting at or before `line` (binary search over the file's list). */
|
|
426
|
+
const enclosing = (lookup, path, kind, line) => {
|
|
427
|
+
const list = lookup.inFile.get(path)?.get(kind) ?? [];
|
|
428
|
+
let low = 0;
|
|
429
|
+
let high = list.length - 1;
|
|
430
|
+
let found;
|
|
431
|
+
while (low <= high) {
|
|
432
|
+
const mid = (low + high) >> 1;
|
|
433
|
+
const candidate = list[mid];
|
|
434
|
+
if (nodeLineStart(candidate) <= line) {
|
|
435
|
+
found = candidate;
|
|
436
|
+
low = mid + 1;
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
high = mid - 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return found;
|
|
443
|
+
};
|
|
444
|
+
const resolveTarget = (lookup, path, kind, reference) => {
|
|
445
|
+
if (kind === "file") {
|
|
446
|
+
const unit = resolveUnit(reference, lookup.known);
|
|
447
|
+
return { id: unit, found: lookup.known.has(unit) };
|
|
107
448
|
}
|
|
108
|
-
const
|
|
449
|
+
const node = lookup.byPathKindName.get(`${path}\u0000${kind}\u0000${reference}`) ??
|
|
450
|
+
lookup.byKindName.get(`${kind}\u0000${reference}`);
|
|
451
|
+
return node === undefined ? { id: reference, found: false } : { id: nodeId(node), found: true };
|
|
452
|
+
};
|
|
453
|
+
const scanEdges = (paths, contents, nodes, rules) => {
|
|
454
|
+
const lookup = lookupOf(nodes);
|
|
455
|
+
const lineOf = new Map(nodes.map((node) => [nodeId(node), nodeLineStart(node)]));
|
|
109
456
|
const edges = [];
|
|
110
|
-
|
|
457
|
+
const unresolved = [];
|
|
458
|
+
const seen = new Set();
|
|
459
|
+
const push = (edge) => {
|
|
460
|
+
const key = `${edge.from}\u0000${edge.to}\u0000${edge.kind}`;
|
|
461
|
+
if (!seen.has(key)) {
|
|
462
|
+
seen.add(key);
|
|
463
|
+
edges.push(edge);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
for (const rule of rules) {
|
|
111
467
|
const filePattern = new RegExp(rule.files);
|
|
112
468
|
for (const path of paths.filter((path) => filePattern.test(path))) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
469
|
+
const text = contents.get(path) ?? "";
|
|
470
|
+
const locate = lineLocator(text);
|
|
471
|
+
const lines = text.split(/\r?\n/);
|
|
472
|
+
for (const hit of captures(rule.pattern, text, locate)) {
|
|
473
|
+
const reference = rule.positional === true ? hit.first : hit.groups.to;
|
|
474
|
+
if (reference === undefined) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const fromNode = rule.fromKind === "file" ? undefined : enclosing(lookup, path, rule.fromKind, hit.line);
|
|
478
|
+
const from = fromNode === undefined ? unitName(path) : nodeId(fromNode);
|
|
479
|
+
const evidence = GraphEvidence.make({
|
|
480
|
+
file: path,
|
|
481
|
+
line: hit.line,
|
|
482
|
+
snippet: (lines[hit.line - 1] ?? "").trim()
|
|
118
483
|
});
|
|
119
|
-
|
|
120
|
-
|
|
484
|
+
const target = resolveTarget(lookup, path, rule.toKind, reference);
|
|
485
|
+
const targets = [];
|
|
486
|
+
if (target.found && hit.groups.thru !== undefined && rule.toKind !== "file") {
|
|
487
|
+
const end = resolveTarget(lookup, path, rule.toKind, hit.groups.thru);
|
|
488
|
+
const startLine = lineOf.get(target.id) ?? 0;
|
|
489
|
+
const endLine = end.found ? (lineOf.get(end.id) ?? startLine) : startLine;
|
|
490
|
+
targets.push(...(lookup.inFile.get(path)?.get(rule.toKind) ?? [])
|
|
491
|
+
.filter((node) => nodeLineStart(node) >= startLine && nodeLineStart(node) <= endLine)
|
|
492
|
+
.map(nodeId));
|
|
493
|
+
}
|
|
494
|
+
else if (target.found || rule.toKind === "file") {
|
|
495
|
+
targets.push(target.id);
|
|
496
|
+
}
|
|
497
|
+
if (!target.found) {
|
|
498
|
+
unresolved.push(Unresolved.make({
|
|
499
|
+
reason: "edge-target",
|
|
500
|
+
rule: rule.kind,
|
|
501
|
+
node: from,
|
|
502
|
+
reference,
|
|
503
|
+
file: path,
|
|
504
|
+
line: hit.line
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
for (const to of targets) {
|
|
508
|
+
push(SurveyEdge.make({
|
|
509
|
+
from,
|
|
510
|
+
to,
|
|
511
|
+
kind: rule.kind,
|
|
512
|
+
origin: scanner,
|
|
513
|
+
confidence: exact,
|
|
514
|
+
rule: rule.kind,
|
|
515
|
+
mechanism: "capture",
|
|
516
|
+
evidence
|
|
517
|
+
}));
|
|
121
518
|
}
|
|
122
519
|
}
|
|
123
520
|
}
|
|
124
521
|
}
|
|
125
|
-
return
|
|
522
|
+
return { edges, unresolved };
|
|
523
|
+
};
|
|
524
|
+
/** Nodes and captured edges only; joins and the unresolved summary come from `applyFillsAndJoins`. */
|
|
525
|
+
export const scanGraph = (paths, contents, options) => {
|
|
526
|
+
const nodes = scanNodes(paths, contents, options.coverage, options.rules.nodes);
|
|
527
|
+
const { edges, unresolved } = scanEdges(paths, contents, nodes, options.rules.edges);
|
|
528
|
+
return SurveyGraph.make({ nodes, edges, unresolved, fills: [] });
|
|
529
|
+
};
|
|
530
|
+
/** Directories `D` with some scanned path under `D/WEB-INF/`, sorted; the per-app scope of url joins. */
|
|
531
|
+
export const appRoots = (paths) => [
|
|
532
|
+
...new Set(paths.flatMap((path) => {
|
|
533
|
+
const index = path.indexOf("/WEB-INF/");
|
|
534
|
+
return index < 0 ? [] : [path.slice(0, index)];
|
|
535
|
+
}))
|
|
536
|
+
].sort();
|
|
537
|
+
/** The longest app root that prefixes `path`, or "" for the estate scope. */
|
|
538
|
+
export const appOf = (path, roots) => [...roots]
|
|
539
|
+
.filter((root) => path === root || path.startsWith(`${root}/`))
|
|
540
|
+
.sort((left, right) => right.length - left.length)[0] ?? "";
|
|
541
|
+
const withFills = (nodes, fills) => nodes.map((node) => {
|
|
542
|
+
const own = fills.filter((fill) => fill.node === nodeId(node));
|
|
543
|
+
return own.length === 0
|
|
544
|
+
? node
|
|
545
|
+
: new SurveyNode({
|
|
546
|
+
...node,
|
|
547
|
+
attrs: {
|
|
548
|
+
...nodeAttrs(node),
|
|
549
|
+
...Object.fromEntries(own.map((fill) => [fill.key, fill.value]))
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
const joinRank = { exact: 0, prefix: 1, extension: 2 };
|
|
554
|
+
/** A join attr: a named group copied into attrs, or the node's own name for `name`. */
|
|
555
|
+
const attrOf = (node, attr) => attr === "name" ? node.name : nodeAttrs(node)[attr];
|
|
556
|
+
const llm = "llm";
|
|
557
|
+
const joinEdges = (nodes, fills, rules, roots) => {
|
|
558
|
+
const filled = new Set(fills.map((fill) => `${fill.node}\u0000${fill.key}`));
|
|
559
|
+
const edges = [];
|
|
560
|
+
const unresolved = [];
|
|
561
|
+
const seen = new Set();
|
|
562
|
+
for (const rule of rules) {
|
|
563
|
+
const sources = nodes.filter((node) => nodeKind(node) === rule.fromKind);
|
|
564
|
+
const targets = nodes.filter((node) => nodeKind(node) === rule.toKind);
|
|
565
|
+
for (const source of sources) {
|
|
566
|
+
const raw = attrOf(source, rule.fromAttr);
|
|
567
|
+
if (raw === undefined) {
|
|
568
|
+
unresolved.push(Unresolved.make({
|
|
569
|
+
reason: "missing-attr",
|
|
570
|
+
rule: rule.kind,
|
|
571
|
+
node: nodeId(source),
|
|
572
|
+
file: source.path,
|
|
573
|
+
line: nodeLineStart(source)
|
|
574
|
+
}));
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const request = rule.match === "url" ? normalizeUrl(raw) : raw.trim();
|
|
578
|
+
const inScope = targets.filter((target) => rule.scope === "file"
|
|
579
|
+
? target.path === source.path
|
|
580
|
+
: rule.scope === "app"
|
|
581
|
+
? appOf(target.path, roots) === appOf(source.path, roots)
|
|
582
|
+
: true);
|
|
583
|
+
const matched = inScope.flatMap((target) => {
|
|
584
|
+
const value = attrOf(target, rule.toAttr);
|
|
585
|
+
if (value === undefined) {
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
const how = rule.match === "url"
|
|
589
|
+
? matchUrl(request, value)
|
|
590
|
+
: value.trim() === request
|
|
591
|
+
? "exact"
|
|
592
|
+
: undefined;
|
|
593
|
+
return how === undefined ? [] : [{ target, how, value }];
|
|
594
|
+
});
|
|
595
|
+
// Servlet-spec order: exact beats the longest prefix beats an extension.
|
|
596
|
+
const best = matched.sort((left, right) => joinRank[left.how] - joinRank[right.how] || right.value.length - left.value.length)[0];
|
|
597
|
+
if (best === undefined) {
|
|
598
|
+
unresolved.push(Unresolved.make({
|
|
599
|
+
reason: "join-from",
|
|
600
|
+
rule: rule.kind,
|
|
601
|
+
node: nodeId(source),
|
|
602
|
+
reference: raw,
|
|
603
|
+
file: source.path,
|
|
604
|
+
line: nodeLineStart(source)
|
|
605
|
+
}));
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const inferred = filled.has(`${nodeId(source)}\u0000${rule.fromAttr}`) ||
|
|
609
|
+
filled.has(`${nodeId(best.target)}\u0000${rule.toAttr}`);
|
|
610
|
+
const key = `${nodeId(source)}\u0000${nodeId(best.target)}\u0000${rule.kind}`;
|
|
611
|
+
if (!seen.has(key)) {
|
|
612
|
+
seen.add(key);
|
|
613
|
+
edges.push(SurveyEdge.make({
|
|
614
|
+
from: nodeId(source),
|
|
615
|
+
to: nodeId(best.target),
|
|
616
|
+
kind: rule.kind,
|
|
617
|
+
origin: inferred ? llm : scanner,
|
|
618
|
+
confidence: inferred ? "inferred" : exact,
|
|
619
|
+
rule: rule.kind,
|
|
620
|
+
mechanism: rule.match === "url" ? "join:url" : "join:exact",
|
|
621
|
+
join: best.how,
|
|
622
|
+
evidence: GraphEvidence.make({
|
|
623
|
+
file: source.path,
|
|
624
|
+
line: nodeLineStart(source),
|
|
625
|
+
snippet: raw
|
|
626
|
+
})
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// A join target nothing reaches, reported once per node whatever the kind:
|
|
632
|
+
// a mapping hit by a form but by no ajax call is wired, not a hole.
|
|
633
|
+
const reached = new Set(edges.map((edge) => edge.to));
|
|
634
|
+
const reportedKinds = new Set();
|
|
635
|
+
for (const rule of rules) {
|
|
636
|
+
if (reportedKinds.has(rule.toKind)) {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
reportedKinds.add(rule.toKind);
|
|
640
|
+
for (const target of nodes.filter((node) => nodeKind(node) === rule.toKind)) {
|
|
641
|
+
if (!reached.has(nodeId(target))) {
|
|
642
|
+
unresolved.push(Unresolved.make({
|
|
643
|
+
reason: "join-to",
|
|
644
|
+
rule: rule.kind,
|
|
645
|
+
node: nodeId(target),
|
|
646
|
+
file: target.path,
|
|
647
|
+
line: nodeLineStart(target)
|
|
648
|
+
}));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { edges, unresolved };
|
|
653
|
+
};
|
|
654
|
+
/**
|
|
655
|
+
* Pure re-derivation: every `join:*` edge is dropped and rebuilt from the
|
|
656
|
+
* current attrs plus `fills`; `unresolved` is recomputed from scratch except
|
|
657
|
+
* for `edge-target` entries, which only the scanner knows. `paths` defaults
|
|
658
|
+
* to the node paths; pass the scanned list to compute app roots precisely.
|
|
659
|
+
*/
|
|
660
|
+
export const applyFillsAndJoins = (graph, fills, rules, paths = graph.nodes.map((node) => node.path)) => {
|
|
661
|
+
const nodes = withFills(graph.nodes, fills);
|
|
662
|
+
const kept = graph.edges.filter((edge) => !(edge.mechanism ?? "").startsWith("join:"));
|
|
663
|
+
const joined = joinEdges(nodes, fills, rules.joins, appRoots(paths));
|
|
664
|
+
const edges = [...kept, ...joined.edges];
|
|
665
|
+
const projected = projectToFiles(SurveyGraph.make({ nodes, edges, unresolved: [], fills }));
|
|
666
|
+
// A file that only holds wiring records (web.xml) is a descriptor file, not
|
|
667
|
+
// a unit nothing references: it never counts as isolated.
|
|
668
|
+
const wiringFiles = new Set(nodes.filter(isDescriptor).map((node) => node.path));
|
|
669
|
+
const isolated = projected.nodes
|
|
670
|
+
.filter((node) => !wiringFiles.has(node.path) &&
|
|
671
|
+
projected.incoming(node.name).length === 0 &&
|
|
672
|
+
projected.outgoing(node.name).length === 0)
|
|
673
|
+
.map((node) => Unresolved.make({
|
|
674
|
+
reason: "isolated",
|
|
675
|
+
rule: "file",
|
|
676
|
+
node: node.name,
|
|
677
|
+
file: node.path,
|
|
678
|
+
line: 1
|
|
679
|
+
}));
|
|
680
|
+
// An edge-target hole retires once a non-scanner edge leaves the same node
|
|
681
|
+
// from the same line: the seat (or an external index) answered it.
|
|
682
|
+
const answered = new Set(edges
|
|
683
|
+
.filter((edge) => edgeOrigin(edge) !== "scanner" && edge.evidence !== undefined)
|
|
684
|
+
.map((edge) => `${edge.from}\u0000${edge.evidence?.file}\u0000${edge.evidence?.line}`));
|
|
685
|
+
return SurveyGraph.make({
|
|
686
|
+
nodes,
|
|
687
|
+
edges,
|
|
688
|
+
unresolved: [
|
|
689
|
+
...graph.unresolved.filter((item) => item.reason === "edge-target" &&
|
|
690
|
+
!answered.has(`${item.node}\u0000${item.file}\u0000${item.line}`)),
|
|
691
|
+
...joined.unresolved,
|
|
692
|
+
...isolated
|
|
693
|
+
],
|
|
694
|
+
fills
|
|
695
|
+
});
|
|
696
|
+
};
|
|
697
|
+
export const buildCodeGraph = Effect.fn("@llm4ts/flow/Survey.buildCodeGraph")(function* (workspace, options) {
|
|
698
|
+
// The source regex narrows discovery itself, so the workspace's result cap
|
|
699
|
+
// counts candidate units rather than every jar, image, and generated file
|
|
700
|
+
// sharing the tree with them.
|
|
701
|
+
const paths = [
|
|
702
|
+
...(yield* workspace.discover("**/*", {
|
|
703
|
+
matching: new RegExp(options.sources),
|
|
704
|
+
...(options.exclude === undefined ? {} : { excluding: new RegExp(options.exclude) })
|
|
705
|
+
}))
|
|
706
|
+
].sort();
|
|
707
|
+
const contents = new Map();
|
|
708
|
+
const files = {};
|
|
709
|
+
for (const path of paths) {
|
|
710
|
+
const text = yield* workspace.read(path);
|
|
711
|
+
contents.set(path, text);
|
|
712
|
+
files[path] = sha256(text);
|
|
713
|
+
}
|
|
714
|
+
const scanned = scanGraph(paths, contents, options);
|
|
715
|
+
return { graph: applyFillsAndJoins(scanned, [], options.rules, paths), files };
|
|
716
|
+
});
|
|
717
|
+
/** The pre-ADR-0030 entry: Survey rules only, file nodes only. Delegates to `buildCodeGraph`. */
|
|
718
|
+
export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (workspace, sources, units, edgeRules, options = {}) {
|
|
719
|
+
const build = yield* buildCodeGraph(workspace, {
|
|
720
|
+
sources,
|
|
721
|
+
...(options.exclude === undefined ? {} : { exclude: options.exclude }),
|
|
722
|
+
coverage: units,
|
|
723
|
+
rules: { ...emptyGraphRules, edges: edgeRules.map(edgeRuleOfSurvey) }
|
|
724
|
+
});
|
|
725
|
+
return build.graph;
|
|
126
726
|
});
|
|
127
727
|
export const mergeSurveyEdges = (graph, refined) => {
|
|
128
728
|
const known = new Set(graph.nodes.map((node) => node.name));
|
|
@@ -150,7 +750,10 @@ export const mergeSurveyEdges = (graph, refined) => {
|
|
|
150
750
|
edges: [...graph.edges, ...kept]
|
|
151
751
|
});
|
|
152
752
|
};
|
|
153
|
-
export const renderSurveyInventory = (
|
|
753
|
+
export const renderSurveyInventory = (full) => {
|
|
754
|
+
// The inventory is a table of UNITS (files); sub-file nodes and descriptors
|
|
755
|
+
// fold onto their files first (ADR 0030).
|
|
756
|
+
const graph = projectToFiles(full);
|
|
154
757
|
const rows = [...graph.nodes]
|
|
155
758
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
156
759
|
.map((node) => {
|
|
@@ -159,7 +762,7 @@ export const renderSurveyInventory = (graph) => {
|
|
|
159
762
|
const flags = incoming === 0 && outgoing === 0 ? "unreferenced — retire candidate?" : "";
|
|
160
763
|
return `| ${node.name} | ${node.path} | ${node.lines} | ${node.units} | ${incoming} | ${outgoing} | ${flags} |`;
|
|
161
764
|
});
|
|
162
|
-
const refined =
|
|
765
|
+
const refined = full.edges.filter((edge) => edge.kind.startsWith("llm-") || edgeOrigin(edge) === "llm").length;
|
|
163
766
|
return [
|
|
164
767
|
"# Estate inventory",
|
|
165
768
|
"",
|
|
@@ -207,8 +810,8 @@ export const surveyRefinePrompt = (graph, context) => [
|
|
|
207
810
|
'- "notes": references you could not resolve to a unit — indirect targets whose value you',
|
|
208
811
|
" could not trace, external systems. Empty if none.",
|
|
209
812
|
"",
|
|
210
|
-
`Units: ${graph
|
|
211
|
-
.map((node) => node.name)
|
|
813
|
+
`Units: ${projectToFiles(graph)
|
|
814
|
+
.nodes.map((node) => node.name)
|
|
212
815
|
.sort()
|
|
213
816
|
.join(", ")}`,
|
|
214
817
|
"",
|