@llm4ts/flow 2.16.0 → 2.18.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/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/CoverageLedger.d.ts +180 -0
- package/dist/CoverageLedger.d.ts.map +1 -0
- package/dist/CoverageLedger.js +415 -0
- package/dist/CoverageLedger.js.map +1 -0
- package/dist/EpicBrief.d.ts +239 -0
- package/dist/EpicBrief.d.ts.map +1 -0
- package/dist/EpicBrief.js +868 -0
- package/dist/EpicBrief.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 +23 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +30 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Landing.d.ts +1 -1
- package/dist/Landing.d.ts.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 +3 -3
- 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/Roster.d.ts +1 -1
- package/dist/Roster.d.ts.map +1 -1
- package/dist/RosterSeats.d.ts +1 -1
- package/dist/RosterSeats.d.ts.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/CoverageLedger.ts +549 -0
- package/src/EpicBrief.ts +1014 -0
- package/src/FlowError.ts +39 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
import {
|
|
3
|
+
BriefStatus,
|
|
4
|
+
crossProblem,
|
|
5
|
+
dispositionWords,
|
|
6
|
+
hasOverride,
|
|
7
|
+
type BriefDisposition,
|
|
8
|
+
type EpicBrief,
|
|
9
|
+
type InheritedDecision,
|
|
10
|
+
type PackIndex
|
|
11
|
+
} from "./EpicBrief.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The coverage ledger: what the epic briefs of a target repository have
|
|
15
|
+
* decided about a legacy extract pack, scenario by scenario. It is derived,
|
|
16
|
+
* never state: a pure function of the pack, the briefs and the epics'
|
|
17
|
+
* recorded progress. The report, the context the next brief inherits and the
|
|
18
|
+
* checks across briefs all read it. Only approved briefs bind; a draft's
|
|
19
|
+
* claims are shown as proposed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const Disposition = Schema.Literals(["in-scope", "dropped", "provided", "deferred"])
|
|
23
|
+
|
|
24
|
+
/** One brief's word on one scenario. */
|
|
25
|
+
export const Claim = Schema.Struct({
|
|
26
|
+
epicId: Schema.String,
|
|
27
|
+
/** Whether the brief is approved; a draft's claim binds nothing. */
|
|
28
|
+
approved: Schema.Boolean,
|
|
29
|
+
disposition: Disposition,
|
|
30
|
+
/** The in-scope item's title, the reason, the pointer or the deferral note. */
|
|
31
|
+
note: Schema.String,
|
|
32
|
+
/** The epics this claim was kept against on purpose (`keep: <why>` in its brief). */
|
|
33
|
+
keptAgainst: Schema.Array(Schema.String),
|
|
34
|
+
/** Whether the claim still binds: false for a draft's, an overruled one, a deferral handed over. */
|
|
35
|
+
standing: Schema.Boolean
|
|
36
|
+
})
|
|
37
|
+
export type Claim = typeof Claim.Type
|
|
38
|
+
|
|
39
|
+
export const LedgerStatus = Schema.Literals([
|
|
40
|
+
"unclaimed",
|
|
41
|
+
"in-scope",
|
|
42
|
+
"dropped",
|
|
43
|
+
"provided",
|
|
44
|
+
"deferred",
|
|
45
|
+
"proposed",
|
|
46
|
+
"conflict"
|
|
47
|
+
])
|
|
48
|
+
export type LedgerStatus = typeof LedgerStatus.Type
|
|
49
|
+
|
|
50
|
+
/** How far the owning epic is: brief approved, planned, stories merging, landed. */
|
|
51
|
+
export const DeliveryState = Schema.Literals(["approved", "planned", "in-progress", "landed"])
|
|
52
|
+
export type DeliveryState = typeof DeliveryState.Type
|
|
53
|
+
|
|
54
|
+
export const LedgerEntry = Schema.Struct({
|
|
55
|
+
program: Schema.String,
|
|
56
|
+
scenario: Schema.String,
|
|
57
|
+
status: LedgerStatus,
|
|
58
|
+
/** Every claim on the scenario, approved or not, in brief order. */
|
|
59
|
+
claims: Schema.Array(Claim),
|
|
60
|
+
/** The approved epics behind the status; both sides of a conflict. */
|
|
61
|
+
owners: Schema.Array(Schema.String),
|
|
62
|
+
/** In-scope entries only: the owning epic's delivery state. */
|
|
63
|
+
delivery: Schema.optionalKey(DeliveryState)
|
|
64
|
+
})
|
|
65
|
+
export type LedgerEntry = typeof LedgerEntry.Type
|
|
66
|
+
|
|
67
|
+
export const ProgramTotals = Schema.Struct({
|
|
68
|
+
program: Schema.String,
|
|
69
|
+
scenarios: Schema.Int,
|
|
70
|
+
inScope: Schema.Int,
|
|
71
|
+
dropped: Schema.Int,
|
|
72
|
+
provided: Schema.Int,
|
|
73
|
+
deferred: Schema.Int,
|
|
74
|
+
proposed: Schema.Int,
|
|
75
|
+
unclaimed: Schema.Int,
|
|
76
|
+
conflicts: Schema.Int
|
|
77
|
+
})
|
|
78
|
+
export type ProgramTotals = typeof ProgramTotals.Type
|
|
79
|
+
|
|
80
|
+
/** A claim on a program or scenario the pack no longer has. */
|
|
81
|
+
export const StaleClaim = Schema.Struct({
|
|
82
|
+
epicId: Schema.String,
|
|
83
|
+
program: Schema.String,
|
|
84
|
+
scenario: Schema.String,
|
|
85
|
+
disposition: Disposition
|
|
86
|
+
})
|
|
87
|
+
export type StaleClaim = typeof StaleClaim.Type
|
|
88
|
+
|
|
89
|
+
export const LedgerBriefInfo = Schema.Struct({
|
|
90
|
+
epicId: Schema.String,
|
|
91
|
+
status: BriefStatus,
|
|
92
|
+
delivery: Schema.optionalKey(DeliveryState)
|
|
93
|
+
})
|
|
94
|
+
export type LedgerBriefInfo = typeof LedgerBriefInfo.Type
|
|
95
|
+
|
|
96
|
+
export const CoverageLedger = Schema.Struct({
|
|
97
|
+
entries: Schema.Array(LedgerEntry),
|
|
98
|
+
programs: Schema.Array(ProgramTotals),
|
|
99
|
+
stale: Schema.Array(StaleClaim),
|
|
100
|
+
/** Briefs designed against another legacy repository than the pack in hand. */
|
|
101
|
+
skipped: Schema.Array(Schema.String),
|
|
102
|
+
briefs: Schema.Array(LedgerBriefInfo),
|
|
103
|
+
totals: Schema.Struct({
|
|
104
|
+
scenarios: Schema.Int,
|
|
105
|
+
/** Decided: in scope, dropped or provided by an approved brief. */
|
|
106
|
+
accounted: Schema.Int,
|
|
107
|
+
/** Dropped, provided, or in scope in an epic that landed. */
|
|
108
|
+
delivered: Schema.Int,
|
|
109
|
+
/** Unclaimed, deferred, only proposed, or in conflict. */
|
|
110
|
+
remaining: Schema.Int,
|
|
111
|
+
conflicts: Schema.Int
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
export type CoverageLedger = typeof CoverageLedger.Type
|
|
115
|
+
|
|
116
|
+
export interface LedgerBrief {
|
|
117
|
+
/** The epic's folder name. */
|
|
118
|
+
readonly epicId: string
|
|
119
|
+
readonly brief: EpicBrief
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** What `epic-stories` recorded for an epic. */
|
|
123
|
+
export interface EpicProgress {
|
|
124
|
+
readonly epicId: string
|
|
125
|
+
readonly planned: boolean
|
|
126
|
+
readonly stories: number
|
|
127
|
+
readonly merged: number
|
|
128
|
+
readonly landed: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface LedgerInputs {
|
|
132
|
+
readonly pack: PackIndex
|
|
133
|
+
readonly briefs: ReadonlyArray<LedgerBrief>
|
|
134
|
+
readonly epics: ReadonlyArray<EpicProgress>
|
|
135
|
+
/** The legacy repository the pack came from; briefs naming another are skipped. */
|
|
136
|
+
readonly legacy?: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface Located {
|
|
140
|
+
readonly epicId: string
|
|
141
|
+
readonly approved: boolean
|
|
142
|
+
readonly disposition: BriefDisposition
|
|
143
|
+
readonly note: string
|
|
144
|
+
readonly program: string
|
|
145
|
+
readonly scenario: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const claimsOf = (epicId: string, brief: EpicBrief): ReadonlyArray<Located> => {
|
|
149
|
+
const approved = brief.status === "approved"
|
|
150
|
+
const disposed = (
|
|
151
|
+
disposition: BriefDisposition,
|
|
152
|
+
entries: EpicBrief["dropped"]
|
|
153
|
+
): ReadonlyArray<Located> =>
|
|
154
|
+
entries.map((entry) => ({
|
|
155
|
+
epicId,
|
|
156
|
+
approved,
|
|
157
|
+
disposition,
|
|
158
|
+
note: entry.note,
|
|
159
|
+
program: entry.program,
|
|
160
|
+
scenario: entry.scenario
|
|
161
|
+
}))
|
|
162
|
+
return [
|
|
163
|
+
...brief.scope.flatMap((item) =>
|
|
164
|
+
item.citations.map(
|
|
165
|
+
(citation): Located => ({
|
|
166
|
+
epicId,
|
|
167
|
+
approved,
|
|
168
|
+
disposition: "in-scope",
|
|
169
|
+
note: item.title,
|
|
170
|
+
program: citation.program,
|
|
171
|
+
scenario: citation.scenario
|
|
172
|
+
})
|
|
173
|
+
)
|
|
174
|
+
),
|
|
175
|
+
...disposed("dropped", brief.dropped),
|
|
176
|
+
...disposed("provided", brief.provided),
|
|
177
|
+
...disposed("deferred", brief.deferred)
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const unique = (values: ReadonlyArray<string>): ReadonlyArray<string> => [...new Set(values)]
|
|
182
|
+
|
|
183
|
+
const trimmed = (path: string): string => path.replace(/[\\/]+$/, "")
|
|
184
|
+
const lastSegment = (path: string): string => trimmed(path).split(/[\\/]/).at(-1) ?? ""
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether two `Legacy:` paths name the same repository. The same checkout is
|
|
188
|
+
* written with or without a trailing slash, and lives under another home on a
|
|
189
|
+
* colleague's machine or in CI: the folder's name is what stays.
|
|
190
|
+
*/
|
|
191
|
+
export const sameLegacy = (left: string, right: string): boolean =>
|
|
192
|
+
trimmed(left) === trimmed(right) ||
|
|
193
|
+
(lastSegment(left).length > 0 && lastSegment(left) === lastSegment(right))
|
|
194
|
+
|
|
195
|
+
const deliveryOf = (progress: EpicProgress | undefined): DeliveryState =>
|
|
196
|
+
progress === undefined
|
|
197
|
+
? "approved"
|
|
198
|
+
: progress.landed
|
|
199
|
+
? "landed"
|
|
200
|
+
: progress.merged > 0
|
|
201
|
+
? "in-progress"
|
|
202
|
+
: progress.planned
|
|
203
|
+
? "planned"
|
|
204
|
+
: "approved"
|
|
205
|
+
|
|
206
|
+
interface Settled {
|
|
207
|
+
readonly status: LedgerStatus
|
|
208
|
+
readonly owners: ReadonlyArray<string>
|
|
209
|
+
readonly claims: ReadonlyArray<Claim>
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const buildLedger = (inputs: LedgerInputs): CoverageLedger => {
|
|
213
|
+
const legacy = inputs.legacy
|
|
214
|
+
const skipped = inputs.briefs
|
|
215
|
+
.filter((entry) => legacy !== undefined && !sameLegacy(entry.brief.legacy, legacy))
|
|
216
|
+
.map((entry) => entry.epicId)
|
|
217
|
+
const read = inputs.briefs.filter((entry) => !skipped.includes(entry.epicId))
|
|
218
|
+
const briefById = new Map(read.map((entry) => [entry.epicId, entry.brief]))
|
|
219
|
+
const progressById = new Map(inputs.epics.map((epic) => [epic.epicId, epic]))
|
|
220
|
+
const known = new Map(inputs.pack.programs.map((program) => [program.name, program.scenarios]))
|
|
221
|
+
|
|
222
|
+
const bySlot = new Map<string, Array<Located>>()
|
|
223
|
+
const stale: Array<StaleClaim> = []
|
|
224
|
+
for (const entry of read) {
|
|
225
|
+
for (const claim of claimsOf(entry.epicId, entry.brief)) {
|
|
226
|
+
if (!(known.get(claim.program) ?? []).includes(claim.scenario)) {
|
|
227
|
+
stale.push({
|
|
228
|
+
epicId: claim.epicId,
|
|
229
|
+
program: claim.program,
|
|
230
|
+
scenario: claim.scenario,
|
|
231
|
+
disposition: claim.disposition
|
|
232
|
+
})
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
const key = `${claim.program}\u0000${claim.scenario}`
|
|
236
|
+
bySlot.set(key, [...(bySlot.get(key) ?? []), claim])
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The status the approved claims give a scenario. Deferrals bind nobody.
|
|
242
|
+
* Every other pair of claims is put to `crossProblem`, the rule the
|
|
243
|
+
* per-brief check uses: a clash nobody kept on purpose is a conflict;
|
|
244
|
+
* shared ownership needs one `keep:`; a contradiction is settled for the
|
|
245
|
+
* one brief that kept its decision, and the other claim becomes history.
|
|
246
|
+
*/
|
|
247
|
+
const settle = (program: string, scenario: string, located: ReadonlyArray<Located>): Settled => {
|
|
248
|
+
const binding = located.filter((claim) => claim.approved && claim.disposition !== "deferred")
|
|
249
|
+
const deferrers = located.filter((claim) => claim.approved && claim.disposition === "deferred")
|
|
250
|
+
const kept = (mine: Located, theirs: Located): boolean => {
|
|
251
|
+
const brief = briefById.get(mine.epicId)
|
|
252
|
+
const problem = crossProblem(program, scenario, mine.disposition, {
|
|
253
|
+
epic: theirs.epicId,
|
|
254
|
+
disposition: theirs.disposition
|
|
255
|
+
})
|
|
256
|
+
return brief !== undefined && problem !== undefined && hasOverride(brief, problem)
|
|
257
|
+
}
|
|
258
|
+
const keptAgainst = new Map<Located, Array<string>>()
|
|
259
|
+
const overruled = new Set<Located>()
|
|
260
|
+
let disputed = false
|
|
261
|
+
for (let i = 0; i < binding.length; i += 1) {
|
|
262
|
+
for (let j = i + 1; j < binding.length; j += 1) {
|
|
263
|
+
const left = binding[i]
|
|
264
|
+
const right = binding[j]
|
|
265
|
+
if (left === undefined || right === undefined) continue
|
|
266
|
+
if (left.epicId === right.epicId) {
|
|
267
|
+
// One brief disposing of a scenario twice disagrees with itself.
|
|
268
|
+
if (left.disposition !== right.disposition) disputed = true
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
const clash = crossProblem(program, scenario, left.disposition, {
|
|
272
|
+
epic: right.epicId,
|
|
273
|
+
disposition: right.disposition
|
|
274
|
+
})
|
|
275
|
+
if (clash === undefined) continue
|
|
276
|
+
const leftKeeps = kept(left, right)
|
|
277
|
+
const rightKeeps = kept(right, left)
|
|
278
|
+
if (leftKeeps) keptAgainst.set(left, [...(keptAgainst.get(left) ?? []), right.epicId])
|
|
279
|
+
if (rightKeeps) keptAgainst.set(right, [...(keptAgainst.get(right) ?? []), left.epicId])
|
|
280
|
+
if (clash.kind === "AlreadyOwned") {
|
|
281
|
+
if (!leftKeeps && !rightKeeps) disputed = true
|
|
282
|
+
} else if (leftKeeps === rightKeeps) {
|
|
283
|
+
// Nobody kept it, or both insist: still a contradiction.
|
|
284
|
+
disputed = true
|
|
285
|
+
} else {
|
|
286
|
+
overruled.add(leftKeeps ? right : left)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const survivors = binding.filter((claim) => !overruled.has(claim))
|
|
291
|
+
const conflict = disputed || (binding.length > 0 && survivors.length === 0)
|
|
292
|
+
const status: LedgerStatus =
|
|
293
|
+
located.filter((claim) => claim.approved).length === 0
|
|
294
|
+
? located.length === 0
|
|
295
|
+
? "unclaimed"
|
|
296
|
+
: "proposed"
|
|
297
|
+
: conflict
|
|
298
|
+
? "conflict"
|
|
299
|
+
: (survivors[0]?.disposition ?? "deferred")
|
|
300
|
+
const standing = (claim: Located): boolean =>
|
|
301
|
+
!claim.approved
|
|
302
|
+
? false
|
|
303
|
+
: conflict
|
|
304
|
+
? claim.disposition !== "deferred"
|
|
305
|
+
: claim.disposition === "deferred"
|
|
306
|
+
? status === "deferred"
|
|
307
|
+
: !overruled.has(claim)
|
|
308
|
+
const owners =
|
|
309
|
+
status === "conflict"
|
|
310
|
+
? unique(binding.map((claim) => claim.epicId))
|
|
311
|
+
: status === "deferred"
|
|
312
|
+
? unique(deferrers.map((claim) => claim.epicId))
|
|
313
|
+
: unique(survivors.map((claim) => claim.epicId))
|
|
314
|
+
return {
|
|
315
|
+
status,
|
|
316
|
+
owners: located.filter((claim) => claim.approved).length === 0 ? [] : owners,
|
|
317
|
+
claims: located.map((claim) => ({
|
|
318
|
+
epicId: claim.epicId,
|
|
319
|
+
approved: claim.approved,
|
|
320
|
+
disposition: claim.disposition,
|
|
321
|
+
note: claim.note,
|
|
322
|
+
keptAgainst: keptAgainst.get(claim) ?? [],
|
|
323
|
+
standing: standing(claim)
|
|
324
|
+
}))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const entries: Array<LedgerEntry> = []
|
|
329
|
+
for (const program of inputs.pack.programs) {
|
|
330
|
+
for (const scenario of program.scenarios) {
|
|
331
|
+
const located = bySlot.get(`${program.name}\u0000${scenario}`) ?? []
|
|
332
|
+
const { status, owners, claims } = settle(program.name, scenario, located)
|
|
333
|
+
const [owner] = owners
|
|
334
|
+
entries.push({
|
|
335
|
+
program: program.name,
|
|
336
|
+
scenario,
|
|
337
|
+
status,
|
|
338
|
+
claims,
|
|
339
|
+
owners,
|
|
340
|
+
...(status === "in-scope" && owner !== undefined
|
|
341
|
+
? { delivery: deliveryOf(progressById.get(owner)) }
|
|
342
|
+
: {})
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const count = (list: ReadonlyArray<LedgerEntry>, status: LedgerStatus): number =>
|
|
348
|
+
list.filter((entry) => entry.status === status).length
|
|
349
|
+
const programs = inputs.pack.programs.map((program): ProgramTotals => {
|
|
350
|
+
const own = entries.filter((entry) => entry.program === program.name)
|
|
351
|
+
return {
|
|
352
|
+
program: program.name,
|
|
353
|
+
scenarios: own.length,
|
|
354
|
+
inScope: count(own, "in-scope"),
|
|
355
|
+
dropped: count(own, "dropped"),
|
|
356
|
+
provided: count(own, "provided"),
|
|
357
|
+
deferred: count(own, "deferred"),
|
|
358
|
+
proposed: count(own, "proposed"),
|
|
359
|
+
unclaimed: count(own, "unclaimed"),
|
|
360
|
+
conflicts: count(own, "conflict")
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
const accounted =
|
|
364
|
+
count(entries, "in-scope") + count(entries, "dropped") + count(entries, "provided")
|
|
365
|
+
return {
|
|
366
|
+
entries,
|
|
367
|
+
programs,
|
|
368
|
+
stale,
|
|
369
|
+
skipped,
|
|
370
|
+
briefs: read.map((entry) => ({
|
|
371
|
+
epicId: entry.epicId,
|
|
372
|
+
status: entry.brief.status,
|
|
373
|
+
...(entry.brief.status === "approved"
|
|
374
|
+
? { delivery: deliveryOf(progressById.get(entry.epicId)) }
|
|
375
|
+
: {})
|
|
376
|
+
})),
|
|
377
|
+
totals: {
|
|
378
|
+
scenarios: entries.length,
|
|
379
|
+
accounted,
|
|
380
|
+
delivered:
|
|
381
|
+
count(entries, "dropped") +
|
|
382
|
+
count(entries, "provided") +
|
|
383
|
+
entries.filter((entry) => entry.status === "in-scope" && entry.delivery === "landed")
|
|
384
|
+
.length,
|
|
385
|
+
remaining: entries.length - accounted,
|
|
386
|
+
conflicts: count(entries, "conflict")
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* What approved briefs decided, scenario by scenario: the view another brief
|
|
393
|
+
* inherits. Only claims that still stand: an overruled decision, or a
|
|
394
|
+
* deferral another epic took up, binds nobody.
|
|
395
|
+
*/
|
|
396
|
+
export const inheritedFrom = (ledger: CoverageLedger): ReadonlyArray<InheritedDecision> =>
|
|
397
|
+
ledger.entries.flatMap((entry) =>
|
|
398
|
+
entry.claims
|
|
399
|
+
.filter((claim) => claim.approved && claim.standing)
|
|
400
|
+
.map((claim) => ({
|
|
401
|
+
program: entry.program,
|
|
402
|
+
scenario: entry.scenario,
|
|
403
|
+
epic: claim.epicId,
|
|
404
|
+
disposition: claim.disposition,
|
|
405
|
+
note: claim.note,
|
|
406
|
+
...(claim.keptAgainst.length === 0 ? {} : { keptAgainst: claim.keptAgainst })
|
|
407
|
+
}))
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
// ---- The report -----------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
const percent = (part: number, whole: number): string =>
|
|
413
|
+
// Floored: 299 of 300 is 99%, never a whole it has not reached.
|
|
414
|
+
`${whole === 0 ? 0 : Math.floor((part / whole) * 100)}%`
|
|
415
|
+
|
|
416
|
+
const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`
|
|
417
|
+
|
|
418
|
+
/** `142 scenarios · 96 accounted for (68%) · 58 delivered · 46 remaining · 2 conflicts`. */
|
|
419
|
+
export const ledgerHeadline = (ledger: CoverageLedger): string => {
|
|
420
|
+
const totals = ledger.totals
|
|
421
|
+
return [
|
|
422
|
+
plural(totals.scenarios, "scenario"),
|
|
423
|
+
`${totals.accounted} accounted for (${percent(totals.accounted, totals.scenarios)})`,
|
|
424
|
+
`${totals.delivered} delivered`,
|
|
425
|
+
`${totals.remaining} remaining`,
|
|
426
|
+
plural(totals.conflicts, "conflict")
|
|
427
|
+
].join(" · ")
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const deliveryWords: Readonly<Record<DeliveryState, string>> = {
|
|
431
|
+
approved: "approved",
|
|
432
|
+
planned: "planned",
|
|
433
|
+
"in-progress": "in progress",
|
|
434
|
+
landed: "landed"
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const claimText = (claim: Claim): string =>
|
|
438
|
+
`${claim.epicId}: ${dispositionWords[claim.disposition]}${claim.note.length === 0 ? "" : ` (${claim.note})`}`
|
|
439
|
+
|
|
440
|
+
const titled = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
441
|
+
lines.length === 0 ? [] : [`## ${title}`, "", ...lines, ""]
|
|
442
|
+
|
|
443
|
+
export interface RenderLedgerOptions {
|
|
444
|
+
/** The legacy repository the pack was read from. */
|
|
445
|
+
readonly legacy: string
|
|
446
|
+
/** Brief files that did not parse, with the first thing wrong in each. */
|
|
447
|
+
readonly unreadable?: ReadonlyArray<{ readonly dir: string; readonly reason: string }>
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The ledger as a page a person reads: the headline, a table per program,
|
|
452
|
+
* then only the lists that need someone: conflicts, scenarios still
|
|
453
|
+
* deferred, unclaimed ones, what drafts propose, stale citations.
|
|
454
|
+
*/
|
|
455
|
+
export const renderLedger = (ledger: CoverageLedger, options: RenderLedgerOptions): string => {
|
|
456
|
+
const of = (status: LedgerStatus): ReadonlyArray<LedgerEntry> =>
|
|
457
|
+
ledger.entries.filter((entry) => entry.status === status)
|
|
458
|
+
const name = (entry: LedgerEntry): string => `${entry.program} › ${entry.scenario}`
|
|
459
|
+
const unclaimed = ledger.programs.flatMap((program) => {
|
|
460
|
+
const open = of("unclaimed").filter((entry) => entry.program === program.program)
|
|
461
|
+
return open.length === 0
|
|
462
|
+
? []
|
|
463
|
+
: [`- ${program.program}: ${open.map((entry) => entry.scenario).join("; ")}`]
|
|
464
|
+
})
|
|
465
|
+
return [
|
|
466
|
+
"# Coverage ledger",
|
|
467
|
+
"",
|
|
468
|
+
ledgerHeadline(ledger),
|
|
469
|
+
"",
|
|
470
|
+
`Legacy: ${options.legacy}`,
|
|
471
|
+
"",
|
|
472
|
+
"Derived from the epic briefs of this repository and the legacy extract pack. Regenerate",
|
|
473
|
+
"it with `epic-design --coverage`; to change it, change a brief.",
|
|
474
|
+
"",
|
|
475
|
+
"## Briefs",
|
|
476
|
+
"",
|
|
477
|
+
...(ledger.briefs.length === 0
|
|
478
|
+
? ["no epic brief yet"]
|
|
479
|
+
: ledger.briefs.map((brief) => {
|
|
480
|
+
const delivery =
|
|
481
|
+
brief.delivery === undefined || brief.delivery === "approved"
|
|
482
|
+
? ""
|
|
483
|
+
: `, ${deliveryWords[brief.delivery]}`
|
|
484
|
+
return `- ${brief.epicId} — ${brief.status}${delivery}`
|
|
485
|
+
})),
|
|
486
|
+
"",
|
|
487
|
+
"## Programs",
|
|
488
|
+
"",
|
|
489
|
+
"| Program | Scenarios | In scope | Dropped | Provided | Deferred | Proposed | Unclaimed | Conflicts | Accounted |",
|
|
490
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
491
|
+
...ledger.programs.map(
|
|
492
|
+
(program) =>
|
|
493
|
+
`| ${[
|
|
494
|
+
program.program,
|
|
495
|
+
program.scenarios,
|
|
496
|
+
program.inScope,
|
|
497
|
+
program.dropped,
|
|
498
|
+
program.provided,
|
|
499
|
+
program.deferred,
|
|
500
|
+
program.proposed,
|
|
501
|
+
program.unclaimed,
|
|
502
|
+
program.conflicts,
|
|
503
|
+
percent(program.inScope + program.dropped + program.provided, program.scenarios)
|
|
504
|
+
].join(" | ")} |`
|
|
505
|
+
),
|
|
506
|
+
"",
|
|
507
|
+
...titled(
|
|
508
|
+
"Conflicts",
|
|
509
|
+
of("conflict").map(
|
|
510
|
+
(entry) =>
|
|
511
|
+
`- ${name(entry)} — ${entry.claims
|
|
512
|
+
.filter((claim) => claim.approved)
|
|
513
|
+
.map(claimText)
|
|
514
|
+
.join("; ")}`
|
|
515
|
+
)
|
|
516
|
+
),
|
|
517
|
+
...titled(
|
|
518
|
+
"Deferred, still waiting",
|
|
519
|
+
of("deferred").map((entry) => {
|
|
520
|
+
const claim = entry.claims.find((c) => c.approved && c.disposition === "deferred")
|
|
521
|
+
return `- ${name(entry)} — deferred by ${claim?.epicId ?? entry.owners.join(", ")}${
|
|
522
|
+
claim === undefined || claim.note.length === 0 ? "" : `: ${claim.note}`
|
|
523
|
+
}`
|
|
524
|
+
})
|
|
525
|
+
),
|
|
526
|
+
...titled("Unclaimed", unclaimed),
|
|
527
|
+
...titled(
|
|
528
|
+
"Proposed by drafts",
|
|
529
|
+
of("proposed").map((entry) => `- ${name(entry)} — ${entry.claims.map(claimText).join("; ")}`)
|
|
530
|
+
),
|
|
531
|
+
...titled(
|
|
532
|
+
"Stale citations",
|
|
533
|
+
ledger.stale.map(
|
|
534
|
+
(claim) =>
|
|
535
|
+
`- ${claim.epicId} cites ${claim.program} › ${claim.scenario} (${dispositionWords[claim.disposition]}), which the pack no longer has`
|
|
536
|
+
)
|
|
537
|
+
),
|
|
538
|
+
...titled(
|
|
539
|
+
"Skipped briefs",
|
|
540
|
+
ledger.skipped.map((epicId) => `- ${epicId}`)
|
|
541
|
+
),
|
|
542
|
+
...titled(
|
|
543
|
+
"Briefs that could not be read",
|
|
544
|
+
(options.unreadable ?? []).map((brief) => `- ${brief.dir}: ${brief.reason}`)
|
|
545
|
+
)
|
|
546
|
+
]
|
|
547
|
+
.join("\n")
|
|
548
|
+
.replace(/\n+$/, "\n")
|
|
549
|
+
}
|