@llm4ts/flow 0.17.0 → 0.18.1
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/Approval.d.ts +27 -0
- package/dist/Approval.d.ts.map +1 -0
- package/dist/Approval.js +36 -0
- package/dist/Approval.js.map +1 -0
- package/dist/Artifacts.d.ts +57 -0
- package/dist/Artifacts.d.ts.map +1 -0
- package/dist/Artifacts.js +103 -0
- package/dist/Artifacts.js.map +1 -0
- package/dist/Pack.d.ts +6 -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 +16 -0
- package/dist/PageSpec.d.ts.map +1 -1
- package/dist/PageSpec.js +78 -7
- package/dist/PageSpec.js.map +1 -1
- package/dist/SpecChecks.d.ts +42 -2
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +81 -22
- package/dist/SpecChecks.js.map +1 -1
- package/package.json +4 -2
- package/src/Approval.ts +51 -0
- package/src/Artifacts.ts +161 -0
- package/src/Pack.ts +13 -0
- package/src/PageSpec.ts +92 -8
- package/src/SpecChecks.ts +128 -28
package/src/PageSpec.ts
CHANGED
|
@@ -59,6 +59,9 @@ export class PageDto extends Schema.Class<PageDto>("PageDto")({
|
|
|
59
59
|
fields: Schema.Array(FieldMapping)
|
|
60
60
|
}) {}
|
|
61
61
|
|
|
62
|
+
export const ResponseShape = Schema.Literals(["single", "list"])
|
|
63
|
+
export type ResponseShape = typeof ResponseShape.Type
|
|
64
|
+
|
|
62
65
|
export class PageApiCall extends Schema.Class<PageApiCall>("PageApiCall")({
|
|
63
66
|
/** Domain operation id, e.g. `listAccounts` — becomes the OpenAPI operationId. */
|
|
64
67
|
operation: Schema.String,
|
|
@@ -70,9 +73,20 @@ export class PageApiCall extends Schema.Class<PageApiCall>("PageApiCall")({
|
|
|
70
73
|
Schema.withConstructorDefault(Effect.succeed(emptyMappings)),
|
|
71
74
|
Schema.withDecodingDefaultKey(Effect.succeed(emptyMappings))
|
|
72
75
|
),
|
|
76
|
+
/**
|
|
77
|
+
* The response's fields when it is an ad-hoc object. A response that is
|
|
78
|
+
* one of the page's DTOs names it with `responseDto` instead — the
|
|
79
|
+
* `domainName` of an entry in `dtos` — and `responseShape` says whether
|
|
80
|
+
* the endpoint returns one of them or a list; a table screen is a list.
|
|
81
|
+
*/
|
|
73
82
|
response: Schema.Array(FieldMapping).pipe(
|
|
74
83
|
Schema.withConstructorDefault(Effect.succeed(emptyMappings)),
|
|
75
84
|
Schema.withDecodingDefaultKey(Effect.succeed(emptyMappings))
|
|
85
|
+
),
|
|
86
|
+
responseDto: Schema.optionalKey(Schema.String),
|
|
87
|
+
responseShape: ResponseShape.pipe(
|
|
88
|
+
Schema.withConstructorDefault(Effect.succeed("single" as const)),
|
|
89
|
+
Schema.withDecodingDefaultKey(Effect.succeed("single" as const))
|
|
76
90
|
)
|
|
77
91
|
}) {}
|
|
78
92
|
|
|
@@ -131,6 +145,20 @@ export class PageSpec extends Schema.Class<PageSpec>("PageSpec")({
|
|
|
131
145
|
)
|
|
132
146
|
}) {}
|
|
133
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The block's shape in one paragraph, for the finding an undecodable block
|
|
150
|
+
* raises: the analyst that wrote its own richer shape is told exactly which
|
|
151
|
+
* keys the decoder accepts instead of only where decoding stopped.
|
|
152
|
+
*/
|
|
153
|
+
export const pageSpecShapeHint =
|
|
154
|
+
"The block must be exactly: { page, route, title, complexity: low|medium|high, " +
|
|
155
|
+
"forms: [{ name, action, fields: [{ name, label, type, required?, validations: [{ rule, message?, enforcedAt: client|server|both }] }] }], " +
|
|
156
|
+
"dtos: [{ legacyName, domainName, fields: [{ legacyName, domainName, type }] }], " +
|
|
157
|
+
"apiCalls: [{ operation, method, path, esbService (the ESB service the legacy call goes through — omit only when there is none), request: [{ legacyName, domainName, type }], response: [{ legacyName, domainName, type }] for an ad-hoc object, or responseDto: <domainName of one of the dtos> with responseShape: single|list when the endpoint returns that DTO or a list of it (a table screen is a list) }], " +
|
|
158
|
+
"navigation: { inbound: [string], outbound: [string], steps: [string] }, sessionState: [string], openQuestions: [string] }. " +
|
|
159
|
+
"No other keys (no id, url, queryParams, esbCall, trigger, serverController); every apiCalls entry is an object with operation/method/path; " +
|
|
160
|
+
"put anything that does not fit into the prose sections or openQuestions."
|
|
161
|
+
|
|
134
162
|
/** The fence info string marking a page-spec block inside spec markdown. */
|
|
135
163
|
export const pageSpecFenceInfo = "json pagespec"
|
|
136
164
|
|
|
@@ -199,6 +227,9 @@ const yamlText = (value: string): string => JSON.stringify(value)
|
|
|
199
227
|
const schemaName = (operation: string, side: "Request" | "Response"): string =>
|
|
200
228
|
`${operation.charAt(0).toUpperCase()}${operation.slice(1)}${side}`
|
|
201
229
|
|
|
230
|
+
const dtoSchemaName = (domainName: string): string =>
|
|
231
|
+
domainName.replace(/[^A-Za-z0-9]+/g, "") || "Dto"
|
|
232
|
+
|
|
202
233
|
const propertyLines = (
|
|
203
234
|
fields: ReadonlyArray<FieldMapping>,
|
|
204
235
|
indent: string
|
|
@@ -239,13 +270,34 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
239
270
|
lines[lines.length - 1] = "paths: {}"
|
|
240
271
|
}
|
|
241
272
|
for (const [path, calls] of paths) {
|
|
242
|
-
lines.push(` ${path}:`)
|
|
243
|
-
|
|
273
|
+
lines.push(` ${path.startsWith("/") ? path : `/${path}`}:`)
|
|
274
|
+
// One operation per method: an OpenAPI path item cannot repeat a method,
|
|
275
|
+
// so calls that share one (a page load and its JSON refresh on the same
|
|
276
|
+
// GET) collapse into the first, which names the variants it stands for.
|
|
277
|
+
const byMethod = new Map<string, Array<PageApiCall>>()
|
|
278
|
+
for (const call of calls) {
|
|
244
279
|
const method = call.method.toLowerCase()
|
|
280
|
+
byMethod.set(method, [...(byMethod.get(method) ?? []), call])
|
|
281
|
+
}
|
|
282
|
+
for (const [method, variants] of [...byMethod.entries()].sort(([a], [b]) =>
|
|
283
|
+
a.localeCompare(b)
|
|
284
|
+
)) {
|
|
285
|
+
const call = variants[0]!
|
|
245
286
|
lines.push(` ${method}:`)
|
|
246
287
|
lines.push(` operationId: ${call.operation}`)
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
const notes = [
|
|
289
|
+
...(call.esbService === undefined ? [] : [`backed by ESB service ${call.esbService}`]),
|
|
290
|
+
...(variants.length > 1
|
|
291
|
+
? [
|
|
292
|
+
`also serves: ${variants
|
|
293
|
+
.slice(1)
|
|
294
|
+
.map((variant) => variant.operation)
|
|
295
|
+
.join(", ")}`
|
|
296
|
+
]
|
|
297
|
+
: [])
|
|
298
|
+
]
|
|
299
|
+
if (notes.length > 0) {
|
|
300
|
+
lines.push(` description: ${yamlText(notes.join("; "))}`)
|
|
249
301
|
}
|
|
250
302
|
if (call.request.length > 0 && method === "get") {
|
|
251
303
|
lines.push(" parameters:")
|
|
@@ -273,15 +325,40 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
273
325
|
lines.push(" content:")
|
|
274
326
|
lines.push(" application/json:")
|
|
275
327
|
lines.push(" schema:")
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
328
|
+
const responseRef = `"#/components/schemas/${
|
|
329
|
+
call.responseDto === undefined
|
|
330
|
+
? schemaName(call.operation, "Response")
|
|
331
|
+
: dtoSchemaName(call.responseDto)
|
|
332
|
+
}"`
|
|
333
|
+
if (call.responseShape === "list") {
|
|
334
|
+
lines.push(" type: array")
|
|
335
|
+
lines.push(" items:")
|
|
336
|
+
lines.push(` $ref: ${responseRef}`)
|
|
337
|
+
} else {
|
|
338
|
+
lines.push(` $ref: ${responseRef}`)
|
|
339
|
+
}
|
|
279
340
|
}
|
|
280
341
|
}
|
|
281
342
|
lines.push("components:")
|
|
282
343
|
lines.push(" schemas:")
|
|
283
344
|
const schemaCalls = [...spec.apiCalls].sort((a, b) => a.operation.localeCompare(b.operation))
|
|
284
345
|
let wroteSchema = false
|
|
346
|
+
// Every DTO a response names becomes a component the calls reference —
|
|
347
|
+
// one schema per domain entity, shared by every endpoint returning it.
|
|
348
|
+
const referencedDtos = new Set(
|
|
349
|
+
spec.apiCalls.flatMap((call) => (call.responseDto === undefined ? [] : [call.responseDto]))
|
|
350
|
+
)
|
|
351
|
+
for (const dto of [...spec.dtos].sort((a, b) => a.domainName.localeCompare(b.domainName))) {
|
|
352
|
+
if (!referencedDtos.has(dto.domainName)) {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
wroteSchema = true
|
|
356
|
+
lines.push(` ${dtoSchemaName(dto.domainName)}:`)
|
|
357
|
+
lines.push(" type: object")
|
|
358
|
+
lines.push(` description: ${yamlText(`legacy: ${dto.legacyName}`)}`)
|
|
359
|
+
lines.push(" properties:")
|
|
360
|
+
lines.push(...propertyLines(dto.fields, " "))
|
|
361
|
+
}
|
|
285
362
|
for (const call of schemaCalls) {
|
|
286
363
|
for (const [side, fields] of [
|
|
287
364
|
["Request", call.request],
|
|
@@ -290,6 +367,9 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
290
367
|
if (side === "Request" && (fields.length === 0 || call.method.toLowerCase() === "get")) {
|
|
291
368
|
continue
|
|
292
369
|
}
|
|
370
|
+
if (side === "Response" && call.responseDto !== undefined) {
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
293
373
|
wroteSchema = true
|
|
294
374
|
lines.push(` ${schemaName(call.operation, side)}:`)
|
|
295
375
|
lines.push(" type: object")
|
|
@@ -332,7 +412,11 @@ export const renderPageSpec = (spec: PageSpec): string => {
|
|
|
332
412
|
lines.push("", "## API calls")
|
|
333
413
|
for (const call of spec.apiCalls) {
|
|
334
414
|
const esb = call.esbService === undefined ? "" : ` — ESB ${call.esbService}`
|
|
335
|
-
|
|
415
|
+
const returns =
|
|
416
|
+
call.responseDto === undefined
|
|
417
|
+
? ""
|
|
418
|
+
: ` → ${call.responseShape === "list" ? `list of ${call.responseDto}` : call.responseDto}`
|
|
419
|
+
lines.push(`- ${call.operation}: ${call.method} ${call.path}${esb}${returns}`)
|
|
336
420
|
}
|
|
337
421
|
}
|
|
338
422
|
if (spec.dtos.length > 0) {
|
package/src/SpecChecks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
2
|
import * as Schema from "effect/Schema"
|
|
3
|
+
import { pageSpecShapeHint, parsePageSpec } from "./PageSpec.ts"
|
|
3
4
|
import { ReviewIssue, ReviewResult } from "./Review.ts"
|
|
4
5
|
import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
|
|
5
6
|
|
|
@@ -38,56 +39,119 @@ export const matchingFiles = (
|
|
|
38
39
|
})
|
|
39
40
|
.pipe(Effect.map((paths) => [...paths].sort()))
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
/** A unit a coverage rule captured, with every file it was captured from. */
|
|
43
|
+
export interface CapturedUnit {
|
|
44
|
+
readonly rule: string
|
|
45
|
+
readonly unit: string
|
|
46
|
+
readonly paths: ReadonlyArray<string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every unit each rule captures across the workspace, in first-seen order
|
|
51
|
+
* per rule, each with the files it was found in — the provenance a
|
|
52
|
+
* wave-scoped gate needs to tell a unit of this wave from one of another.
|
|
53
|
+
*/
|
|
54
|
+
export const capturedUnits = Effect.fn("@llm4ts/flow/SpecChecks.capturedUnits")(function* (
|
|
42
55
|
workspace: WorkspaceShape,
|
|
43
56
|
rules: ReadonlyArray<CoverageRule>
|
|
44
|
-
): Effect.fn.Return<
|
|
57
|
+
): Effect.fn.Return<ReadonlyArray<CapturedUnit>, WorkspaceError> {
|
|
45
58
|
const paths = yield* workspace.discover()
|
|
46
|
-
const result:
|
|
59
|
+
const result: Array<{ rule: string; unit: string; paths: Array<string> }> = []
|
|
47
60
|
for (const rule of rules) {
|
|
48
61
|
const filePattern = new RegExp(rule.files)
|
|
49
62
|
const unitPattern = new RegExp(rule.unit)
|
|
50
|
-
const units: Array<string> = []
|
|
51
63
|
for (const path of paths.filter((path) => filePattern.test(path))) {
|
|
52
64
|
const contents = yield* workspace.read(path)
|
|
53
65
|
for (const line of contents.split(/\r?\n/)) {
|
|
54
66
|
for (const unit of capture(unitPattern, line)) {
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
const existing = result.find((entry) => entry.rule === rule.name && entry.unit === unit)
|
|
68
|
+
if (existing === undefined) {
|
|
69
|
+
result.push({ rule: rule.name, unit, paths: [path] })
|
|
70
|
+
} else if (!existing.paths.includes(path)) {
|
|
71
|
+
existing.paths.push(path)
|
|
57
72
|
}
|
|
58
73
|
}
|
|
59
74
|
}
|
|
60
75
|
}
|
|
61
|
-
result[rule.name] = units
|
|
62
76
|
}
|
|
63
77
|
return result
|
|
64
78
|
})
|
|
65
79
|
|
|
80
|
+
export const coverageUnits = Effect.fn("@llm4ts/flow/SpecChecks.coverageUnits")(function* (
|
|
81
|
+
workspace: WorkspaceShape,
|
|
82
|
+
rules: ReadonlyArray<CoverageRule>
|
|
83
|
+
): Effect.fn.Return<Readonly<Record<string, ReadonlyArray<string>>>, WorkspaceError> {
|
|
84
|
+
const captured = yield* capturedUnits(workspace, rules)
|
|
85
|
+
const result: Record<string, ReadonlyArray<string>> = {}
|
|
86
|
+
for (const rule of rules) {
|
|
87
|
+
result[rule.name] = captured
|
|
88
|
+
.filter((entry) => entry.rule === rule.name)
|
|
89
|
+
.map((entry) => entry.unit)
|
|
90
|
+
}
|
|
91
|
+
return result
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export interface CoverageOptions {
|
|
95
|
+
/**
|
|
96
|
+
* Restricts the gate to units captured from at least one file this
|
|
97
|
+
* predicate accepts. A wave-scoped extraction passes the wave's program
|
|
98
|
+
* files here: a unit that lives only in another wave's sources (or in an
|
|
99
|
+
* estate-wide descriptor such as web.xml) is reported in `outOfScope`
|
|
100
|
+
* rather than failing this wave's gate. Absent: every unit gates.
|
|
101
|
+
*/
|
|
102
|
+
readonly inScope?: (path: string) => boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface CoverageReport {
|
|
106
|
+
readonly result: ReviewResult
|
|
107
|
+
/** Uncovered units the scope excluded from the gate, as `rule: unit`. */
|
|
108
|
+
readonly outOfScope: ReadonlyArray<string>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const uncoveredIssue = (rule: string, unit: string): ReviewIssue =>
|
|
112
|
+
ReviewIssue.make({
|
|
113
|
+
severity: "Critical",
|
|
114
|
+
title: `uncovered ${rule}: ${unit}`,
|
|
115
|
+
description:
|
|
116
|
+
`'${unit}' exists in the legacy source but does not ` + "appear in the traceability matrix."
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/** Coverage with the scope split: what gates, and what was left to a later wave. */
|
|
120
|
+
export const coverageReport = Effect.fn("@llm4ts/flow/SpecChecks.coverageReport")(function* (
|
|
121
|
+
workspace: WorkspaceShape,
|
|
122
|
+
rules: ReadonlyArray<CoverageRule>,
|
|
123
|
+
traceability: string,
|
|
124
|
+
options: CoverageOptions = {}
|
|
125
|
+
): Effect.fn.Return<CoverageReport, WorkspaceError> {
|
|
126
|
+
const captured = yield* capturedUnits(workspace, rules)
|
|
127
|
+
const issues: Array<ReviewIssue> = []
|
|
128
|
+
const outOfScope: Array<string> = []
|
|
129
|
+
for (const entry of captured) {
|
|
130
|
+
if (traceability.includes(entry.unit)) {
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (options.inScope === undefined || entry.paths.some(options.inScope)) {
|
|
134
|
+
issues.push(uncoveredIssue(entry.rule, entry.unit))
|
|
135
|
+
} else {
|
|
136
|
+
outOfScope.push(`${entry.rule}: ${entry.unit}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
result: ReviewResult.make({
|
|
141
|
+
issues,
|
|
142
|
+
summary: issues.length === 0 ? "coverage complete" : `${issues.length} unit(s) uncovered`
|
|
143
|
+
}),
|
|
144
|
+
outOfScope
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
66
148
|
export const coverage = Effect.fn("@llm4ts/flow/SpecChecks.coverage")(function* (
|
|
67
149
|
workspace: WorkspaceShape,
|
|
68
150
|
rules: ReadonlyArray<CoverageRule>,
|
|
69
|
-
traceability: string
|
|
151
|
+
traceability: string,
|
|
152
|
+
options: CoverageOptions = {}
|
|
70
153
|
): Effect.fn.Return<ReviewResult, WorkspaceError> {
|
|
71
|
-
|
|
72
|
-
const issues = rules.flatMap((rule) =>
|
|
73
|
-
(units[rule.name] ?? []).flatMap((unit) =>
|
|
74
|
-
traceability.includes(unit)
|
|
75
|
-
? []
|
|
76
|
-
: [
|
|
77
|
-
ReviewIssue.make({
|
|
78
|
-
severity: "Critical",
|
|
79
|
-
title: `uncovered ${rule.name}: ${unit}`,
|
|
80
|
-
description:
|
|
81
|
-
`'${unit}' exists in the legacy source but does not ` +
|
|
82
|
-
"appear in the traceability matrix."
|
|
83
|
-
})
|
|
84
|
-
]
|
|
85
|
-
)
|
|
86
|
-
)
|
|
87
|
-
return ReviewResult.make({
|
|
88
|
-
issues,
|
|
89
|
-
summary: issues.length === 0 ? "coverage complete" : `${issues.length} unit(s) uncovered`
|
|
90
|
-
})
|
|
154
|
+
return (yield* coverageReport(workspace, rules, traceability, options)).result
|
|
91
155
|
})
|
|
92
156
|
|
|
93
157
|
const featureIssue = (path: string, contents: string): ReviewIssue | undefined => {
|
|
@@ -147,3 +211,39 @@ export const features = Effect.fn("@llm4ts/flow/SpecChecks.features")(function*
|
|
|
147
211
|
summary: issues.length === 0 ? "features well-formed" : `${issues.length} malformed`
|
|
148
212
|
})
|
|
149
213
|
})
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Deterministic schema check of the specs a pack declares `spec-schema:`
|
|
217
|
+
* for: each program's spec must embed a block the schema decodes. A failure
|
|
218
|
+
* is a per-program Critical finding (titled like a judge finding, so the
|
|
219
|
+
* extraction gate's fix turn repairs that program), never a guess in a
|
|
220
|
+
* later phase — convert-page would otherwise be the first to notice.
|
|
221
|
+
*/
|
|
222
|
+
export const specSchemaIssues = Effect.fn("@llm4ts/flow/SpecChecks.specSchemaIssues")(function* (
|
|
223
|
+
schema: string | undefined,
|
|
224
|
+
specs: ReadonlyArray<{ readonly name: string; readonly markdown: string | undefined }>
|
|
225
|
+
): Effect.fn.Return<ReadonlyArray<ReviewIssue>, never> {
|
|
226
|
+
if (schema !== "pagespec") {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
const issues: Array<ReviewIssue> = []
|
|
230
|
+
for (const spec of specs) {
|
|
231
|
+
const problem =
|
|
232
|
+
spec.markdown === undefined
|
|
233
|
+
? "spec file is missing"
|
|
234
|
+
: yield* parsePageSpec(spec.markdown).pipe(
|
|
235
|
+
Effect.map(() => undefined),
|
|
236
|
+
Effect.catch((error) => Effect.succeed(error.message))
|
|
237
|
+
)
|
|
238
|
+
if (problem !== undefined) {
|
|
239
|
+
issues.push(
|
|
240
|
+
ReviewIssue.make({
|
|
241
|
+
severity: "Critical",
|
|
242
|
+
title: `judge[${spec.name}]: invalid pagespec block`,
|
|
243
|
+
description: `${problem}. ${pageSpecShapeHint}`
|
|
244
|
+
})
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return issues
|
|
249
|
+
})
|