@llm4ts/flow 2.37.0 → 2.39.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.
Files changed (56) hide show
  1. package/dist/Domains.d.ts +1 -1
  2. package/dist/Domains.d.ts.map +1 -1
  3. package/dist/Domains.js +7 -3
  4. package/dist/Domains.js.map +1 -1
  5. package/dist/Flow.d.ts +3 -1
  6. package/dist/Flow.d.ts.map +1 -1
  7. package/dist/Flow.js +1 -0
  8. package/dist/Flow.js.map +1 -1
  9. package/dist/GraphCache.d.ts +70 -0
  10. package/dist/GraphCache.d.ts.map +1 -0
  11. package/dist/GraphCache.js +111 -0
  12. package/dist/GraphCache.js.map +1 -0
  13. package/dist/GraphQuery.d.ts +67 -0
  14. package/dist/GraphQuery.d.ts.map +1 -0
  15. package/dist/GraphQuery.js +212 -0
  16. package/dist/GraphQuery.js.map +1 -0
  17. package/dist/GraphRender.d.ts +26 -0
  18. package/dist/GraphRender.d.ts.map +1 -0
  19. package/dist/GraphRender.js +154 -0
  20. package/dist/GraphRender.js.map +1 -0
  21. package/dist/GraphRules.d.ts +86 -0
  22. package/dist/GraphRules.d.ts.map +1 -0
  23. package/dist/GraphRules.js +246 -0
  24. package/dist/GraphRules.js.map +1 -0
  25. package/dist/GraphWorklist.d.ts +64 -0
  26. package/dist/GraphWorklist.d.ts.map +1 -0
  27. package/dist/GraphWorklist.js +283 -0
  28. package/dist/GraphWorklist.js.map +1 -0
  29. package/dist/Pack.d.ts +7 -0
  30. package/dist/Pack.d.ts.map +1 -1
  31. package/dist/Pack.js +9 -5
  32. package/dist/Pack.js.map +1 -1
  33. package/dist/Review.d.ts +12 -0
  34. package/dist/Review.d.ts.map +1 -1
  35. package/dist/Review.js +11 -1
  36. package/dist/Review.js.map +1 -1
  37. package/dist/Stories.d.ts +16 -0
  38. package/dist/Stories.d.ts.map +1 -1
  39. package/dist/Stories.js +42 -7
  40. package/dist/Stories.js.map +1 -1
  41. package/dist/Survey.d.ts +112 -3
  42. package/dist/Survey.d.ts.map +1 -1
  43. package/dist/Survey.js +644 -41
  44. package/dist/Survey.js.map +1 -1
  45. package/package.json +7 -2
  46. package/src/Domains.ts +7 -4
  47. package/src/Flow.ts +5 -1
  48. package/src/GraphCache.ts +191 -0
  49. package/src/GraphQuery.ts +315 -0
  50. package/src/GraphRender.ts +208 -0
  51. package/src/GraphRules.ts +293 -0
  52. package/src/GraphWorklist.ts +405 -0
  53. package/src/Pack.ts +15 -5
  54. package/src/Review.ts +25 -1
  55. package/src/Stories.ts +66 -12
  56. package/src/Survey.ts +846 -52
