@llm4ts/flow 0.13.4 → 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/AzureDevOpsTool.d.ts +16 -0
- package/dist/AzureDevOpsTool.d.ts.map +1 -1
- package/dist/AzureDevOpsTool.js +73 -0
- package/dist/AzureDevOpsTool.js.map +1 -1
- 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/AzureDevOpsTool.ts +115 -0
- 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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Ref from "effect/Ref"
|
|
3
|
+
import * as Stream from "effect/Stream"
|
|
4
|
+
import type { LlmError } from "@llm4ts/core/Errors"
|
|
5
|
+
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
6
|
+
import { LlmChunk, TokenUsage, type Message } from "@llm4ts/core/Models"
|
|
7
|
+
import { estimateCostUsd } from "./PriceList.ts"
|
|
8
|
+
|
|
9
|
+
// ESTIMATED token accounting for connectors that report none (every CLI
|
|
10
|
+
// connector: `makeCliConnector` returns no usage at all). The decorator
|
|
11
|
+
// counts characters on both sides of each request and, when the backend
|
|
12
|
+
// reported nothing, appends a synthetic final chunk carrying the estimate —
|
|
13
|
+
// so `collect`, `Chat`, `CostTracker`, and the cost summary all see it
|
|
14
|
+
// through the normal event path, labelled `estimated:<model>` so no report
|
|
15
|
+
// can mistake it for measured usage (ADR 0012; the PoC decision of
|
|
16
|
+
// 2026-08-30 is estimates-only, no CLI usage parsing).
|
|
17
|
+
|
|
18
|
+
export interface EstimatedUsageOptions {
|
|
19
|
+
/** Pricing reference for the estimate — a `PriceList` model prefix. */
|
|
20
|
+
readonly referenceModel: string
|
|
21
|
+
/** Characters per token for the estimate. Default 4. */
|
|
22
|
+
readonly charsPerToken?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const defaultReferenceModel = "claude-sonnet-4"
|
|
26
|
+
|
|
27
|
+
export const estimatedUsageOptionsFromEnv = (
|
|
28
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
29
|
+
): EstimatedUsageOptions => {
|
|
30
|
+
const parsed = Number.parseInt(environment.LLM4TS_ESTIMATE_CHARS_PER_TOKEN ?? "", 10)
|
|
31
|
+
return {
|
|
32
|
+
referenceModel: environment.LLM4TS_ESTIMATE_MODEL?.trim() || defaultReferenceModel,
|
|
33
|
+
...(Number.isFinite(parsed) && parsed > 0 ? { charsPerToken: parsed } : {})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The model label estimates are published under — never a real model id. */
|
|
38
|
+
export const estimatedModelLabel = (referenceModel: string): string => `estimated:${referenceModel}`
|
|
39
|
+
|
|
40
|
+
export const isEstimatedModel = (model: string | undefined): boolean =>
|
|
41
|
+
model !== undefined && model.startsWith("estimated:")
|
|
42
|
+
|
|
43
|
+
const tokensFor = (chars: number, charsPerToken: number): number =>
|
|
44
|
+
Math.ceil(Math.max(0, chars) / charsPerToken)
|
|
45
|
+
|
|
46
|
+
export const estimateUsage = (
|
|
47
|
+
promptChars: number,
|
|
48
|
+
completionChars: number,
|
|
49
|
+
options: EstimatedUsageOptions
|
|
50
|
+
): TokenUsage => {
|
|
51
|
+
const charsPerToken = options.charsPerToken ?? 4
|
|
52
|
+
const prompt = tokensFor(promptChars, charsPerToken)
|
|
53
|
+
const completion = tokensFor(completionChars, charsPerToken)
|
|
54
|
+
const base = TokenUsage.make({ prompt, completion, total: prompt + completion })
|
|
55
|
+
const costUsd = estimateCostUsd(options.referenceModel, base)
|
|
56
|
+
return costUsd === undefined ? base : TokenUsage.make({ ...base, costUsd })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const messagesChars = (messages: ReadonlyArray<Message>): number =>
|
|
60
|
+
messages.reduce((sum, message) => sum + message.content.length, 0)
|
|
61
|
+
|
|
62
|
+
const mergeTotals = (previous: TokenUsage | undefined, usage: TokenUsage): TokenUsage => {
|
|
63
|
+
const cached = [previous?.cached, usage.cached].flatMap((value) =>
|
|
64
|
+
value === undefined ? [] : [value]
|
|
65
|
+
)
|
|
66
|
+
const cost = [previous?.costUsd, usage.costUsd].flatMap((value) =>
|
|
67
|
+
value === undefined ? [] : [value]
|
|
68
|
+
)
|
|
69
|
+
return TokenUsage.make({
|
|
70
|
+
prompt: (previous?.prompt ?? 0) + usage.prompt,
|
|
71
|
+
completion: (previous?.completion ?? 0) + usage.completion,
|
|
72
|
+
total: (previous?.total ?? 0) + usage.total,
|
|
73
|
+
...(cached.length === 0 ? {} : { cached: cached.reduce((sum, value) => sum + value, 0) }),
|
|
74
|
+
...(cost.length === 0 ? {} : { costUsd: cost.reduce((sum, value) => sum + value, 0) })
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface EstimatedUsageMeter {
|
|
79
|
+
/** The decorated service — use this in place of the raw connector. */
|
|
80
|
+
readonly service: LlmServiceShape
|
|
81
|
+
/** Cumulative usage seen so far: backend-reported where present, estimated otherwise. */
|
|
82
|
+
readonly totals: Effect.Effect<TokenUsage | undefined>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const estimatedStream = (
|
|
86
|
+
stream: Stream.Stream<LlmChunk, LlmError>,
|
|
87
|
+
promptChars: number,
|
|
88
|
+
options: EstimatedUsageOptions,
|
|
89
|
+
record: (usage: TokenUsage) => Effect.Effect<void>
|
|
90
|
+
): Stream.Stream<LlmChunk, LlmError> =>
|
|
91
|
+
Stream.unwrap(
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const sawUsage = yield* Ref.make(false)
|
|
94
|
+
const completionChars = yield* Ref.make(0)
|
|
95
|
+
const tapped = stream.pipe(
|
|
96
|
+
Stream.tap((chunk) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
yield* Ref.update(completionChars, (count) => count + chunk.delta.length)
|
|
99
|
+
if (chunk.usage !== undefined) {
|
|
100
|
+
yield* Ref.set(sawUsage, true)
|
|
101
|
+
yield* record(chunk.usage)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
// Appended only after a SUCCESSFUL stream that reported nothing: a
|
|
107
|
+
// failed request estimates nothing, and a backend that reported real
|
|
108
|
+
// usage is never double-counted.
|
|
109
|
+
const tail: Stream.Stream<LlmChunk, LlmError> = Stream.unwrap(
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
if (yield* Ref.get(sawUsage)) {
|
|
112
|
+
return Stream.empty
|
|
113
|
+
}
|
|
114
|
+
const usage = estimateUsage(promptChars, yield* Ref.get(completionChars), options)
|
|
115
|
+
yield* record(usage)
|
|
116
|
+
return Stream.succeed(
|
|
117
|
+
LlmChunk.make({
|
|
118
|
+
delta: "",
|
|
119
|
+
usage,
|
|
120
|
+
metadata: { model: estimatedModelLabel(options.referenceModel) }
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
return Stream.concat(tapped, tail)
|
|
126
|
+
})
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decorates a service so every request yields usage — real when the backend
|
|
131
|
+
* reported it, estimated (and labelled so) when it did not — and exposes the
|
|
132
|
+
* running totals for per-run reports.
|
|
133
|
+
*/
|
|
134
|
+
export const makeEstimatedUsageMeter = Effect.fn("@llm4ts/flow/EstimatedUsage.make")(function* (
|
|
135
|
+
service: LlmServiceShape,
|
|
136
|
+
options: EstimatedUsageOptions
|
|
137
|
+
): Effect.fn.Return<EstimatedUsageMeter> {
|
|
138
|
+
const totals = yield* Ref.make<TokenUsage | undefined>(undefined)
|
|
139
|
+
const record = (usage: TokenUsage): Effect.Effect<void> =>
|
|
140
|
+
Ref.update(totals, (previous) => mergeTotals(previous, usage))
|
|
141
|
+
|
|
142
|
+
const decorated: LlmServiceShape = {
|
|
143
|
+
executeStream: (prompt) =>
|
|
144
|
+
estimatedStream(service.executeStream(prompt), prompt.length, options, record),
|
|
145
|
+
executeStreamWithHistory: (messages) =>
|
|
146
|
+
estimatedStream(
|
|
147
|
+
service.executeStreamWithHistory(messages),
|
|
148
|
+
messagesChars(messages),
|
|
149
|
+
options,
|
|
150
|
+
record
|
|
151
|
+
),
|
|
152
|
+
executeWithTools: (prompt, tools) => service.executeWithTools(prompt, tools),
|
|
153
|
+
executeStructured: (prompt, schema, jsonSchema) =>
|
|
154
|
+
service
|
|
155
|
+
.executeStructured(prompt, schema, jsonSchema)
|
|
156
|
+
.pipe(
|
|
157
|
+
Effect.tap((value) =>
|
|
158
|
+
record(estimateUsage(prompt.length, (JSON.stringify(value) ?? "").length, options))
|
|
159
|
+
)
|
|
160
|
+
),
|
|
161
|
+
executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
|
|
162
|
+
service.executeStructuredWithUsage(prompt, schema, jsonSchema).pipe(
|
|
163
|
+
Effect.flatMap(([value, usage, model]) => {
|
|
164
|
+
if (usage !== undefined) {
|
|
165
|
+
return record(usage).pipe(Effect.as([value, usage, model] as const))
|
|
166
|
+
}
|
|
167
|
+
const estimated = estimateUsage(
|
|
168
|
+
prompt.length,
|
|
169
|
+
(JSON.stringify(value) ?? "").length,
|
|
170
|
+
options
|
|
171
|
+
)
|
|
172
|
+
return record(estimated).pipe(
|
|
173
|
+
Effect.as([value, estimated, estimatedModelLabel(options.referenceModel)] as const)
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
),
|
|
177
|
+
isAvailable: service.isAvailable
|
|
178
|
+
}
|
|
179
|
+
return { service: decorated, totals: Ref.get(totals) }
|
|
180
|
+
})
|
package/src/Pack.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface Pack {
|
|
|
21
21
|
readonly source: string
|
|
22
22
|
readonly scaffold: string | undefined
|
|
23
23
|
readonly sources: string | undefined
|
|
24
|
+
// Regex over repo-relative paths that `sources:` matches but the estate
|
|
25
|
+
// does not own: vendored copies, generated exports, test doubles. Excluded
|
|
26
|
+
// paths never enter the survey graph or the extraction inventory.
|
|
27
|
+
readonly exclude: string | undefined
|
|
24
28
|
readonly programs: string | undefined
|
|
25
29
|
readonly specsDir: string
|
|
26
30
|
readonly featuresDir: string
|
|
@@ -202,11 +206,18 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
202
206
|
message: `pack manifest 'programFiles:' is not a valid regex template: ${programFiles}`
|
|
203
207
|
})
|
|
204
208
|
}
|
|
209
|
+
const exclude = fields.exclude
|
|
210
|
+
if (exclude !== undefined && !isValidRegExp(exclude)) {
|
|
211
|
+
return yield* PlanParseError.make({
|
|
212
|
+
message: `pack manifest 'exclude:' is not a valid regex: ${exclude}`
|
|
213
|
+
})
|
|
214
|
+
}
|
|
205
215
|
const pack: Pack = {
|
|
206
216
|
name: manifest.name,
|
|
207
217
|
source: fields.source ?? "",
|
|
208
218
|
scaffold: fields.scaffold,
|
|
209
219
|
sources: fields.sources,
|
|
220
|
+
exclude,
|
|
210
221
|
programs: fields.programs,
|
|
211
222
|
specsDir: fields["specs-dir"] ?? "docs/specs",
|
|
212
223
|
featuresDir: fields["features-dir"] ?? "features",
|
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,
|