@llm4ts/flow 2.1.0 → 2.2.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.
Files changed (57) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/Decisions.d.ts +187 -0
  10. package/dist/Decisions.d.ts.map +1 -0
  11. package/dist/Decisions.js +677 -0
  12. package/dist/Decisions.js.map +1 -0
  13. package/dist/Domains.d.ts +125 -0
  14. package/dist/Domains.d.ts.map +1 -0
  15. package/dist/Domains.js +528 -0
  16. package/dist/Domains.js.map +1 -0
  17. package/dist/Equiv.d.ts +3 -3
  18. package/dist/Equiv.d.ts.map +1 -1
  19. package/dist/Flow.d.ts +1 -1
  20. package/dist/Flow.d.ts.map +1 -1
  21. package/dist/FlowError.d.ts +25 -1
  22. package/dist/FlowError.d.ts.map +1 -1
  23. package/dist/FlowError.js +33 -1
  24. package/dist/FlowError.js.map +1 -1
  25. package/dist/Pack.d.ts +16 -0
  26. package/dist/Pack.d.ts.map +1 -1
  27. package/dist/Pack.js +53 -1
  28. package/dist/Pack.js.map +1 -1
  29. package/dist/PageSpec.d.ts +21 -7
  30. package/dist/PageSpec.d.ts.map +1 -1
  31. package/dist/PageSpec.js +83 -14
  32. package/dist/PageSpec.js.map +1 -1
  33. package/dist/Persistence.d.ts +2 -2
  34. package/dist/Persistence.d.ts.map +1 -1
  35. package/dist/PlanExecution.d.ts +1 -1
  36. package/dist/PlanExecution.d.ts.map +1 -1
  37. package/dist/ProgramJudge.d.ts +1 -1
  38. package/dist/ProgramJudge.d.ts.map +1 -1
  39. package/dist/Replay.d.ts +2 -2
  40. package/dist/Replay.d.ts.map +1 -1
  41. package/dist/Review.d.ts +2 -2
  42. package/dist/Review.d.ts.map +1 -1
  43. package/dist/ReviewCache.d.ts +1 -1
  44. package/dist/ReviewCache.d.ts.map +1 -1
  45. package/dist/SpecChecks.d.ts +8 -0
  46. package/dist/SpecChecks.d.ts.map +1 -1
  47. package/dist/SpecChecks.js +7 -1
  48. package/dist/SpecChecks.js.map +1 -1
  49. package/dist/Stories.d.ts +1 -1
  50. package/dist/Stories.d.ts.map +1 -1
  51. package/package.json +4 -2
  52. package/src/Decisions.ts +844 -0
  53. package/src/Domains.ts +650 -0
  54. package/src/FlowError.ts +41 -1
  55. package/src/Pack.ts +79 -1
  56. package/src/PageSpec.ts +132 -8
  57. package/src/SpecChecks.ts +15 -1
package/src/Pack.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import * as Schema from "effect/Schema"
3
3
  import { Dimension } from "@llm4ts/core/eval/Eval"
4
+ import type { ConsolidateRules } from "./Domains.ts"
4
5
  import { PlanParseError } from "./FlowError.ts"
5
6
  import { parseReviewer, type Reviewer } from "./Reviewer.ts"
6
7
  import { CoverageRule } from "./SpecChecks.ts"
