@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/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(name) {
21
- return this.edges.filter((edge) => edge.to === name);
90
+ incoming(id) {
91
+ return indexOf(this).incoming.get(id) ?? [];
22
92
  }
23
- outgoing(name) {
24
- return this.edges.filter((edge) => edge.from === name);
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 pathOf = new Map(graph.nodes.map((node) => [node.name, node.path]));
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) => graph.outgoing(from).map((edge) => edge.to)))
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 matches = (regex, contents) => {
81
- const expression = new RegExp(regex, "g");
82
- return [...contents.matchAll(expression)].map((match) => match[1] ?? match[0]);
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
- export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (workspace, sources, units, edgeRules, options = {}) {
85
- // The source regex narrows discovery itself, so the workspace's result cap
86
- // counts candidate units rather than every jar, image, and generated file
87
- // sharing the tree with them.
88
- const paths = [
89
- ...(yield* workspace.discover("**/*", {
90
- matching: new RegExp(sources),
91
- ...(options.exclude === undefined ? {} : { excluding: new RegExp(options.exclude) })
92
- }))
93
- ].sort();
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 = yield* workspace.read(path);
98
- contents.set(path, text);
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: text.split(/\r?\n/).length,
341
+ lines,
103
342
  units: units
104
343
  .filter((rule) => new RegExp(rule.files).test(path))
105
- .reduce((count, rule) => count + matches(rule.unit, text).length, 0)
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 known = new Set(nodes.map((node) => node.name));
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
- for (const rule of edgeRules) {
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
- for (const target of new Set(matches(rule.unit, contents.get(path) ?? ""))) {
114
- const edge = SurveyEdge.make({
115
- from: unitName(path),
116
- to: resolveUnit(target, known),
117
- kind: rule.name
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
- if (!edges.some((current) => current.from === edge.from && current.to === edge.to && current.kind === edge.kind)) {
120
- edges.push(edge);
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 SurveyGraph.make({ nodes, edges });
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 = (graph) => {
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 = graph.edges.filter((edge) => edge.kind.startsWith("llm-")).length;
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.nodes
211
- .map((node) => node.name)
813
+ `Units: ${projectToFiles(graph)
814
+ .nodes.map((node) => node.name)
212
815
  .sort()
213
816
  .join(", ")}`,
214
817
  "",