@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.
@@ -0,0 +1,405 @@
1
+ import * as Schema from "effect/Schema"
2
+ import type { JsonSchema } from "@llm4ts/core/Models"
3
+ import type { GraphRules } from "./GraphRules.ts"
4
+ import {
5
+ Fill,
6
+ GraphEvidence,
7
+ SurveyEdge,
8
+ SurveyGraph,
9
+ applyFillsAndJoins,
10
+ nodeAttrs,
11
+ nodeId,
12
+ nodeKind,
13
+ nodeLabel,
14
+ nodeLineStart,
15
+ type SurveyNode,
16
+ type Unresolved
17
+ } from "./Survey.ts"
18
+
19
+ /**
20
+ * The bounded LLM pass of the code graph (ADR 0030 decision 4). The scanner's
21
+ * `unresolved` list becomes a worklist; the seat may fill an attr of an
22
+ * existing node or add an edge between two existing nodes, each tied to a
23
+ * source line that is checked here before anything is merged. It may not
24
+ * create nodes.
25
+ */
26
+ export interface WorklistCandidate {
27
+ readonly id: string
28
+ readonly label: string
29
+ readonly attrs: Readonly<Record<string, string>>
30
+ }
31
+
32
+ export interface WorklistItem {
33
+ readonly unresolved: Unresolved
34
+ readonly node: SurveyNode
35
+ /** Fifteen lines either side of the item's line, numbered. */
36
+ readonly context: string
37
+ readonly candidates: ReadonlyArray<WorklistCandidate>
38
+ }
39
+
40
+ const contextWindow = 15
41
+ const candidateLimit = 8
42
+
43
+ const tokens = (text: string): ReadonlyArray<string> => [
44
+ ...new Set(
45
+ text
46
+ .toLowerCase()
47
+ .split(/[^a-z0-9]+/)
48
+ .filter((token) => token.length >= 3)
49
+ )
50
+ ]
51
+
52
+ const numbered = (text: string, line: number): string =>
53
+ text
54
+ .split(/\r?\n/)
55
+ .map((content, index) => ({ number: index + 1, content }))
56
+ .filter(({ number }) => Math.abs(number - line) <= contextWindow)
57
+ .map(({ number, content }) => `${String(number).padStart(4, " ")} ${content}`)
58
+ .join("\n")
59
+
60
+ /**
61
+ * Up to eight existing nodes worth naming to the seat: lexical matches on the
62
+ * item's reference, name and attrs, and for a join hole every node of the
63
+ * join's target kind (a dynamic URL matches no token of any mapping).
64
+ */
65
+ const candidatesFor = (
66
+ graph: SurveyGraph,
67
+ item: Unresolved,
68
+ node: SurveyNode,
69
+ rules: GraphRules | undefined
70
+ ): ReadonlyArray<WorklistCandidate> => {
71
+ const needles = tokens(
72
+ `${item.reference ?? ""} ${node.name} ${Object.values(nodeAttrs(node)).join(" ")}`
73
+ )
74
+ const targetKinds = new Set(
75
+ item.reason === "missing-attr" || item.reason === "join-from"
76
+ ? (rules?.joins ?? []).filter((rule) => rule.kind === item.rule).map((rule) => rule.toKind)
77
+ : []
78
+ )
79
+ return graph.nodes
80
+ .filter((candidate) => nodeId(candidate) !== nodeId(node))
81
+ .map((candidate) => {
82
+ const hay =
83
+ `${nodeId(candidate)} ${nodeLabel(candidate)} ${Object.values(nodeAttrs(candidate)).join(" ")}`.toLowerCase()
84
+ const lexical = needles.filter((needle) => hay.includes(needle)).length
85
+ return { candidate, score: lexical + (targetKinds.has(nodeKind(candidate)) ? 1 : 0) }
86
+ })
87
+ .filter(({ score }) => score > 0)
88
+ .sort(
89
+ (left, right) =>
90
+ right.score - left.score || nodeId(left.candidate).localeCompare(nodeId(right.candidate))
91
+ )
92
+ .slice(0, candidateLimit)
93
+ .map(({ candidate }) => ({
94
+ id: nodeId(candidate),
95
+ label: nodeLabel(candidate),
96
+ attrs: nodeAttrs(candidate)
97
+ }))
98
+ }
99
+
100
+ /** Unresolved items, highest-degree node first (hubs resolve first), capped at `max`. */
101
+ export const worklistOf = (
102
+ graph: SurveyGraph,
103
+ contents: (path: string) => string | undefined,
104
+ max: number,
105
+ rules?: GraphRules
106
+ ): ReadonlyArray<WorklistItem> => {
107
+ const degree = (id: string): number => graph.incoming(id).length + graph.outgoing(id).length
108
+ // Rank and cut first; reading context and scoring candidates is paid only
109
+ // for the items actually offered.
110
+ const ranked = graph.unresolved
111
+ .flatMap((item) => {
112
+ const node = graph.node(item.node)
113
+ return node === undefined ? [] : [{ item, node, degree: degree(item.node) }]
114
+ })
115
+ .sort(
116
+ (left, right) => right.degree - left.degree || left.item.node.localeCompare(right.item.node)
117
+ )
118
+ .slice(0, max)
119
+ return ranked.map(({ item, node }) => ({
120
+ unresolved: item,
121
+ node,
122
+ context: numbered(contents(item.file) ?? "", item.line),
123
+ candidates: candidatesFor(graph, item, node, rules)
124
+ }))
125
+ }
126
+
127
+ export const worklistBatches = <A>(
128
+ items: ReadonlyArray<A>,
129
+ size: number
130
+ ): ReadonlyArray<ReadonlyArray<A>> => {
131
+ const step = Math.max(1, Math.floor(size))
132
+ const batches: Array<ReadonlyArray<A>> = []
133
+ for (let index = 0; index < items.length; index += step) {
134
+ batches.push(items.slice(index, index + step))
135
+ }
136
+ return batches
137
+ }
138
+
139
+ const describeCandidate = (candidate: WorklistCandidate): string =>
140
+ Object.keys(candidate.attrs).length === 0
141
+ ? candidate.id
142
+ : `${candidate.id} ${JSON.stringify(candidate.attrs)}`
143
+
144
+ const describeItem = (item: WorklistItem, index: number): string =>
145
+ [
146
+ `### Item ${index + 1}: ${item.unresolved.reason} — rule '${item.unresolved.rule}'`,
147
+ `Node: ${nodeId(item.node)} (line ${nodeLineStart(item.node)} of ${item.node.path})`,
148
+ ...(item.unresolved.reference === undefined
149
+ ? []
150
+ : [`Reference the scanner could not resolve: ${item.unresolved.reference}`]),
151
+ `Candidates (existing nodes with matching tokens): ${
152
+ item.candidates.length === 0 ? "none" : item.candidates.map(describeCandidate).join("; ")
153
+ }`,
154
+ "Source context:",
155
+ "```",
156
+ item.context,
157
+ "```"
158
+ ].join("\n")
159
+
160
+ const defaultGuidance = [
161
+ "Regexes miss links the source establishes indirectly: a URL assembled from a variable or",
162
+ "configuration entry, a target held in a field, wiring declared in a descriptor, a fragment",
163
+ "pulled in by templating."
164
+ ].join("\n")
165
+
166
+ export const worklistPrompt = (
167
+ batch: ReadonlyArray<WorklistItem>,
168
+ guidance: string | undefined
169
+ ): string =>
170
+ [
171
+ "You are resolving holes in the dependency graph of a legacy estate. The graph was built",
172
+ "deterministically from pack rules; each item below is something the scanner could not",
173
+ "resolve. You have read-only access to the estate.",
174
+ guidance ?? defaultGuidance,
175
+ "",
176
+ "For each item, either resolve it or leave it. You may:",
177
+ '- fill a missing attribute of an EXISTING node ("attrs": node id, key, value) — e.g. the URL an',
178
+ " ajax call actually hits once its prefix variable is known;",
179
+ '- add an edge between two EXISTING nodes ("edges": from id, to id, a short kebab-case kind).',
180
+ "Every entry carries evidence: the file, the 1-based line, and the exact statement on that line.",
181
+ "No evidence, no entry. Use node ids exactly as given; never invent a node, a file or a line.",
182
+ 'External systems, platform services and third-party libraries are not nodes; put them in "notes".',
183
+ "",
184
+ ...batch.map(describeItem),
185
+ "",
186
+ 'Respond only with JSON: {"edges":[{"from","to","kind","evidence":{"file","line","snippet"}}],',
187
+ '"attrs":[{"node","key","value","evidence":{"file","line","snippet"}}],"notes":[…]}'
188
+ ].join("\n")
189
+
190
+ export class WorklistEdge extends Schema.Class<WorklistEdge>("WorklistEdge")({
191
+ from: Schema.String,
192
+ to: Schema.String,
193
+ kind: Schema.String,
194
+ evidence: GraphEvidence
195
+ }) {}
196
+
197
+ export class WorklistAttr extends Schema.Class<WorklistAttr>("WorklistAttr")({
198
+ node: Schema.String,
199
+ key: Schema.String,
200
+ value: Schema.String,
201
+ evidence: GraphEvidence
202
+ }) {}
203
+
204
+ export class WorklistAnswer extends Schema.Class<WorklistAnswer>("WorklistAnswer")({
205
+ edges: Schema.Array(WorklistEdge),
206
+ attrs: Schema.Array(WorklistAttr),
207
+ notes: Schema.Array(Schema.String)
208
+ }) {}
209
+
210
+ const evidenceJsonSchema: JsonSchema = {
211
+ type: "object",
212
+ properties: {
213
+ file: { type: "string" },
214
+ line: { type: "integer" },
215
+ snippet: { type: "string" }
216
+ },
217
+ required: ["file", "line", "snippet"]
218
+ }
219
+
220
+ export const worklistAnswerJsonSchema: JsonSchema = {
221
+ type: "object",
222
+ properties: {
223
+ edges: {
224
+ type: "array",
225
+ items: {
226
+ type: "object",
227
+ properties: {
228
+ from: { type: "string" },
229
+ to: { type: "string" },
230
+ kind: { type: "string" },
231
+ evidence: evidenceJsonSchema
232
+ },
233
+ required: ["from", "to", "kind", "evidence"]
234
+ }
235
+ },
236
+ attrs: {
237
+ type: "array",
238
+ items: {
239
+ type: "object",
240
+ properties: {
241
+ node: { type: "string" },
242
+ key: { type: "string" },
243
+ value: { type: "string" },
244
+ evidence: evidenceJsonSchema
245
+ },
246
+ required: ["node", "key", "value", "evidence"]
247
+ }
248
+ },
249
+ notes: { type: "array", items: { type: "string" } }
250
+ },
251
+ required: ["edges", "attrs", "notes"]
252
+ }
253
+
254
+ export interface VerifiedWorklist {
255
+ readonly edges: ReadonlyArray<SurveyEdge>
256
+ readonly fills: ReadonlyArray<Fill>
257
+ readonly dropped: ReadonlyArray<{ readonly what: string; readonly reason: string }>
258
+ }
259
+
260
+ /** The 1-based line within ±2 of `line` whose text contains the trimmed snippet, or undefined. */
261
+ const locateSnippet = (
262
+ text: string | undefined,
263
+ line: number,
264
+ snippet: string
265
+ ): number | undefined => {
266
+ if (text === undefined) {
267
+ return undefined
268
+ }
269
+ const needle = snippet.trim()
270
+ if (needle.length === 0) {
271
+ return undefined
272
+ }
273
+ const lines = text.split(/\r?\n/)
274
+ for (const delta of [0, -1, 1, -2, 2]) {
275
+ const candidate = line + delta
276
+ if (candidate >= 1 && (lines[candidate - 1] ?? "").includes(needle)) {
277
+ return candidate
278
+ }
279
+ }
280
+ return undefined
281
+ }
282
+
283
+ export const verifyWorklistAnswer = (
284
+ answer: WorklistAnswer,
285
+ graph: SurveyGraph,
286
+ contents: (path: string) => string | undefined
287
+ ): VerifiedWorklist => {
288
+ const ids = new Set(graph.nodes.map(nodeId))
289
+ const existing = new Set(graph.edges.map((edge) => `${edge.from}\u0000${edge.to}`))
290
+ const edges: Array<SurveyEdge> = []
291
+ const fills: Array<Fill> = []
292
+ const dropped: Array<{ what: string; reason: string }> = []
293
+ const verified = (evidence: GraphEvidence): GraphEvidence | string => {
294
+ const line = locateSnippet(contents(evidence.file), evidence.line, evidence.snippet)
295
+ return line === undefined
296
+ ? `snippet not found within two lines of line ${evidence.line}`
297
+ : GraphEvidence.make({ file: evidence.file, line, snippet: evidence.snippet.trim() })
298
+ }
299
+ for (const edge of answer.edges) {
300
+ const what = `${edge.from} -> ${edge.to} (${edge.kind})`
301
+ const unknown = [edge.from, edge.to].find((id) => !ids.has(id))
302
+ if (unknown !== undefined) {
303
+ dropped.push({ what, reason: `unknown endpoint: ${unknown}` })
304
+ continue
305
+ }
306
+ if (edge.from === edge.to) {
307
+ dropped.push({ what, reason: "self loop" })
308
+ continue
309
+ }
310
+ if (existing.has(`${edge.from}\u0000${edge.to}`)) {
311
+ dropped.push({ what, reason: "duplicate of an existing edge" })
312
+ continue
313
+ }
314
+ const evidence = verified(edge.evidence)
315
+ if (typeof evidence === "string") {
316
+ dropped.push({ what, reason: evidence })
317
+ continue
318
+ }
319
+ existing.add(`${edge.from}\u0000${edge.to}`)
320
+ edges.push(
321
+ SurveyEdge.make({
322
+ from: edge.from,
323
+ to: edge.to,
324
+ kind: edge.kind.replace(/^llm-/, ""),
325
+ origin: "llm",
326
+ confidence: "inferred",
327
+ rule: "llm",
328
+ mechanism: "llm",
329
+ evidence
330
+ })
331
+ )
332
+ }
333
+ const nodesById = new Map(graph.nodes.map((node) => [nodeId(node), node]))
334
+ for (const attr of answer.attrs) {
335
+ const what = `${attr.node}.${attr.key} = ${attr.value}`
336
+ const node = nodesById.get(attr.node)
337
+ if (node === undefined) {
338
+ dropped.push({ what, reason: `unknown node: ${attr.node}` })
339
+ continue
340
+ }
341
+ // The seat fills holes; it never overrides what the scanner read.
342
+ if (nodeAttrs(node)[attr.key] !== undefined) {
343
+ dropped.push({ what, reason: `attr '${attr.key}' already set by the scanner` })
344
+ continue
345
+ }
346
+ const evidence = verified(attr.evidence)
347
+ if (typeof evidence === "string") {
348
+ dropped.push({ what, reason: evidence })
349
+ continue
350
+ }
351
+ fills.push(Fill.make({ node: attr.node, key: attr.key, value: attr.value, evidence }))
352
+ }
353
+ return { edges, fills, dropped }
354
+ }
355
+
356
+ export const mergeWorklist = (
357
+ graph: SurveyGraph,
358
+ verified: VerifiedWorklist,
359
+ rules: GraphRules
360
+ ): SurveyGraph =>
361
+ applyFillsAndJoins(
362
+ SurveyGraph.make({
363
+ nodes: graph.nodes,
364
+ edges: [...graph.edges, ...verified.edges],
365
+ unresolved: graph.unresolved,
366
+ fills: []
367
+ }),
368
+ [...graph.fills, ...verified.fills],
369
+ rules
370
+ )
371
+
372
+ const cell = (text: string): string => text.split(/\r?\n/).join(" ").replaceAll("|", "\\|")
373
+
374
+ export const renderWorklistReport = (
375
+ items: ReadonlyArray<WorklistItem>,
376
+ verified: VerifiedWorklist,
377
+ notes: ReadonlyArray<string>
378
+ ): string =>
379
+ [
380
+ "# Graph refine (LLM worklist)",
381
+ "",
382
+ `${items.length} unresolved item(s) offered; ${verified.edges.length} edge(s) and ${verified.fills.length} attr fill(s) accepted; ${verified.dropped.length} dropped.`,
383
+ "Accepted entries carry the source line that establishes them and are re-verified on every rebuild.",
384
+ "",
385
+ "## Accepted",
386
+ "",
387
+ "| What | Evidence |",
388
+ "| ---- | -------- |",
389
+ ...verified.edges.map(
390
+ (edge) =>
391
+ `| ${cell(`${edge.from} → ${edge.to} (${edge.kind})`)} | ${cell(`${edge.evidence?.file}:${edge.evidence?.line} ${edge.evidence?.snippet ?? ""}`)} |`
392
+ ),
393
+ ...verified.fills.map(
394
+ (fill) =>
395
+ `| ${cell(`${fill.node}.${fill.key} = ${fill.value}`)} | ${cell(`${fill.evidence.file}:${fill.evidence.line} ${fill.evidence.snippet}`)} |`
396
+ ),
397
+ "",
398
+ "## Dropped",
399
+ "",
400
+ "| What | Reason |",
401
+ "| ---- | ------ |",
402
+ ...verified.dropped.map((drop) => `| ${cell(drop.what)} | ${cell(drop.reason)} |`),
403
+ "",
404
+ ...(notes.length === 0 ? [] : ["## Notes", "", ...notes.map((note) => `- ${cell(note)}`), ""])
405
+ ].join("\n")
package/src/Pack.ts CHANGED
@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"
4
4
  import { Dimension } from "@llm4ts/core/eval/Eval"