@@ -41,12 +42,25 @@ export interface Pack {
41
42
  // paths), `<NAME>` substituted with the program name. The seam that makes
42
43
  // per-program judging possible.
43
44
  readonly programFiles: string | undefined
45
+ /**
46
+ * Regex template locating a DOMAIN FEATURE's target files (ADR 0012
47
+ * addendum): `<NAME>` is the feature id, `<PAGES>` an alternation of its
48
+ * page names. A feature's scope is this template plus every page's own
49
+ * `program-files` scope.
50
+ */
51
+ readonly featureFiles: string | undefined
44
52
  /**
45
53
  * The schema every program spec must embed, validated deterministically
46
54
  * by the extraction gate: `pagespec` (a ```json pagespec block decodable
47
55
  * as `PageSpec`) is the only one today; absent means prose-only specs.
48
56
  */
49
57
  readonly specSchema: string | undefined
58
+ /**
59
+ * The `## Consolidate` section (ADR 0015): which survey edge kinds put two
60
+ * units in one domain feature (`cluster:`) and which only attach a shared
61
+ * fragment as context (`context:`). Absent: every program is its own feature.
62
+ */
63
+ readonly consolidate: ConsolidateRules | undefined
50
64
  readonly dir: string
51
65
  readonly gate: (name: string) => ReadonlyArray<string> | undefined
52
66
  readonly prompt: (name: string) => string | undefined
@@ -54,6 +68,8 @@ export interface Pack {
54
68
  // template with `<NAME>` substituted, or a case-insensitive "path contains
55
69
  // the program name" fallback.
56
70
  readonly filesFor: (program: string) => RegExp
71
+ /** The regex for a feature's files: its template (when set) or any of its pages' scopes. */
72
+ readonly filesForFeature: (feature: string, pages: ReadonlyArray<string>) => RegExp
57
73
  }
58
74
 
59
75
  interface ParsedManifest {
@@ -152,6 +168,12 @@ const dimensions = (body: string | undefined): ReadonlyArray<Dimension> =>
152
168
  : [Dimension.make({ name, rubric, maxScore })]
153
169
  })
154
170
 
171
+ const commaList = (value: string | undefined): ReadonlyArray<string> =>
172
+ (value ?? "")
173
+ .split(",")
174
+ .map((item) => item.trim())
175
+ .filter((item) => item.length > 0)
176
+
155
177
  const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
156
178
 
157
179
  const isValidRegExp = (source: string): boolean => {
@@ -219,12 +241,48 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
219
241
  message: `pack manifest 'programFiles:' is not a valid regex template: ${programFiles}`
220
242
  })
221
243
  }
244
+ const featureFiles = fields["feature-files"]
245
+ if (
246
+ featureFiles !== undefined &&
247
+ !isValidRegExp(featureFiles.replaceAll("<NAME>", "PROBE").replaceAll("<PAGES>", "PROBE"))
248
+ ) {
249
+ return yield* PlanParseError.make({
250
+ message: `pack manifest 'feature-files:' is not a valid regex template: ${featureFiles}`
251
+ })
252
+ }
222
253
  const specSchema = fields["spec-schema"]
223
254
  if (specSchema !== undefined && specSchema !== "pagespec") {
224
255
  return yield* PlanParseError.make({
225
256
  message: `pack manifest 'spec-schema:' must be 'pagespec' when set, got: ${specSchema}`
226
257
  })
227
258
  }
259
+ const consolidateValues = namedItems(section(manifest.sections, "Consolidate"))
260
+ const consolidate: ConsolidateRules | undefined =
261
+ section(manifest.sections, "Consolidate") === undefined
262
+ ? undefined
263
+ : {
264
+ cluster: commaList(consolidateValues.cluster),
265
+ context: commaList(consolidateValues.context)
266
+ }
267
+ if (consolidate !== undefined) {
268
+ const surveyNames = new Set(rules(manifest.sections, "## Survey: ").map((rule) => rule.name))
269
+ const unknown = [...consolidate.cluster, ...consolidate.context].filter(
270
+ (kind) => !surveyNames.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
271
+ )
272
+ if (unknown.length > 0) {
273
+ return yield* PlanParseError.make({
274
+ message:
275
+ `pack manifest '## Consolidate' names edge kinds no '## Survey:' rule produces: ${unknown.join(", ")} ` +
276
+ `(known: ${[...surveyNames].join(", ") || "none"}; 'llm-*' matches refined edges)`
277
+ })
278
+ }
279
+ const both = consolidate.cluster.filter((kind) => consolidate.context.includes(kind))
280
+ if (both.length > 0) {
281
+ return yield* PlanParseError.make({
282
+ message: `pack manifest '## Consolidate' lists ${both.join(", ")} as both cluster and context`
283
+ })
284
+ }
285
+ }
228
286
  const exclude = fields.exclude
