@llm4ts/flow 0.13.5 → 0.14.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/dist/BoardSync.d.ts +81 -0
- package/dist/BoardSync.d.ts.map +1 -0
- package/dist/BoardSync.js +259 -0
- package/dist/BoardSync.js.map +1 -0
- package/dist/EstimatedUsage.d.ts +28 -0
- package/dist/EstimatedUsage.d.ts.map +1 -0
- package/dist/EstimatedUsage.js +91 -0
- package/dist/EstimatedUsage.js.map +1 -0
- package/dist/Pack.d.ts +1 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +7 -0
- package/dist/Pack.js.map +1 -1
- package/dist/PageSpec.d.ts +104 -0
- package/dist/PageSpec.d.ts.map +1 -0
- package/dist/PageSpec.js +272 -0
- package/dist/PageSpec.js.map +1 -0
- package/dist/SpecChecks.d.ts +6 -1
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +11 -4
- package/dist/SpecChecks.js.map +1 -1
- package/dist/Survey.d.ts +38 -1
- package/dist/Survey.d.ts.map +1 -1
- package/dist/Survey.js +101 -5
- package/dist/Survey.js.map +1 -1
- package/dist/Workspace.d.ts +44 -5
- package/dist/Workspace.d.ts.map +1 -1
- package/dist/Workspace.js +84 -14
- package/dist/Workspace.js.map +1 -1
- package/package.json +5 -2
- package/src/BoardSync.ts +358 -0
- package/src/EstimatedUsage.ts +180 -0
- package/src/Pack.ts +11 -0
- package/src/PageSpec.ts +354 -0
- package/src/SpecChecks.ts +12 -6
- package/src/Survey.ts +137 -5
- package/src/Workspace.ts +120 -15
package/src/PageSpec.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { PlanParseError } from "./FlowError.ts"
|
|
4
|
+
|
|
5
|
+
// The Page Spec is the per-page contract of the J2EE→SPA conversion scenario
|
|
6
|
+
// (ADR 0012): extraction embeds it in the spec markdown as a ```json pagespec
|
|
7
|
+
// fenced block, conversion decodes it here and derives the anti-corruption
|
|
8
|
+
// contract from its API section. It rides inside the existing extract
|
|
9
|
+
// artifacts rather than adding a fifth artifact stream, so modernize-extract
|
|
10
|
+
// stays untouched and the judge sees spec and page-spec as one document.
|
|
11
|
+
|
|
12
|
+
export const PageSpecVersion = 1
|
|
13
|
+
|
|
14
|
+
export const ValidationSite = Schema.Literals(["client", "server", "both"])
|
|
15
|
+
export type ValidationSite = typeof ValidationSite.Type
|
|
16
|
+
|
|
17
|
+
const emptyStrings: ReadonlyArray<string> = Object.freeze([])
|
|
18
|
+
|
|
19
|
+
export class PageValidation extends Schema.Class<PageValidation>("PageValidation")({
|
|
20
|
+
rule: Schema.String,
|
|
21
|
+
message: Schema.optionalKey(Schema.String),
|
|
22
|
+
enforcedAt: ValidationSite
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
const emptyValidations: ReadonlyArray<PageValidation> = Object.freeze([])
|
|
26
|
+
|
|
27
|
+
export class PageFormField extends Schema.Class<PageFormField>("PageFormField")({
|
|
28
|
+
name: Schema.String,
|
|
29
|
+
label: Schema.String,
|
|
30
|
+
type: Schema.String,
|
|
31
|
+
required: Schema.Boolean.pipe(
|
|
32
|
+
Schema.withConstructorDefault(Effect.succeed(false)),
|
|
33
|
+
Schema.withDecodingDefaultKey(Effect.succeed(false))
|
|
34
|
+
),
|
|
35
|
+
validations: Schema.Array(PageValidation).pipe(
|
|
36
|
+
Schema.withConstructorDefault(Effect.succeed(emptyValidations)),
|
|
37
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyValidations))
|
|
38
|
+
)
|
|
39
|
+
}) {}
|
|
40
|
+
|
|
41
|
+
export class PageForm extends Schema.Class<PageForm>("PageForm")({
|
|
42
|
+
name: Schema.String,
|
|
43
|
+
action: Schema.String,
|
|
44
|
+
fields: Schema.Array(PageFormField)
|
|
45
|
+
}) {}
|
|
46
|
+
|
|
47
|
+
/** One legacy field renamed into domain language — the anti-corruption table. */
|
|
48
|
+
export class FieldMapping extends Schema.Class<FieldMapping>("FieldMapping")({
|
|
49
|
+
legacyName: Schema.String,
|
|
50
|
+
domainName: Schema.String,
|
|
51
|
+
type: Schema.String
|
|
52
|
+
}) {}
|
|
53
|
+
|
|
54
|
+
const emptyMappings: ReadonlyArray<FieldMapping> = Object.freeze([])
|
|
55
|
+
|
|
56
|
+
export class PageDto extends Schema.Class<PageDto>("PageDto")({
|
|
57
|
+
legacyName: Schema.String,
|
|
58
|
+
domainName: Schema.String,
|
|
59
|
+
fields: Schema.Array(FieldMapping)
|
|
60
|
+
}) {}
|
|
61
|
+
|
|
62
|
+
export class PageApiCall extends Schema.Class<PageApiCall>("PageApiCall")({
|
|
63
|
+
/** Domain operation id, e.g. `listAccounts` — becomes the OpenAPI operationId. */
|
|
64
|
+
operation: Schema.String,
|
|
65
|
+
method: Schema.String,
|
|
66
|
+
path: Schema.String,
|
|
67
|
+
/** The ESB service behind the legacy endpoint, when known. */
|
|
68
|
+
esbService: Schema.optionalKey(Schema.String),
|
|
69
|
+
request: Schema.Array(FieldMapping).pipe(
|
|
70
|
+
Schema.withConstructorDefault(Effect.succeed(emptyMappings)),
|
|
71
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyMappings))
|
|
72
|
+
),
|
|
73
|
+
response: Schema.Array(FieldMapping).pipe(
|
|
74
|
+
Schema.withConstructorDefault(Effect.succeed(emptyMappings)),
|
|
75
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyMappings))
|
|
76
|
+
)
|
|
77
|
+
}) {}
|
|
78
|
+
|
|
79
|
+
export class PageNavigation extends Schema.Class<PageNavigation>("PageNavigation")({
|
|
80
|
+
inbound: Schema.Array(Schema.String).pipe(
|
|
81
|
+
Schema.withConstructorDefault(Effect.succeed(emptyStrings)),
|
|
82
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyStrings))
|
|
83
|
+
),
|
|
84
|
+
outbound: Schema.Array(Schema.String).pipe(
|
|
85
|
+
Schema.withConstructorDefault(Effect.succeed(emptyStrings)),
|
|
86
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyStrings))
|
|
87
|
+
),
|
|
88
|
+
/** Multi-step flows: the page names in order, when this page is one step. */
|
|
89
|
+
steps: Schema.Array(Schema.String).pipe(
|
|
90
|
+
Schema.withConstructorDefault(Effect.succeed(emptyStrings)),
|
|
91
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyStrings))
|
|
92
|
+
)
|
|
93
|
+
}) {}
|
|
94
|
+
|
|
95
|
+
export const PageComplexity = Schema.Literals(["low", "medium", "high"])
|
|
96
|
+
export type PageComplexity = typeof PageComplexity.Type
|
|
97
|
+
|
|
98
|
+
const emptyForms: ReadonlyArray<PageForm> = Object.freeze([])
|
|
99
|
+
const emptyDtos: ReadonlyArray<PageDto> = Object.freeze([])
|
|
100
|
+
const emptyCalls: ReadonlyArray<PageApiCall> = Object.freeze([])
|
|
101
|
+
|
|
102
|
+
export class PageSpec extends Schema.Class<PageSpec>("PageSpec")({
|
|
103
|
+
/** The program name keying every artifact — matches `specs/<page>.md`. */
|
|
104
|
+
page: Schema.String,
|
|
105
|
+
route: Schema.String,
|
|
106
|
+
title: Schema.String,
|
|
107
|
+
complexity: PageComplexity,
|
|
108
|
+
forms: Schema.Array(PageForm).pipe(
|
|
109
|
+
Schema.withConstructorDefault(Effect.succeed(emptyForms)),
|
|
110
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyForms))
|
|
111
|
+
),
|
|
112
|
+
dtos: Schema.Array(PageDto).pipe(
|
|
113
|
+
Schema.withConstructorDefault(Effect.succeed(emptyDtos)),
|
|
114
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyDtos))
|
|
115
|
+
),
|
|
116
|
+
apiCalls: Schema.Array(PageApiCall).pipe(
|
|
117
|
+
Schema.withConstructorDefault(Effect.succeed(emptyCalls)),
|
|
118
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyCalls))
|
|
119
|
+
),
|
|
120
|
+
navigation: PageNavigation.pipe(
|
|
121
|
+
Schema.withConstructorDefault(Effect.sync(() => PageNavigation.make({}))),
|
|
122
|
+
Schema.withDecodingDefaultKey(Effect.sync(() => PageNavigation.make({})))
|
|
123
|
+
),
|
|
124
|
+
sessionState: Schema.Array(Schema.String).pipe(
|
|
125
|
+
Schema.withConstructorDefault(Effect.succeed(emptyStrings)),
|
|
126
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyStrings))
|
|
127
|
+
),
|
|
128
|
+
openQuestions: Schema.Array(Schema.String).pipe(
|
|
129
|
+
Schema.withConstructorDefault(Effect.succeed(emptyStrings)),
|
|
130
|
+
Schema.withDecodingDefaultKey(Effect.succeed(emptyStrings))
|
|
131
|
+
)
|
|
132
|
+
}) {}
|
|
133
|
+
|
|
134
|
+
/** The fence info string marking a page-spec block inside spec markdown. */
|
|
135
|
+
export const pageSpecFenceInfo = "json pagespec"
|
|
136
|
+
|
|
137
|
+
const fencePattern = /```json[ \t]+pagespec[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/
|
|
138
|
+
|
|
139
|
+
/** The raw JSON of the first ```json pagespec fenced block, if any. */
|
|
140
|
+
export const pageSpecBlock = (markdown: string): string | undefined =>
|
|
141
|
+
fencePattern.exec(markdown)?.[1]
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decodes the page spec embedded in a spec markdown document. A missing block
|
|
145
|
+
* and a malformed one are both `PlanParseError`s — the extraction gate treats
|
|
146
|
+
* either as an incomplete extraction, never as "no spec needed".
|
|
147
|
+
*/
|
|
148
|
+
export const parsePageSpec = Effect.fn("@llm4ts/flow/PageSpec.parse")(function* (
|
|
149
|
+
markdown: string
|
|
150
|
+
): Effect.fn.Return<PageSpec, PlanParseError> {
|
|
151
|
+
const block = pageSpecBlock(markdown)
|
|
152
|
+
if (block === undefined) {
|
|
153
|
+
return yield* PlanParseError.make({
|
|
154
|
+
message: "no ```json pagespec fenced block in the spec markdown"
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PageSpec))(block).pipe(
|
|
158
|
+
Effect.mapError((error) =>
|
|
159
|
+
PlanParseError.make({
|
|
160
|
+
message: `invalid page spec block: ${String(error)}`
|
|
161
|
+
})
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/** Renders a spec as a ```json pagespec fenced block — the inverse of `parsePageSpec`. */
|
|
167
|
+
export const renderPageSpecBlock = Effect.fn("@llm4ts/flow/PageSpec.renderBlock")(function* (
|
|
168
|
+
spec: PageSpec
|
|
169
|
+
): Effect.fn.Return<string, PlanParseError> {
|
|
170
|
+
const encoded = yield* Schema.encodeEffect(PageSpec)(spec).pipe(
|
|
171
|
+
Effect.mapError((error) =>
|
|
172
|
+
PlanParseError.make({
|
|
173
|
+
message: `failed to encode page spec: ${String(error)}`
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return `\`\`\`${pageSpecFenceInfo}\n${JSON.stringify(encoded, undefined, 2)}\n\`\`\``
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const openApiType = (type: string): { readonly type: string; readonly format?: string } => {
|
|
181
|
+
const lowered = type.toLowerCase()
|
|
182
|
+
if (/int|number|decimal|amount|count/.test(lowered)) {
|
|
183
|
+
return { type: "number" }
|
|
184
|
+
}
|
|
185
|
+
if (/bool/.test(lowered)) {
|
|
186
|
+
return { type: "boolean" }
|
|
187
|
+
}
|
|
188
|
+
if (/datetime|timestamp/.test(lowered)) {
|
|
189
|
+
return { type: "string", format: "date-time" }
|
|
190
|
+
}
|
|
191
|
+
if (/date/.test(lowered)) {
|
|
192
|
+
return { type: "string", format: "date" }
|
|
193
|
+
}
|
|
194
|
+
return { type: "string" }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const yamlText = (value: string): string => JSON.stringify(value)
|
|
198
|
+
|
|
199
|
+
const schemaName = (operation: string, side: "Request" | "Response"): string =>
|
|
200
|
+
`${operation.charAt(0).toUpperCase()}${operation.slice(1)}${side}`
|
|
201
|
+
|
|
202
|
+
const propertyLines = (
|
|
203
|
+
fields: ReadonlyArray<FieldMapping>,
|
|
204
|
+
indent: string
|
|
205
|
+
): ReadonlyArray<string> =>
|
|
206
|
+
fields.flatMap((field) => {
|
|
207
|
+
const mapped = openApiType(field.type)
|
|
208
|
+
return [
|
|
209
|
+
`${indent}${field.domainName}:`,
|
|
210
|
+
`${indent} type: ${mapped.type}`,
|
|
211
|
+
...(mapped.format === undefined ? [] : [`${indent} format: ${mapped.format}`]),
|
|
212
|
+
`${indent} description: ${yamlText(`legacy: ${field.legacyName}`)}`
|
|
213
|
+
]
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Deterministic OpenAPI 3.0 fragment for the page's API calls, in DOMAIN
|
|
218
|
+
* names — the anti-corruption contract the port, the mock adapter, and a
|
|
219
|
+
* future B4F implement. Emitted by code, not by a model: the contract must
|
|
220
|
+
* be a projection of the reviewed page spec, never an invention.
|
|
221
|
+
*/
|
|
222
|
+
export const openApiFor = (spec: PageSpec): string => {
|
|
223
|
+
const byPath = new Map<string, Array<PageApiCall>>()
|
|
224
|
+
for (const call of spec.apiCalls) {
|
|
225
|
+
const bucket = byPath.get(call.path) ?? []
|
|
226
|
+
bucket.push(call)
|
|
227
|
+
byPath.set(call.path, bucket)
|
|
228
|
+
}
|
|
229
|
+
const paths = [...byPath.entries()].sort(([left], [right]) => left.localeCompare(right))
|
|
230
|
+
const lines: Array<string> = [
|
|
231
|
+
"openapi: 3.0.3",
|
|
232
|
+
"info:",
|
|
233
|
+
` title: ${yamlText(`${spec.title} service contract`)}`,
|
|
234
|
+
` description: ${yamlText(`Anti-corruption contract for page ${spec.page} (${spec.route})`)}`,
|
|
235
|
+
" version: 0.1.0",
|
|
236
|
+
"paths:"
|
|
237
|
+
]
|
|
238
|
+
if (paths.length === 0) {
|
|
239
|
+
lines[lines.length - 1] = "paths: {}"
|
|
240
|
+
}
|
|
241
|
+
for (const [path, calls] of paths) {
|
|
242
|
+
lines.push(` ${path}:`)
|
|
243
|
+
for (const call of [...calls].sort((a, b) => a.method.localeCompare(b.method))) {
|
|
244
|
+
const method = call.method.toLowerCase()
|
|
245
|
+
lines.push(` ${method}:`)
|
|
246
|
+
lines.push(` operationId: ${call.operation}`)
|
|
247
|
+
if (call.esbService !== undefined) {
|
|
248
|
+
lines.push(` description: ${yamlText(`backed by ESB service ${call.esbService}`)}`)
|
|
249
|
+
}
|
|
250
|
+
if (call.request.length > 0 && method === "get") {
|
|
251
|
+
lines.push(" parameters:")
|
|
252
|
+
for (const field of call.request) {
|
|
253
|
+
const mapped = openApiType(field.type)
|
|
254
|
+
lines.push(` - name: ${field.domainName}`)
|
|
255
|
+
lines.push(" in: query")
|
|
256
|
+
lines.push(" schema:")
|
|
257
|
+
lines.push(` type: ${mapped.type}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (call.request.length > 0 && method !== "get") {
|
|
261
|
+
lines.push(" requestBody:")
|
|
262
|
+
lines.push(" required: true")
|
|
263
|
+
lines.push(" content:")
|
|
264
|
+
lines.push(" application/json:")
|
|
265
|
+
lines.push(" schema:")
|
|
266
|
+
lines.push(
|
|
267
|
+
` $ref: "#/components/schemas/${schemaName(call.operation, "Request")}"`
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
lines.push(" responses:")
|
|
271
|
+
lines.push(' "200":')
|
|
272
|
+
lines.push(` description: ${yamlText(`${call.operation} result`)}`)
|
|
273
|
+
lines.push(" content:")
|
|
274
|
+
lines.push(" application/json:")
|
|
275
|
+
lines.push(" schema:")
|
|
276
|
+
lines.push(
|
|
277
|
+
` $ref: "#/components/schemas/${schemaName(call.operation, "Response")}"`
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
lines.push("components:")
|
|
282
|
+
lines.push(" schemas:")
|
|
283
|
+
const schemaCalls = [...spec.apiCalls].sort((a, b) => a.operation.localeCompare(b.operation))
|
|
284
|
+
let wroteSchema = false
|
|
285
|
+
for (const call of schemaCalls) {
|
|
286
|
+
for (const [side, fields] of [
|
|
287
|
+
["Request", call.request],
|
|
288
|
+
["Response", call.response]
|
|
289
|
+
] as const) {
|
|
290
|
+
if (side === "Request" && (fields.length === 0 || call.method.toLowerCase() === "get")) {
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
wroteSchema = true
|
|
294
|
+
lines.push(` ${schemaName(call.operation, side)}:`)
|
|
295
|
+
lines.push(" type: object")
|
|
296
|
+
if (fields.length === 0) {
|
|
297
|
+
lines.push(" properties: {}")
|
|
298
|
+
} else {
|
|
299
|
+
lines.push(" properties:")
|
|
300
|
+
lines.push(...propertyLines(fields, " "))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!wroteSchema) {
|
|
305
|
+
lines[lines.length - 1] = " schemas: {}"
|
|
306
|
+
}
|
|
307
|
+
return lines.join("\n") + "\n"
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Human-readable summary — the review surface next to the JSON contract. */
|
|
311
|
+
export const renderPageSpec = (spec: PageSpec): string => {
|
|
312
|
+
const lines: Array<string> = [
|
|
313
|
+
`# Page: ${spec.page}`,
|
|
314
|
+
"",
|
|
315
|
+
`- Route: ${spec.route}`,
|
|
316
|
+
`- Title: ${spec.title}`,
|
|
317
|
+
`- Complexity: ${spec.complexity}`
|
|
318
|
+
]
|
|
319
|
+
for (const form of spec.forms) {
|
|
320
|
+
lines.push("", `## Form: ${form.name} → ${form.action}`)
|
|
321
|
+
for (const field of form.fields) {
|
|
322
|
+
const rules = field.validations
|
|
323
|
+
.map((validation) => `${validation.rule} (${validation.enforcedAt})`)
|
|
324
|
+
.join(", ")
|
|
325
|
+
lines.push(
|
|
326
|
+
`- ${field.name} (${field.type})${field.required ? " required" : ""}` +
|
|
327
|
+
(rules.length === 0 ? "" : ` — ${rules}`)
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (spec.apiCalls.length > 0) {
|
|
332
|
+
lines.push("", "## API calls")
|
|
333
|
+
for (const call of spec.apiCalls) {
|
|
334
|
+
const esb = call.esbService === undefined ? "" : ` — ESB ${call.esbService}`
|
|
335
|
+
lines.push(`- ${call.operation}: ${call.method} ${call.path}${esb}`)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (spec.dtos.length > 0) {
|
|
339
|
+
lines.push("", "## Anti-corruption renames")
|
|
340
|
+
for (const dto of spec.dtos) {
|
|
341
|
+
lines.push(`- ${dto.legacyName} → ${dto.domainName}`)
|
|
342
|
+
for (const field of dto.fields) {
|
|
343
|
+
lines.push(` - ${field.legacyName} → ${field.domainName} (${field.type})`)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (spec.sessionState.length > 0) {
|
|
348
|
+
lines.push("", "## Session state", ...spec.sessionState.map((item) => `- ${item}`))
|
|
349
|
+
}
|
|
350
|
+
if (spec.openQuestions.length > 0) {
|
|
351
|
+
lines.push("", "## Open questions", ...spec.openQuestions.map((item) => `- ${item}`))
|
|
352
|
+
}
|
|
353
|
+
return lines.join("\n") + "\n"
|
|
354
|
+
}
|
package/src/SpecChecks.ts
CHANGED
|
@@ -21,16 +21,22 @@ const capture = (regex: RegExp, text: string): ReadonlyArray<string> => {
|
|
|
21
21
|
return values
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Repo-relative paths matching `regex` and not matching `exclude`, sorted.
|
|
26
|
+
* The regex is applied INSIDE discovery so the workspace's result cap counts
|
|
27
|
+
* candidate units, not every file that shares the tree with them.
|
|
28
|
+
*/
|
|
24
29
|
export const matchingFiles = (
|
|
25
30
|
workspace: WorkspaceShape,
|
|
26
|
-
regex: string
|
|
31
|
+
regex: string,
|
|
32
|
+
exclude?: string
|
|
27
33
|
): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
|
|
28
|
-
workspace
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
workspace
|
|
35
|
+
.discover("**/*", {
|
|
36
|
+
matching: new RegExp(regex),
|
|
37
|
+
...(exclude === undefined ? {} : { excluding: new RegExp(exclude) })
|
|
32
38
|
})
|
|
33
|
-
|
|
39
|
+
.pipe(Effect.map((paths) => [...paths].sort()))
|
|
34
40
|
|
|
35
41
|
export const coverageUnits = Effect.fn("@llm4ts/flow/SpecChecks.coverageUnits")(function* (
|
|
36
42
|
workspace: WorkspaceShape,
|
package/src/Survey.ts
CHANGED
|
@@ -66,25 +66,59 @@ export const closureFor = (
|
|
|
66
66
|
return walk([program], new Set([program]), [])
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* A source file's unit name: its basename without the extension. The graph
|
|
71
|
+
* keys nodes by it, and `resolveUnit` folds edge targets onto it.
|
|
72
|
+
*/
|
|
73
|
+
export const unitName = (path: string): string => {
|
|
70
74
|
const base = path.split("/").at(-1) ?? path
|
|
71
75
|
const dot = base.lastIndexOf(".")
|
|
72
76
|
return dot < 0 ? base : base.slice(0, dot)
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The unit a captured reference points at. COBOL rules capture the bare unit
|
|
81
|
+
* name (`CALL 'FEECALC'`), but web estates reference units by PATH —
|
|
82
|
+
* `<jsp:include page="header.jsp">`, `page="/WEB-INF/fragments/footer.jsp"`
|
|
83
|
+
* — so a raw capture would never equal a node name, every fragment would show
|
|
84
|
+
* zero incoming edges, and the inventory would flag the most-included files
|
|
85
|
+
* in the estate as retire candidates. Unknown references stay as captured:
|
|
86
|
+
* an edge to a unit the estate does not contain is itself a finding.
|
|
87
|
+
*/
|
|
88
|
+
export const resolveUnit = (reference: string, known: ReadonlySet<string>): string => {
|
|
89
|
+
if (known.has(reference)) {
|
|
90
|
+
return reference
|
|
91
|
+
}
|
|
92
|
+
const folded = unitName(reference)
|
|
93
|
+
return known.has(folded) ? folded : reference
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
const matches = (regex: string, contents: string): ReadonlyArray<string> => {
|
|
76
97
|
const expression = new RegExp(regex, "g")
|
|
77
98
|
return [...contents.matchAll(expression)].map((match) => match[1] ?? match[0])
|
|
78
99
|
}
|
|
79
100
|
|
|
101
|
+
export interface SurveyGraphOptions {
|
|
102
|
+
/** Regex over repo-relative paths to leave out even when `sources` matches. */
|
|
103
|
+
readonly exclude?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
80
106
|
export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
|
|
81
107
|
workspace: WorkspaceShape,
|
|
82
108
|
sources: string,
|
|
83
109
|
units: ReadonlyArray<CoverageRule>,
|
|
84
|
-
edgeRules: ReadonlyArray<CoverageRule
|
|
110
|
+
edgeRules: ReadonlyArray<CoverageRule>,
|
|
111
|
+
options: SurveyGraphOptions = {}
|
|
85
112
|
): Effect.fn.Return<SurveyGraph, WorkspaceError> {
|
|
86
|
-
|
|
87
|
-
|
|
113
|
+
// The source regex narrows discovery itself, so the workspace's result cap
|
|
114
|
+
// counts candidate units rather than every jar, image, and generated file
|
|
115
|
+
// sharing the tree with them.
|
|
116
|
+
const paths = [
|
|
117
|
+
...(yield* workspace.discover("**/*", {
|
|
118
|
+
matching: new RegExp(sources),
|
|
119
|
+
...(options.exclude === undefined ? {} : { excluding: new RegExp(options.exclude) })
|
|
120
|
+
}))
|
|
121
|
+
].sort()
|
|
88
122
|
const nodes: Array<SurveyNode> = []
|
|
89
123
|
const contents = new Map<string, string>()
|
|
90
124
|
for (const path of paths) {
|
|
@@ -101,6 +135,7 @@ export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
|
|
|
101
135
|
})
|
|
102
136
|
)
|
|
103
137
|
}
|
|
138
|
+
const known = new Set(nodes.map((node) => node.name))
|
|
104
139
|
const edges: Array<SurveyEdge> = []
|
|
105
140
|
for (const rule of edgeRules) {
|
|
106
141
|
const filePattern = new RegExp(rule.files)
|
|
@@ -108,7 +143,7 @@ export const surveyGraph = Effect.fn("@llm4ts/flow/Survey.graph")(function* (
|
|
|
108
143
|
for (const target of new Set(matches(rule.unit, contents.get(path) ?? ""))) {
|
|
109
144
|
const edge = SurveyEdge.make({
|
|
110
145
|
from: unitName(path),
|
|
111
|
-
to: target,
|
|
146
|
+
to: resolveUnit(target, known),
|
|
112
147
|
kind: rule.name
|
|
113
148
|
})
|
|
114
149
|
if (
|
|
@@ -179,3 +214,100 @@ export const renderSurveyInventory = (graph: SurveyGraph): string => {
|
|
|
179
214
|
""
|
|
180
215
|
].join("\n")
|
|
181
216
|
}
|
|
217
|
+
|
|
218
|
+
/** The `graph.json` artifact: the graph as it is read back by later phases. */
|
|
219
|
+
export const renderSurveyGraphJson = (graph: SurveyGraph): string =>
|
|
220
|
+
JSON.stringify(graph, undefined, 2)
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What a pack contributes to the survey's two reasoning prompts. The frame
|
|
224
|
+
* around it — the JSON contracts, the evidence rule, the wave discipline — is
|
|
225
|
+
* stack-neutral and lives here; everything that names a technology comes
|
|
226
|
+
* from the pack: the edge rules its graph was built from, and the optional
|
|
227
|
+
* `prompts/survey-refine.md` / `prompts/survey-triage.md` sidecars describing
|
|
228
|
+
* where THAT stack hides the links regexes miss and how to weigh its units.
|
|
229
|
+
*/
|
|
230
|
+
export interface SurveyPromptContext {
|
|
231
|
+
/** The pack's `## Survey:` edge rules — the graph's provenance, by name. */
|
|
232
|
+
readonly rules: ReadonlyArray<CoverageRule>
|
|
233
|
+
/** The pack's stack-specific guidance, or undefined for the neutral default. */
|
|
234
|
+
readonly guidance: string | undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ruleNames = (context: SurveyPromptContext): string =>
|
|
238
|
+
context.rules.length === 0 ? "none" : context.rules.map((rule) => rule.name).join(", ")
|
|
239
|
+
|
|
240
|
+
const defaultRefineGuidance = [
|
|
241
|
+
"Regexes miss links the source establishes indirectly: invocations whose target is held",
|
|
242
|
+
"in a variable or configuration entry, wiring declared in descriptors instead of code,",
|
|
243
|
+
"fragments pulled in by inclusion or templating, and units only a build or scheduler",
|
|
244
|
+
"step names."
|
|
245
|
+
].join("\n")
|
|
246
|
+
|
|
247
|
+
const defaultTriageGuidance = [
|
|
248
|
+
"Weigh each unit by what depends on it and what it depends on: shared units many others",
|
|
249
|
+
"reference are migrated early or wrapped; units nothing references are retire candidates",
|
|
250
|
+
"unless an entry point outside the graph (scheduler, external caller, deployment",
|
|
251
|
+
"descriptor) reaches them."
|
|
252
|
+
].join("\n")
|
|
253
|
+
|
|
254
|
+
export const surveyRefinePrompt = (graph: SurveyGraph, context: SurveyPromptContext): string =>
|
|
255
|
+
[
|
|
256
|
+
"You are refining the dependency graph of a legacy estate. The graph below was built",
|
|
257
|
+
"deterministically — one node per source file (named by its file name without the",
|
|
258
|
+
`extension), one edge per regex match of the pack's survey rules (${ruleNames(context)}).`,
|
|
259
|
+
context.guidance ?? defaultRefineGuidance,
|
|
260
|
+
'You have read-only access to the estate — read the sources (each node\'s "path" names its',
|
|
261
|
+
"file) and find the dependency edges the regexes missed. Prioritise the suspicious shapes:",
|
|
262
|
+
"units with fewer outgoing edges than the source suggests, units nothing references, units",
|
|
263
|
+
"with degree 0.",
|
|
264
|
+
"",
|
|
265
|
+
"Produce:",
|
|
266
|
+
'- "edges": ONLY links the graph does not already have, and ONLY between the units listed',
|
|
267
|
+
' below (use the exact unit names). Each edge: "from" (the referencing unit), "to" (the',
|
|
268
|
+
' referenced unit), "kind" (how the link is made — a short kebab-case label), and',
|
|
269
|
+
' "evidence" (file, line, and the statement that establishes the link — no evidence, no',
|
|
270
|
+
" edge). References to external systems, platform services, or third-party libraries are",
|
|
271
|
+
' NOT edges; put them in "notes".',
|
|
272
|
+
'- "notes": references you could not resolve to a unit — indirect targets whose value you',
|
|
273
|
+
" could not trace, external systems. Empty if none.",
|
|
274
|
+
"",
|
|
275
|
+
`Units: ${graph.nodes
|
|
276
|
+
.map((node) => node.name)
|
|
277
|
+
.sort()
|
|
278
|
+
.join(", ")}`,
|
|
279
|
+
"",
|
|
280
|
+
"Graph (JSON):",
|
|
281
|
+
renderSurveyGraphJson(graph)
|
|
282
|
+
].join("\n")
|
|
283
|
+
|
|
284
|
+
export const surveyTriagePrompt = (
|
|
285
|
+
graph: SurveyGraph,
|
|
286
|
+
inventory: string,
|
|
287
|
+
context: SurveyPromptContext
|
|
288
|
+
): string =>
|
|
289
|
+
[
|
|
290
|
+
"You are triaging a legacy estate for modernization. Below are its inventory and",
|
|
291
|
+
`dependency graph (regex-derived from the source by the pack's survey rules — ${ruleNames(context)} —`,
|
|
292
|
+
"plus `llm-…` edges the graph-refine step grounded in the source with evidence — trust them).",
|
|
293
|
+
context.guidance ?? defaultTriageGuidance,
|
|
294
|
+
"",
|
|
295
|
+
"Produce:",
|
|
296
|
+
'- "triage": for EVERY unit in the inventory, a disposition:',
|
|
297
|
+
' - "rewrite": actively used business logic or user-facing behaviour to modernize;',
|
|
298
|
+
' - "retire": unreferenced/dead — candidate for decommissioning, with the evidence;',
|
|
299
|
+
' - "wrap": keep on the legacy platform and front with an API (shared units other',
|
|
300
|
+
" estates still call, or units out of this modernization's scope).",
|
|
301
|
+
" Rationale in one sentence, grounded in the graph (degrees, callers, size).",
|
|
302
|
+
'- "waves": dependency-coherent migration slices for the REWRITE units: a wave\'s units',
|
|
303
|
+
" should depend only on already-migrated or same-wave units where possible; leaves and",
|
|
304
|
+
" low-fan-in units first; name each wave (wave-1, wave-2, …) and give the ordering rationale.",
|
|
305
|
+
'- "notes": anything the graph could not resolve — indirect references, cycles worth a',
|
|
306
|
+
" human look. Empty if none.",
|
|
307
|
+
"",
|
|
308
|
+
"Inventory:",
|
|
309
|
+
inventory,
|
|
310
|
+
"",
|
|
311
|
+
"Graph (JSON):",
|
|
312
|
+
renderSurveyGraphJson(graph)
|
|
313
|
+
].join("\n")
|