@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,308 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { blockedRequirements } from '../handoff.ts'
|
|
6
|
+
import { runHandoff } from '../handoff-cmd.ts'
|
|
7
|
+
import type { Requirement } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
let root: string
|
|
10
|
+
let source: string
|
|
11
|
+
|
|
12
|
+
function req(id: string, over: Partial<Requirement> = {}): Requirement {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
cap: 'user-management',
|
|
16
|
+
requirement: `requirement ${id}`,
|
|
17
|
+
actors: 'User',
|
|
18
|
+
objects: 'Thing',
|
|
19
|
+
rules: 'none',
|
|
20
|
+
origin: 'intended',
|
|
21
|
+
confidence: { kind: 'confirmed' },
|
|
22
|
+
citations: [{ kind: 'src', path: 'app.js', lines: [1, 1] }],
|
|
23
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
24
|
+
batch: 'b-extract-1',
|
|
25
|
+
...over,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PHASES = ['probe', 'enumerate', 'seam', 'extract', 'parity', 'queue', 'adjudicate', 'handoff']
|
|
30
|
+
|
|
31
|
+
async function jsonl(name: string, rows: unknown[]): Promise<void> {
|
|
32
|
+
await writeFile(join(root, '.migrate', name), rows.map((r) => `${JSON.stringify(r)}\n`).join(''))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A complete, gate-clean store with one declared surface and no closers, so a
|
|
36
|
+
// handoff test does not have to satisfy eight lens censuses to reach the thing
|
|
37
|
+
// it is actually asserting.
|
|
38
|
+
async function store(over: { requirements?: Requirement[]; queueStatus?: string } = {}) {
|
|
39
|
+
await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
|
|
40
|
+
await writeFile(
|
|
41
|
+
join(root, '.migrate', 'config.toml'),
|
|
42
|
+
[
|
|
43
|
+
'[source]',
|
|
44
|
+
`path = "${source}"`,
|
|
45
|
+
'scope = "handoff fixture"',
|
|
46
|
+
'stack = "unknown"',
|
|
47
|
+
'vcs = "none"',
|
|
48
|
+
'basis = "source-only"',
|
|
49
|
+
'',
|
|
50
|
+
'[target]',
|
|
51
|
+
'name = "target"',
|
|
52
|
+
'stack = "unknown"',
|
|
53
|
+
'parity_test_path = "tests/parity/{capability}/{fr_slug}.test.ts"',
|
|
54
|
+
'',
|
|
55
|
+
'[surfaces]',
|
|
56
|
+
'types = ["routes"]',
|
|
57
|
+
'',
|
|
58
|
+
'[closers]',
|
|
59
|
+
'set = []',
|
|
60
|
+
'',
|
|
61
|
+
'[handoff]',
|
|
62
|
+
'adapter = "markdown"',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const requirements = over.requirements ?? [req('UM-001'), req('UM-002')]
|
|
68
|
+
await jsonl('elements.jsonl', [
|
|
69
|
+
{
|
|
70
|
+
id: 'route-get-users',
|
|
71
|
+
surface: 'routes',
|
|
72
|
+
element: 'GET /users',
|
|
73
|
+
found_by: ['code'],
|
|
74
|
+
disposition: { kind: 'mapped', fr: 'UM-001' },
|
|
75
|
+
refs: [],
|
|
76
|
+
lens: 'code',
|
|
77
|
+
batch: 'b-routes-1',
|
|
78
|
+
notes: '',
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
await jsonl('requirements.jsonl', requirements)
|
|
82
|
+
await jsonl('capabilities.jsonl', [
|
|
83
|
+
{
|
|
84
|
+
slug: 'user-management',
|
|
85
|
+
title: 'User management',
|
|
86
|
+
ns: 'UM',
|
|
87
|
+
elements: ['route-get-users'],
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
await jsonl('census.jsonl', [
|
|
91
|
+
{
|
|
92
|
+
kind: 'lens',
|
|
93
|
+
surface: 'routes',
|
|
94
|
+
phase: 'enumerate',
|
|
95
|
+
directions: {
|
|
96
|
+
code: { count: 1, evidence: 'grep app.get' },
|
|
97
|
+
nav: { count: 1, evidence: 'walked the router' },
|
|
98
|
+
},
|
|
99
|
+
total: 1,
|
|
100
|
+
in_ledger: 0,
|
|
101
|
+
added: 1,
|
|
102
|
+
skipped: [],
|
|
103
|
+
queued: [],
|
|
104
|
+
batch: 'b-routes-1',
|
|
105
|
+
},
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
await writeFile(
|
|
109
|
+
join(root, '.migrate', 'queue', 'q-open-question.md'),
|
|
110
|
+
[
|
|
111
|
+
'---',
|
|
112
|
+
'id: q-open-question',
|
|
113
|
+
'severity: moderate',
|
|
114
|
+
`status: ${over.queueStatus ?? 'adjudicated'}`,
|
|
115
|
+
...(over.queueStatus === 'open' ? [] : ['ruling: settled in favour of billing']),
|
|
116
|
+
'---',
|
|
117
|
+
'',
|
|
118
|
+
'## Evidence',
|
|
119
|
+
'',
|
|
120
|
+
'e',
|
|
121
|
+
'',
|
|
122
|
+
'## Options',
|
|
123
|
+
'',
|
|
124
|
+
'o',
|
|
125
|
+
'',
|
|
126
|
+
'## Recommendation',
|
|
127
|
+
'',
|
|
128
|
+
'r',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n'),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const phases: Record<string, unknown> = {}
|
|
134
|
+
for (const p of PHASES) {
|
|
135
|
+
phases[p] = {
|
|
136
|
+
status: 'done',
|
|
137
|
+
batches:
|
|
138
|
+
p === 'enumerate'
|
|
139
|
+
? [{ id: 'b-routes-1', count: 1 }]
|
|
140
|
+
: p === 'extract'
|
|
141
|
+
? [{ id: 'b-extract-1', count: requirements.length }]
|
|
142
|
+
: [],
|
|
143
|
+
pending: [],
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// handoff has not run yet: this is the command under test.
|
|
147
|
+
phases.handoff = { status: 'pending', batches: [], pending: [] }
|
|
148
|
+
await writeFile(
|
|
149
|
+
join(root, '.migrate', 'phases.json'),
|
|
150
|
+
JSON.stringify({ version: 1, phases }, null, 2),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
beforeEach(async () => {
|
|
155
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-handoff-'))
|
|
156
|
+
source = join(root, 'legacy')
|
|
157
|
+
await Bun.write(join(source, 'app.js'), '// legacy\n')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
afterEach(async () => {
|
|
161
|
+
await rm(root, { recursive: true, force: true })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// --- blockedRequirements, pure ---
|
|
165
|
+
|
|
166
|
+
test('a queued confidence blocks only while its queue item is open', () => {
|
|
167
|
+
const queued = req('UM-001', { confidence: { kind: 'queued', queue: 'q-x' } })
|
|
168
|
+
|
|
169
|
+
expect(blockedRequirements([queued], new Set(['q-x']))).toEqual([{ fr: 'UM-001', queue: 'q-x' }])
|
|
170
|
+
// The correction this milestone exists to make. Once the item is
|
|
171
|
+
// adjudicated it is no longer open, the decision is settled, and the
|
|
172
|
+
// requirement must stop blocking handoff even though its confidence still
|
|
173
|
+
// reads `queued`. Treating any queued confidence as a blocker would refuse
|
|
174
|
+
// handoff forever unless every such requirement were re-imported first.
|
|
175
|
+
expect(blockedRequirements([queued], new Set())).toEqual([])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('a sub-high rubric parity blocks on its own queue item', () => {
|
|
179
|
+
const moderate = req('UM-002', {
|
|
180
|
+
parity: { kind: 'rubric', level: 'moderate', queue: 'q-y' },
|
|
181
|
+
})
|
|
182
|
+
expect(blockedRequirements([moderate], new Set(['q-y']))).toEqual([
|
|
183
|
+
{ fr: 'UM-002', queue: 'q-y' },
|
|
184
|
+
])
|
|
185
|
+
expect(blockedRequirements([moderate], new Set())).toEqual([])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('a high rubric and a differential parity never block', () => {
|
|
189
|
+
const high = req('UM-003')
|
|
190
|
+
const differential = req('UM-004', { parity: { kind: 'differential', ref: 't.test.ts' } })
|
|
191
|
+
expect(blockedRequirements([high, differential], new Set(['q-x']))).toEqual([])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('one requirement blocked from both directions is reported once per path', () => {
|
|
195
|
+
const both = req('UM-005', {
|
|
196
|
+
confidence: { kind: 'queued', queue: 'q-a' },
|
|
197
|
+
parity: { kind: 'rubric', level: 'low', queue: 'q-b' },
|
|
198
|
+
})
|
|
199
|
+
expect(blockedRequirements([both], new Set(['q-a', 'q-b']))).toEqual([
|
|
200
|
+
{ fr: 'UM-005', queue: 'q-a' },
|
|
201
|
+
{ fr: 'UM-005', queue: 'q-b' },
|
|
202
|
+
])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// --- the verb ---
|
|
206
|
+
|
|
207
|
+
test('handoff writes handoff.json and records the phase batch', async () => {
|
|
208
|
+
await store()
|
|
209
|
+
const code = await runHandoff({ root })
|
|
210
|
+
expect(code).toBe(0)
|
|
211
|
+
|
|
212
|
+
const file = JSON.parse(await readFile(join(root, '.migrate', 'handoff.json'), 'utf8'))
|
|
213
|
+
expect(file.adapter).toBe('markdown')
|
|
214
|
+
expect(file.items).toHaveLength(1)
|
|
215
|
+
expect(file.items[0].frs).toEqual(['UM-001', 'UM-002'])
|
|
216
|
+
expect(file.basis).toEqual({ confirmed: 2, emitted: 2, order: ['user-management'] })
|
|
217
|
+
// The stored item drops the rendered body, which is regenerated on every
|
|
218
|
+
// plan() and would otherwise churn the file on wording alone.
|
|
219
|
+
expect(file.items[0].body).toBeUndefined()
|
|
220
|
+
|
|
221
|
+
const phases = JSON.parse(await readFile(join(root, '.migrate', 'phases.json'), 'utf8'))
|
|
222
|
+
expect(phases.phases.handoff.batches).toHaveLength(1)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('handoff.json is byte-identical across two runs over one store', async () => {
|
|
226
|
+
await store()
|
|
227
|
+
await runHandoff({ root })
|
|
228
|
+
const first = await readFile(join(root, '.migrate', 'handoff.json'), 'utf8')
|
|
229
|
+
await runHandoff({ root })
|
|
230
|
+
const second = await readFile(join(root, '.migrate', 'handoff.json'), 'utf8')
|
|
231
|
+
expect(second).toBe(first)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('handoff refuses while a queue item is open, naming it', async () => {
|
|
235
|
+
await store({ queueStatus: 'open' })
|
|
236
|
+
const code = await runHandoff({ root })
|
|
237
|
+
expect(code).toBe(1)
|
|
238
|
+
expect(await Bun.file(join(root, '.migrate', 'handoff.json')).exists()).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('handoff refuses a requirement blocked by an open item, naming both', async () => {
|
|
242
|
+
await store({
|
|
243
|
+
requirements: [
|
|
244
|
+
req('UM-001'),
|
|
245
|
+
req('UM-002', { confidence: { kind: 'queued', queue: 'q-open-question' } }),
|
|
246
|
+
],
|
|
247
|
+
queueStatus: 'open',
|
|
248
|
+
})
|
|
249
|
+
// Asserting the message, not just the exit code. Gate 11 already fails an
|
|
250
|
+
// open queue item, so a test that only checked `code === 1` passed even with
|
|
251
|
+
// blockedRequirements stubbed out to return nothing, and the "naming both"
|
|
252
|
+
// in this test's own name went unverified.
|
|
253
|
+
const written: string[] = []
|
|
254
|
+
const original = process.stderr.write.bind(process.stderr)
|
|
255
|
+
process.stderr.write = ((chunk: string) => {
|
|
256
|
+
written.push(String(chunk))
|
|
257
|
+
return true
|
|
258
|
+
}) as typeof process.stderr.write
|
|
259
|
+
let code: number
|
|
260
|
+
try {
|
|
261
|
+
code = await runHandoff({ root })
|
|
262
|
+
} finally {
|
|
263
|
+
process.stderr.write = original
|
|
264
|
+
}
|
|
265
|
+
expect(code).toBe(1)
|
|
266
|
+
expect(written.join('')).toContain('UM-002 blocked by q-open-question')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('a corrupt handoff.json is a violation, not a crash', async () => {
|
|
270
|
+
await store()
|
|
271
|
+
await writeFile(join(root, '.migrate', 'handoff.json'), '{"items": "not an array"}')
|
|
272
|
+
const { runCheck } = await import('../check.ts')
|
|
273
|
+
// Bounded checks must still work: the file is read only for the gate that
|
|
274
|
+
// needs it, so a corrupt one cannot break `check --phase probe`, `status` or
|
|
275
|
+
// `report`.
|
|
276
|
+
const early = await runCheck({ root, phase: 'probe' })
|
|
277
|
+
expect(early.violations.filter((v) => v.gate === 'handoff')).toEqual([])
|
|
278
|
+
const full = await runCheck({ root })
|
|
279
|
+
const named = full.violations.filter((v) => v.gate === 'handoff')
|
|
280
|
+
expect(named.length).toBeGreaterThan(0)
|
|
281
|
+
expect(named[0]?.message).toContain('handoff.json')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('--dry-run runs the preflight and writes nothing', async () => {
|
|
285
|
+
await store()
|
|
286
|
+
const code = await runHandoff({ root, dryRun: true })
|
|
287
|
+
expect(code).toBe(0)
|
|
288
|
+
expect(await Bun.file(join(root, '.migrate', 'handoff.json')).exists()).toBe(false)
|
|
289
|
+
expect(await Bun.file(join(root, 'docs', 'migrate', 'roadmap.md')).exists()).toBe(false)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('--dry-run still refuses on an open queue item', async () => {
|
|
293
|
+
await store({ queueStatus: 'open' })
|
|
294
|
+
expect(await runHandoff({ root, dryRun: true })).toBe(1)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('an unknown adapter is a usage error naming the three that exist', async () => {
|
|
298
|
+
await store()
|
|
299
|
+
const code = await runHandoff({ root, adapter: 'jira' })
|
|
300
|
+
expect(code).toBe(2)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('the preflight is bounded at adjudicate, so gate 12 cannot block the run that satisfies it', async () => {
|
|
304
|
+
// handoff.json does not exist yet, and gate 12 requires it. An unbounded
|
|
305
|
+
// preflight would therefore refuse every first handoff, forever.
|
|
306
|
+
await store()
|
|
307
|
+
expect(await runHandoff({ root })).toBe(0)
|
|
308
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { buildWorkItems, dependencyOrder } from '../handoff.ts'
|
|
3
|
+
import type { Capability, Requirement } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
function cap(slug: string, elements: string[]): Capability {
|
|
6
|
+
return { slug, title: slug.replace(/-/g, ' '), ns: slug.slice(0, 2).toUpperCase(), elements }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// A requirement in `capSlug` citing each of `cites` as a ledger element. The
|
|
10
|
+
// edge under test is directional: the capability that owns the cited element
|
|
11
|
+
// is the dependency, and the capability doing the citing is the dependent.
|
|
12
|
+
function req(id: string, capSlug: string, cites: string[]): Requirement {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
cap: capSlug,
|
|
16
|
+
requirement: `requirement ${id}`,
|
|
17
|
+
actors: 'User',
|
|
18
|
+
objects: 'Thing',
|
|
19
|
+
rules: 'none',
|
|
20
|
+
origin: 'intended',
|
|
21
|
+
confidence: { kind: 'confirmed' },
|
|
22
|
+
citations: cites.map((c) => ({ kind: 'ledger' as const, id: c })),
|
|
23
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
24
|
+
batch: 'b-1',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const slugs = (caps: Capability[]): string[] => caps.map((c) => c.slug)
|
|
29
|
+
|
|
30
|
+
test('a chain emits its dependencies first', () => {
|
|
31
|
+
// alpha cites an element owned by beta; beta cites one owned by gamma.
|
|
32
|
+
const caps = [cap('alpha', ['el-a']), cap('beta', ['el-b']), cap('gamma', ['el-c'])]
|
|
33
|
+
const reqs = [
|
|
34
|
+
req('AL-001', 'alpha', ['el-b']),
|
|
35
|
+
req('BE-001', 'beta', ['el-c']),
|
|
36
|
+
req('GA-001', 'gamma', []),
|
|
37
|
+
]
|
|
38
|
+
const { ordered, cycle } = dependencyOrder(caps, reqs)
|
|
39
|
+
expect(slugs(ordered)).toEqual(['gamma', 'beta', 'alpha'])
|
|
40
|
+
expect(cycle).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('a diamond emits a valid order, deterministically', () => {
|
|
44
|
+
// top depends on left and right; both depend on base.
|
|
45
|
+
const caps = [
|
|
46
|
+
cap('top', ['el-t']),
|
|
47
|
+
cap('left', ['el-l']),
|
|
48
|
+
cap('right', ['el-r']),
|
|
49
|
+
cap('base', ['el-b']),
|
|
50
|
+
]
|
|
51
|
+
const reqs = [
|
|
52
|
+
req('TO-001', 'top', ['el-l', 'el-r']),
|
|
53
|
+
req('LE-001', 'left', ['el-b']),
|
|
54
|
+
req('RI-001', 'right', ['el-b']),
|
|
55
|
+
req('BA-001', 'base', []),
|
|
56
|
+
]
|
|
57
|
+
const first = dependencyOrder(caps, reqs)
|
|
58
|
+
expect(slugs(first.ordered)).toEqual(['base', 'left', 'right', 'top'])
|
|
59
|
+
// Re-running over the same input, and over a shuffled input, gives the same
|
|
60
|
+
// answer: the tie between left and right is broken by slug, not by position.
|
|
61
|
+
const shuffled = dependencyOrder([...caps].reverse(), [...reqs].reverse())
|
|
62
|
+
expect(slugs(shuffled.ordered)).toEqual(slugs(first.ordered))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a cycle emits its members in slug order and reports them', () => {
|
|
66
|
+
const caps = [cap('yin', ['el-y']), cap('yang', ['el-z']), cap('solo', ['el-s'])]
|
|
67
|
+
const reqs = [
|
|
68
|
+
req('YI-001', 'yin', ['el-z']),
|
|
69
|
+
req('YA-001', 'yang', ['el-y']),
|
|
70
|
+
req('SO-001', 'solo', []),
|
|
71
|
+
]
|
|
72
|
+
const { ordered, cycle } = dependencyOrder(caps, reqs)
|
|
73
|
+
// solo is unblocked and goes first; the two-cycle follows in slug order.
|
|
74
|
+
expect(slugs(ordered)).toEqual(['solo', 'yang', 'yin'])
|
|
75
|
+
expect(cycle).toEqual(['yang', 'yin'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a capability whose requirements cite no ledger elements is unblocked', () => {
|
|
79
|
+
const caps = [cap('alpha', ['el-a']), cap('beta', ['el-b'])]
|
|
80
|
+
const reqs = [
|
|
81
|
+
// A src citation is not a ledger citation and creates no edge.
|
|
82
|
+
{
|
|
83
|
+
...req('AL-001', 'alpha', []),
|
|
84
|
+
citations: [{ kind: 'src' as const, path: 'app.js', lines: [1, 2] as [number, number] }],
|
|
85
|
+
},
|
|
86
|
+
req('BE-001', 'beta', []),
|
|
87
|
+
]
|
|
88
|
+
const { ordered, cycle } = dependencyOrder(caps, reqs)
|
|
89
|
+
expect(slugs(ordered)).toEqual(['alpha', 'beta'])
|
|
90
|
+
expect(cycle).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('a capability citing an element it owns itself does not depend on itself', () => {
|
|
94
|
+
const caps = [cap('alpha', ['el-a', 'el-a2'])]
|
|
95
|
+
const reqs = [req('AL-001', 'alpha', ['el-a2'])]
|
|
96
|
+
const { ordered, cycle } = dependencyOrder(caps, reqs)
|
|
97
|
+
expect(slugs(ordered)).toEqual(['alpha'])
|
|
98
|
+
expect(cycle).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('buildWorkItems carries frs, weight and dependsOn per capability', () => {
|
|
102
|
+
const caps = [cap('alpha', ['el-a']), cap('beta', ['el-b'])]
|
|
103
|
+
const reqs = [
|
|
104
|
+
req('AL-001', 'alpha', ['el-b']),
|
|
105
|
+
req('AL-002', 'alpha', []),
|
|
106
|
+
req('BE-001', 'beta', []),
|
|
107
|
+
]
|
|
108
|
+
const items = buildWorkItems(caps, reqs)
|
|
109
|
+
expect(items.map((i) => i.key)).toEqual(['beta', 'alpha'])
|
|
110
|
+
|
|
111
|
+
const alpha = items.find((i) => i.key === 'alpha')
|
|
112
|
+
expect(alpha?.frs).toEqual(['AL-001', 'AL-002'])
|
|
113
|
+
expect(alpha?.weight).toBe(2)
|
|
114
|
+
expect(alpha?.dependsOn).toEqual(['beta'])
|
|
115
|
+
expect(alpha?.body).toContain('AL-001')
|
|
116
|
+
expect(alpha?.body).toContain('AL-002')
|
|
117
|
+
|
|
118
|
+
const beta = items.find((i) => i.key === 'beta')
|
|
119
|
+
expect(beta?.dependsOn).toEqual([])
|
|
120
|
+
expect(beta?.weight).toBe(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('buildWorkItems emits a capability with no requirements at weight zero', () => {
|
|
124
|
+
// An empty capability is still a real partition entry, and dropping it would
|
|
125
|
+
// silently narrow what handoff emitted relative to what the seam decided.
|
|
126
|
+
const items = buildWorkItems([cap('empty', ['el-e'])], [])
|
|
127
|
+
expect(items).toHaveLength(1)
|
|
128
|
+
expect(items[0]?.weight).toBe(0)
|
|
129
|
+
expect(items[0]?.frs).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('only genuine cycle members are reported, and what follows a cycle still sorts', () => {
|
|
133
|
+
// "Everything left is in a cycle" was false: anything transitively blocked
|
|
134
|
+
// by one was reported as a member too, and its satisfiable placement was
|
|
135
|
+
// thrown away. Here yin and yang cycle; alpha depends on yin and zulu on
|
|
136
|
+
// alpha, so both have a valid position after the cycle is broken.
|
|
137
|
+
const caps = [
|
|
138
|
+
cap('yin', ['el-y']),
|
|
139
|
+
cap('yang', ['el-z']),
|
|
140
|
+
cap('alpha', ['el-a']),
|
|
141
|
+
cap('zulu', ['el-u']),
|
|
142
|
+
]
|
|
143
|
+
const reqs = [
|
|
144
|
+
req('YI-001', 'yin', ['el-z']),
|
|
145
|
+
req('YA-001', 'yang', ['el-y']),
|
|
146
|
+
req('AL-001', 'alpha', ['el-y']),
|
|
147
|
+
req('ZU-001', 'zulu', ['el-a']),
|
|
148
|
+
]
|
|
149
|
+
const { ordered, cycle } = dependencyOrder(caps, reqs)
|
|
150
|
+
expect(cycle).toEqual(['yang', 'yin'])
|
|
151
|
+
// alpha comes after the cycle it depends on, and zulu after alpha.
|
|
152
|
+
const at = (slug: string): number => slugs(ordered).indexOf(slug)
|
|
153
|
+
expect(at('alpha')).toBeGreaterThan(at('yin'))
|
|
154
|
+
expect(at('zulu')).toBeGreaterThan(at('alpha'))
|
|
155
|
+
expect(slugs(ordered).sort()).toEqual(['alpha', 'yang', 'yin', 'zulu'])
|
|
156
|
+
})
|