@iceinvein/agent-skills 0.2.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 +2 -2
- package/dist/cli/index.js +14 -10
- package/package.json +1 -1
- package/skills/index.json +4 -4
- 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
- package/skills/sluice/SKILL.md +20 -7
- package/skills/sluice/references/deep-channel.md +20 -0
- package/skills/sluice/references/finish.md +4 -2
- package/skills/sluice/references/meter.md +38 -0
- package/skills/sluice/scripts/run-stats.sh +236 -0
- package/skills/sluice/skill.json +4 -3
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|