@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.
- package/dist/Approval.d.ts +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Decisions.d.ts +187 -0
- package/dist/Decisions.d.ts.map +1 -0
- package/dist/Decisions.js +677 -0
- package/dist/Decisions.js.map +1 -0
- package/dist/Domains.d.ts +125 -0
- package/dist/Domains.d.ts.map +1 -0
- package/dist/Domains.js +528 -0
- package/dist/Domains.js.map +1 -0
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +1 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/FlowError.d.ts +25 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +33 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Pack.d.ts +16 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +53 -1
- package/dist/Pack.js.map +1 -1
- package/dist/PageSpec.d.ts +21 -7
- package/dist/PageSpec.d.ts.map +1 -1
- package/dist/PageSpec.js +83 -14
- package/dist/PageSpec.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +2 -2
- package/dist/Review.d.ts.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/SpecChecks.d.ts +8 -0
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +7 -1
- package/dist/SpecChecks.js.map +1 -1
- package/dist/Stories.d.ts +1 -1
- package/dist/Stories.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/Decisions.ts +844 -0
- package/src/Domains.ts +650 -0
- package/src/FlowError.ts +41 -1
- package/src/Pack.ts +79 -1
- package/src/PageSpec.ts +132 -8
- package/src/SpecChecks.ts +15 -1
package/src/Domains.ts
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
4
|
+
import {
|
|
5
|
+
OpenPoint,
|
|
6
|
+
makeOpenPointsCollector,
|
|
7
|
+
parseApprovalMarker,
|
|
8
|
+
renderOpenPoints
|
|
9
|
+
} from "./Decisions.ts"
|
|
10
|
+
import { DecisionsInvalid } from "./FlowError.ts"
|
|
11
|
+
import { stableHash } from "./Plan.ts"
|
|
12
|
+
import type { SurveyGraph } from "./Survey.ts"
|
|
13
|
+
|
|
14
|
+
export { DecisionsInvalid } from "./FlowError.ts"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The grouping overlay of a modernization spec pack (ADR 0015): extraction is
|
|
18
|
+
* per program, delivery is per DOMAIN FEATURE — the pages that share a form
|
|
19
|
+
* target, the steps of one wizard, the shell of included fragments. Seeded
|
|
20
|
+
* deterministically from the survey graph by the pack's `## Consolidate`
|
|
21
|
+
* rules, named and adjusted by the model, approved by a human, and the input
|
|
22
|
+
* `plan.md` is regenerated from.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ---- Deterministic clustering ------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface ConsolidateRules {
|
|
28
|
+
/** Survey edge kinds that put two units in the same feature. `llm-*` matches by prefix. */
|
|
29
|
+
readonly cluster: ReadonlyArray<string>
|
|
30
|
+
/** Edge kinds whose target attaches as shared context (a fragment) and never clusters. */
|
|
31
|
+
readonly context: ReadonlyArray<string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Cluster {
|
|
35
|
+
readonly programs: ReadonlyArray<string>
|
|
36
|
+
/** Fragments the cluster's programs include, transitively. */
|
|
37
|
+
readonly context: ReadonlyArray<string>
|
|
38
|
+
/** A cluster of fragments themselves: the shell everything includes. */
|
|
39
|
+
readonly shell: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const kindMatches = (kind: string, patterns: ReadonlyArray<string>): boolean =>
|
|
43
|
+
patterns.some((pattern) =>
|
|
44
|
+
pattern.endsWith("*") ? kind.startsWith(pattern.slice(0, -1)) : kind === pattern
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
class UnionFind {
|
|
48
|
+
private readonly parent = new Map<string, string>()
|
|
49
|
+
|
|
50
|
+
find(name: string): string {
|
|
51
|
+
const parent = this.parent.get(name)
|
|
52
|
+
if (parent === undefined || parent === name) {
|
|
53
|
+
this.parent.set(name, name)
|
|
54
|
+
return name
|
|
55
|
+
}
|
|
56
|
+
const root = this.find(parent)
|
|
57
|
+
this.parent.set(name, root)
|
|
58
|
+
return root
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
union(left: string, right: string): void {
|
|
62
|
+
const a = this.find(left)
|
|
63
|
+
const b = this.find(right)
|
|
64
|
+
if (a !== b) {
|
|
65
|
+
this.parent.set(a < b ? b : a, a < b ? a : b)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const byFirst = (left: Cluster, right: Cluster): number =>
|
|
71
|
+
(left.programs[0] ?? "").localeCompare(right.programs[0] ?? "")
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Programs grouped by the pack's cluster edges: two units joined by an edge of
|
|
75
|
+
* a `cluster:` kind land in one feature, through any intermediate unit (a
|
|
76
|
+
* shared form target, a servlet). Programs that are the target of a
|
|
77
|
+
* `context:` edge are fragments: their own edges never merge anything, they
|
|
78
|
+
* attach to the clusters that include them, and they form shell clusters
|
|
79
|
+
* among themselves. Shell clusters first, then by first program name.
|
|
80
|
+
*/
|
|
81
|
+
export const clusterPrograms = (
|
|
82
|
+
graph: SurveyGraph,
|
|
83
|
+
programs: ReadonlyArray<string>,
|
|
84
|
+
rules: ConsolidateRules
|
|
85
|
+
): ReadonlyArray<Cluster> => {
|
|
86
|
+
const programSet = new Set(programs)
|
|
87
|
+
const contextEdges = graph.edges.filter((edge) => kindMatches(edge.kind, rules.context))
|
|
88
|
+
const fragments = new Set(
|
|
89
|
+
contextEdges.map((edge) => edge.to).filter((name) => programSet.has(name))
|
|
90
|
+
)
|
|
91
|
+
const pages = new UnionFind()
|
|
92
|
+
for (const program of programs) {
|
|
93
|
+
pages.find(program)
|
|
94
|
+
}
|
|
95
|
+
for (const edge of graph.edges) {
|
|
96
|
+
if (
|
|
97
|
+
kindMatches(edge.kind, rules.cluster) &&
|
|
98
|
+
!fragments.has(edge.from) &&
|
|
99
|
+
!fragments.has(edge.to)
|
|
100
|
+
) {
|
|
101
|
+
pages.union(edge.from, edge.to)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const shell = new UnionFind()
|
|
105
|
+
for (const fragment of fragments) {
|
|
106
|
+
shell.find(fragment)
|
|
107
|
+
}
|
|
108
|
+
for (const edge of contextEdges) {
|
|
109
|
+
if (fragments.has(edge.from) && fragments.has(edge.to)) {
|
|
110
|
+
shell.union(edge.from, edge.to)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const includes = new Map<string, Array<string>>()
|
|
114
|
+
for (const edge of contextEdges) {
|
|
115
|
+
if (fragments.has(edge.to)) {
|
|
116
|
+
includes.set(edge.from, [...(includes.get(edge.from) ?? []), edge.to])
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const contextOf = (members: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
120
|
+
const seen = new Set<string>()
|
|
121
|
+
const frontier = [...members]
|
|
122
|
+
while (frontier.length > 0) {
|
|
123
|
+
const current = frontier.pop()
|
|
124
|
+
if (current === undefined) {
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
for (const included of includes.get(current) ?? []) {
|
|
128
|
+
if (!seen.has(included)) {
|
|
129
|
+
seen.add(included)
|
|
130
|
+
frontier.push(included)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...seen].sort()
|
|
135
|
+
}
|
|
136
|
+
const groups = new Map<string, Array<string>>()
|
|
137
|
+
for (const program of programs) {
|
|
138
|
+
if (fragments.has(program)) {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
const root = pages.find(program)
|
|
142
|
+
groups.set(root, [...(groups.get(root) ?? []), program])
|
|
143
|
+
}
|
|
144
|
+
const shells = new Map<string, Array<string>>()
|
|
145
|
+
for (const fragment of fragments) {
|
|
146
|
+
const root = shell.find(fragment)
|
|
147
|
+
shells.set(root, [...(shells.get(root) ?? []), fragment])
|
|
148
|
+
}
|
|
149
|
+
const shellClusters: Array<Cluster> = [...shells.values()]
|
|
150
|
+
.map((members) => ({ programs: [...members].sort(), context: [], shell: true }))
|
|
151
|
+
.sort(byFirst)
|
|
152
|
+
const pageClusters: Array<Cluster> = [...groups.values()]
|
|
153
|
+
.map((members) => {
|
|
154
|
+
const sorted = [...members].sort()
|
|
155
|
+
return { programs: sorted, context: contextOf(sorted), shell: false }
|
|
156
|
+
})
|
|
157
|
+
.sort(byFirst)
|
|
158
|
+
return [...shellClusters, ...pageClusters]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- The map ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export class ScenarioRef extends Schema.Class<ScenarioRef>("ScenarioRef")({
|
|
164
|
+
program: Schema.String,
|
|
165
|
+
title: Schema.String
|
|
166
|
+
}) {}
|
|
167
|
+
|
|
168
|
+
export class FeatureScenario extends Schema.Class<FeatureScenario>("FeatureScenario")({
|
|
169
|
+
program: Schema.String,
|
|
170
|
+
title: Schema.String,
|
|
171
|
+
/** Scenarios of other programs this one absorbs as the same behaviour. */
|
|
172
|
+
mergedFrom: Schema.Array(ScenarioRef)
|
|
173
|
+
}) {}
|
|
174
|
+
|
|
175
|
+
export class DomainFeature extends Schema.Class<DomainFeature>("DomainFeature")({
|
|
176
|
+
/** kebab-case, the branch and contract name downstream. */
|
|
177
|
+
id: Schema.String,
|
|
178
|
+
name: Schema.String,
|
|
179
|
+
programs: Schema.Array(Schema.String),
|
|
180
|
+
context: Schema.Array(Schema.String),
|
|
181
|
+
scenarios: Schema.Array(FeatureScenario),
|
|
182
|
+
evidence: Schema.String
|
|
183
|
+
}) {}
|
|
184
|
+
|
|
185
|
+
export class Domains extends Schema.Class<Domains>("Domains")({
|
|
186
|
+
features: Schema.Array(DomainFeature),
|
|
187
|
+
/** Hash of the specs and decisions the map was built from — staleness check. */
|
|
188
|
+
inputsHash: Schema.String,
|
|
189
|
+
openPoints: Schema.Array(OpenPoint),
|
|
190
|
+
approved: Schema.Boolean
|
|
191
|
+
}) {
|
|
192
|
+
get unansweredOpenPoints(): ReadonlyArray<OpenPoint> {
|
|
193
|
+
return this.openPoints.filter((point) => point.answer === undefined)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
featureOf(program: string): DomainFeature | undefined {
|
|
197
|
+
return this.features.find((feature) => feature.programs.includes(program))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const scenarioKey = (program: string, title: string): string => `${program} / ${title}`
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Every surviving scenario of the pack must be assigned to exactly one
|
|
205
|
+
* feature, directly or as a `mergedFrom` source; a feature may not claim a
|
|
206
|
+
* scenario the pack does not have. Deterministic, run before any human looks.
|
|
207
|
+
*/
|
|
208
|
+
export const checkExactlyOnce = (
|
|
209
|
+
domains: Domains,
|
|
210
|
+
surviving: ReadonlyMap<string, ReadonlySet<string>>
|
|
211
|
+
): ReadonlyArray<string> => {
|
|
212
|
+
const violations: Array<string> = []
|
|
213
|
+
const assigned = new Map<string, Array<string>>()
|
|
214
|
+
const known = (program: string, title: string): boolean =>
|
|
215
|
+
surviving.get(program)?.has(title) ?? false
|
|
216
|
+
for (const feature of domains.features) {
|
|
217
|
+
for (const scenario of feature.scenarios) {
|
|
218
|
+
const refs = [{ program: scenario.program, title: scenario.title }, ...scenario.mergedFrom]
|
|
219
|
+
for (const ref of refs) {
|
|
220
|
+
const key = scenarioKey(ref.program, ref.title)
|
|
221
|
+
const owners = assigned.get(key) ?? []
|
|
222
|
+
owners.push(feature.id)
|
|
223
|
+
assigned.set(key, owners)
|
|
224
|
+
if (owners.length === 2) {
|
|
225
|
+
violations.push(`scenario '${key}' is assigned twice (${owners.join(", ")})`)
|
|
226
|
+
} else if (owners.length === 1 && !known(ref.program, ref.title)) {
|
|
227
|
+
violations.push(
|
|
228
|
+
`scenario '${key}' in feature ${feature.id} is not a surviving scenario of the pack`
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const [program, titles] of surviving) {
|
|
235
|
+
for (const title of titles) {
|
|
236
|
+
if (!assigned.has(scenarioKey(program, title))) {
|
|
237
|
+
violations.push(`scenario '${scenarioKey(program, title)}' is assigned to no feature`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return violations
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- Guide, render, parse ------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
export const domainsGuide = [
|
|
247
|
+
"## How to read this map",
|
|
248
|
+
"",
|
|
249
|
+
"Each `## Feature:` groups the programs that deliver one domain feature, the",
|
|
250
|
+
"fragments they include as `context:`, and every surviving scenario it absorbs,",
|
|
251
|
+
"one per line as `- <program> / <scenario title>`. A scenario that describes",
|
|
252
|
+
"the same behaviour as another page's is listed once with `(merged from: …)`.",
|
|
253
|
+
"Every surviving scenario of the pack appears exactly once across the map.",
|
|
254
|
+
"The clusters were seeded from the survey graph by the pack's `## Consolidate`",
|
|
255
|
+
"rules; the model named them and proposed merges and folds, each with",
|
|
256
|
+
"`evidence:`. Edit freely: move a scenario line, rename a feature, split or",
|
|
257
|
+
"join sections. Answer `## Open points` with an indented `answer: …` line and",
|
|
258
|
+
"rerun; flip the marker at the end to approve. `inputs:` is the hash of the",
|
|
259
|
+
"specs and decisions this map was built from — the flow regroups when it",
|
|
260
|
+
"changes, or on request.",
|
|
261
|
+
""
|
|
262
|
+
].join("\n")
|
|
263
|
+
|
|
264
|
+
export const renderDomains = (domains: Domains): string => {
|
|
265
|
+
const blocks = domains.features.map((feature) =>
|
|
266
|
+
[
|
|
267
|
+
`## Feature: ${feature.name} (${feature.id})`,
|
|
268
|
+
"",
|
|
269
|
+
`programs: ${feature.programs.join(", ")}`,
|
|
270
|
+
`context: ${feature.context.join(", ")}`,
|
|
271
|
+
`evidence: ${feature.evidence}`,
|
|
272
|
+
"",
|
|
273
|
+
...feature.scenarios.map((scenario) => {
|
|
274
|
+
const merged =
|
|
275
|
+
scenario.mergedFrom.length === 0
|
|
276
|
+
? ""
|
|
277
|
+
: ` (merged from: ${scenario.mergedFrom
|
|
278
|
+
.map((ref) => scenarioKey(ref.program, ref.title))
|
|
279
|
+
.join("; ")})`
|
|
280
|
+
return `- ${scenarioKey(scenario.program, scenario.title)}${merged}`
|
|
281
|
+
}),
|
|
282
|
+
""
|
|
283
|
+
].join("\n")
|
|
284
|
+
)
|
|
285
|
+
return [
|
|
286
|
+
"# Domain features",
|
|
287
|
+
"",
|
|
288
|
+
domainsGuide,
|
|
289
|
+
`inputs: ${domains.inputsHash}`,
|
|
290
|
+
"",
|
|
291
|
+
...blocks,
|
|
292
|
+
"## Open points",
|
|
293
|
+
"",
|
|
294
|
+
...renderOpenPoints(domains.openPoints),
|
|
295
|
+
...(domains.openPoints.length === 0 ? [] : [""]),
|
|
296
|
+
domains.approved ? "- [x] Approved" : "- [ ] Approved",
|
|
297
|
+
""
|
|
298
|
+
].join("\n")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const featureHeading = /^## Feature: (.+?) \(([a-z0-9][a-z0-9-]*)\)\s*$/
|
|
302
|
+
const scenarioLine = /^- (.+?) \/ (.+?)(?: \(merged from: (.+)\))?$/
|
|
303
|
+
|
|
304
|
+
interface FeatureDraft {
|
|
305
|
+
id: string
|
|
306
|
+
name: string
|
|
307
|
+
programs: Array<string>
|
|
308
|
+
context: Array<string>
|
|
309
|
+
scenarios: Array<FeatureScenario>
|
|
310
|
+
evidence: string
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const list = (value: string): Array<string> =>
|
|
314
|
+
value
|
|
315
|
+
.split(",")
|
|
316
|
+
.map((item) => item.trim())
|
|
317
|
+
.filter((item) => item.length > 0)
|
|
318
|
+
|
|
319
|
+
export const parseDomains = Effect.fn("@llm4ts/flow/Domains.parse")(function* (
|
|
320
|
+
markdown: string,
|
|
321
|
+
path?: string
|
|
322
|
+
): Effect.fn.Return<Domains, DecisionsInvalid> {
|
|
323
|
+
const features: Array<DomainFeature> = []
|
|
324
|
+
const violations: Array<string> = []
|
|
325
|
+
const points = makeOpenPointsCollector()
|
|
326
|
+
let inputsHash = ""
|
|
327
|
+
let approved = false
|
|
328
|
+
let draft: FeatureDraft | undefined
|
|
329
|
+
let section: "feature" | "open" | "other" = "other"
|
|
330
|
+
let fenced = false
|
|
331
|
+
const close = (): void => {
|
|
332
|
+
if (draft === undefined) {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
if (draft.programs.length === 0) {
|
|
336
|
+
violations.push(`feature '${draft.id}' lists no programs`)
|
|
337
|
+
}
|
|
338
|
+
features.push(DomainFeature.make(draft))
|
|
339
|
+
draft = undefined
|
|
340
|
+
}
|
|
341
|
+
const lines = markdown.split(/\r?\n/)
|
|
342
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
343
|
+
const number = index + 1
|
|
344
|
+
const trimmed = (lines[index] ?? "").trim()
|
|
345
|
+
if (trimmed.startsWith("```")) {
|
|
346
|
+
fenced = !fenced
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if (fenced || trimmed.length === 0 || trimmed.startsWith("# ")) {
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
const marker = parseApprovalMarker(trimmed)
|
|
353
|
+
if (marker !== undefined) {
|
|
354
|
+
approved = marker
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
const heading = featureHeading.exec(trimmed)
|
|
358
|
+
if (heading !== null) {
|
|
359
|
+
close()
|
|
360
|
+
draft = {
|
|
361
|
+
id: heading[2] ?? "",
|
|
362
|
+
name: (heading[1] ?? "").trim(),
|
|
363
|
+
programs: [],
|
|
364
|
+
context: [],
|
|
365
|
+
scenarios: [],
|
|
366
|
+
evidence: ""
|
|
367
|
+
}
|
|
368
|
+
section = "feature"
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
if (trimmed.startsWith("## ")) {
|
|
372
|
+
close()
|
|
373
|
+
section = trimmed === "## Open points" ? "open" : "other"
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if (section === "other") {
|
|
377
|
+
const inputs = /^inputs:\s*(\S+)\s*$/.exec(trimmed)
|
|
378
|
+
if (inputs?.[1] !== undefined) {
|
|
379
|
+
inputsHash = inputs[1]
|
|
380
|
+
}
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
if (section === "open") {
|
|
384
|
+
const violation = points.add(number, trimmed)
|
|
385
|
+
if (violation !== undefined) {
|
|
386
|
+
violations.push(violation)
|
|
387
|
+
}
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
if (draft === undefined) {
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
const field = /^(programs|context|evidence):\s*(.*)$/.exec(trimmed)
|
|
394
|
+
if (field !== null) {
|
|
395
|
+
const value = field[2] ?? ""
|
|
396
|
+
if (field[1] === "programs") {
|
|
397
|
+
draft.programs = list(value)
|
|
398
|
+
} else if (field[1] === "context") {
|
|
399
|
+
draft.context = list(value)
|
|
400
|
+
} else {
|
|
401
|
+
draft.evidence = value.trim()
|
|
402
|
+
}
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
if (trimmed.startsWith("- ")) {
|
|
406
|
+
const match = scenarioLine.exec(trimmed)
|
|
407
|
+
if (match === null) {
|
|
408
|
+
violations.push(
|
|
409
|
+
`line ${number}: scenario lines read \`- <program> / <title>\`, got: ${trimmed.slice(2)}`
|
|
410
|
+
)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
const mergedFrom = (match[3] ?? "")
|
|
414
|
+
.split(";")
|
|
415
|
+
.map((item) => item.trim())
|
|
416
|
+
.filter((item) => item.length > 0)
|
|
417
|
+
.flatMap((item) => {
|
|
418
|
+
const slash = item.indexOf(" / ")
|
|
419
|
+
return slash <= 0
|
|
420
|
+
? []
|
|
421
|
+
: [ScenarioRef.make({ program: item.slice(0, slash), title: item.slice(slash + 3) })]
|
|
422
|
+
})
|
|
423
|
+
draft.scenarios.push(
|
|
424
|
+
FeatureScenario.make({
|
|
425
|
+
program: (match[1] ?? "").trim(),
|
|
426
|
+
title: (match[2] ?? "").trim(),
|
|
427
|
+
mergedFrom
|
|
428
|
+
})
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
close()
|
|
433
|
+
if (violations.length > 0) {
|
|
434
|
+
return yield* DecisionsInvalid.make({ ...(path === undefined ? {} : { path }), violations })
|
|
435
|
+
}
|
|
436
|
+
return Domains.make({ features, inputsHash, openPoints: points.points, approved })
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// ---- Proposal (model) ----------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
export class ProposedScenario extends Schema.Class<ProposedScenario>("ProposedScenario")({
|
|
442
|
+
program: Schema.String,
|
|
443
|
+
title: Schema.String,
|
|
444
|
+
mergedFrom: Schema.optionalKey(Schema.Array(ScenarioRef))
|
|
445
|
+
}) {}
|
|
446
|
+
|
|
447
|
+
export class ProposedFeature extends Schema.Class<ProposedFeature>("ProposedFeature")({
|
|
448
|
+
id: Schema.String,
|
|
449
|
+
name: Schema.String,
|
|
450
|
+
programs: Schema.Array(Schema.String),
|
|
451
|
+
scenarios: Schema.Array(ProposedScenario),
|
|
452
|
+
evidence: Schema.String
|
|
453
|
+
}) {}
|
|
454
|
+
|
|
455
|
+
export class DomainProposal extends Schema.Class<DomainProposal>("DomainProposal")({
|
|
456
|
+
features: Schema.Array(ProposedFeature),
|
|
457
|
+
openPoints: Schema.Array(Schema.String)
|
|
458
|
+
}) {}
|
|
459
|
+
|
|
460
|
+
const scenarioRefJsonSchema: JsonSchema = {
|
|
461
|
+
type: "object",
|
|
462
|
+
properties: { program: { type: "string" }, title: { type: "string" } },
|
|
463
|
+
required: ["program", "title"]
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export const domainProposalJsonSchema: JsonSchema = {
|
|
467
|
+
type: "object",
|
|
468
|
+
properties: {
|
|
469
|
+
features: {
|
|
470
|
+
type: "array",
|
|
471
|
+
items: {
|
|
472
|
+
type: "object",
|
|
473
|
+
properties: {
|
|
474
|
+
id: { type: "string" },
|
|
475
|
+
name: { type: "string" },
|
|
476
|
+
programs: { type: "array", items: { type: "string" } },
|
|
477
|
+
scenarios: {
|
|
478
|
+
type: "array",
|
|
479
|
+
items: {
|
|
480
|
+
type: "object",
|
|
481
|
+
properties: {
|
|
482
|
+
program: { type: "string" },
|
|
483
|
+
title: { type: "string" },
|
|
484
|
+
mergedFrom: { type: "array", items: scenarioRefJsonSchema }
|
|
485
|
+
},
|
|
486
|
+
required: ["program", "title"]
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
evidence: { type: "string" }
|
|
490
|
+
},
|
|
491
|
+
required: ["id", "name", "programs", "scenarios", "evidence"]
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
openPoints: { type: "array", items: { type: "string" } }
|
|
495
|
+
},
|
|
496
|
+
required: ["features", "openPoints"]
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The consolidation ask: the deterministic clusters and every surviving
|
|
501
|
+
* scenario, the rules the answer must keep (every scenario exactly once,
|
|
502
|
+
* clusters may be split, joined, or folded only with evidence), and the
|
|
503
|
+
* pack's stack-specific paragraph on what a feature is.
|
|
504
|
+
*/
|
|
505
|
+
export const consolidatePrompt = (
|
|
506
|
+
clusters: ReadonlyArray<Cluster>,
|
|
507
|
+
scenarios: ReadonlyMap<string, ReadonlySet<string>>,
|
|
508
|
+
packParagraph?: string
|
|
509
|
+
): string => {
|
|
510
|
+
const clusterLines = clusters.map(
|
|
511
|
+
(cluster, index) =>
|
|
512
|
+
`${index + 1}. ${cluster.shell ? "shell fragments" : "programs"}: ${cluster.programs.join(", ")}` +
|
|
513
|
+
(cluster.context.length === 0 ? "" : ` (includes: ${cluster.context.join(", ")})`)
|
|
514
|
+
)
|
|
515
|
+
const scenarioLines = [...scenarios.entries()].flatMap(([program, titles]) => [
|
|
516
|
+
`${program}:`,
|
|
517
|
+
...[...titles].map((title) => ` - ${title}`)
|
|
518
|
+
])
|
|
519
|
+
return [
|
|
520
|
+
"Name the domain features of this legacy estate and assign every surviving scenario to one.",
|
|
521
|
+
"",
|
|
522
|
+
"The clusters below were derived deterministically from the dependency graph (units that",
|
|
523
|
+
"share a form target, a wizard, or a servlet; fragments attach as included context). Each",
|
|
524
|
+
"cluster is a candidate feature. You may split a cluster, join two, or fold single-page",
|
|
525
|
+
"clusters into one feature (e.g. a 'Portal shell and static pages' feature), but ONLY with",
|
|
526
|
+
"evidence from the specs, stated in the feature's `evidence`.",
|
|
527
|
+
"",
|
|
528
|
+
"Rules:",
|
|
529
|
+
"- Every scenario listed below appears EXACTLY once across the features, either as its own",
|
|
530
|
+
" line or inside another scenario's `mergedFrom` when two pages describe the same behaviour.",
|
|
531
|
+
"- Feature names are business language; `id` is kebab-case and unique.",
|
|
532
|
+
"- Anything you could not decide from the specs goes in `openPoints` as a question for the",
|
|
533
|
+
" human, never as a guess.",
|
|
534
|
+
...(packParagraph === undefined || packParagraph.trim().length === 0
|
|
535
|
+
? []
|
|
536
|
+
: ["", packParagraph.trim()]),
|
|
537
|
+
"",
|
|
538
|
+
"Clusters:",
|
|
539
|
+
...clusterLines,
|
|
540
|
+
"",
|
|
541
|
+
"Surviving scenarios per program:",
|
|
542
|
+
...scenarioLines,
|
|
543
|
+
"",
|
|
544
|
+
'Respond only with JSON: {"features":[{"id":"…","name":"…","programs":["…"],',
|
|
545
|
+
'"scenarios":[{"program":"…","title":"…","mergedFrom":[{"program":"…","title":"…"}]}],',
|
|
546
|
+
'"evidence":"…"}],"openPoints":["…"]}'
|
|
547
|
+
].join("\n")
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The map from a proposal: context comes from the clusters, open points are numbered. */
|
|
551
|
+
export const domainsFromProposal = (
|
|
552
|
+
proposal: DomainProposal,
|
|
553
|
+
clusters: ReadonlyArray<Cluster>,
|
|
554
|
+
inputsHash: string
|
|
555
|
+
): Domains =>
|
|
556
|
+
Domains.make({
|
|
557
|
+
features: proposal.features.map((feature) => {
|
|
558
|
+
const context = new Set<string>()
|
|
559
|
+
for (const cluster of clusters) {
|
|
560
|
+
if (cluster.programs.some((program) => feature.programs.includes(program))) {
|
|
561
|
+
for (const fragment of cluster.context) {
|
|
562
|
+
context.add(fragment)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return DomainFeature.make({
|
|
567
|
+
id: feature.id,
|
|
568
|
+
name: feature.name,
|
|
569
|
+
programs: feature.programs,
|
|
570
|
+
context: [...context].sort(),
|
|
571
|
+
scenarios: feature.scenarios.map((scenario) =>
|
|
572
|
+
FeatureScenario.make({
|
|
573
|
+
program: scenario.program,
|
|
574
|
+
title: scenario.title,
|
|
575
|
+
mergedFrom: scenario.mergedFrom ?? []
|
|
576
|
+
})
|
|
577
|
+
),
|
|
578
|
+
evidence: feature.evidence
|
|
579
|
+
})
|
|
580
|
+
}),
|
|
581
|
+
inputsHash,
|
|
582
|
+
openPoints: proposal.openPoints.map((question, index) =>
|
|
583
|
+
OpenPoint.make({ number: index + 1, question })
|
|
584
|
+
),
|
|
585
|
+
approved: false
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
/** Stable over key order; changes with any spec or the decisions text. */
|
|
589
|
+
export const domainsInputsHash = (
|
|
590
|
+
specs: Readonly<Record<string, string>>,
|
|
591
|
+
decisionsText: string
|
|
592
|
+
): string =>
|
|
593
|
+
stableHash(
|
|
594
|
+
[
|
|
595
|
+
...Object.entries(specs)
|
|
596
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
597
|
+
.map(([name, text]) => `${name}\n${text}`),
|
|
598
|
+
decisionsText
|
|
599
|
+
].join("\n\n")
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
// ---- Navigation order ----------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The order a feature's pages convert in: pages no other page of the feature
|
|
606
|
+
* reaches over a cluster edge first (the list before its edit form, step 1
|
|
607
|
+
* before step 2), then breadth-first along those edges, ties and unreached
|
|
608
|
+
* pages in the feature's own order. Edges through a shared non-page unit (a
|
|
609
|
+
* form target both pages post to) say nothing about order and are ignored.
|
|
610
|
+
*/
|
|
611
|
+
export const navigationOrder = (
|
|
612
|
+
feature: DomainFeature,
|
|
613
|
+
graph: SurveyGraph,
|
|
614
|
+
rules: ConsolidateRules
|
|
615
|
+
): ReadonlyArray<string> => {
|
|
616
|
+
const pages = new Set(feature.programs)
|
|
617
|
+
const edges = graph.edges.filter(
|
|
618
|
+
(edge) =>
|
|
619
|
+
kindMatches(edge.kind, rules.cluster) &&
|
|
620
|
+
pages.has(edge.from) &&
|
|
621
|
+
pages.has(edge.to) &&
|
|
622
|
+
edge.from !== edge.to
|
|
623
|
+
)
|
|
624
|
+
const inbound = new Map(feature.programs.map((page) => [page, 0]))
|
|
625
|
+
for (const edge of edges) {
|
|
626
|
+
inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1)
|
|
627
|
+
}
|
|
628
|
+
const ordered: Array<string> = []
|
|
629
|
+
const seen = new Set<string>()
|
|
630
|
+
const queue = feature.programs.filter((page) => (inbound.get(page) ?? 0) === 0)
|
|
631
|
+
while (queue.length > 0) {
|
|
632
|
+
const current = queue.shift()
|
|
633
|
+
if (current === undefined || seen.has(current)) {
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
seen.add(current)
|
|
637
|
+
ordered.push(current)
|
|
638
|
+
for (const edge of edges) {
|
|
639
|
+
if (edge.from === current && !seen.has(edge.to)) {
|
|
640
|
+
queue.push(edge.to)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const page of feature.programs) {
|
|
645
|
+
if (!seen.has(page)) {
|
|
646
|
+
ordered.push(page)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return ordered
|
|
650
|
+
}
|
package/src/FlowError.ts
CHANGED
|
@@ -186,6 +186,43 @@ export class StoryFailed extends Schema.TaggedError<StoryFailed>()("StoryFailed"
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/** A decisions or domains overlay that failed to parse or validate — every violation, not the first (ADR 0015). */
|
|
190
|
+
export class DecisionsInvalid extends Schema.TaggedError<DecisionsInvalid>()("DecisionsInvalid", {
|
|
191
|
+
path: Schema.optionalKey(Schema.String),
|
|
192
|
+
violations: Schema.Array(Schema.String)
|
|
193
|
+
}) {
|
|
194
|
+
get message(): string {
|
|
195
|
+
const where = this.path === undefined ? "" : ` in ${this.path}`
|
|
196
|
+
return `decisions invalid${where}:\n${this.violations.map((violation) => `- ${violation}`).join("\n")}`
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A refinement halted on questions only a human can answer; answer them in the file and rerun. */
|
|
201
|
+
export class OpenPointsPending extends Schema.TaggedError<OpenPointsPending>()(
|
|
202
|
+
"OpenPointsPending",
|
|
203
|
+
{
|
|
204
|
+
path: Schema.String,
|
|
205
|
+
points: Schema.Array(Schema.String)
|
|
206
|
+
}
|
|
207
|
+
) {
|
|
208
|
+
get message(): string {
|
|
209
|
+
return (
|
|
210
|
+
`${this.points.length} open point(s) in ${this.path} — answer them under '## Open points' and rerun:\n` +
|
|
211
|
+
this.points.map((point) => `- ${point}`).join("\n")
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Two page specs of one domain feature disagree on an API operation — never merged silently (ADR 0012 addendum). */
|
|
217
|
+
export class ContractConflict extends Schema.TaggedError<ContractConflict>()("ContractConflict", {
|
|
218
|
+
feature: Schema.String,
|
|
219
|
+
conflicts: Schema.Array(Schema.String)
|
|
220
|
+
}) {
|
|
221
|
+
get message(): string {
|
|
222
|
+
return `feature '${this.feature}' has conflicting API contracts:\n${this.conflicts.map((c) => `- ${c}`).join("\n")}`
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
189
226
|
export const FlowError = Schema.Union([
|
|
190
227
|
PersistenceError,
|
|
191
228
|
PlanParseError,
|
|
@@ -203,6 +240,9 @@ export const FlowError = Schema.Union([
|
|
|
203
240
|
PerimeterViolation,
|
|
204
241
|
MissingDependency,
|
|
205
242
|
MergeConflict,
|
|
206
|
-
StoryFailed
|
|
243
|
+
StoryFailed,
|
|
244
|
+
DecisionsInvalid,
|
|
245
|
+
OpenPointsPending,
|
|
246
|
+
ContractConflict
|
|
207
247
|
])
|
|
208
248
|
export type FlowError = typeof FlowError.Type
|