229
287
  if (exclude !== undefined && !isValidRegExp(exclude)) {
230
288
  return yield* PlanParseError.make({
@@ -258,7 +316,9 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
258
316
  lenses,
259
317
  lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,
260
318
  programFiles,
319
+ featureFiles,
261
320
  specSchema,
321
+ consolidate,
262
322
  dir: directory,
263
323
  gate: (name) => gates[name],
264
324
  prompt: (name) => prompts[name],
@@ -267,7 +327,25 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
267
327
  filesFor: (program) =>
268
328
  programFiles === undefined
269
329
  ? new RegExp(escapeRegExp(program), "i")
270
- : new RegExp(`^(?:${programFiles.replaceAll("<NAME>", program)})$`)
330
+ : new RegExp(`^(?:${programFiles.replaceAll("<NAME>", program)})$`),
331
+ filesForFeature: (feature, pages) => {
332
+ const pageScopes =
333
+ programFiles === undefined
334
+ ? pages.map((page) => escapeRegExp(page))
335
+ : pages.map((page) => programFiles.replaceAll("<NAME>", page))
336
+ const own =
337
+ featureFiles === undefined
338
+ ? []
339
+ : [
340
+ featureFiles
341
+ .replaceAll("<NAME>", feature)
342
+ .replaceAll("<PAGES>", pages.map(escapeRegExp).join("|") || "PROBE")
343
+ ]
344
+ const alternatives = [...own, ...pageScopes]
345
+ return programFiles === undefined && featureFiles === undefined
346
+ ? new RegExp(alternatives.join("|"), "i")
347
+ : new RegExp(`^(?:${alternatives.join("|")})$`)
348
+ }
271
349
  }
272
350
  return pack
273
351
  })
package/src/PageSpec.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import * as Schema from "effect/Schema"
3
- import { PlanParseError } from "./FlowError.ts"
3
+ import { ContractConflict, PlanParseError } from "./FlowError.ts"
4
+
5
+ export { ContractConflict } from "./FlowError.ts"
4
6
 
5
7
  // The Page Spec is the per-page contract of the J2EE→SPA conversion scenario
6
8
  // (ADR 0012): extraction embeds it in the spec markdown as a ```json pagespec
