@iceinvein/agent-skills 0.3.0 → 0.4.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/README.md +1 -1
- package/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/migrate/README.md +35 -23
- package/skills/migrate/SKILL.md +75 -15
- package/skills/migrate/bin/migrate.ts +90 -0
- package/skills/migrate/docs/architecture.md +61 -26
- package/skills/migrate/docs/reference.md +53 -8
- package/skills/migrate/fixtures/fake-gh.ts +113 -0
- package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
- package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
- package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
- package/skills/migrate/package.json +1 -1
- package/skills/migrate/references/phases/adjudicate.md +161 -0
- package/skills/migrate/references/phases/handoff.md +220 -0
- package/skills/migrate/references/phases/probe.md +2 -2
- package/skills/migrate/references/phases/queue.md +21 -14
- package/skills/migrate/references/run-ops.md +17 -13
- package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
- package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
- package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
- package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
- package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
- package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
- package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
- package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
- package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
- package/skills/migrate/scripts/adapters/flow.ts +280 -0
- package/skills/migrate/scripts/adapters/github.ts +260 -0
- package/skills/migrate/scripts/adapters/markdown.ts +175 -0
- package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
- package/skills/migrate/scripts/assumptions.ts +188 -0
- package/skills/migrate/scripts/check.ts +119 -320
- package/skills/migrate/scripts/coverage-cmd.ts +86 -0
- package/skills/migrate/scripts/coverage.ts +151 -0
- package/skills/migrate/scripts/dates.ts +17 -0
- package/skills/migrate/scripts/forecast-cmd.ts +124 -0
- package/skills/migrate/scripts/forecast.ts +264 -0
- package/skills/migrate/scripts/gates/adjudication.ts +30 -0
- package/skills/migrate/scripts/gates/census.ts +107 -0
- package/skills/migrate/scripts/gates/citations.ts +11 -0
- package/skills/migrate/scripts/gates/context.ts +76 -0
- package/skills/migrate/scripts/gates/coverage.ts +22 -0
- package/skills/migrate/scripts/gates/deltas.ts +15 -0
- package/skills/migrate/scripts/gates/handoff.ts +145 -0
- package/skills/migrate/scripts/gates/leaks.ts +11 -0
- package/skills/migrate/scripts/gates/parity.ts +15 -0
- package/skills/migrate/scripts/gates/queue.ts +9 -0
- package/skills/migrate/scripts/gates/refs.ts +97 -0
- package/skills/migrate/scripts/gates/run-state.ts +67 -0
- package/skills/migrate/scripts/gates/source.ts +28 -0
- package/skills/migrate/scripts/handoff-cmd.ts +186 -0
- package/skills/migrate/scripts/handoff.ts +330 -0
- package/skills/migrate/scripts/paths.ts +4 -0
- package/skills/migrate/scripts/types.ts +43 -0
- package/skills/migrate/scripts/validate.ts +12 -0
- package/skills/migrate/skill.json +2 -2
- package/skills/migrate/templates/forecast-assumptions.md +59 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { applyRuling, renderReviewSheet } from '../adjudicate-cmd.ts'
|
|
6
|
+
import { parseQueueItem } from '../queue.ts'
|
|
7
|
+
import type { QueueItem } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
|
|
10
|
+
|
|
11
|
+
let target: string
|
|
12
|
+
let source: string
|
|
13
|
+
|
|
14
|
+
async function migrate(args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
15
|
+
const proc = Bun.spawn(['bun', CLI, ...args], { cwd: target, stdout: 'pipe', stderr: 'pipe' })
|
|
16
|
+
const [out, err] = await Promise.all([
|
|
17
|
+
new Response(proc.stdout).text(),
|
|
18
|
+
new Response(proc.stderr).text(),
|
|
19
|
+
])
|
|
20
|
+
await proc.exited
|
|
21
|
+
return { code: proc.exitCode ?? -1, out, err }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function item(id: string, severity: string, extra = ''): string {
|
|
25
|
+
return `---
|
|
26
|
+
id: ${id}
|
|
27
|
+
severity: ${severity}
|
|
28
|
+
status: open
|
|
29
|
+
${extra}---
|
|
30
|
+
|
|
31
|
+
## Evidence
|
|
32
|
+
|
|
33
|
+
Two tables both look like they own the join.
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
1. Give it to billing.
|
|
38
|
+
2. Give it to scheduling.
|
|
39
|
+
|
|
40
|
+
## Recommendation
|
|
41
|
+
|
|
42
|
+
Billing owns it, on the strength of the write sites.
|
|
43
|
+
`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function addItem(id: string, severity: string, extra = ''): Promise<void> {
|
|
47
|
+
const path = join(target, `${id}.md`)
|
|
48
|
+
await writeFile(path, item(id, severity, extra))
|
|
49
|
+
const added = await migrate(['queue', 'add', path])
|
|
50
|
+
expect(added.code).toBe(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
target = await mkdtemp(join(tmpdir(), 'migrate-adjudicate-'))
|
|
55
|
+
source = join(target, 'legacy')
|
|
56
|
+
await Bun.write(join(source, 'app.js'), '// legacy\n')
|
|
57
|
+
const init = await migrate(['init', '--source', source, '--scope', 'x', '--name', 'target'])
|
|
58
|
+
expect(init.code).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
await rm(target, { recursive: true, force: true })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// --- applyRuling, pure ---
|
|
66
|
+
|
|
67
|
+
test('applyRuling keeps unowned keys in place and the body byte-identical', () => {
|
|
68
|
+
const body = `
|
|
69
|
+
## Evidence
|
|
70
|
+
|
|
71
|
+
A body with a --- line inside it:
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
and a fenced block:
|
|
76
|
+
|
|
77
|
+
\`\`\`
|
|
78
|
+
status: open
|
|
79
|
+
ruling: not a real key
|
|
80
|
+
\`\`\`
|
|
81
|
+
|
|
82
|
+
## Options
|
|
83
|
+
|
|
84
|
+
Only one.
|
|
85
|
+
|
|
86
|
+
## Recommendation
|
|
87
|
+
|
|
88
|
+
Take it.
|
|
89
|
+
`
|
|
90
|
+
// `severity` deliberately sits AFTER `status`, the one owned key already
|
|
91
|
+
// present. With the owned key last, an implementation that simply dropped
|
|
92
|
+
// owned keys and appended them would produce the same output as one that
|
|
93
|
+
// replaces in place, and this test would assert nothing about position.
|
|
94
|
+
const before = `---
|
|
95
|
+
id: q-table-ownership
|
|
96
|
+
owner: dik
|
|
97
|
+
status: open
|
|
98
|
+
severity: critical
|
|
99
|
+
---${body}`
|
|
100
|
+
|
|
101
|
+
const after = applyRuling(before, 'billing owns it', '2026-08-13')
|
|
102
|
+
|
|
103
|
+
// Body preserved byte for byte, including the --- line and the fence.
|
|
104
|
+
expect(after.slice(after.indexOf('\n---', 3) + 4)).toBe(body)
|
|
105
|
+
const fm = after.slice(4, after.indexOf('\n---', 3))
|
|
106
|
+
expect(fm.split('\n')).toEqual([
|
|
107
|
+
'id: q-table-ownership',
|
|
108
|
+
'owner: dik',
|
|
109
|
+
'status: adjudicated',
|
|
110
|
+
'severity: critical',
|
|
111
|
+
'ruling: billing owns it',
|
|
112
|
+
'adjudicated: 2026-08-13',
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('applyRuling rewrites an existing ruling in place rather than appending a second', () => {
|
|
117
|
+
const before = `---
|
|
118
|
+
id: q-x
|
|
119
|
+
severity: minor
|
|
120
|
+
status: adjudicated
|
|
121
|
+
ruling: an earlier call
|
|
122
|
+
adjudicated: 2026-08-01
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Evidence
|
|
126
|
+
|
|
127
|
+
e
|
|
128
|
+
|
|
129
|
+
## Options
|
|
130
|
+
|
|
131
|
+
o
|
|
132
|
+
|
|
133
|
+
## Recommendation
|
|
134
|
+
|
|
135
|
+
r
|
|
136
|
+
`
|
|
137
|
+
const after = applyRuling(before, 'the revised call', '2026-08-13')
|
|
138
|
+
const fm = after.slice(4, after.indexOf('\n---', 3))
|
|
139
|
+
expect(fm.split('\n')).toEqual([
|
|
140
|
+
'id: q-x',
|
|
141
|
+
'severity: minor',
|
|
142
|
+
'status: adjudicated',
|
|
143
|
+
'ruling: the revised call',
|
|
144
|
+
'adjudicated: 2026-08-13',
|
|
145
|
+
])
|
|
146
|
+
expect(after.match(/^ruling:/gm)).toHaveLength(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('applyRuling refuses a ruling that would break the frontmatter it writes into', () => {
|
|
150
|
+
const before = item('q-x', 'minor')
|
|
151
|
+
// A line break is the whole hazard: it lets the value inject further
|
|
152
|
+
// frontmatter keys, or a closing fence. A value of '---' is not a hazard,
|
|
153
|
+
// because it is written as `ruling: ---` and so never sits at line start.
|
|
154
|
+
expect(() => applyRuling(before, 'line one\nline two', '2026-08-13')).toThrow(/newline/)
|
|
155
|
+
expect(() => applyRuling(before, 'line one\rline two', '2026-08-13')).toThrow(/newline/)
|
|
156
|
+
expect(() => applyRuling(before, ' ', '2026-08-13')).toThrow(/empty/)
|
|
157
|
+
expect(applyRuling(before, '---', '2026-08-13')).toContain('ruling: ---')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// --- renderReviewSheet, pure ---
|
|
161
|
+
|
|
162
|
+
test('renderReviewSheet is severity-ordered, carries each recommendation, and counts open', () => {
|
|
163
|
+
const items: QueueItem[] = [
|
|
164
|
+
{
|
|
165
|
+
id: 'q-minor-thing',
|
|
166
|
+
severity: 'minor',
|
|
167
|
+
status: 'open',
|
|
168
|
+
evidence: 'e',
|
|
169
|
+
options: 'o',
|
|
170
|
+
recommendation: 'Leave it alone.\nA second line nobody needs here.',
|
|
171
|
+
path: 'p',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'q-big-thing',
|
|
175
|
+
severity: 'critical',
|
|
176
|
+
status: 'adjudicated',
|
|
177
|
+
ruling: 'done already',
|
|
178
|
+
evidence: 'e',
|
|
179
|
+
options: 'o',
|
|
180
|
+
recommendation: 'Split the table.',
|
|
181
|
+
path: 'p',
|
|
182
|
+
},
|
|
183
|
+
]
|
|
184
|
+
expect(renderReviewSheet(items)).toBe(
|
|
185
|
+
[
|
|
186
|
+
'q-big-thing [critical] adjudicated - Split the table.',
|
|
187
|
+
'q-minor-thing [minor] open - Leave it alone.',
|
|
188
|
+
'',
|
|
189
|
+
'1 open',
|
|
190
|
+
].join('\n'),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// --- the verb ---
|
|
195
|
+
|
|
196
|
+
test('adjudicate with no id prints the review sheet', async () => {
|
|
197
|
+
await addItem('q-alpha', 'critical')
|
|
198
|
+
await addItem('q-beta', 'minor')
|
|
199
|
+
|
|
200
|
+
const sheet = await migrate(['adjudicate'])
|
|
201
|
+
expect(sheet.code).toBe(0)
|
|
202
|
+
expect(sheet.out).toContain('q-alpha [critical] open - Billing owns it')
|
|
203
|
+
expect(sheet.out).toContain('q-beta [minor] open')
|
|
204
|
+
expect(sheet.out).toContain('2 open')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('adjudicate records a ruling, flips status, and records a batch', async () => {
|
|
208
|
+
await addItem('q-alpha', 'critical')
|
|
209
|
+
|
|
210
|
+
const ruled = await migrate(['adjudicate', 'q-alpha', '--ruling', 'billing owns it'])
|
|
211
|
+
expect(ruled.code).toBe(0)
|
|
212
|
+
expect(ruled.out).toContain('q-alpha')
|
|
213
|
+
expect(ruled.out).toContain('open -> adjudicated')
|
|
214
|
+
// The verb points at the writer that applies the consequence, since it does
|
|
215
|
+
// not touch the row files itself.
|
|
216
|
+
expect(ruled.out).toContain('migrate import')
|
|
217
|
+
|
|
218
|
+
const text = await readFile(join(target, '.migrate', 'queue', 'q-alpha.md'), 'utf8')
|
|
219
|
+
expect(text).toContain('status: adjudicated')
|
|
220
|
+
expect(text).toContain('ruling: billing owns it')
|
|
221
|
+
expect(text).toMatch(/adjudicated: \d{4}-\d{2}-\d{2}/)
|
|
222
|
+
// The body survived.
|
|
223
|
+
expect(text).toContain('Two tables both look like they own the join.')
|
|
224
|
+
|
|
225
|
+
const phases = JSON.parse(await readFile(join(target, '.migrate', 'phases.json'), 'utf8'))
|
|
226
|
+
expect(phases.phases.adjudicate.batches).toHaveLength(1)
|
|
227
|
+
expect(phases.phases.adjudicate.batches[0].id).toBe('b-adjudicate-q-alpha')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('adjudicate refuses an unknown id as a usage error', async () => {
|
|
231
|
+
await addItem('q-alpha', 'critical')
|
|
232
|
+
const missing = await migrate(['adjudicate', 'q-nope', '--ruling', 'x'])
|
|
233
|
+
expect(missing.code).toBe(2)
|
|
234
|
+
expect(missing.err).toContain('q-nope')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('adjudicate refuses to overwrite an existing ruling without --force', async () => {
|
|
238
|
+
await addItem('q-alpha', 'critical')
|
|
239
|
+
await migrate(['adjudicate', 'q-alpha', '--ruling', 'the first call'])
|
|
240
|
+
|
|
241
|
+
const again = await migrate(['adjudicate', 'q-alpha', '--ruling', 'a different call'])
|
|
242
|
+
expect(again.code).toBe(1)
|
|
243
|
+
// The existing ruling is printed, so the caller sees what they would have lost.
|
|
244
|
+
expect(again.err).toContain('the first call')
|
|
245
|
+
expect(again.err).toContain('--force')
|
|
246
|
+
|
|
247
|
+
const forced = await migrate(['adjudicate', 'q-alpha', '--ruling', 'a different call', '--force'])
|
|
248
|
+
expect(forced.code).toBe(0)
|
|
249
|
+
const text = await readFile(join(target, '.migrate', 'queue', 'q-alpha.md'), 'utf8')
|
|
250
|
+
expect(text).toContain('ruling: a different call')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('adjudicate refuses a ruling containing a newline', async () => {
|
|
254
|
+
await addItem('q-alpha', 'critical')
|
|
255
|
+
const bad = await migrate(['adjudicate', 'q-alpha', '--ruling', 'one\ntwo'])
|
|
256
|
+
expect(bad.code).toBe(2)
|
|
257
|
+
expect(bad.err).toContain('newline')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('adjudicate reports a queue file that will not parse as a content failure', async () => {
|
|
261
|
+
await addItem('q-alpha', 'critical')
|
|
262
|
+
// Break the file after it is in the store: an empty Options section.
|
|
263
|
+
const path = join(target, '.migrate', 'queue', 'q-alpha.md')
|
|
264
|
+
const text = await readFile(path, 'utf8')
|
|
265
|
+
await writeFile(path, text.replace('1. Give it to billing.\n2. Give it to scheduling.\n', ''))
|
|
266
|
+
|
|
267
|
+
const broken = await migrate(['adjudicate', 'q-alpha', '--ruling', 'x'])
|
|
268
|
+
expect(broken.code).toBe(1)
|
|
269
|
+
expect(broken.err).toContain('q-alpha')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('an indented --- inside the frontmatter is refused, not silently mis-fenced', () => {
|
|
273
|
+
// queue.ts closes the block at any line STARTING with ---, so a line that
|
|
274
|
+
// only looks like a fence after trimming is body to the parser and was a
|
|
275
|
+
// fence here. The owned keys landed above the real fence, the parser's
|
|
276
|
+
// last-key-wins read still saw `status: open`, and the command reported
|
|
277
|
+
// success over an item that stayed open forever.
|
|
278
|
+
const before = `---
|
|
279
|
+
id: q-x
|
|
280
|
+
---
|
|
281
|
+
severity: minor
|
|
282
|
+
status: open
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Evidence
|
|
286
|
+
|
|
287
|
+
e
|
|
288
|
+
|
|
289
|
+
## Options
|
|
290
|
+
|
|
291
|
+
o
|
|
292
|
+
|
|
293
|
+
## Recommendation
|
|
294
|
+
|
|
295
|
+
r
|
|
296
|
+
`
|
|
297
|
+
const after = applyRuling(before, 'settled', '2026-08-13')
|
|
298
|
+
// Whatever this produces, it must read back as adjudicated through the
|
|
299
|
+
// parser every other command uses.
|
|
300
|
+
const reparsed = parseQueueItem(after, 'q-x.md')
|
|
301
|
+
expect(reparsed.ok && reparsed.value.status).toBe('adjudicated')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('a nested key named status is left alone', () => {
|
|
305
|
+
const before = `---
|
|
306
|
+
id: q-x
|
|
307
|
+
severity: minor
|
|
308
|
+
status: open
|
|
309
|
+
meta:
|
|
310
|
+
status: draft
|
|
311
|
+
ruling: none yet
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Evidence
|
|
315
|
+
|
|
316
|
+
e
|
|
317
|
+
|
|
318
|
+
## Options
|
|
319
|
+
|
|
320
|
+
o
|
|
321
|
+
|
|
322
|
+
## Recommendation
|
|
323
|
+
|
|
324
|
+
r
|
|
325
|
+
`
|
|
326
|
+
const after = applyRuling(before, 'settled', '2026-08-13')
|
|
327
|
+
const fm = after.slice(4, after.indexOf('\n---', 3))
|
|
328
|
+
expect(fm).toContain(' status: draft')
|
|
329
|
+
expect(fm).toContain(' ruling: none yet')
|
|
330
|
+
expect(fm.match(/^status:/gm)).toHaveLength(1)
|
|
331
|
+
expect(fm.match(/^ruling:/gm)).toHaveLength(1)
|
|
332
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { parseAssumptions, validateAssumptions } from '../assumptions.ts'
|
|
3
|
+
import type { CapCoverage } from '../coverage.ts'
|
|
4
|
+
|
|
5
|
+
const GOOD = `---
|
|
6
|
+
attestedBy: Dik Rana
|
|
7
|
+
attestedDate: 2026-08-13
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Forecast assumptions
|
|
11
|
+
|
|
12
|
+
Prose the parser ignores.
|
|
13
|
+
|
|
14
|
+
## Territories
|
|
15
|
+
|
|
16
|
+
| capability | territory |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| user-management | established |
|
|
19
|
+
| billing | unknown-ground |
|
|
20
|
+
|
|
21
|
+
## Multipliers
|
|
22
|
+
|
|
23
|
+
| territory | multiplier |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| established | 1.0 |
|
|
26
|
+
| unknown-ground | 2.5 |
|
|
27
|
+
|
|
28
|
+
## Scenarios
|
|
29
|
+
|
|
30
|
+
| label | rate | streams | tax | note |
|
|
31
|
+
| --- | --- | --- | --- | --- |
|
|
32
|
+
| as-is | as-is | 1 | 0 | one stream, measured |
|
|
33
|
+
| pushing | active | 2 | 0.2 | two streams, coordination tax |
|
|
34
|
+
| target | 1.5 | 2 | 0 | owner target, nothing measures this |
|
|
35
|
+
|
|
36
|
+
## Caveats
|
|
37
|
+
|
|
38
|
+
- The billing rewrite has no precedent in this codebase.
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
const cap = (slug: string, confirmedTotal: number): CapCoverage => ({
|
|
42
|
+
slug,
|
|
43
|
+
title: slug,
|
|
44
|
+
confirmedTotal,
|
|
45
|
+
covered: 0,
|
|
46
|
+
coveredIds: [],
|
|
47
|
+
uncoveredIds: [],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a complete file parses into territories, multipliers, scenarios and caveats', () => {
|
|
51
|
+
const a = parseAssumptions(GOOD, 'f.md')
|
|
52
|
+
expect(a.attestedBy).toBe('Dik Rana')
|
|
53
|
+
expect(a.attestedDate).toBe('2026-08-13')
|
|
54
|
+
expect(a.territories).toEqual({ 'user-management': 'established', billing: 'unknown-ground' })
|
|
55
|
+
expect(a.multipliers).toEqual({ established: 1, 'unknown-ground': 2.5 })
|
|
56
|
+
expect(a.scenarios).toEqual([
|
|
57
|
+
{ label: 'as-is', rate: 'as-is', streams: 1, tax: 0, note: 'one stream, measured' },
|
|
58
|
+
{
|
|
59
|
+
label: 'pushing',
|
|
60
|
+
rate: 'active',
|
|
61
|
+
streams: 2,
|
|
62
|
+
tax: 0.2,
|
|
63
|
+
note: 'two streams, coordination tax',
|
|
64
|
+
},
|
|
65
|
+
{ label: 'target', rate: 1.5, streams: 2, tax: 0, note: 'owner target, nothing measures this' },
|
|
66
|
+
])
|
|
67
|
+
expect(a.caveats).toEqual(['The billing rewrite has no precedent in this codebase.'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('an unattested file is refused, naming the missing field', () => {
|
|
71
|
+
expect(() => parseAssumptions(GOOD.replace('attestedBy: Dik Rana\n', ''), 'f.md')).toThrow(
|
|
72
|
+
/attestedBy/,
|
|
73
|
+
)
|
|
74
|
+
expect(() => parseAssumptions(GOOD.replace('attestedDate: 2026-08-13\n', ''), 'f.md')).toThrow(
|
|
75
|
+
/attestedDate/,
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('a missing required section is refused by name', () => {
|
|
80
|
+
const noMultipliers = GOOD.replace(/## Multipliers[\s\S]*?\n\n## Scenarios/, '## Scenarios')
|
|
81
|
+
expect(() => parseAssumptions(noMultipliers, 'f.md')).toThrow(/Multipliers/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('a scenario rate must be as-is, active, or a positive number', () => {
|
|
85
|
+
const bad = GOOD.replace('| as-is | as-is | 1 | 0 |', '| as-is | sometimes | 1 | 0 |')
|
|
86
|
+
expect(() => parseAssumptions(bad, 'f.md')).toThrow(/rate/)
|
|
87
|
+
const zero = GOOD.replace('| target | 1.5 | 2 | 0 |', '| target | 0 | 2 | 0 |')
|
|
88
|
+
expect(() => parseAssumptions(zero, 'f.md')).toThrow(/rate/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('tax must sit in [0, 1)', () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
parseAssumptions(
|
|
94
|
+
GOOD.replace('| pushing | active | 2 | 0.2 |', '| pushing | active | 2 | 1 |'),
|
|
95
|
+
'f.md',
|
|
96
|
+
),
|
|
97
|
+
).toThrow(/tax/)
|
|
98
|
+
expect(() =>
|
|
99
|
+
parseAssumptions(
|
|
100
|
+
GOOD.replace('| pushing | active | 2 | 0.2 |', '| pushing | active | 2 | -0.1 |'),
|
|
101
|
+
'f.md',
|
|
102
|
+
),
|
|
103
|
+
).toThrow(/tax/)
|
|
104
|
+
// 0.9 is legal.
|
|
105
|
+
expect(
|
|
106
|
+
parseAssumptions(
|
|
107
|
+
GOOD.replace('| pushing | active | 2 | 0.2 |', '| pushing | active | 2 | 0.9 |'),
|
|
108
|
+
'f.md',
|
|
109
|
+
).scenarios[1]?.tax,
|
|
110
|
+
).toBe(0.9)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('streams must be positive', () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
parseAssumptions(
|
|
116
|
+
GOOD.replace('| as-is | as-is | 1 | 0 |', '| as-is | as-is | 0 | 0 |'),
|
|
117
|
+
'f.md',
|
|
118
|
+
),
|
|
119
|
+
).toThrow(/streams/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a multiplier must be a positive number', () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
parseAssumptions(GOOD.replace('| established | 1.0 |', '| established | 0 |'), 'f.md'),
|
|
125
|
+
).toThrow(/multiplier/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('every parse failure names the file', () => {
|
|
129
|
+
expect(() => parseAssumptions('no frontmatter here', 'docs/x.md')).toThrow(/docs\/x\.md/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// --- validation against measured coverage ---
|
|
133
|
+
|
|
134
|
+
test('a capability with confirmed requirements and no territory fails by name', () => {
|
|
135
|
+
const a = parseAssumptions(GOOD, 'f.md')
|
|
136
|
+
const errors = validateAssumptions(a, [cap('user-management', 2), cap('notifications', 5)])
|
|
137
|
+
expect(errors).toHaveLength(1)
|
|
138
|
+
expect(errors[0]).toContain('notifications')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('a capability with no confirmed requirements needs no territory', () => {
|
|
142
|
+
const a = parseAssumptions(GOOD, 'f.md')
|
|
143
|
+
expect(validateAssumptions(a, [cap('user-management', 2), cap('notifications', 0)])).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('a territory with no multiplier fails by name', () => {
|
|
147
|
+
const a = parseAssumptions(GOOD.replace('| unknown-ground | 2.5 |\n', ''), 'f.md')
|
|
148
|
+
const errors = validateAssumptions(a, [cap('user-management', 1), cap('billing', 1)])
|
|
149
|
+
expect(errors.some((e) => e.includes('unknown-ground'))).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('zero scenarios fails', () => {
|
|
153
|
+
const a = parseAssumptions(
|
|
154
|
+
GOOD.replace(/\| as-is \| as-is[\s\S]*?nothing measures this \|\n/, ''),
|
|
155
|
+
'f.md',
|
|
156
|
+
)
|
|
157
|
+
expect(a.scenarios).toEqual([])
|
|
158
|
+
expect(validateAssumptions(a, [cap('user-management', 1)])).toContain('no scenarios defined')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('a table written without a separator row keeps every data row', () => {
|
|
162
|
+
// slice(2) dropped two lines positionally, so a missing separator silently
|
|
163
|
+
// ate the first data row: the owner attested two scenarios and got one, and
|
|
164
|
+
// the lost row is the as-is baseline the template puts first.
|
|
165
|
+
const noSeparator = GOOD.replace('| --- | --- | --- | --- | --- |\n', '')
|
|
166
|
+
const a = parseAssumptions(noSeparator, 'f.md')
|
|
167
|
+
expect(a.scenarios.map((s) => s.label)).toEqual(['as-is', 'pushing', 'target'])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('an alignment separator is recognised wherever it sits', () => {
|
|
171
|
+
const aligned = GOOD.replace(
|
|
172
|
+
'| --- | --- |\n| user-management',
|
|
173
|
+
'| :--- | ---: |\n| user-management',
|
|
174
|
+
)
|
|
175
|
+
expect(parseAssumptions(aligned, 'f.md').territories).toEqual({
|
|
176
|
+
'user-management': 'established',
|
|
177
|
+
billing: 'unknown-ground',
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { computeCoverage, renderCoverage } from '../coverage.ts'
|
|
3
|
+
import type { HandoffFile } from '../handoff.ts'
|
|
4
|
+
import type { Requirement, Throughput } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
function req(id: string, cap: string, confidence: Requirement['confidence']): Requirement {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
cap,
|
|
10
|
+
requirement: `requirement ${id}`,
|
|
11
|
+
actors: 'User',
|
|
12
|
+
objects: 'Thing',
|
|
13
|
+
rules: 'none',
|
|
14
|
+
origin: 'intended',
|
|
15
|
+
confidence,
|
|
16
|
+
citations: [],
|
|
17
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
18
|
+
batch: 'b-1',
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CONFIRMED: Requirement['confidence'] = { kind: 'confirmed' }
|
|
23
|
+
const INFERRED: Requirement['confidence'] = { kind: 'inferred' }
|
|
24
|
+
|
|
25
|
+
const REQS = [
|
|
26
|
+
req('UM-001', 'user-management', CONFIRMED),
|
|
27
|
+
req('UM-002', 'user-management', CONFIRMED),
|
|
28
|
+
req('BI-001', 'billing', CONFIRMED),
|
|
29
|
+
req('BI-002', 'billing', INFERRED),
|
|
30
|
+
req('BI-003', 'billing', INFERRED),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const HANDOFF: HandoffFile = {
|
|
34
|
+
version: 1,
|
|
35
|
+
adapter: 'markdown',
|
|
36
|
+
items: [
|
|
37
|
+
{
|
|
38
|
+
key: 'billing',
|
|
39
|
+
title: 'Billing',
|
|
40
|
+
frs: ['BI-001', 'BI-002', 'BI-003'],
|
|
41
|
+
dependsOn: [],
|
|
42
|
+
weight: 3,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'user-management',
|
|
46
|
+
title: 'User management',
|
|
47
|
+
frs: ['UM-001', 'UM-002'],
|
|
48
|
+
dependsOn: ['billing'],
|
|
49
|
+
weight: 2,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
refs: {},
|
|
53
|
+
basis: { confirmed: 3, emitted: 5, order: ['billing', 'user-management'] },
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const through = (completions: Throughput['completions']): Throughput => ({
|
|
57
|
+
completions,
|
|
58
|
+
basis: 'markdown roadmap checkboxes, dated in file',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('the denominator is confirmed requirements only, with exclusions reported', () => {
|
|
62
|
+
const r = computeCoverage({
|
|
63
|
+
requirements: REQS,
|
|
64
|
+
handoff: HANDOFF,
|
|
65
|
+
throughput: through([
|
|
66
|
+
{ fr: 'UM-001', doneAt: '2026-08-12' },
|
|
67
|
+
{ fr: 'BI-001', doneAt: '2026-08-11' },
|
|
68
|
+
]),
|
|
69
|
+
})
|
|
70
|
+
// Five requirements exist; three are confirmed; two of those are built.
|
|
71
|
+
expect(r.confirmed).toBe(3)
|
|
72
|
+
expect(r.built).toBe(2)
|
|
73
|
+
expect(r.nonConfirmed).toEqual([{ slug: 'billing', count: 2 }])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a completion for a non-confirmed requirement does not inflate the figure', () => {
|
|
77
|
+
// BI-002 is inferred, so it is outside the denominator. Reporting it as
|
|
78
|
+
// complete must not push built above the confirmed total it sits over.
|
|
79
|
+
const r = computeCoverage({
|
|
80
|
+
requirements: REQS,
|
|
81
|
+
handoff: HANDOFF,
|
|
82
|
+
throughput: through([
|
|
83
|
+
{ fr: 'BI-001', doneAt: '2026-08-11' },
|
|
84
|
+
{ fr: 'BI-002', doneAt: '2026-08-11' },
|
|
85
|
+
]),
|
|
86
|
+
})
|
|
87
|
+
expect(r.built).toBe(1)
|
|
88
|
+
expect(r.confirmed).toBe(3)
|
|
89
|
+
expect(r.unknown).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('capabilities are reported in the emitted dependency order', () => {
|
|
93
|
+
const r = computeCoverage({ requirements: REQS, handoff: HANDOFF, throughput: through([]) })
|
|
94
|
+
expect(r.caps.map((c) => c.slug)).toEqual(['billing', 'user-management'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('an undated completion counts as built and is reported as undated', () => {
|
|
98
|
+
const r = computeCoverage({
|
|
99
|
+
requirements: REQS,
|
|
100
|
+
handoff: HANDOFF,
|
|
101
|
+
throughput: through([{ fr: 'UM-001', doneAt: null }]),
|
|
102
|
+
})
|
|
103
|
+
expect(r.built).toBe(1)
|
|
104
|
+
expect(r.undated).toBe(1)
|
|
105
|
+
expect(renderCoverage(r)).toContain('undated: 1 completion(s)')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a completion naming an unknown requirement is collected, not counted', () => {
|
|
109
|
+
const r = computeCoverage({
|
|
110
|
+
requirements: REQS,
|
|
111
|
+
handoff: HANDOFF,
|
|
112
|
+
throughput: through([
|
|
113
|
+
{ fr: 'UM-001', doneAt: '2026-08-12' },
|
|
114
|
+
{ fr: 'ZZ-999', doneAt: '2026-08-12' },
|
|
115
|
+
]),
|
|
116
|
+
})
|
|
117
|
+
expect(r.unknown).toEqual(['ZZ-999'])
|
|
118
|
+
expect(r.built).toBe(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('the rendered report names its evidence and marks a finished capability', () => {
|
|
122
|
+
const r = computeCoverage({
|
|
123
|
+
requirements: REQS,
|
|
124
|
+
handoff: HANDOFF,
|
|
125
|
+
throughput: through([
|
|
126
|
+
{ fr: 'UM-001', doneAt: '2026-08-12' },
|
|
127
|
+
{ fr: 'UM-002', doneAt: '2026-08-13' },
|
|
128
|
+
]),
|
|
129
|
+
})
|
|
130
|
+
expect(renderCoverage(r)).toBe(
|
|
131
|
+
[
|
|
132
|
+
'built 2/3 confirmed requirements (67%)',
|
|
133
|
+
'evidence: markdown roadmap checkboxes, dated in file',
|
|
134
|
+
'excluded: 2 non-confirmed (billing 2)',
|
|
135
|
+
'',
|
|
136
|
+
'billing 0/1',
|
|
137
|
+
'user-management 2/2 done',
|
|
138
|
+
].join('\n'),
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('a store with no confirmed requirements reports zero rather than dividing by zero', () => {
|
|
143
|
+
const r = computeCoverage({
|
|
144
|
+
requirements: [req('BI-002', 'billing', INFERRED)],
|
|
145
|
+
handoff: { ...HANDOFF, basis: { confirmed: 0, emitted: 1, order: ['billing'] } },
|
|
146
|
+
throughput: through([]),
|
|
147
|
+
})
|
|
148
|
+
expect(r.confirmed).toBe(0)
|
|
149
|
+
expect(renderCoverage(r)).toContain('built 0/0 confirmed requirements (0%)')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('a capability the emitted order omits is counted and named as stale', () => {
|
|
153
|
+
// A stale handoff.json used to narrow both numerator and denominator
|
|
154
|
+
// silently, reporting 100% while confirmed requirements sat unbuilt in a
|
|
155
|
+
// capability that appeared nowhere in the output.
|
|
156
|
+
const r = computeCoverage({
|
|
157
|
+
requirements: REQS,
|
|
158
|
+
handoff: { ...HANDOFF, basis: { confirmed: 3, emitted: 5, order: ['user-management'] } },
|
|
159
|
+
throughput: through([
|
|
160
|
+
{ fr: 'UM-001', doneAt: '2026-08-12' },
|
|
161
|
+
{ fr: 'UM-002', doneAt: '2026-08-13' },
|
|
162
|
+
]),
|
|
163
|
+
})
|
|
164
|
+
expect(r.confirmed).toBe(3)
|
|
165
|
+
expect(r.built).toBe(2)
|
|
166
|
+
expect(r.stale).toEqual(['billing'])
|
|
167
|
+
expect(r.caps.map((c) => c.slug)).toEqual(['user-management', 'billing'])
|
|
168
|
+
expect(renderCoverage(r)).toContain('stale: 1 capability(ies) not in the emitted work (billing)')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('the percentage never contradicts the fraction beside it', () => {
|
|
172
|
+
const at = (built: number, confirmed: number): string => {
|
|
173
|
+
const reqs = Array.from({ length: confirmed }, (_, i) => req(`X-${i}`, 'billing', CONFIRMED))
|
|
174
|
+
return renderCoverage(
|
|
175
|
+
computeCoverage({
|
|
176
|
+
requirements: reqs,
|
|
177
|
+
handoff: {
|
|
178
|
+
...HANDOFF,
|
|
179
|
+
items: [],
|
|
180
|
+
basis: { confirmed, emitted: confirmed, order: ['billing'] },
|
|
181
|
+
},
|
|
182
|
+
throughput: through(
|
|
183
|
+
Array.from({ length: built }, (_, i) => ({ fr: `X-${i}`, doneAt: '2026-08-12' })),
|
|
184
|
+
),
|
|
185
|
+
}),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
expect(at(199, 200)).toContain('built 199/200 confirmed requirements (99%)')
|
|
189
|
+
expect(at(1, 250)).toContain('built 1/250 confirmed requirements (1%)')
|
|
190
|
+
expect(at(200, 200)).toContain('(100%)')
|
|
191
|
+
expect(at(0, 200)).toContain('(0%)')
|
|
192
|
+
})
|