@llm4ts/flow 0.1.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/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/AzureDevOpsTool.d.ts +60 -0
- package/dist/AzureDevOpsTool.d.ts.map +1 -0
- package/dist/AzureDevOpsTool.js +163 -0
- package/dist/AzureDevOpsTool.js.map +1 -0
- package/dist/Bench.d.ts +133 -0
- package/dist/Bench.d.ts.map +1 -0
- package/dist/Bench.js +272 -0
- package/dist/Bench.js.map +1 -0
- package/dist/BenchReport.d.ts +9 -0
- package/dist/BenchReport.d.ts.map +1 -0
- package/dist/BenchReport.js +360 -0
- package/dist/BenchReport.js.map +1 -0
- package/dist/CapabilityGuard.d.ts +6 -0
- package/dist/CapabilityGuard.d.ts.map +1 -0
- package/dist/CapabilityGuard.js +25 -0
- package/dist/CapabilityGuard.js.map +1 -0
- package/dist/Chat.d.ts +15 -0
- package/dist/Chat.d.ts.map +1 -0
- package/dist/Chat.js +36 -0
- package/dist/Chat.js.map +1 -0
- package/dist/Classified.d.ts +14 -0
- package/dist/Classified.d.ts.map +1 -0
- package/dist/Classified.js +48 -0
- package/dist/Classified.js.map +1 -0
- package/dist/CostLedger.d.ts +55 -0
- package/dist/CostLedger.d.ts.map +1 -0
- package/dist/CostLedger.js +126 -0
- package/dist/CostLedger.js.map +1 -0
- package/dist/CostTracker.d.ts +13 -0
- package/dist/CostTracker.d.ts.map +1 -0
- package/dist/CostTracker.js +116 -0
- package/dist/CostTracker.js.map +1 -0
- package/dist/Equiv.d.ts +163 -0
- package/dist/Equiv.d.ts.map +1 -0
- package/dist/Equiv.js +256 -0
- package/dist/Equiv.js.map +1 -0
- package/dist/EquivReport.d.ts +16 -0
- package/dist/EquivReport.d.ts.map +1 -0
- package/dist/EquivReport.js +77 -0
- package/dist/EquivReport.js.map +1 -0
- package/dist/Flow.d.ts +25 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +39 -0
- package/dist/Flow.js.map +1 -0
- package/dist/FlowContext.d.ts +23 -0
- package/dist/FlowContext.d.ts.map +1 -0
- package/dist/FlowContext.js +4 -0
- package/dist/FlowContext.js.map +1 -0
- package/dist/FlowError.d.ts +80 -0
- package/dist/FlowError.d.ts.map +1 -0
- package/dist/FlowError.js +92 -0
- package/dist/FlowError.js.map +1 -0
- package/dist/FlowEvents.d.ts +101 -0
- package/dist/FlowEvents.d.ts.map +1 -0
- package/dist/FlowEvents.js +103 -0
- package/dist/FlowEvents.js.map +1 -0
- package/dist/FlowRecorder.d.ts +26 -0
- package/dist/FlowRecorder.d.ts.map +1 -0
- package/dist/FlowRecorder.js +74 -0
- package/dist/FlowRecorder.js.map +1 -0
- package/dist/GitHubTool.d.ts +53 -0
- package/dist/GitHubTool.d.ts.map +1 -0
- package/dist/GitHubTool.js +191 -0
- package/dist/GitHubTool.js.map +1 -0
- package/dist/GitTool.d.ts +46 -0
- package/dist/GitTool.d.ts.map +1 -0
- package/dist/GitTool.js +97 -0
- package/dist/GitTool.js.map +1 -0
- package/dist/Interaction.d.ts +21 -0
- package/dist/Interaction.d.ts.map +1 -0
- package/dist/Interaction.js +9 -0
- package/dist/Interaction.js.map +1 -0
- package/dist/McpServer.d.ts +18 -0
- package/dist/McpServer.d.ts.map +1 -0
- package/dist/McpServer.js +122 -0
- package/dist/McpServer.js.map +1 -0
- package/dist/Mermaid.d.ts +7 -0
- package/dist/Mermaid.d.ts.map +1 -0
- package/dist/Mermaid.js +98 -0
- package/dist/Mermaid.js.map +1 -0
- package/dist/Pack.d.ts +40 -0
- package/dist/Pack.d.ts.map +1 -0
- package/dist/Pack.js +141 -0
- package/dist/Pack.js.map +1 -0
- package/dist/Package.d.ts +2 -0
- package/dist/Package.d.ts.map +1 -0
- package/dist/Package.js +2 -0
- package/dist/Package.js.map +1 -0
- package/dist/Persistence.d.ts +32 -0
- package/dist/Persistence.d.ts.map +1 -0
- package/dist/Persistence.js +85 -0
- package/dist/Persistence.js.map +1 -0
- package/dist/Plan.d.ts +26 -0
- package/dist/Plan.d.ts.map +1 -0
- package/dist/Plan.js +101 -0
- package/dist/Plan.js.map +1 -0
- package/dist/PlanExecution.d.ts +7 -0
- package/dist/PlanExecution.d.ts.map +1 -0
- package/dist/PlanExecution.js +19 -0
- package/dist/PlanExecution.js.map +1 -0
- package/dist/Planner.d.ts +32 -0
- package/dist/Planner.d.ts.map +1 -0
- package/dist/Planner.js +97 -0
- package/dist/Planner.js.map +1 -0
- package/dist/PrSummary.d.ts +15 -0
- package/dist/PrSummary.d.ts.map +1 -0
- package/dist/PrSummary.js +29 -0
- package/dist/PrSummary.js.map +1 -0
- package/dist/PriceList.d.ts +11 -0
- package/dist/PriceList.d.ts.map +1 -0
- package/dist/PriceList.js +79 -0
- package/dist/PriceList.js.map +1 -0
- package/dist/Provenance.d.ts +27 -0
- package/dist/Provenance.d.ts.map +1 -0
- package/dist/Provenance.js +38 -0
- package/dist/Provenance.js.map +1 -0
- package/dist/Replay.d.ts +29 -0
- package/dist/Replay.d.ts.map +1 -0
- package/dist/Replay.js +181 -0
- package/dist/Replay.js.map +1 -0
- package/dist/Review.d.ts +71 -0
- package/dist/Review.d.ts.map +1 -0
- package/dist/Review.js +204 -0
- package/dist/Review.js.map +1 -0
- package/dist/ReviewCache.d.ts +6 -0
- package/dist/ReviewCache.d.ts.map +1 -0
- package/dist/ReviewCache.js +28 -0
- package/dist/ReviewCache.js.map +1 -0
- package/dist/Reviewer.d.ts +12 -0
- package/dist/Reviewer.d.ts.map +1 -0
- package/dist/Reviewer.js +30 -0
- package/dist/Reviewer.js.map +1 -0
- package/dist/SpecChecks.d.ts +17 -0
- package/dist/SpecChecks.d.ts.map +1 -0
- package/dist/SpecChecks.js +107 -0
- package/dist/SpecChecks.js.map +1 -0
- package/dist/Survey.d.ts +32 -0
- package/dist/Survey.d.ts.map +1 -0
- package/dist/Survey.js +119 -0
- package/dist/Survey.js.map +1 -0
- package/dist/TransientRetry.d.ts +16 -0
- package/dist/TransientRetry.d.ts.map +1 -0
- package/dist/TransientRetry.js +102 -0
- package/dist/TransientRetry.js.map +1 -0
- package/dist/Workspace.d.ts +39 -0
- package/dist/Workspace.d.ts.map +1 -0
- package/dist/Workspace.js +192 -0
- package/dist/Workspace.js.map +1 -0
- package/dist/WorkspaceLayout.d.ts +3 -0
- package/dist/WorkspaceLayout.d.ts.map +1 -0
- package/dist/WorkspaceLayout.js +19 -0
- package/dist/WorkspaceLayout.js.map +1 -0
- package/dist/WorkspaceTools.d.ts +4 -0
- package/dist/WorkspaceTools.d.ts.map +1 -0
- package/dist/WorkspaceTools.js +130 -0
- package/dist/WorkspaceTools.js.map +1 -0
- package/package.json +91 -0
- package/src/AzureDevOpsTool.ts +291 -0
- package/src/Bench.ts +340 -0
- package/src/BenchReport.ts +447 -0
- package/src/CapabilityGuard.ts +43 -0
- package/src/Chat.ts +60 -0
- package/src/Classified.ts +67 -0
- package/src/CostLedger.ts +171 -0
- package/src/CostTracker.ts +170 -0
- package/src/Equiv.ts +378 -0
- package/src/EquivReport.ts +92 -0
- package/src/Flow.ts +72 -0
- package/src/FlowContext.ts +23 -0
- package/src/FlowError.ts +112 -0
- package/src/FlowEvents.ts +147 -0
- package/src/FlowRecorder.ts +116 -0
- package/src/GitHubTool.ts +315 -0
- package/src/GitTool.ts +214 -0
- package/src/Interaction.ts +23 -0
- package/src/McpServer.ts +168 -0
- package/src/Mermaid.ts +112 -0
- package/src/Pack.ts +213 -0
- package/src/Package.ts +1 -0
- package/src/Persistence.ts +198 -0
- package/src/Plan.ts +119 -0
- package/src/PlanExecution.ts +46 -0
- package/src/Planner.ts +134 -0
- package/src/PrSummary.ts +37 -0
- package/src/PriceList.ts +91 -0
- package/src/Provenance.ts +85 -0
- package/src/Replay.ts +265 -0
- package/src/Review.ts +337 -0
- package/src/ReviewCache.ts +45 -0
- package/src/Reviewer.ts +31 -0
- package/src/SpecChecks.ts +143 -0
- package/src/Survey.ts +144 -0
- package/src/TransientRetry.ts +238 -0
- package/src/Workspace.ts +255 -0
- package/src/WorkspaceLayout.ts +22 -0
- package/src/WorkspaceTools.ts +188 -0
package/src/Review.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
4
|
+
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
5
|
+
import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
6
|
+
import { Capabilities } from "@llm4ts/core/Capability"
|
|
7
|
+
import { guarded } from "./CapabilityGuard.ts"
|
|
8
|
+
import type { Chat } from "./Chat.ts"
|
|
9
|
+
import { FlowLlmError, ProcessError, type FlowError } from "./FlowError.ts"
|
|
10
|
+
import { Info, type FlowEventsShape } from "./FlowEvents.ts"
|
|
11
|
+
import { Reviewer } from "./Reviewer.ts"
|
|
12
|
+
|
|
13
|
+
export const Severity = Schema.Literals(["Critical", "Warning", "Info"])
|
|
14
|
+
export type Severity = typeof Severity.Type
|
|
15
|
+
|
|
16
|
+
export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
|
|
17
|
+
severity: Severity,
|
|
18
|
+
title: Schema.String,
|
|
19
|
+
description: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed(""))),
|
|
20
|
+
file: Schema.optionalKey(Schema.String),
|
|
21
|
+
line: Schema.optionalKey(Schema.Int),
|
|
22
|
+
suggestion: Schema.optionalKey(Schema.String),
|
|
23
|
+
confidence: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1)))
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
export class ReviewResult extends Schema.Class<ReviewResult>("ReviewResult")({
|
|
27
|
+
issues: Schema.Array(ReviewIssue),
|
|
28
|
+
summary: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("")))
|
|
29
|
+
}) {
|
|
30
|
+
get isClean(): boolean {
|
|
31
|
+
return this.issues.length === 0
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const reviewJsonSchema: JsonSchema = {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
issues: {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
severity: { type: "string", enum: ["Critical", "Warning", "Info"] },
|
|
44
|
+
title: { type: "string" },
|
|
45
|
+
description: { type: "string" },
|
|
46
|
+
file: { type: "string" },
|
|
47
|
+
line: { type: "integer" },
|
|
48
|
+
suggestion: { type: "string" },
|
|
49
|
+
confidence: { type: "number" }
|
|
50
|
+
},
|
|
51
|
+
required: ["severity", "title", "description"]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
summary: { type: "string" }
|
|
55
|
+
},
|
|
56
|
+
required: ["issues", "summary"]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reviewer = (name: string, systemPrompt: string, files = ".*"): Reviewer =>
|
|
60
|
+
Reviewer.make({ name, systemPrompt, files })
|
|
61
|
+
|
|
62
|
+
export const correctnessReviewer = reviewer(
|
|
63
|
+
"code-functionality",
|
|
64
|
+
[
|
|
65
|
+
"Review for functional correctness. Check that the change implements the task's intent,",
|
|
66
|
+
"handles obvious edge cases, and does not regress existing behavior. Report only concrete",
|
|
67
|
+
"logic errors, wrong conditions, mishandled error paths, and missing cases. Ignore style."
|
|
68
|
+
].join("\n")
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
export const readabilityReviewer = reviewer(
|
|
72
|
+
"readability",
|
|
73
|
+
[
|
|
74
|
+
"Review for readability and clarity. Check names, focused functions, understandable control",
|
|
75
|
+
"flow, and useful comments. Report only concrete, actionable readability problems."
|
|
76
|
+
].join("\n")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
export const testReviewer = reviewer(
|
|
80
|
+
"test",
|
|
81
|
+
[
|
|
82
|
+
"Review test coverage and quality. Check that new behavior and important error paths are",
|
|
83
|
+
"covered by tests that assert real outcomes and would fail on regression. Report concrete gaps."
|
|
84
|
+
].join("\n")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
export const structureReviewer = reviewer(
|
|
88
|
+
"code-structure",
|
|
89
|
+
"Review module boundaries, dependency direction, cohesion, and unnecessary coupling. Report concrete structural problems."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
export const performanceReviewer = reviewer(
|
|
93
|
+
"performance",
|
|
94
|
+
"Review for material performance regressions, unbounded work, avoidable repeated I/O, and resource leaks."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
export const securityReviewer = reviewer(
|
|
98
|
+
"security",
|
|
99
|
+
"Review trust boundaries, input handling, secrets, authorization, injection risks, and unsafe defaults."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
export const effectReviewer = reviewer(
|
|
103
|
+
"effect-ts",
|
|
104
|
+
"Review Effect usage: typed errors, scoped resources, service boundaries, interruption, concurrency, and runtime ownership.",
|
|
105
|
+
".*\\.(ts|tsx|mts|cts)$"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
export const minimalReviewers: ReadonlyArray<Reviewer> = Object.freeze([
|
|
109
|
+
correctnessReviewer,
|
|
110
|
+
readabilityReviewer,
|
|
111
|
+
testReviewer
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
export const allReviewers: ReadonlyArray<Reviewer> = Object.freeze([
|
|
115
|
+
correctnessReviewer,
|
|
116
|
+
testReviewer,
|
|
117
|
+
readabilityReviewer,
|
|
118
|
+
structureReviewer,
|
|
119
|
+
performanceReviewer,
|
|
120
|
+
securityReviewer,
|
|
121
|
+
effectReviewer
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
export interface ReviewerSelector {
|
|
125
|
+
readonly select: (
|
|
126
|
+
reviewers: ReadonlyArray<Reviewer>,
|
|
127
|
+
changedFiles: ReadonlyArray<string>,
|
|
128
|
+
round: number,
|
|
129
|
+
previous: ReviewResult | undefined
|
|
130
|
+
) => Effect.Effect<ReadonlyArray<Reviewer>, FlowError>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const allEveryRound: ReviewerSelector = {
|
|
134
|
+
select: (reviewers, changedFiles, _round, _previous) =>
|
|
135
|
+
Effect.succeed(reviewers.filter((candidate) => candidate.matches(changedFiles)))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const whileDirty: ReviewerSelector = {
|
|
139
|
+
select: (reviewers, changedFiles, round, previous) =>
|
|
140
|
+
Effect.succeed(
|
|
141
|
+
round === 1 || previous === undefined || !previous.isClean
|
|
142
|
+
? reviewers.filter((candidate) => candidate.matches(changedFiles))
|
|
143
|
+
: []
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class ReviewerPick extends Schema.Class<ReviewerPick>("ReviewerPick")({
|
|
148
|
+
reviewers: Schema.Array(Schema.String)
|
|
149
|
+
}) {}
|
|
150
|
+
|
|
151
|
+
const reviewerPickJsonSchema: JsonSchema = {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
reviewers: {
|
|
155
|
+
type: "array",
|
|
156
|
+
items: { type: "string" }
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
required: ["reviewers"]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const llmDriven = (picker: LlmServiceShape): ReviewerSelector => ({
|
|
163
|
+
select: (reviewers, changedFiles, _round, _previous) => {
|
|
164
|
+
const scoped = reviewers.filter((candidate) => candidate.matches(changedFiles))
|
|
165
|
+
if (scoped.length <= 1 || changedFiles.length === 0) {
|
|
166
|
+
return Effect.succeed(scoped)
|
|
167
|
+
}
|
|
168
|
+
const names = scoped.map((candidate) => candidate.name)
|
|
169
|
+
const prompt = [
|
|
170
|
+
`Pick the reviewers relevant to these files. Available reviewers: ${names.join(", ")}.`,
|
|
171
|
+
"Changed files:",
|
|
172
|
+
...changedFiles,
|
|
173
|
+
'Respond only with JSON: {"reviewers":["name", ...]}.'
|
|
174
|
+
].join("\n")
|
|
175
|
+
return picker.executeStructured(prompt, ReviewerPick, reviewerPickJsonSchema).pipe(
|
|
176
|
+
Effect.map((pick) => scoped.filter((candidate) => pick.reviewers.includes(candidate.name))),
|
|
177
|
+
Effect.catch(() => Effect.succeed(scoped)),
|
|
178
|
+
Effect.map((chosen) => (chosen.length === 0 ? scoped : chosen))
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
export const reviewPrompt = (task: string, diff: string): string =>
|
|
184
|
+
[
|
|
185
|
+
`Review the change below for task "${task}". Report problems as JSON:`,
|
|
186
|
+
'{"issues":[{"severity":"Critical|Warning|Info","title":"...",',
|
|
187
|
+
'"description":"..."}],"summary":"..."}.',
|
|
188
|
+
'An empty "issues" array means the change is acceptable. Respond with JSON only.',
|
|
189
|
+
"",
|
|
190
|
+
"Diff:",
|
|
191
|
+
diff
|
|
192
|
+
].join("\n")
|
|
193
|
+
|
|
194
|
+
export const fixPrompt = (result: ReviewResult): string =>
|
|
195
|
+
[
|
|
196
|
+
"Address these review findings, then stop:",
|
|
197
|
+
...result.issues.map((issue) => `- [${issue.severity}] ${issue.title}: ${issue.description}`)
|
|
198
|
+
].join("\n")
|
|
199
|
+
|
|
200
|
+
export const mergeReviewResults = (results: ReadonlyArray<ReviewResult>): ReviewResult =>
|
|
201
|
+
ReviewResult.make({
|
|
202
|
+
issues: results.flatMap((result) => result.issues),
|
|
203
|
+
summary: results
|
|
204
|
+
.map((result) => result.summary)
|
|
205
|
+
.filter((summary) => summary.length > 0)
|
|
206
|
+
.join("; ")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const processProblem = (
|
|
210
|
+
stdout: ReadonlyArray<string>,
|
|
211
|
+
stderr: ReadonlyArray<string>,
|
|
212
|
+
exitCode: number
|
|
213
|
+
): string => {
|
|
214
|
+
const detail = [...stdout, ...stderr].join("\n").trim()
|
|
215
|
+
return detail.length === 0 ? `process exited with code ${exitCode}` : detail
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function* (
|
|
219
|
+
process: ProcessExecutorShape,
|
|
220
|
+
events: FlowEventsShape,
|
|
221
|
+
command: ReadonlyArray<string>,
|
|
222
|
+
workDir: string
|
|
223
|
+
): Effect.fn.Return<ReviewResult, FlowError> {
|
|
224
|
+
const executable = command[0]
|
|
225
|
+
if (executable === undefined) {
|
|
226
|
+
return ReviewResult.make({ issues: [], summary: "" })
|
|
227
|
+
}
|
|
228
|
+
const result = yield* guarded(
|
|
229
|
+
Capabilities.Exec(executable),
|
|
230
|
+
`lint: ${command.join(" ")}`,
|
|
231
|
+
events,
|
|
232
|
+
process.run(command, workDir, {}).pipe(
|
|
233
|
+
Effect.mapError((cause) =>
|
|
234
|
+
ProcessError.make({
|
|
235
|
+
message: command.join(" "),
|
|
236
|
+
detail: cause.message
|
|
237
|
+
})
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
if (result.exitCode === 0) {
|
|
242
|
+
return ReviewResult.make({ issues: [], summary: "lint passed" })
|
|
243
|
+
}
|
|
244
|
+
return ReviewResult.make({
|
|
245
|
+
issues: [
|
|
246
|
+
ReviewIssue.make({
|
|
247
|
+
severity: "Critical",
|
|
248
|
+
title: `lint failed: ${command.join(" ")}`,
|
|
249
|
+
description: processProblem(result.stdout, result.stderr, result.exitCode)
|
|
250
|
+
})
|
|
251
|
+
],
|
|
252
|
+
summary: "lint failed"
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
export interface ReviewAndFixOptions {
|
|
257
|
+
readonly reviewers: ReadonlyArray<Reviewer>
|
|
258
|
+
readonly reviewerService: LlmServiceShape
|
|
259
|
+
readonly coder: Chat
|
|
260
|
+
readonly taskTitle: string
|
|
261
|
+
readonly currentDiff: Effect.Effect<string, FlowError>
|
|
262
|
+
readonly events: FlowEventsShape
|
|
263
|
+
readonly changedFiles?: Effect.Effect<ReadonlyArray<string>, FlowError>
|
|
264
|
+
readonly maxRounds?: number
|
|
265
|
+
readonly selector?: ReviewerSelector
|
|
266
|
+
readonly lint?: Effect.Effect<ReviewResult, FlowError>
|
|
267
|
+
readonly parallelism?: number
|
|
268
|
+
readonly format?: Effect.Effect<void, FlowError>
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const reviewWith = (
|
|
272
|
+
service: LlmServiceShape,
|
|
273
|
+
lens: Reviewer,
|
|
274
|
+
taskTitle: string,
|
|
275
|
+
diff: string
|
|
276
|
+
): Effect.Effect<ReviewResult, FlowLlmError> =>
|
|
277
|
+
service
|
|
278
|
+
.executeStructured(
|
|
279
|
+
`${lens.systemPrompt}\n\n${reviewPrompt(taskTitle, diff)}`,
|
|
280
|
+
ReviewResult,
|
|
281
|
+
reviewJsonSchema
|
|
282
|
+
)
|
|
283
|
+
.pipe(Effect.mapError(FlowLlmError.from))
|
|
284
|
+
|
|
285
|
+
export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop")(function* (
|
|
286
|
+
options: ReviewAndFixOptions
|
|
287
|
+
): Effect.fn.Return<ReviewResult, FlowError> {
|
|
288
|
+
const maxRounds = Math.max(1, options.maxRounds ?? 3)
|
|
289
|
+
const selector = options.selector ?? allEveryRound
|
|
290
|
+
const changedFiles = options.changedFiles ?? Effect.succeed([])
|
|
291
|
+
const format = options.format ?? Effect.void
|
|
292
|
+
|
|
293
|
+
const reviewOnce = (
|
|
294
|
+
round: number,
|
|
295
|
+
previous: ReviewResult | undefined
|
|
296
|
+
): Effect.Effect<ReviewResult, FlowError> =>
|
|
297
|
+
Effect.gen(function* () {
|
|
298
|
+
const lint = yield* options.lint ?? Effect.succeed(ReviewResult.make({ issues: [] }))
|
|
299
|
+
if (!lint.isClean) {
|
|
300
|
+
return lint
|
|
301
|
+
}
|
|
302
|
+
const diff = yield* options.currentDiff
|
|
303
|
+
const files = yield* changedFiles
|
|
304
|
+
const chosen = yield* selector.select(options.reviewers, files, round, previous)
|
|
305
|
+
const run = (lens: Reviewer) =>
|
|
306
|
+
reviewWith(options.reviewerService, lens, options.taskTitle, diff)
|
|
307
|
+
const parallelism = options.parallelism ?? 0
|
|
308
|
+
const results =
|
|
309
|
+
parallelism > 0
|
|
310
|
+
? yield* Effect.forEach(chosen, run, { concurrency: parallelism })
|
|
311
|
+
: yield* Effect.forEach(chosen, run, { concurrency: "unbounded" })
|
|
312
|
+
return mergeReviewResults(results)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const loop = (
|
|
316
|
+
round: number,
|
|
317
|
+
previous: ReviewResult | undefined
|
|
318
|
+
): Effect.Effect<ReviewResult, FlowError> =>
|
|
319
|
+
Effect.gen(function* () {
|
|
320
|
+
yield* format
|
|
321
|
+
const result = yield* reviewOnce(round, previous)
|
|
322
|
+
const verdict = result.isClean ? "clean" : `${result.issues.length} issue(s)`
|
|
323
|
+
if (result.isClean || round >= maxRounds) {
|
|
324
|
+
yield* options.events.publish(
|
|
325
|
+
Info.make({ message: `review settled after round ${round}: ${verdict}` })
|
|
326
|
+
)
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
yield* options.events.publish(
|
|
330
|
+
Info.make({ message: `review round ${round}: ${verdict}, fixing` })
|
|
331
|
+
)
|
|
332
|
+
yield* options.coder.ask(fixPrompt(result))
|
|
333
|
+
return yield* loop(round + 1, result)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
return yield* loop(1, undefined)
|
|
337
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { PersistenceError, type FlowError } from "./FlowError.ts"
|
|
4
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
5
|
+
import { ReviewResult } from "./Review.ts"
|
|
6
|
+
|
|
7
|
+
class ReviewCacheEntry extends Schema.Class<ReviewCacheEntry>("ReviewCacheEntry")({
|
|
8
|
+
fingerprint: Schema.String,
|
|
9
|
+
result: ReviewResult
|
|
10
|
+
}) {}
|
|
11
|
+
|
|
12
|
+
const decodeEntry = (contents: string): Effect.Effect<ReviewCacheEntry | undefined> =>
|
|
13
|
+
Schema.decodeUnknownEffect(Schema.fromJsonString(ReviewCacheEntry))(contents).pipe(
|
|
14
|
+
Effect.option,
|
|
15
|
+
Effect.map((option) => (option._tag === "Some" ? option.value : undefined))
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
export const cachedReview = Effect.fn("@llm4ts/flow/ReviewCache.cached")(function* <R>(
|
|
19
|
+
files: PlainFileStoreShape,
|
|
20
|
+
path: string,
|
|
21
|
+
fingerprint: string,
|
|
22
|
+
evaluate: Effect.Effect<ReviewResult, FlowError, R>
|
|
23
|
+
): Effect.fn.Return<ReviewResult, FlowError, R> {
|
|
24
|
+
const contents = yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
25
|
+
const entry = contents === undefined ? undefined : yield* decodeEntry(contents)
|
|
26
|
+
if (entry?.fingerprint === fingerprint) {
|
|
27
|
+
return entry.result
|
|
28
|
+
}
|
|
29
|
+
const result = yield* evaluate
|
|
30
|
+
const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(ReviewCacheEntry))(
|
|
31
|
+
ReviewCacheEntry.make({
|
|
32
|
+
fingerprint,
|
|
33
|
+
result
|
|
34
|
+
})
|
|
35
|
+
).pipe(
|
|
36
|
+
Effect.mapError((error) =>
|
|
37
|
+
PersistenceError.make({
|
|
38
|
+
message: `failed to encode review cache: ${String(error)}`,
|
|
39
|
+
cause: error
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
yield* files.writeAtomic(path, encoded)
|
|
44
|
+
return result
|
|
45
|
+
})
|
package/src/Reviewer.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
|
|
3
|
+
export class Reviewer extends Schema.Class<Reviewer>("Reviewer")({
|
|
4
|
+
name: Schema.String,
|
|
5
|
+
systemPrompt: Schema.String,
|
|
6
|
+
files: Schema.optionalKey(Schema.String)
|
|
7
|
+
}) {
|
|
8
|
+
matches(changedFiles: ReadonlyArray<string>): boolean {
|
|
9
|
+
if (this.files === undefined || changedFiles.length === 0) {
|
|
10
|
+
return true
|
|
11
|
+
}
|
|
12
|
+
const pattern = new RegExp(this.files)
|
|
13
|
+
return changedFiles.some((path) => pattern.test(path))
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const parseReviewer = (name: string, text: string): Reviewer => {
|
|
18
|
+
const parts = text.split("---\n", 3)
|
|
19
|
+
const frontmatter = parts.length === 3 ? (parts[1] ?? "") : ""
|
|
20
|
+
const body = parts.length === 3 ? (parts[2] ?? "").trim() : text.trim()
|
|
21
|
+
const files = frontmatter
|
|
22
|
+
.split(/\r?\n/)
|
|
23
|
+
.find((line) => line.trim().startsWith("files:"))
|
|
24
|
+
?.split("files:", 2)[1]
|
|
25
|
+
?.trim()
|
|
26
|
+
return Reviewer.make({
|
|
27
|
+
name,
|
|
28
|
+
systemPrompt: body,
|
|
29
|
+
...(files === undefined || files.length === 0 ? {} : { files })
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { ReviewIssue, ReviewResult } from "./Review.ts"
|
|
4
|
+
import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
|
|
5
|
+
|
|
6
|
+
export class CoverageRule extends Schema.Class<CoverageRule>("CoverageRule")({
|
|
7
|
+
name: Schema.String,
|
|
8
|
+
files: Schema.String,
|
|
9
|
+
unit: Schema.String
|
|
10
|
+
}) {}
|
|
11
|
+
|
|
12
|
+
const capture = (regex: RegExp, text: string): ReadonlyArray<string> => {
|
|
13
|
+
const expression = new RegExp(
|
|
14
|
+
regex.source,
|
|
15
|
+
regex.flags.includes("g") ? regex.flags : `${regex.flags}g`
|
|
16
|
+
)
|
|
17
|
+
const values: Array<string> = []
|
|
18
|
+
for (const match of text.matchAll(expression)) {
|
|
19
|
+
values.push(match[1] ?? match[0])
|
|
20
|
+
}
|
|
21
|
+
return values
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const matchingFiles = (
|
|
25
|
+
workspace: WorkspaceShape,
|
|
26
|
+
regex: string
|
|
27
|
+
): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
|
|
28
|
+
workspace.discover().pipe(
|
|
29
|
+
Effect.map((paths) => {
|
|
30
|
+
const expression = new RegExp(regex)
|
|
31
|
+
return paths.filter((path) => expression.test(path)).sort()
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export const coverageUnits = Effect.fn("@llm4ts/flow/SpecChecks.coverageUnits")(function* (
|
|
36
|
+
workspace: WorkspaceShape,
|
|
37
|
+
rules: ReadonlyArray<CoverageRule>
|
|
38
|
+
): Effect.fn.Return<Readonly<Record<string, ReadonlyArray<string>>>, WorkspaceError> {
|
|
39
|
+
const paths = yield* workspace.discover()
|
|
40
|
+
const result: Record<string, ReadonlyArray<string>> = {}
|
|
41
|
+
for (const rule of rules) {
|
|
42
|
+
const filePattern = new RegExp(rule.files)
|
|
43
|
+
const unitPattern = new RegExp(rule.unit)
|
|
44
|
+
const units: Array<string> = []
|
|
45
|
+
for (const path of paths.filter((path) => filePattern.test(path))) {
|
|
46
|
+
const contents = yield* workspace.read(path)
|
|
47
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
48
|
+
for (const unit of capture(unitPattern, line)) {
|
|
49
|
+
if (!units.includes(unit)) {
|
|
50
|
+
units.push(unit)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
result[rule.name] = units
|
|
56
|
+
}
|
|
57
|
+
return result
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
export const coverage = Effect.fn("@llm4ts/flow/SpecChecks.coverage")(function* (
|
|
61
|
+
workspace: WorkspaceShape,
|
|
62
|
+
rules: ReadonlyArray<CoverageRule>,
|
|
63
|
+
traceability: string
|
|
64
|
+
): Effect.fn.Return<ReviewResult, WorkspaceError> {
|
|
65
|
+
const units = yield* coverageUnits(workspace, rules)
|
|
66
|
+
const issues = rules.flatMap((rule) =>
|
|
67
|
+
(units[rule.name] ?? []).flatMap((unit) =>
|
|
68
|
+
traceability.includes(unit)
|
|
69
|
+
? []
|
|
70
|
+
: [
|
|
71
|
+
ReviewIssue.make({
|
|
72
|
+
severity: "Critical",
|
|
73
|
+
title: `uncovered ${rule.name}: ${unit}`,
|
|
74
|
+
description:
|
|
75
|
+
`'${unit}' exists in the legacy source but does not ` +
|
|
76
|
+
"appear in the traceability matrix."
|
|
77
|
+
})
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return ReviewResult.make({
|
|
82
|
+
issues,
|
|
83
|
+
summary: issues.length === 0 ? "coverage complete" : `${issues.length} unit(s) uncovered`
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const featureIssue = (path: string, contents: string): ReviewIssue | undefined => {
|
|
88
|
+
const lines = contents.split(/\r?\n/).map((line) => line.trim())
|
|
89
|
+
const scenarios = lines.filter(
|
|
90
|
+
(line) => line.startsWith("Scenario:") || line.startsWith("Scenario Outline:")
|
|
91
|
+
).length
|
|
92
|
+
const steps = lines.filter((line) =>
|
|
93
|
+
["Given ", "When ", "Then ", "And ", "But "].some((prefix) => line.startsWith(prefix))
|
|
94
|
+
).length
|
|
95
|
+
const problem = !lines.some((line) => line.startsWith("Feature:"))
|
|
96
|
+
? "missing a 'Feature:' header"
|
|
97
|
+
: scenarios === 0
|
|
98
|
+
? "contains no scenarios"
|
|
99
|
+
: steps === 0
|
|
100
|
+
? "has scenarios but no Given/When/Then steps"
|
|
101
|
+
: undefined
|
|
102
|
+
return problem === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: ReviewIssue.make({
|
|
105
|
+
severity: "Critical",
|
|
106
|
+
title: `malformed feature: ${path}`,
|
|
107
|
+
description: problem,
|
|
108
|
+
file: path
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const features = Effect.fn("@llm4ts/flow/SpecChecks.features")(function* (
|
|
113
|
+
workspace: WorkspaceShape,
|
|
114
|
+
directory: string
|
|
115
|
+
): Effect.fn.Return<ReviewResult, WorkspaceError> {
|
|
116
|
+
const prefix = directory.replace(/[\\/]+$/, "")
|
|
117
|
+
const paths = (yield* workspace.discover("**/*.feature")).filter(
|
|
118
|
+
(path) => path === prefix || path.startsWith(`${prefix}/`)
|
|
119
|
+
)
|
|
120
|
+
if (paths.length === 0) {
|
|
121
|
+
return ReviewResult.make({
|
|
122
|
+
issues: [
|
|
123
|
+
ReviewIssue.make({
|
|
124
|
+
severity: "Critical",
|
|
125
|
+
title: "no feature files",
|
|
126
|
+
description: `no .feature files found under ${directory}`
|
|
127
|
+
})
|
|
128
|
+
],
|
|
129
|
+
summary: "no features"
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
const issues: Array<ReviewIssue> = []
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
const issue = featureIssue(path, yield* workspace.read(path))
|
|
135
|
+
if (issue !== undefined) {
|
|
136
|
+
issues.push(issue)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return ReviewResult.make({
|
|
140
|
+
issues,
|
|
141
|
+
summary: issues.length === 0 ? "features well-formed" : `${issues.length} malformed`
|
|
142
|
+
})
|
|
143
|
+
})
|
package/src/Survey.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import type { CoverageRule } from "./SpecChecks.ts"
|
|
4
|
+
import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
|
|
5
|
+
|
|
6
|
+
export class SurveyNode extends Schema.Class<SurveyNode>("SurveyNode")({
|
|
7
|
+
path: Schema.String,
|
|
8
|
+
name: Schema.String,
|
|
9
|
+
lines: Schema.Int,
|
|
10
|
+
units: Schema.Int
|
|
11
|
+
}) {}
|
|
12
|
+
|
|
13
|
+
export class SurveyEdge extends Schema.Class<SurveyEdge>("SurveyEdge")({
|
|
14
|
+
from: Schema.String,
|
|
15
|
+
to: Schema.String,
|
|
16
|
+
kind: Schema.String
|
|
17
|
+
}) {}
|
|
18
|
+
|
|
19
|
+
export class SurveyGraph extends Schema.Class<SurveyGraph>("SurveyGraph")({
|
|
20
|
+
nodes: Schema.Array(SurveyNode),
|
|
21
|
+
edges: Schema.Array(SurveyEdge)
|
|
22
|
+
}) {
|
|
23
|
+
incoming(name: string): ReadonlyArray<SurveyEdge> {
|
|
24
|
+
return this.edges.filter((edge) => edge.to === name)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
outgoing(name: string): ReadonlyArray<SurveyEdge> {
|
|
28
|
+
return this.edges.filter((edge) => edge.from === name)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const unitName = (path: string): string => {
|
|
33
|
+
const base = path.split("/").at(-1) ?? path
|
|
34
|
+
const dot = base.lastIndexOf(".")
|
|
35
|
+
return dot < 0 ? base : base.slice(0, dot)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const matches = (regex: string, contents: string): ReadonlyArray<string> => {
|
|
39
|
+
const expression = new RegExp(regex, "g")
|
|
40
|
+
return [...contents.matchAll(expression)].map((match) => match[1] ?? match[0])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
|
|
44
|
+
workspace: WorkspaceShape,
|
|
45
|
+
sources: string,
|
|
46
|
+
units: ReadonlyArray<CoverageRule>,
|
|
47
|
+
edgeRules: ReadonlyArray<CoverageRule>
|
|
48
|
+
): Effect.fn.Return<SurveyGraph, WorkspaceError> {
|
|
49
|
+
const sourcePattern = new RegExp(sources)
|
|
50
|
+
const paths = (yield* workspace.discover()).filter((path) => sourcePattern.test(path)).sort()
|
|
51
|
+
const nodes: Array<SurveyNode> = []
|
|
52
|
+
const contents = new Map<string, string>()
|
|
53
|
+
for (const path of paths) {
|
|
54
|
+
const text = yield* workspace.read(path)
|
|
55
|
+
contents.set(path, text)
|
|
56
|
+
nodes.push(
|
|
57
|
+
SurveyNode.make({
|
|
58
|
+
path,
|
|
59
|
+
name: unitName(path),
|
|
60
|
+
lines: text.split(/\r?\n/).length,
|
|
61
|
+
units: units
|
|
62
|
+
.filter((rule) => new RegExp(rule.files).test(path))
|
|
63
|
+
.reduce((count, rule) => count + matches(rule.unit, text).length, 0)
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
const edges: Array<SurveyEdge> = []
|
|
68
|
+
for (const rule of edgeRules) {
|
|
69
|
+
const filePattern = new RegExp(rule.files)
|
|
70
|
+
for (const path of paths.filter((path) => filePattern.test(path))) {
|
|
71
|
+
for (const target of new Set(matches(rule.unit, contents.get(path) ?? ""))) {
|
|
72
|
+
const edge = SurveyEdge.make({
|
|
73
|
+
from: unitName(path),
|
|
74
|
+
to: target,
|
|
75
|
+
kind: rule.name
|
|
76
|
+
})
|
|
77
|
+
if (
|
|
78
|
+
!edges.some(
|
|
79
|
+
(current) =>
|
|
80
|
+
current.from === edge.from && current.to === edge.to && current.kind === edge.kind
|
|
81
|
+
)
|
|
82
|
+
) {
|
|
83
|
+
edges.push(edge)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return SurveyGraph.make({ nodes, edges })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
export const mergeSurveyEdges = (
|
|
92
|
+
graph: SurveyGraph,
|
|
93
|
+
refined: ReadonlyArray<SurveyEdge>
|
|
94
|
+
): SurveyGraph => {
|
|
95
|
+
const known = new Set(graph.nodes.map((node) => node.name))
|
|
96
|
+
const existing = new Set(graph.edges.map((edge) => `${edge.from}\u0000${edge.to}`))
|
|
97
|
+
const seen = new Set<string>()
|
|
98
|
+
const kept = refined.flatMap((edge) => {
|
|
99
|
+
const key = `${edge.from}\u0000${edge.to}`
|
|
100
|
+
if (
|
|
101
|
+
edge.from === edge.to ||
|
|
102
|
+
!known.has(edge.from) ||
|
|
103
|
+
!known.has(edge.to) ||
|
|
104
|
+
existing.has(key) ||
|
|
105
|
+
seen.has(key)
|
|
106
|
+
) {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
seen.add(key)
|
|
110
|
+
return [
|
|
111
|
+
new SurveyEdge({
|
|
112
|
+
...edge,
|
|
113
|
+
kind: edge.kind.startsWith("llm-") ? edge.kind : `llm-${edge.kind}`
|
|
114
|
+
})
|
|
115
|
+
]
|
|
116
|
+
})
|
|
117
|
+
return new SurveyGraph({
|
|
118
|
+
nodes: graph.nodes,
|
|
119
|
+
edges: [...graph.edges, ...kept]
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const renderSurveyInventory = (graph: SurveyGraph): string => {
|
|
124
|
+
const rows = [...graph.nodes]
|
|
125
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
126
|
+
.map((node) => {
|
|
127
|
+
const incoming = graph.incoming(node.name).length
|
|
128
|
+
const outgoing = graph.outgoing(node.name).length
|
|
129
|
+
const flags = incoming === 0 && outgoing === 0 ? "unreferenced — retire candidate?" : ""
|
|
130
|
+
return `| ${node.name} | ${node.path} | ${node.lines} | ${node.units} | ${incoming} | ${outgoing} | ${flags} |`
|
|
131
|
+
})
|
|
132
|
+
const refined = graph.edges.filter((edge) => edge.kind.startsWith("llm-")).length
|
|
133
|
+
return [
|
|
134
|
+
"# Estate inventory",
|
|
135
|
+
"",
|
|
136
|
+
"| Unit | Path | Lines | Units | In | Out | Flags |",
|
|
137
|
+
"| ---- | ---- | ----- | ----- | -- | --- | ----- |",
|
|
138
|
+
...rows,
|
|
139
|
+
"",
|
|
140
|
+
`${graph.nodes.length} unit(s), ${graph.edges.length} edge(s).`,
|
|
141
|
+
...(refined === 0 ? [] : [`${refined} edge(s) came from the LLM graph-refine step.`]),
|
|
142
|
+
""
|
|
143
|
+
].join("\n")
|
|
144
|
+
}
|