5
5
  import type { ConsolidateRules } from "./Domains.ts"
6
6
  import { PlanParseError } from "./FlowError.ts"
7
+ import { edgeKindsOf, parseGraphRules, type GraphRules } from "./GraphRules.ts"
7
8
  import { oracleRulesFrom, type OracleRules } from "./OracleGuard.ts"
8
9
  import { Reviewer, parseReviewer } from "./Reviewer.ts"
9
10
  import { CoverageRule } from "./SpecChecks.ts"
@@ -37,6 +38,12 @@ export interface Pack {
37
38
  readonly judgeDimensions: ReadonlyArray<Dimension>
38
39
  readonly coverage: ReadonlyArray<CoverageRule>
39
40
  readonly survey: ReadonlyArray<CoverageRule>
41
+ /**
42
+ * The graph vocabulary (ADR 0030): `## Node:`, `## Edge:` (with every
43
+ * `## Survey:` folded in as a file-to-file edge), `## Join:`, `## Probe:`
44
+ * and the `## Graph` budgets.
45
+ */
46
+ readonly graph: GraphRules
40
47
  readonly prompts: Readonly<Record<string, string>>
41
48
  readonly lenses: ReadonlyArray<Reviewer>
42
49
  readonly lessons: string | undefined
@@ -391,16 +398,18 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
391
398
  cluster: commaList(consolidateValues.cluster),
392
399
  context: commaList(consolidateValues.context)
393
400
  }