@@ -251,9 +253,20 @@ const propertyLines = (
251
253
  * future B4F implement. Emitted by code, not by a model: the contract must
252
254
  * be a projection of the reviewed page spec, never an invention.
253
255
  */
254
- export const openApiFor = (spec: PageSpec): string => {
256
+ interface ContractInfo {
257
+ readonly title: string
258
+ readonly description: string
259
+ }
260
+
261
+ /** The YAML writer shared by the per-page and the per-feature contracts. */
262
+ const renderOpenApi = (
263
+ info: ContractInfo,
264
+ apiCalls: ReadonlyArray<PageApiCall>,
265
+ dtos: ReadonlyArray<PageDto>,
266
+ origins?: ReadonlyMap<string, ReadonlyArray<string>>
267
+ ): string => {
255
268
  const byPath = new Map<string, Array<PageApiCall>>()
256
- for (const call of spec.apiCalls) {
269
+ for (const call of apiCalls) {
257
270
  const bucket = byPath.get(call.path) ?? []
258
271
  bucket.push(call)
259
272
  byPath.set(call.path, bucket)
@@ -262,8 +275,8 @@ export const openApiFor = (spec: PageSpec): string => {
262
275
  const lines: Array<string> = [
263
276
  "openapi: 3.0.3",
264
277
  "info:",
265
- ` title: ${yamlText(`${spec.title} service contract`)}`,
266
- ` description: ${yamlText(`Anti-corruption contract for page ${spec.page} (${spec.route})`)}`,
278
+ ` title: ${yamlText(info.title)}`,
279
+ ` description: ${yamlText(info.description)}`,
267
280
  " version: 0.1.0",
268
281
  "paths:"
269
282
  ]
@@ -286,8 +299,12 @@ export const openApiFor = (spec: PageSpec): string => {
286
299
  const call = variants[0]!
287
300
  lines.push(` ${method}:`)
288
301
  lines.push(` operationId: ${call.operation}`)
302
+ const origin = origins?.get(`${call.method.toUpperCase()} ${call.path}`)
289
303
  const notes = [
290
304
  ...(call.esbService === undefined ? [] : [`backed by ESB service ${call.esbService}`]),
305
+ ...(origin === undefined
306
+ ? []
307
+ : [`declared by page${origin.length > 1 ? "s" : ""} ${origin.join(", ")}`]),
291
308
  ...(variants.length > 1
292
309
  ? [
293
310
  `also serves: ${variants
@@ -342,14 +359,14 @@ export const openApiFor = (spec: PageSpec): string => {
342
359
  }
343
360
  lines.push("components:")
344
361
  lines.push(" schemas:")
345
- const schemaCalls = [...spec.apiCalls].sort((a, b) => a.operation.localeCompare(b.operation))
362
+ const schemaCalls = [...apiCalls].sort((a, b) => a.operation.localeCompare(b.operation))
346
363
  let wroteSchema = false
347
364
  // Every DTO a response names becomes a component the calls reference —
348
365
  // one schema per domain entity, shared by every endpoint returning it.
349
366
  const referencedDtos = new Set(
350
- spec.apiCalls.flatMap((call) => (call.responseDto === undefined ? [] : [call.responseDto]))
367
+ apiCalls.flatMap((call) => (call.responseDto === undefined ? [] : [call.responseDto]))
351
368
  )
352
- for (const dto of [...spec.dtos].sort((a, b) => a.domainName.localeCompare(b.domainName))) {
369
+ for (const dto of [...dtos].sort((a, b) => a.domainName.localeCompare(b.domainName))) {
353
370
  if (!referencedDtos.has(dto.domainName)) {
354
371
  continue
355
372
  }
@@ -388,6 +405,113 @@ export const openApiFor = (spec: PageSpec): string => {
388
405
  return lines.join("\n") + "\n"
389
406
  }
390
407
 
408
+ export const openApiFor = (spec: PageSpec): string =>
409
+ renderOpenApi(
410
+ {
411
+ title: `${spec.title} service contract`,
412
+ description: `Anti-corruption contract for page ${spec.page} (${spec.route})`
413
+ },
414
+ spec.apiCalls,
415
+ spec.dtos
416
+ )
417
+
418
+ const sameFields = (
419
+ left: ReadonlyArray<FieldMapping>,
420
+ right: ReadonlyArray<FieldMapping>
421
+ ): boolean =>
422
+ left.length === right.length &&
423
+ left.every(
424
+ (field, index) =>
425
+ field.legacyName === right[index]?.legacyName &&
426
+ field.domainName === right[index]?.domainName &&
427
+ field.type === right[index]?.type
428
+ )
429
+
430
+ const sameCall = (left: PageApiCall, right: PageApiCall): boolean =>
431
+ left.operation === right.operation &&
432
+ left.responseDto === right.responseDto &&
433
+ left.responseShape === right.responseShape &&
434
+ sameFields(left.request, right.request) &&
435
+ sameFields(left.response, right.response)
436
+
437
+ export interface FeatureContract {
438
+ readonly yaml: string
439
+ /** `<method> <path>` of every operation and the pages that declare it. */
440
+ readonly operations: ReadonlyArray<{
441
+ readonly key: string
442
+ readonly pages: ReadonlyArray<string>
443
+ }>
444
+ }
445
+
446
+ /**
447
+ * ONE contract for a domain feature (ADR 0012 addendum): the union of its
448
+ * pages' API sections by method + path, DTOs by domain name. Two pages that
449
+ * declare the same method + path (or the same operation name) with different
450
+ * shapes, or the same DTO with different fields, are a `ContractConflict`
451
+ * listing every disagreement — never a silent merge. The page each
452
+ * operation came from is recorded in its description.
453
+ */
454
+ export const openApiForFeature = (
455
+ feature: { readonly id: string; readonly name: string },
456
+ specs: ReadonlyArray<PageSpec>
457
+ ): Effect.Effect<FeatureContract, ContractConflict> => {
458
+ const conflicts: Array<string> = []
459
+ const calls = new Map<string, { call: PageApiCall; pages: Array<string> }>()
460
+ const byOperation = new Map<string, string>()
461
+ for (const spec of specs) {
462
+ for (const call of spec.apiCalls) {
463
+ const key = `${call.method.toUpperCase()} ${call.path}`
464
+ const existing = calls.get(key)
465
+ if (existing === undefined) {
466
+ const owner = byOperation.get(call.operation)
467
+ if (owner !== undefined && owner !== key) {
468
+ conflicts.push(
469
+ `operation '${call.operation}' is declared on ${owner} and on ${key} (${spec.page})`
470
+ )
471
+ continue
472
+ }
473
+ byOperation.set(call.operation, key)
474
+ calls.set(key, { call, pages: [spec.page] })
475
+ } else if (sameCall(existing.call, call)) {
476
+ existing.pages.push(spec.page)
477
+ } else {
478
+ conflicts.push(
479
+ `${key} differs between ${existing.pages.join(", ")} (${existing.call.operation}) and ${spec.page} (${call.operation})`
480
+ )
481
+ }
482
+ }
483
+ }
484
+ const dtos = new Map<string, { dto: PageDto; page: string }>()
485
+ for (const spec of specs) {
486
+ for (const dto of spec.dtos) {
487
+ const existing = dtos.get(dto.domainName)
488
+ if (existing === undefined) {
489
+ dtos.set(dto.domainName, { dto, page: spec.page })
490
+ } else if (!sameFields(existing.dto.fields, dto.fields)) {
491
+ conflicts.push(
492
+ `DTO '${dto.domainName}' has different fields in ${existing.page} and ${spec.page}`
493
+ )
494
+ }
495
+ }
496
+ }
497
+ if (conflicts.length > 0) {
498
+ return Effect.fail(ContractConflict.make({ feature: feature.id, conflicts }))
499
+ }
500
+ const operations = [...calls.entries()].map(([key, { pages }]) => ({ key, pages }))
501
+ const yaml = renderOpenApi(
502
+ {
503
+ title: `${feature.name} service contract`,
504
+ description:
505
+ `Anti-corruption contract for domain feature ${feature.id} — pages ` +
506
+ specs.map((spec) => spec.page).join(", ")
507
+ },
508
+ [...calls.values()].map(({ call }) => call),
509
+ [...dtos.values()].map(({ dto }) => dto),
510
+ new Map(operations.map(({ key, pages }) => [key, pages]))
511
+ )
512
+ return Effect.succeed({ yaml, operations })
513
+ }
514
+
391
515
  /** Human-readable summary — the review surface next to the JSON contract. */
392
516
  export const renderPageSpec = (spec: PageSpec): string => {
393
517
  const lines: Array<string> = [
package/src/SpecChecks.ts CHANGED
@@ -100,12 +100,20 @@ export interface CoverageOptions {
100
100
  * rather than failing this wave's gate. Absent: every unit gates.
101
101
  */
102
102
  readonly inScope?: (path: string) => boolean
103
+ /**
104
+ * Units a decisions overlay waives (ADR 0015): the legacy has them, a
105
+ * human decided not to carry them, so they never gate and are reported
106
+ * as waived rather than uncovered.
107
+ */
108
+ readonly waived?: ReadonlySet<string>
103
109
  }
104
110
 
105
111
  export interface CoverageReport {
106
112
  readonly result: ReviewResult
107
113
  /** Uncovered units the scope excluded from the gate, as `rule: unit`. */
108
114
  readonly outOfScope: ReadonlyArray<string>
115
+ /** Uncovered units a decision waived, as `rule: unit`. */
116
+ readonly waived: ReadonlyArray<string>
109
117
  }
110
118
 
111
119
  const uncoveredIssue = (rule: string, unit: string): ReviewIssue =>
@@ -126,10 +134,15 @@ export const coverageReport = Effect.fn("@llm4ts/flow/SpecChecks.coverageReport"
126
134
  const captured = yield* capturedUnits(workspace, rules)
127
135
  const issues: Array<ReviewIssue> = []
128
136
  const outOfScope: Array<string> = []
137
+ const waived: Array<string> = []
129
138
  for (const entry of captured) {
130
139
  if (traceability.includes(entry.unit)) {
131
140
  continue
132
141
  }
142
+ if (options.waived?.has(entry.unit) === true) {
143
+ waived.push(`${entry.rule}: ${entry.unit}`)
144
+ continue
145
+ }
133
146
  if (options.inScope === undefined || entry.paths.some(options.inScope)) {
134
147
  issues.push(uncoveredIssue(entry.rule, entry.unit))
135
148
  } else {
@@ -141,7 +154,8 @@ export const coverageReport = Effect.fn("@llm4ts/flow/SpecChecks.coverageReport"
141
154
  issues,
142
155
  summary: issues.length === 0 ? "coverage complete" : `${issues.length} unit(s) uncovered`
143
156
  }),
144
- outOfScope
157
+ outOfScope,
158
+ waived
145
159
  }
146
160
  })
147
161