@@ -0,0 +1,293 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { PlanParseError } from "./FlowError.ts"
4
+ import { fingerprintOf } from "./ReviewCache.ts"
5
+ import type { CoverageRule } from "./SpecChecks.ts"
6
+ import type { JoinMatch } from "./Survey.ts"
7
+
8
+ /**
9
+ * The pack's graph vocabulary (ADR 0030). A `## Node:` rule names a sub-file
10
+ * node kind, an `## Edge:` rule a captured link, a `## Join:` rule a link by
11
+ * attribute equality across files, a `## Probe:` a flow that must be
12
+ * connected end to end. `## Survey:` rules are folded in as file-to-file
13
+ * Edge rules so a pre-0030 pack produces the same graph.
14
+ */
15
+ export class NodeRule extends Schema.Class<NodeRule>("NodeRule")({
16
+ kind: Schema.String,
17
+ files: Schema.String,
18
+ pattern: Schema.String,
19
+ descriptor: Schema.Boolean,
20
+ anchor: Schema.optionalKey(Schema.String),
21
+ /** `attr → named group` copies beyond the default "every group but name". */
22
+ attrs: Schema.Record(Schema.String, Schema.String)
23
+ }) {}
24
+
25
+ export class EdgeRule extends Schema.Class<EdgeRule>("EdgeRule")({
26
+ kind: Schema.String,
27
+ files: Schema.String,
28
+ pattern: Schema.String,
29
+ fromKind: Schema.String,
30
+ toKind: Schema.String,
31
+ /** A folded `## Survey:` rule: the target is the first positional group (or the whole match), as before ADR 0030. */
32
+ positional: Schema.optionalKey(Schema.Boolean)
33
+ }) {}
34
+
35
+ export const JoinMatchMode = Schema.Literals(["exact", "url"])
36
+ export type JoinMatchMode = typeof JoinMatchMode.Type
37
+ export const JoinScope = Schema.Literals(["estate", "app", "file"])
38
+ export type JoinScope = typeof JoinScope.Type
39
+
40
+ export class JoinRule extends Schema.Class<JoinRule>("JoinRule")({
41
+ kind: Schema.String,
42
+ fromKind: Schema.String,
43
+ fromAttr: Schema.String,
44
+ toKind: Schema.String,
45
+ toAttr: Schema.String,
46
+ match: JoinMatchMode,
47
+ scope: JoinScope
48
+ }) {}
49
+
50
+ export class ProbeRule extends Schema.Class<ProbeRule>("ProbeRule")({
51
+ name: Schema.String,
52
+ from: Schema.String,
53
+ to: Schema.String
54
+ }) {}
55
+
56
+ export interface GraphRules {
57
+ readonly nodes: ReadonlyArray<NodeRule>
58
+ readonly edges: ReadonlyArray<EdgeRule>
59
+ readonly joins: ReadonlyArray<JoinRule>
60
+ readonly probes: ReadonlyArray<ProbeRule>
61
+ readonly worklistMax: number
62
+ readonly batchSize: number
63
+ }
64
+
65
+ export const emptyGraphRules: GraphRules = {
66
+ nodes: [],
67
+ edges: [],
68
+ joins: [],
69
+ probes: [],
70
+ worklistMax: 200,
71
+ batchSize: 20
72
+ }
73
+
74
+ /**
75
+ * `## Survey:` is an Edge rule between files whose first positional capture
76
+ * (or the whole match) is the target. The pattern is kept verbatim: rewriting
77
+ * its first `(` would break `(?:…)`, lookbehinds and escaped parens.
78
+ */
79
+ export const edgeRuleOfSurvey = (rule: CoverageRule): EdgeRule =>
80
+ EdgeRule.make({
81
+ kind: rule.name,
82
+ files: rule.files,
83
+ pattern: rule.unit,
84
+ fromKind: "file",
85
+ toKind: "file",
86
+ positional: true
87
+ })
88
+
89
+ const fail = (message: string): Effect.Effect<never, PlanParseError> =>
90
+ Effect.fail(PlanParseError.make({ message }))
91
+
92
+ const isValidRegExp = (source: string): boolean => {
93
+ try {
94
+ new RegExp(source, "gm")
95
+ return true
96
+ } catch {
97
+ return false
98
+ }
99
+ }
100
+
101
+ const fieldsOf = (lines: ReadonlyArray<string>): Readonly<Record<string, string>> =>
102
+ Object.fromEntries(
103
+ lines.flatMap((line) => {
104
+ const trimmed = line.replace(/^- /, "")
105
+ const index = trimmed.indexOf(":")
106
+ return index < 0 ? [] : [[trimmed.slice(0, index).trim(), trimmed.slice(index + 1).trim()]]
107
+ })
108
+ )
109
+
110
+ const yes = (value: string | undefined): boolean =>
111
+ value !== undefined && ["yes", "true", "on"].includes(value.trim().toLowerCase())
112
+
113
+ const attrCopies = (value: string | undefined): Readonly<Record<string, string>> =>
114
+ Object.fromEntries(
115
+ (value ?? "")
116
+ .split(",")
117
+ .map((item) => item.trim())
118
+ .filter((item) => item.length > 0)
119
+ .flatMap((item) => {
120
+ const [attr, group] = item.split("=").map((part) => part.trim())
121
+ return attr !== undefined && group !== undefined && attr.length > 0 && group.length > 0
122
+ ? [[attr, group]]
123
+ : []
124
+ })
125
+ )
126
+
127
+ const kindAndAttr = (value: string | undefined): readonly [string, string] | undefined => {
128
+ const dot = (value ?? "").indexOf(".")
129
+ if (value === undefined || dot <= 0 || dot === value.length - 1) {
130
+ return undefined
131
+ }
132
+ return [value.slice(0, dot).trim(), value.slice(dot + 1).trim()]
133
+ }
134
+
135
+ export const parseGraphRules = (
136
+ sections: ReadonlyArray<string>,
137
+ survey: ReadonlyArray<CoverageRule>
138
+ ): Effect.Effect<GraphRules, PlanParseError> =>
139
+ Effect.gen(function* () {
140
+ const nodes: Array<NodeRule> = []
141
+ const edges: Array<EdgeRule> = survey.map(edgeRuleOfSurvey)
142
+ const joins: Array<JoinRule> = []
143
+ const probes: Array<ProbeRule> = []
144
+ let worklistMax = emptyGraphRules.worklistMax
145
+ let batchSize = emptyGraphRules.batchSize
146
+ for (const section of sections) {
147
+ const lines = section.split(/\r?\n/)
148
+ const heading = lines[0]?.trim() ?? ""
149
+ const fields = fieldsOf(lines.slice(1))
150
+ if (heading.startsWith("## Node: ")) {
151
+ const kind = heading.slice("## Node: ".length).trim()
152
+ if (fields.files === undefined || fields.pattern === undefined) {
153
+ return yield* fail(`'${heading}' needs 'files:' and 'pattern:'`)
154
+ }
155
+ if (!isValidRegExp(fields.pattern)) {
156
+ return yield* fail(`'${heading}' pattern is not a valid regex: ${fields.pattern}`)
157
+ }
158
+ if (!fields.pattern.includes("(?<name>")) {
159
+ return yield* fail(`'${heading}' pattern needs a (?<name>…) group`)
160
+ }
161
+ nodes.push(
162
+ NodeRule.make({
163
+ kind,
164
+ files: fields.files,
165
+ pattern: fields.pattern,
166
+ descriptor: yes(fields.descriptor),
167
+ ...(fields.anchor === undefined ? {} : { anchor: fields.anchor }),
168
+ attrs: attrCopies(fields.attrs)
169
+ })
170
+ )
171
+ } else if (heading.startsWith("## Edge: ")) {
172
+ const kind = heading.slice("## Edge: ".length).trim()
173
+ if (fields.files === undefined || fields.pattern === undefined) {
174
+ return yield* fail(`'${heading}' needs 'files:' and 'pattern:'`)
175
+ }
176
+ if (!isValidRegExp(fields.pattern)) {
177
+ return yield* fail(`'${heading}' pattern is not a valid regex: ${fields.pattern}`)
178
+ }
179
+ if (!fields.pattern.includes("(?<to>")) {
180
+ return yield* fail(`'${heading}' pattern needs a (?<to>…) group`)
181
+ }
182
+ edges.push(
183
+ EdgeRule.make({
184
+ kind,
185
+ files: fields.files,
186
+ pattern: fields.pattern,
187
+ fromKind: fields.from ?? "file",
188
+ toKind: fields.to ?? "file"
189
+ })
190
+ )
191
+ } else if (heading.startsWith("## Join: ")) {
192
+ const kind = heading.slice("## Join: ".length).trim()
193
+ const from = kindAndAttr(fields.from)
194
+ const to = kindAndAttr(fields.to)
195
+ if (from === undefined || to === undefined) {
196
+ return yield* fail(`'${heading}' needs 'from: <kind>.<attr>' and 'to: <kind>.<attr>'`)
197
+ }
198
+ const match: JoinMatchMode = fields.match === "url" ? "url" : "exact"
199
+ const scope: JoinScope =
200
+ fields.scope === "file" || fields.scope === "app" || fields.scope === "estate"
201
+ ? fields.scope
202
+ : match === "url"
203
+ ? "app"
204
+ : "estate"
205
+ joins.push(
206
+ JoinRule.make({
207
+ kind,
208
+ fromKind: from[0],
209
+ fromAttr: from[1],
210
+ toKind: to[0],
211
+ toAttr: to[1],
212
+ match,
213
+ scope
214
+ })
215
+ )
216
+ } else if (heading.startsWith("## Probe: ")) {
217
+ const name = heading.slice("## Probe: ".length).trim()
218
+ if (fields.from === undefined || fields.to === undefined) {
219
+ return yield* fail(`'${heading}' needs 'from:' and 'to:'`)
220
+ }
221
+ probes.push(ProbeRule.make({ name, from: fields.from, to: fields.to }))
222
+ } else if (heading === "## Graph") {
223
+ const max = Number.parseInt(fields["worklist-max"] ?? "", 10)
224
+ const batch = Number.parseInt(fields["batch-size"] ?? "", 10)
225
+ worklistMax = Number.isInteger(max) && max > 0 ? max : worklistMax
226
+ batchSize = Number.isInteger(batch) && batch > 0 ? batch : batchSize
227
+ }
228
+ }
229
+ const nodeKinds = new Set(["file", ...nodes.map((rule) => rule.kind)])
230
+ for (const rule of edges) {
231
+ for (const kind of [rule.fromKind, rule.toKind]) {
232
+ if (!nodeKinds.has(kind)) {
233
+ return yield* fail(
234
+ `'## Edge: ${rule.kind}' names node kind '${kind}' no '## Node:' declares`
235
+ )
236
+ }
237
+ }
238
+ }
239
+ for (const rule of joins) {
240
+ for (const kind of [rule.fromKind, rule.toKind]) {
241
+ if (!nodeKinds.has(kind)) {
242
+ return yield* fail(
243
+ `'## Join: ${rule.kind}' names node kind '${kind}' no '## Node:' declares`
244
+ )
245
+ }
246
+ }
247
+ }
248
+ return { nodes, edges, joins, probes, worklistMax, batchSize }
249
+ })
250
+
251
+ /** Every edge kind the rules can produce: captured kinds plus join kinds. */
252
+ export const edgeKindsOf = (rules: GraphRules): ReadonlySet<string> =>
253
+ new Set([...rules.edges.map((rule) => rule.kind), ...rules.joins.map((rule) => rule.kind)])
254
+
255
+ /** Changes when any rule that shapes the graph changes; probes and budgets do not. */
256
+ export const graphRulesHash = (rules: GraphRules): string =>
257
+ fingerprintOf([JSON.stringify({ n: rules.nodes, e: rules.edges, j: rules.joins })])
258
+
259
+ /** `${ctx}/a/b?x=1` → `/a/b`; `salva.do` → `/salva.do`; scheme, host and fragment dropped. */
260
+ export const normalizeUrl = (raw: string): string => {
261
+ let url = raw.trim()
262
+ url = url.replace(/^(\$\{[^}]*\}|<%=[^%]*%>)/, "")
263
+ url = url.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, "")
264
+ url = url.replace(/[?#].*$/, "")
265
+ if (!url.startsWith("/")) {
266
+ url = `/${url}`
267
+ }
268
+ if (url.length > 1 && url.endsWith("/")) {
269
+ url = url.slice(0, -1)
270
+ }
271
+ return url
272
+ }
273
+
274
+ /**
275
+ * Servlet-spec matching of a normalised request against a `url-pattern`:
276
+ * exact, then a `/*` prefix, then a `*.ext` extension. The extension case
277
+ * reads the raw pattern, since normalising `*.do` would prefix a slash.
278
+ */
279
+ export const matchUrl = (request: string, pattern: string): JoinMatch | undefined => {
280
+ const trimmed = pattern.trim()
281
+ if (trimmed.startsWith("*.")) {
282
+ return request.endsWith(trimmed.slice(1)) ? "extension" : undefined
283
+ }
284
+ const target = normalizeUrl(trimmed)
285
+ if (request === target) {
286
+ return "exact"
287
+ }
288
+ if (target.endsWith("/*")) {
289
+ const prefix = target.slice(0, -2)
290
+ return request === prefix || request.startsWith(`${prefix}/`) ? "prefix" : undefined
291
+ }
292
+ return undefined
293
+ }
@@ -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")