401
+ const survey = rules(manifest.sections, "## Survey: ")
402
+ const graph = yield* parseGraphRules(manifest.sections, survey)
394
403
  if (consolidate !== undefined) {
395
- const surveyNames = new Set(rules(manifest.sections, "## Survey: ").map((rule) => rule.name))
404
+ const knownKinds = new Set([...survey.map((rule) => rule.name), ...edgeKindsOf(graph)])
396
405
  const unknown = [...consolidate.cluster, ...consolidate.context].filter(
397
- (kind) => !surveyNames.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
406
+ (kind) => !knownKinds.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
398
407
  )
399
408
  if (unknown.length > 0) {
400
409
  return yield* PlanParseError.make({
401
410
  message:
402
- `pack manifest '## Consolidate' names edge kinds no '## Survey:' rule produces: ${unknown.join(", ")} ` +
403
- `(known: ${[...surveyNames].join(", ") || "none"}; 'llm-*' matches refined edges)`
411
+ `pack manifest '## Consolidate' names edge kinds no '## Survey:', '## Edge:' or '## Join:' rule produces: ${unknown.join(", ")} ` +
412
+ `(known: ${[...knownKinds].join(", ") || "none"}; 'llm-*' matches refined edges)`
404
413
  })
405
414
  }
406
415
  const both = consolidate.cluster.filter((kind) => consolidate.context.includes(kind))
@@ -438,7 +447,8 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
438
447
  }),
439
448
  judgeDimensions: dimensions(section(manifest.sections, "Judge")),
440
449
  coverage: rules(manifest.sections, "## Coverage: "),
441
- survey: rules(manifest.sections, "## Survey: "),
450
+ survey,
451
+ graph,
442
452
  prompts,
443
453
  lenses,
444
454
  lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,