@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,232 @@
|
|
|
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 { github } from '../adapters/github.ts'
|
|
6
|
+
import type { Config } from '../config.ts'
|
|
7
|
+
import { buildWorkItems, type HandoffInput } from '../handoff.ts'
|
|
8
|
+
import type { Capability, Requirement } from '../types.ts'
|
|
9
|
+
|
|
10
|
+
const FAKE_GH = join(import.meta.dir, '..', '..', 'fixtures', 'fake-gh.ts')
|
|
11
|
+
|
|
12
|
+
let root: string
|
|
13
|
+
let statePath: string
|
|
14
|
+
let logPath: string
|
|
15
|
+
|
|
16
|
+
const config = (): Config => ({
|
|
17
|
+
source: {
|
|
18
|
+
path: join(root, 'legacy'),
|
|
19
|
+
scope: 'x',
|
|
20
|
+
stack: 'unknown',
|
|
21
|
+
vcs: 'none',
|
|
22
|
+
basis: 'source-only',
|
|
23
|
+
},
|
|
24
|
+
target: {
|
|
25
|
+
name: 'target',
|
|
26
|
+
stack: 'unknown',
|
|
27
|
+
parity_test_path: 'tests/parity/{capability}/{fr_slug}.test.ts',
|
|
28
|
+
layout: {},
|
|
29
|
+
commands: {},
|
|
30
|
+
},
|
|
31
|
+
surfaces: ['routes'],
|
|
32
|
+
surfaceSingular: {},
|
|
33
|
+
closers: [],
|
|
34
|
+
handoff: { adapter: 'github' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function cap(slug: string, title: string, elements: string[]): Capability {
|
|
38
|
+
return { slug, title, ns: slug.slice(0, 2).toUpperCase(), elements }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function req(id: string, capSlug: string, text: string, cites: string[] = []): Requirement {
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
cap: capSlug,
|
|
45
|
+
requirement: text,
|
|
46
|
+
actors: 'User',
|
|
47
|
+
objects: 'Thing',
|
|
48
|
+
rules: 'none',
|
|
49
|
+
origin: 'intended',
|
|
50
|
+
confidence: { kind: 'confirmed' },
|
|
51
|
+
citations: cites.map((c) => ({ kind: 'ledger' as const, id: c })),
|
|
52
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
53
|
+
batch: 'b-1',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CAPS = [
|
|
58
|
+
cap('billing', 'Billing', ['el-b']),
|
|
59
|
+
cap('user-management', 'User management', ['el-u']),
|
|
60
|
+
]
|
|
61
|
+
const REQS = [
|
|
62
|
+
req('UM-001', 'user-management', 'Authenticate a user', ['el-b']),
|
|
63
|
+
req('UM-002', 'user-management', 'Lock an account'),
|
|
64
|
+
req('BI-001', 'billing', 'Raise an invoice'),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function input(reqs = REQS): HandoffInput {
|
|
68
|
+
return {
|
|
69
|
+
requirements: reqs,
|
|
70
|
+
capabilities: CAPS,
|
|
71
|
+
deltas: [],
|
|
72
|
+
config: config(),
|
|
73
|
+
root,
|
|
74
|
+
gitBin: 'git',
|
|
75
|
+
ghBin: FAKE_GH,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function log(): Promise<string[]> {
|
|
80
|
+
const text = await readFile(logPath, 'utf8')
|
|
81
|
+
return text.split('\n').filter((l) => l.length > 0)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-gh-adapter-'))
|
|
86
|
+
await Bun.write(join(root, 'legacy', 'app.js'), '// legacy\n')
|
|
87
|
+
// The fake gh keys its state and log off its working directory, which the
|
|
88
|
+
// adapter sets to the target root.
|
|
89
|
+
statePath = join(root, 'gh-state.json')
|
|
90
|
+
logPath = join(root, 'gh-log.txt')
|
|
91
|
+
await writeFile(logPath, '')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await rm(root, { recursive: true, force: true })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('apply creates one milestone per capability and one issue per requirement', async () => {
|
|
99
|
+
const result = await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
100
|
+
|
|
101
|
+
expect(result.created.sort()).toEqual(['billing', 'user-management'])
|
|
102
|
+
// The ref is the milestone the capability's issues were filed under.
|
|
103
|
+
expect(result.refs['billing']).toBe('milestone:1')
|
|
104
|
+
|
|
105
|
+
const lines = await log()
|
|
106
|
+
expect(lines.filter((l) => l.includes('-X POST'))).toHaveLength(2)
|
|
107
|
+
expect(lines.filter((l) => l.startsWith('issue create'))).toHaveLength(3)
|
|
108
|
+
// Every issue body carries the marker that makes the next run idempotent.
|
|
109
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
110
|
+
expect(state.issues).toHaveLength(3)
|
|
111
|
+
expect(state.issues[0].body).toContain('<!-- migrate:fr=')
|
|
112
|
+
// The milestone is keyed on the slug as well as the title: two capabilities
|
|
113
|
+
// can share a title and can never share a slug.
|
|
114
|
+
expect(state.issues.map((i: { milestone: string }) => i.milestone).sort()).toEqual([
|
|
115
|
+
'Billing (billing)',
|
|
116
|
+
'User management (user-management)',
|
|
117
|
+
'User management (user-management)',
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a second apply creates nothing and reports every item unchanged', async () => {
|
|
122
|
+
const items = buildWorkItems(CAPS, REQS)
|
|
123
|
+
await github.apply(items, input())
|
|
124
|
+
await writeFile(logPath, '')
|
|
125
|
+
|
|
126
|
+
const second = await github.apply(items, input())
|
|
127
|
+
expect(second.created).toEqual([])
|
|
128
|
+
expect(second.updated).toEqual([])
|
|
129
|
+
expect(second.unchanged.sort()).toEqual(['billing', 'user-management'])
|
|
130
|
+
|
|
131
|
+
const lines = await log()
|
|
132
|
+
expect(lines.filter((l) => l.startsWith('issue create'))).toHaveLength(0)
|
|
133
|
+
expect(lines.filter((l) => l.includes('-X POST'))).toHaveLength(0)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('an issue found only by its marker is edited rather than duplicated', async () => {
|
|
137
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
138
|
+
|
|
139
|
+
// The requirement's text changes, so its issue body must be brought up to
|
|
140
|
+
// date. The adapter has no stored ref for the issue: the marker in the body
|
|
141
|
+
// is the whole identity mechanism, which is what makes this safe after a
|
|
142
|
+
// lost handoff.json.
|
|
143
|
+
const edited = [
|
|
144
|
+
req('UM-001', 'user-management', 'Authenticate a user against stored credentials', ['el-b']),
|
|
145
|
+
req('UM-002', 'user-management', 'Lock an account'),
|
|
146
|
+
req('BI-001', 'billing', 'Raise an invoice'),
|
|
147
|
+
]
|
|
148
|
+
await writeFile(logPath, '')
|
|
149
|
+
const second = await github.apply(buildWorkItems(CAPS, edited), input(edited))
|
|
150
|
+
|
|
151
|
+
expect(second.updated).toEqual(['user-management'])
|
|
152
|
+
expect(second.unchanged).toEqual(['billing'])
|
|
153
|
+
const lines = await log()
|
|
154
|
+
expect(lines.filter((l) => l.startsWith('issue create'))).toHaveLength(0)
|
|
155
|
+
expect(lines.filter((l) => l.startsWith('issue edit'))).toHaveLength(1)
|
|
156
|
+
|
|
157
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
158
|
+
expect(state.issues).toHaveLength(3)
|
|
159
|
+
const um1 = state.issues.filter((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
160
|
+
// Exactly one issue still carries the marker: the edit updated it in place
|
|
161
|
+
// rather than filing a second issue for the same requirement.
|
|
162
|
+
expect(um1).toHaveLength(1)
|
|
163
|
+
expect(um1[0].body).toContain('against stored credentials')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('throughput reads closed issues and dates them from closedAt', async () => {
|
|
167
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
168
|
+
|
|
169
|
+
// Found by marker, not by index: billing sorts first in dependency order, so
|
|
170
|
+
// issue 0 is BI-001.
|
|
171
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
172
|
+
const target = state.issues.find((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
173
|
+
target.state = 'CLOSED'
|
|
174
|
+
target.closedAt = '2026-08-12T04:11:00Z'
|
|
175
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
176
|
+
|
|
177
|
+
const t = await github.throughput?.(input())
|
|
178
|
+
expect(t?.completions).toEqual([{ fr: 'UM-001', doneAt: '2026-08-12' }])
|
|
179
|
+
expect(t?.basis).toContain('closed')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an issue with no marker is ignored rather than mistaken for a requirement', async () => {
|
|
183
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
184
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
185
|
+
state.issues.push({
|
|
186
|
+
number: 500,
|
|
187
|
+
title: 'Someone else’s issue',
|
|
188
|
+
body: 'Nothing to do with the migration.',
|
|
189
|
+
state: 'CLOSED',
|
|
190
|
+
closedAt: '2026-08-01T00:00:00Z',
|
|
191
|
+
milestone: null,
|
|
192
|
+
})
|
|
193
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
194
|
+
|
|
195
|
+
const t = await github.throughput?.(input())
|
|
196
|
+
expect(t?.completions).toEqual([])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('an issue that merely mentions a marker is not hijacked', async () => {
|
|
200
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
201
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
202
|
+
const foreign = {
|
|
203
|
+
number: 900,
|
|
204
|
+
title: 'Investigate the flaky login test',
|
|
205
|
+
body: 'Related to the auth work at <!-- migrate:fr=UM-999 --> which is not this issue.\nA week of repro steps.',
|
|
206
|
+
state: 'OPEN',
|
|
207
|
+
closedAt: null,
|
|
208
|
+
milestone: null,
|
|
209
|
+
}
|
|
210
|
+
state.issues.push(foreign)
|
|
211
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
212
|
+
await writeFile(logPath, '')
|
|
213
|
+
|
|
214
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
215
|
+
const after = JSON.parse(await readFile(statePath, 'utf8'))
|
|
216
|
+
const kept = after.issues.find((i: { number: number }) => i.number === 900)
|
|
217
|
+
expect(kept.body).toBe(foreign.body)
|
|
218
|
+
expect(kept.milestone).toBeNull()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a human note below the fence survives a re-run', async () => {
|
|
222
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
223
|
+
const state = JSON.parse(await readFile(statePath, 'utf8'))
|
|
224
|
+
const target = state.issues.find((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
225
|
+
target.body = `${target.body}\n\n## Delivery notes\n\nSee PR #77.`
|
|
226
|
+
await writeFile(statePath, JSON.stringify(state, null, 2))
|
|
227
|
+
|
|
228
|
+
await github.apply(buildWorkItems(CAPS, REQS), input())
|
|
229
|
+
const after = JSON.parse(await readFile(statePath, 'utf8'))
|
|
230
|
+
const kept = after.issues.find((i: { body: string }) => i.body.includes('migrate:fr=UM-001'))
|
|
231
|
+
expect(kept.body).toContain('See PR #77.')
|
|
232
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
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 { markdown, parseRoadmap } from '../adapters/markdown.ts'
|
|
6
|
+
import type { Config } from '../config.ts'
|
|
7
|
+
import { buildWorkItems, type HandoffInput } from '../handoff.ts'
|
|
8
|
+
import type { Capability, Requirement } from '../types.ts'
|
|
9
|
+
|
|
10
|
+
let root: string
|
|
11
|
+
|
|
12
|
+
const config = (): Config => ({
|
|
13
|
+
source: {
|
|
14
|
+
path: join(root, 'legacy'),
|
|
15
|
+
scope: 'x',
|
|
16
|
+
stack: 'unknown',
|
|
17
|
+
vcs: 'none',
|
|
18
|
+
basis: 'source-only',
|
|
19
|
+
},
|
|
20
|
+
target: {
|
|
21
|
+
name: 'target',
|
|
22
|
+
stack: 'unknown',
|
|
23
|
+
parity_test_path: 'tests/parity/{capability}/{fr_slug}.test.ts',
|
|
24
|
+
layout: {},
|
|
25
|
+
commands: {},
|
|
26
|
+
},
|
|
27
|
+
surfaces: ['routes'],
|
|
28
|
+
surfaceSingular: {},
|
|
29
|
+
closers: [],
|
|
30
|
+
handoff: { adapter: 'markdown' },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function cap(slug: string, title: string, elements: string[]): Capability {
|
|
34
|
+
return { slug, title, ns: slug.slice(0, 2).toUpperCase(), elements }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function req(id: string, capSlug: string, text: string, cites: string[] = []): Requirement {
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
cap: capSlug,
|
|
41
|
+
requirement: text,
|
|
42
|
+
actors: 'User',
|
|
43
|
+
objects: 'Thing',
|
|
44
|
+
rules: 'none',
|
|
45
|
+
origin: 'intended',
|
|
46
|
+
confidence: { kind: 'confirmed' },
|
|
47
|
+
citations: cites.map((c) => ({ kind: 'ledger' as const, id: c })),
|
|
48
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
49
|
+
batch: 'b-1',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CAPS = [
|
|
54
|
+
cap('billing', 'Billing', ['el-b']),
|
|
55
|
+
cap('user-management', 'User management', ['el-u']),
|
|
56
|
+
]
|
|
57
|
+
const REQS = [
|
|
58
|
+
req('UM-001', 'user-management', 'Authenticate a user against stored credentials', ['el-b']),
|
|
59
|
+
req('UM-002', 'user-management', 'Lock an account after five failed attempts'),
|
|
60
|
+
req('BI-001', 'billing', 'Raise an invoice for a completed booking'),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
function input(caps = CAPS, reqs = REQS): HandoffInput {
|
|
64
|
+
return {
|
|
65
|
+
requirements: reqs,
|
|
66
|
+
capabilities: caps,
|
|
67
|
+
deltas: [],
|
|
68
|
+
config: config(),
|
|
69
|
+
root,
|
|
70
|
+
gitBin: 'git',
|
|
71
|
+
ghBin: 'gh',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const roadmapPath = (): string => join(root, 'docs', 'migrate', 'roadmap.md')
|
|
76
|
+
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-md-adapter-'))
|
|
79
|
+
await Bun.write(join(root, 'legacy', 'app.js'), '// legacy\n')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
await rm(root, { recursive: true, force: true })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('apply writes a roadmap in dependency order and one file per capability', async () => {
|
|
87
|
+
const items = buildWorkItems(CAPS, REQS)
|
|
88
|
+
const result = await markdown.apply(items, input())
|
|
89
|
+
|
|
90
|
+
expect(result.created.sort()).toEqual(['billing', 'user-management'])
|
|
91
|
+
expect(result.unchanged).toEqual([])
|
|
92
|
+
expect(result.refs['billing']).toBe('docs/migrate/capabilities/billing.md')
|
|
93
|
+
|
|
94
|
+
const roadmap = await readFile(roadmapPath(), 'utf8')
|
|
95
|
+
// billing owns the element user-management cites, so it comes first.
|
|
96
|
+
expect(roadmap.indexOf('Billing')).toBeLessThan(roadmap.indexOf('User management'))
|
|
97
|
+
expect(roadmap).toContain('- [ ] BI-001 Raise an invoice for a completed booking')
|
|
98
|
+
expect(roadmap).toContain('- [ ] UM-001 Authenticate a user against stored credentials')
|
|
99
|
+
|
|
100
|
+
const capFile = await readFile(
|
|
101
|
+
join(root, 'docs', 'migrate', 'capabilities', 'user-management.md'),
|
|
102
|
+
'utf8',
|
|
103
|
+
)
|
|
104
|
+
expect(capFile).toContain('# User management')
|
|
105
|
+
expect(capFile).toContain('UM-001')
|
|
106
|
+
expect(capFile).toContain('billing')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('re-applying preserves every ticked box and its date', async () => {
|
|
110
|
+
const items = buildWorkItems(CAPS, REQS)
|
|
111
|
+
await markdown.apply(items, input())
|
|
112
|
+
|
|
113
|
+
// The owner ticks two boxes by hand, one with a date and one without.
|
|
114
|
+
const before = await readFile(roadmapPath(), 'utf8')
|
|
115
|
+
await writeFile(
|
|
116
|
+
roadmapPath(),
|
|
117
|
+
before
|
|
118
|
+
.replace(
|
|
119
|
+
'- [ ] UM-001 Authenticate a user against stored credentials',
|
|
120
|
+
'- [x] UM-001 <!-- done:2026-08-12 --> Authenticate a user against stored credentials',
|
|
121
|
+
)
|
|
122
|
+
.replace('- [ ] BI-001 ', '- [x] BI-001 '),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// A new requirement lands and handoff runs again.
|
|
126
|
+
const grown = [...REQS, req('UM-003', 'user-management', 'Expire a session after idle time')]
|
|
127
|
+
await markdown.apply(buildWorkItems(CAPS, grown), input(CAPS, grown))
|
|
128
|
+
|
|
129
|
+
const after = await readFile(roadmapPath(), 'utf8')
|
|
130
|
+
expect(after).toContain(
|
|
131
|
+
'- [x] UM-001 <!-- done:2026-08-12 --> Authenticate a user against stored credentials',
|
|
132
|
+
)
|
|
133
|
+
expect(after).toContain('- [x] BI-001 Raise an invoice for a completed booking')
|
|
134
|
+
expect(after).toContain('- [ ] UM-003 Expire a session after idle time')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('throughput reads ticked boxes, with a null date for an undated tick', async () => {
|
|
138
|
+
const items = buildWorkItems(CAPS, REQS)
|
|
139
|
+
await markdown.apply(items, input())
|
|
140
|
+
const before = await readFile(roadmapPath(), 'utf8')
|
|
141
|
+
await writeFile(
|
|
142
|
+
roadmapPath(),
|
|
143
|
+
before
|
|
144
|
+
.replace('- [ ] UM-001 ', '- [x] UM-001 <!-- done:2026-08-12 --> ')
|
|
145
|
+
.replace('- [ ] BI-001 ', '- [x] BI-001 '),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const t = await markdown.throughput?.(input())
|
|
149
|
+
expect(t?.completions).toEqual([
|
|
150
|
+
{ fr: 'BI-001', doneAt: null },
|
|
151
|
+
{ fr: 'UM-001', doneAt: '2026-08-12' },
|
|
152
|
+
])
|
|
153
|
+
expect(t?.basis).toContain('roadmap')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('throughput on a roadmap that was never written reports nothing built', async () => {
|
|
157
|
+
const t = await markdown.throughput?.(input())
|
|
158
|
+
expect(t?.completions).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('a second apply over an unchanged store reports every item unchanged', async () => {
|
|
162
|
+
const items = buildWorkItems(CAPS, REQS)
|
|
163
|
+
await markdown.apply(items, input())
|
|
164
|
+
const second = await markdown.apply(items, input())
|
|
165
|
+
expect(second.created).toEqual([])
|
|
166
|
+
expect(second.updated).toEqual([])
|
|
167
|
+
expect(second.unchanged.sort()).toEqual(['billing', 'user-management'])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('plan is apply-free: it writes nothing', async () => {
|
|
171
|
+
const items = await markdown.plan(input())
|
|
172
|
+
expect(items.map((i) => i.key)).toEqual(['billing', 'user-management'])
|
|
173
|
+
expect(await Bun.file(roadmapPath()).exists()).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('a requirement whose text opens with a parenthesised date is not read as dated', () => {
|
|
177
|
+
// The date lives in an HTML comment precisely so ordinary requirement prose
|
|
178
|
+
// cannot fabricate one. Parenthesised, this parsed as a completion date and
|
|
179
|
+
// fed a number nobody recorded into the measured velocity.
|
|
180
|
+
const roadmap = '- [x] BI-001 (2026-08-12) Raise an invoice\n'
|
|
181
|
+
const state = parseRoadmap(roadmap)
|
|
182
|
+
expect(state.get('BI-001')).toEqual({ checked: true, date: null })
|
|
183
|
+
})
|