@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/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
- for (const call of [...calls].sort((a, b) => a.method.localeCompare(b.method))) {
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
- if (call.esbService !== undefined) {
248
- lines.push(` description: ${yamlText(`backed by ESB service ${call.esbService}`)}`)
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
- lines.push(
277
- ` $ref: "#/components/schemas/${schemaName(call.operation, "Response")}"`
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
- lines.push(`- ${call.operation}: ${call.method} ${call.path}${esb}`)
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
- export const coverageUnits = Effect.fn("@llm4ts/flow/SpecChecks.coverageUnits")(function* (
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<Readonly<Record<string, ReadonlyArray<string>>>, WorkspaceError> {
57
+ ): Effect.fn.Return<ReadonlyArray<CapturedUnit>, WorkspaceError> {
45
58
  const paths = yield* workspace.discover()
46
- const result: Record<string, ReadonlyArray<string>> = {}
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
- if (!units.includes(unit)) {
56
- units.push(unit)
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
- const units = yield* coverageUnits(workspace, rules)
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
+ })