@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/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(name: string): ReadonlyArray<SurveyEdge> {
24
- return this.edges.filter((edge) => edge.to === name)
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
- outgoing(name: string): ReadonlyArray<SurveyEdge> {
28
- return this.edges.filter((edge) => edge.from === name)
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 pathOf = new Map(graph.nodes.map((node) => [node.name, node.path]))
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) => graph.outgoing(from).map((edge) => edge.to)))
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 const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
107
- workspace: WorkspaceShape,
108
- sources: string,
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
- edgeRules: ReadonlyArray<CoverageRule>,
111
- options: SurveyGraphOptions = {}
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 = yield* workspace.read(path)
126
- contents.set(path, text)
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: text.split(/\r?\n/).length,
449
+ lines,
132
450
  units: units
133
451
  .filter((rule) => new RegExp(rule.files).test(path))
134
- .reduce((count, rule) => count + matches(rule.unit, text).length, 0)
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
- const known = new Set(nodes.map((node) => node.name))
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
- for (const rule of edgeRules) {
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
- for (const target of new Set(matches(rule.unit, contents.get(path) ?? ""))) {
144
- const edge = SurveyEdge.make({
145
- from: unitName(path),
146
- to: resolveUnit(target, known),
147
- kind: rule.name
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
- if (
150
- !edges.some(
151
- (current) =>
152
- current.from === edge.from && current.to === edge.to && current.kind === edge.kind
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 SurveyGraph.make({ nodes, edges })
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 = (graph: SurveyGraph): string => {
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 = graph.edges.filter((edge) => edge.kind.startsWith("llm-")).length
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.nodes
276
- .map((node) => node.name)
1069
+ `Units: ${projectToFiles(graph)
1070
+ .nodes.map((node) => node.name)
277
1071
  .sort()
278
1072
  .join(", ")}`,
279
1073
  "",