@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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/dist/AzureDevOpsTool.d.ts +60 -0
  4. package/dist/AzureDevOpsTool.d.ts.map +1 -0
  5. package/dist/AzureDevOpsTool.js +163 -0
  6. package/dist/AzureDevOpsTool.js.map +1 -0
  7. package/dist/Bench.d.ts +133 -0
  8. package/dist/Bench.d.ts.map +1 -0
  9. package/dist/Bench.js +272 -0
  10. package/dist/Bench.js.map +1 -0
  11. package/dist/BenchReport.d.ts +9 -0
  12. package/dist/BenchReport.d.ts.map +1 -0
  13. package/dist/BenchReport.js +360 -0
  14. package/dist/BenchReport.js.map +1 -0
  15. package/dist/CapabilityGuard.d.ts +6 -0
  16. package/dist/CapabilityGuard.d.ts.map +1 -0
  17. package/dist/CapabilityGuard.js +25 -0
  18. package/dist/CapabilityGuard.js.map +1 -0
  19. package/dist/Chat.d.ts +15 -0
  20. package/dist/Chat.d.ts.map +1 -0
  21. package/dist/Chat.js +36 -0
  22. package/dist/Chat.js.map +1 -0
  23. package/dist/Classified.d.ts +14 -0
  24. package/dist/Classified.d.ts.map +1 -0
  25. package/dist/Classified.js +48 -0
  26. package/dist/Classified.js.map +1 -0
  27. package/dist/CostLedger.d.ts +55 -0
  28. package/dist/CostLedger.d.ts.map +1 -0
  29. package/dist/CostLedger.js +126 -0
  30. package/dist/CostLedger.js.map +1 -0
  31. package/dist/CostTracker.d.ts +13 -0
  32. package/dist/CostTracker.d.ts.map +1 -0
  33. package/dist/CostTracker.js +116 -0
  34. package/dist/CostTracker.js.map +1 -0
  35. package/dist/Equiv.d.ts +163 -0
  36. package/dist/Equiv.d.ts.map +1 -0
  37. package/dist/Equiv.js +256 -0
  38. package/dist/Equiv.js.map +1 -0
  39. package/dist/EquivReport.d.ts +16 -0
  40. package/dist/EquivReport.d.ts.map +1 -0
  41. package/dist/EquivReport.js +77 -0
  42. package/dist/EquivReport.js.map +1 -0
  43. package/dist/Flow.d.ts +25 -0
  44. package/dist/Flow.d.ts.map +1 -0
  45. package/dist/Flow.js +39 -0
  46. package/dist/Flow.js.map +1 -0
  47. package/dist/FlowContext.d.ts +23 -0
  48. package/dist/FlowContext.d.ts.map +1 -0
  49. package/dist/FlowContext.js +4 -0
  50. package/dist/FlowContext.js.map +1 -0
  51. package/dist/FlowError.d.ts +80 -0
  52. package/dist/FlowError.d.ts.map +1 -0
  53. package/dist/FlowError.js +92 -0
  54. package/dist/FlowError.js.map +1 -0
  55. package/dist/FlowEvents.d.ts +101 -0
  56. package/dist/FlowEvents.d.ts.map +1 -0
  57. package/dist/FlowEvents.js +103 -0
  58. package/dist/FlowEvents.js.map +1 -0
  59. package/dist/FlowRecorder.d.ts +26 -0
  60. package/dist/FlowRecorder.d.ts.map +1 -0
  61. package/dist/FlowRecorder.js +74 -0
  62. package/dist/FlowRecorder.js.map +1 -0
  63. package/dist/GitHubTool.d.ts +53 -0
  64. package/dist/GitHubTool.d.ts.map +1 -0
  65. package/dist/GitHubTool.js +191 -0
  66. package/dist/GitHubTool.js.map +1 -0
  67. package/dist/GitTool.d.ts +46 -0
  68. package/dist/GitTool.d.ts.map +1 -0
  69. package/dist/GitTool.js +97 -0
  70. package/dist/GitTool.js.map +1 -0
  71. package/dist/Interaction.d.ts +21 -0
  72. package/dist/Interaction.d.ts.map +1 -0
  73. package/dist/Interaction.js +9 -0
  74. package/dist/Interaction.js.map +1 -0
  75. package/dist/McpServer.d.ts +18 -0
  76. package/dist/McpServer.d.ts.map +1 -0
  77. package/dist/McpServer.js +122 -0
  78. package/dist/McpServer.js.map +1 -0
  79. package/dist/Mermaid.d.ts +7 -0
  80. package/dist/Mermaid.d.ts.map +1 -0
  81. package/dist/Mermaid.js +98 -0
  82. package/dist/Mermaid.js.map +1 -0
  83. package/dist/Pack.d.ts +40 -0
  84. package/dist/Pack.d.ts.map +1 -0
  85. package/dist/Pack.js +141 -0
  86. package/dist/Pack.js.map +1 -0
  87. package/dist/Package.d.ts +2 -0
  88. package/dist/Package.d.ts.map +1 -0
  89. package/dist/Package.js +2 -0
  90. package/dist/Package.js.map +1 -0
  91. package/dist/Persistence.d.ts +32 -0
  92. package/dist/Persistence.d.ts.map +1 -0
  93. package/dist/Persistence.js +85 -0
  94. package/dist/Persistence.js.map +1 -0
  95. package/dist/Plan.d.ts +26 -0
  96. package/dist/Plan.d.ts.map +1 -0
  97. package/dist/Plan.js +101 -0
  98. package/dist/Plan.js.map +1 -0
  99. package/dist/PlanExecution.d.ts +7 -0
  100. package/dist/PlanExecution.d.ts.map +1 -0
  101. package/dist/PlanExecution.js +19 -0
  102. package/dist/PlanExecution.js.map +1 -0
  103. package/dist/Planner.d.ts +32 -0
  104. package/dist/Planner.d.ts.map +1 -0
  105. package/dist/Planner.js +97 -0
  106. package/dist/Planner.js.map +1 -0
  107. package/dist/PrSummary.d.ts +15 -0
  108. package/dist/PrSummary.d.ts.map +1 -0
  109. package/dist/PrSummary.js +29 -0
  110. package/dist/PrSummary.js.map +1 -0
  111. package/dist/PriceList.d.ts +11 -0
  112. package/dist/PriceList.d.ts.map +1 -0
  113. package/dist/PriceList.js +79 -0
  114. package/dist/PriceList.js.map +1 -0
  115. package/dist/Provenance.d.ts +27 -0
  116. package/dist/Provenance.d.ts.map +1 -0
  117. package/dist/Provenance.js +38 -0
  118. package/dist/Provenance.js.map +1 -0
  119. package/dist/Replay.d.ts +29 -0
  120. package/dist/Replay.d.ts.map +1 -0
  121. package/dist/Replay.js +181 -0
  122. package/dist/Replay.js.map +1 -0
  123. package/dist/Review.d.ts +71 -0
  124. package/dist/Review.d.ts.map +1 -0
  125. package/dist/Review.js +204 -0
  126. package/dist/Review.js.map +1 -0
  127. package/dist/ReviewCache.d.ts +6 -0
  128. package/dist/ReviewCache.d.ts.map +1 -0
  129. package/dist/ReviewCache.js +28 -0
  130. package/dist/ReviewCache.js.map +1 -0
  131. package/dist/Reviewer.d.ts +12 -0
  132. package/dist/Reviewer.d.ts.map +1 -0
  133. package/dist/Reviewer.js +30 -0
  134. package/dist/Reviewer.js.map +1 -0
  135. package/dist/SpecChecks.d.ts +17 -0
  136. package/dist/SpecChecks.d.ts.map +1 -0
  137. package/dist/SpecChecks.js +107 -0
  138. package/dist/SpecChecks.js.map +1 -0
  139. package/dist/Survey.d.ts +32 -0
  140. package/dist/Survey.d.ts.map +1 -0
  141. package/dist/Survey.js +119 -0
  142. package/dist/Survey.js.map +1 -0
  143. package/dist/TransientRetry.d.ts +16 -0
  144. package/dist/TransientRetry.d.ts.map +1 -0
  145. package/dist/TransientRetry.js +102 -0
  146. package/dist/TransientRetry.js.map +1 -0
  147. package/dist/Workspace.d.ts +39 -0
  148. package/dist/Workspace.d.ts.map +1 -0
  149. package/dist/Workspace.js +192 -0
  150. package/dist/Workspace.js.map +1 -0
  151. package/dist/WorkspaceLayout.d.ts +3 -0
  152. package/dist/WorkspaceLayout.d.ts.map +1 -0
  153. package/dist/WorkspaceLayout.js +19 -0
  154. package/dist/WorkspaceLayout.js.map +1 -0
  155. package/dist/WorkspaceTools.d.ts +4 -0
  156. package/dist/WorkspaceTools.d.ts.map +1 -0
  157. package/dist/WorkspaceTools.js +130 -0
  158. package/dist/WorkspaceTools.js.map +1 -0
  159. package/package.json +91 -0
  160. package/src/AzureDevOpsTool.ts +291 -0
  161. package/src/Bench.ts +340 -0
  162. package/src/BenchReport.ts +447 -0
  163. package/src/CapabilityGuard.ts +43 -0
  164. package/src/Chat.ts +60 -0
  165. package/src/Classified.ts +67 -0
  166. package/src/CostLedger.ts +171 -0
  167. package/src/CostTracker.ts +170 -0
  168. package/src/Equiv.ts +378 -0
  169. package/src/EquivReport.ts +92 -0
  170. package/src/Flow.ts +72 -0
  171. package/src/FlowContext.ts +23 -0
  172. package/src/FlowError.ts +112 -0
  173. package/src/FlowEvents.ts +147 -0
  174. package/src/FlowRecorder.ts +116 -0
  175. package/src/GitHubTool.ts +315 -0
  176. package/src/GitTool.ts +214 -0
  177. package/src/Interaction.ts +23 -0
  178. package/src/McpServer.ts +168 -0
  179. package/src/Mermaid.ts +112 -0
  180. package/src/Pack.ts +213 -0
  181. package/src/Package.ts +1 -0
  182. package/src/Persistence.ts +198 -0
  183. package/src/Plan.ts +119 -0
  184. package/src/PlanExecution.ts +46 -0
  185. package/src/Planner.ts +134 -0
  186. package/src/PrSummary.ts +37 -0
  187. package/src/PriceList.ts +91 -0
  188. package/src/Provenance.ts +85 -0
  189. package/src/Replay.ts +265 -0
  190. package/src/Review.ts +337 -0
  191. package/src/ReviewCache.ts +45 -0
  192. package/src/Reviewer.ts +31 -0
  193. package/src/SpecChecks.ts +143 -0
  194. package/src/Survey.ts +144 -0
  195. package/src/TransientRetry.ts +238 -0
  196. package/src/Workspace.ts +255 -0
  197. package/src/WorkspaceLayout.ts +22 -0
  198. 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
+ })
@@ -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
+ }