@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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/index.json +2 -2
  4. package/skills/migrate/README.md +35 -23
  5. package/skills/migrate/SKILL.md +75 -15
  6. package/skills/migrate/bin/migrate.ts +90 -0
  7. package/skills/migrate/docs/architecture.md +61 -26
  8. package/skills/migrate/docs/reference.md +53 -8
  9. package/skills/migrate/fixtures/fake-gh.ts +113 -0
  10. package/skills/migrate/fixtures/flow-target/docs/WORK.md +12 -0
  11. package/skills/migrate/fixtures/flow-target/docs/modernisation/capability-map/.gitkeep +0 -0
  12. package/skills/migrate/fixtures/flow-target/tools/flow/src/cli.ts +156 -0
  13. package/skills/migrate/package.json +1 -1
  14. package/skills/migrate/references/phases/adjudicate.md +161 -0
  15. package/skills/migrate/references/phases/handoff.md +220 -0
  16. package/skills/migrate/references/phases/probe.md +2 -2
  17. package/skills/migrate/references/phases/queue.md +21 -14
  18. package/skills/migrate/references/run-ops.md +17 -13
  19. package/skills/migrate/scripts/__tests__/adapter-flow.test.ts +290 -0
  20. package/skills/migrate/scripts/__tests__/adapter-github.test.ts +232 -0
  21. package/skills/migrate/scripts/__tests__/adapter-markdown.test.ts +183 -0
  22. package/skills/migrate/scripts/__tests__/adjudicate.test.ts +332 -0
  23. package/skills/migrate/scripts/__tests__/assumptions.test.ts +179 -0
  24. package/skills/migrate/scripts/__tests__/coverage.test.ts +192 -0
  25. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +167 -7
  26. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +9 -4
  27. package/skills/migrate/scripts/__tests__/forecast.test.ts +280 -0
  28. package/skills/migrate/scripts/__tests__/gates-handoff.test.ts +309 -0
  29. package/skills/migrate/scripts/__tests__/handoff-cmd.test.ts +308 -0
  30. package/skills/migrate/scripts/__tests__/handoff-order.test.ts +156 -0
  31. package/skills/migrate/scripts/adapters/flow.ts +280 -0
  32. package/skills/migrate/scripts/adapters/github.ts +260 -0
  33. package/skills/migrate/scripts/adapters/markdown.ts +175 -0
  34. package/skills/migrate/scripts/adjudicate-cmd.ts +243 -0
  35. package/skills/migrate/scripts/assumptions.ts +188 -0
  36. package/skills/migrate/scripts/check.ts +119 -320
  37. package/skills/migrate/scripts/coverage-cmd.ts +86 -0
  38. package/skills/migrate/scripts/coverage.ts +151 -0
  39. package/skills/migrate/scripts/dates.ts +17 -0
  40. package/skills/migrate/scripts/forecast-cmd.ts +124 -0
  41. package/skills/migrate/scripts/forecast.ts +264 -0
  42. package/skills/migrate/scripts/gates/adjudication.ts +30 -0
  43. package/skills/migrate/scripts/gates/census.ts +107 -0
  44. package/skills/migrate/scripts/gates/citations.ts +11 -0
  45. package/skills/migrate/scripts/gates/context.ts +76 -0
  46. package/skills/migrate/scripts/gates/coverage.ts +22 -0
  47. package/skills/migrate/scripts/gates/deltas.ts +15 -0
  48. package/skills/migrate/scripts/gates/handoff.ts +145 -0
  49. package/skills/migrate/scripts/gates/leaks.ts +11 -0
  50. package/skills/migrate/scripts/gates/parity.ts +15 -0
  51. package/skills/migrate/scripts/gates/queue.ts +9 -0
  52. package/skills/migrate/scripts/gates/refs.ts +97 -0
  53. package/skills/migrate/scripts/gates/run-state.ts +67 -0
  54. package/skills/migrate/scripts/gates/source.ts +28 -0
  55. package/skills/migrate/scripts/handoff-cmd.ts +186 -0
  56. package/skills/migrate/scripts/handoff.ts +330 -0
  57. package/skills/migrate/scripts/paths.ts +4 -0
  58. package/skills/migrate/scripts/types.ts +43 -0
  59. package/skills/migrate/scripts/validate.ts +12 -0
  60. package/skills/migrate/skill.json +2 -2
  61. 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
+ })