@iceinvein/agent-skills 0.1.39 → 0.2.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 +18 -2
- package/dist/cli/index.js +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/skill.json +32 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { runCheck } from '../check.ts'
|
|
6
|
+
|
|
7
|
+
let root: string
|
|
8
|
+
let source: string
|
|
9
|
+
|
|
10
|
+
async function init(): Promise<void> {
|
|
11
|
+
const { runInit } = await import('../init-cmd.ts')
|
|
12
|
+
const code = await runInit({
|
|
13
|
+
root,
|
|
14
|
+
sourcePath: source,
|
|
15
|
+
scope: 'run-state fixture',
|
|
16
|
+
targetName: 'target',
|
|
17
|
+
})
|
|
18
|
+
expect(code).toBe(0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function setPhases(states: Record<string, unknown>): Promise<void> {
|
|
22
|
+
const all: Record<string, unknown> = {}
|
|
23
|
+
for (const p of [
|
|
24
|
+
'probe',
|
|
25
|
+
'enumerate',
|
|
26
|
+
'seam',
|
|
27
|
+
'extract',
|
|
28
|
+
'parity',
|
|
29
|
+
'queue',
|
|
30
|
+
'adjudicate',
|
|
31
|
+
'handoff',
|
|
32
|
+
]) {
|
|
33
|
+
all[p] = { status: 'pending', batches: [], pending: [], ...(states[p] as object) }
|
|
34
|
+
}
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(root, '.migrate', 'phases.json'),
|
|
37
|
+
JSON.stringify({ version: 1, phases: all }),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-runstate-'))
|
|
43
|
+
source = join(root, 'legacy')
|
|
44
|
+
await Bun.write(join(source, 'app.js'), '// legacy\n')
|
|
45
|
+
await init()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(root, { recursive: true, force: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('a store nobody worked in fails run-state once per phase', async () => {
|
|
53
|
+
const result = await runCheck({ root })
|
|
54
|
+
const runState = result.violations.filter((v) => v.gate === 'run-state')
|
|
55
|
+
expect(runState.length).toBe(8)
|
|
56
|
+
expect(runState.some((v) => v.message.includes('probe is pending'))).toBe(true)
|
|
57
|
+
expect(runState.some((v) => v.message.includes('handoff is pending'))).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('--phase bounds run-state at the named phase', async () => {
|
|
61
|
+
await setPhases({ probe: { status: 'done' } })
|
|
62
|
+
const result = await runCheck({ root, phase: 'probe' })
|
|
63
|
+
expect(result.violations.filter((v) => v.gate === 'run-state').length).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('--phase still fails when a phase at or below the terminus is short of done', async () => {
|
|
67
|
+
await setPhases({ probe: { status: 'done' }, enumerate: { status: 'running' } })
|
|
68
|
+
const result = await runCheck({ root, phase: 'enumerate' })
|
|
69
|
+
const runState = result.violations.filter((v) => v.gate === 'run-state')
|
|
70
|
+
expect(runState.length).toBe(1)
|
|
71
|
+
expect(runState[0]?.message).toContain('enumerate is running')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a phase marked done above a pending predecessor is named', async () => {
|
|
75
|
+
await setPhases({ seam: { status: 'done' } })
|
|
76
|
+
const result = await runCheck({ root })
|
|
77
|
+
const runState = result.violations.filter((v) => v.gate === 'run-state')
|
|
78
|
+
expect(
|
|
79
|
+
runState.some((v) => v.message.includes('seam is done while enumerate is still pending')),
|
|
80
|
+
).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a lens census naming an uncommitted batch is named', async () => {
|
|
84
|
+
await setPhases({
|
|
85
|
+
probe: { status: 'done' },
|
|
86
|
+
enumerate: { status: 'done', batches: [{ id: 'b-other-001', count: 1 }] },
|
|
87
|
+
})
|
|
88
|
+
await writeFile(
|
|
89
|
+
join(root, '.migrate', 'census.jsonl'),
|
|
90
|
+
`${JSON.stringify({
|
|
91
|
+
kind: 'lens',
|
|
92
|
+
surface: 'routes',
|
|
93
|
+
phase: 'enumerate',
|
|
94
|
+
directions: {
|
|
95
|
+
code: { count: 0, evidence: 'rg -n "app.(get|post)" app.js' },
|
|
96
|
+
nav: { count: 0, evidence: 'manual review of route registrations' },
|
|
97
|
+
},
|
|
98
|
+
total: 0,
|
|
99
|
+
in_ledger: 0,
|
|
100
|
+
added: 0,
|
|
101
|
+
skipped: [],
|
|
102
|
+
queued: [],
|
|
103
|
+
batch: 'b-routes-census-001',
|
|
104
|
+
})}\n`,
|
|
105
|
+
)
|
|
106
|
+
const result = await runCheck({ root, phase: 'enumerate' })
|
|
107
|
+
const runState = result.violations.filter((v) => v.gate === 'run-state')
|
|
108
|
+
expect(runState.some((v) => v.message.includes('b-routes-census-001'))).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('citations run by default and can be turned off', async () => {
|
|
112
|
+
await writeFile(
|
|
113
|
+
join(root, '.migrate', 'capabilities.jsonl'),
|
|
114
|
+
`${JSON.stringify({ slug: 'core', title: 'Core', ns: 'core', elements: [] })}\n`,
|
|
115
|
+
)
|
|
116
|
+
await writeFile(
|
|
117
|
+
join(root, '.migrate', 'requirements.jsonl'),
|
|
118
|
+
`${JSON.stringify({
|
|
119
|
+
id: 'UM-001',
|
|
120
|
+
cap: 'core',
|
|
121
|
+
requirement: 'r',
|
|
122
|
+
actors: 'a',
|
|
123
|
+
objects: 'o',
|
|
124
|
+
rules: 'x',
|
|
125
|
+
origin: 'intended',
|
|
126
|
+
confidence: { kind: 'confirmed' },
|
|
127
|
+
citations: [{ kind: 'src', path: 'Gone.cs' }],
|
|
128
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
129
|
+
batch: 'b-001',
|
|
130
|
+
})}\n`,
|
|
131
|
+
)
|
|
132
|
+
const on = await runCheck({ root })
|
|
133
|
+
expect(on.violations.some((v) => v.gate === 'citations')).toBe(true)
|
|
134
|
+
const off = await runCheck({ root, citations: false })
|
|
135
|
+
expect(off.violations.some((v) => v.gate === 'citations')).toBe(false)
|
|
136
|
+
})
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { writeConfig } from '../config.ts'
|
|
7
|
+
import { storePaths } from '../paths.ts'
|
|
8
|
+
import { loadPhases, recordBatch, setPhaseStatus } from '../phases.ts'
|
|
9
|
+
import { loadQueue } from '../queue.ts'
|
|
10
|
+
import { runReset } from '../reset-cmd.ts'
|
|
11
|
+
import { runStatus } from '../status-cmd.ts'
|
|
12
|
+
import { readRows, writeRows } from '../store.ts'
|
|
13
|
+
import type { Capability, Census, Delta, Element, Requirement } from '../types.ts'
|
|
14
|
+
|
|
15
|
+
let root: string
|
|
16
|
+
let source: string
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-status-'))
|
|
20
|
+
source = join(root, 'legacy')
|
|
21
|
+
await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
|
|
22
|
+
await mkdir(source, { recursive: true })
|
|
23
|
+
await writeConfig(root, { sourcePath: source, scope: 'all', targetName: 'newapp' })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await rm(root, { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const ELEMENT: Element = {
|
|
31
|
+
id: 'route-a',
|
|
32
|
+
surface: 'routes',
|
|
33
|
+
element: 'GET /a',
|
|
34
|
+
found_by: ['code'],
|
|
35
|
+
disposition: { kind: 'mapped', fr: 'UM-001' },
|
|
36
|
+
refs: [],
|
|
37
|
+
lens: 'code',
|
|
38
|
+
batch: 'b-1',
|
|
39
|
+
notes: '',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REQUIREMENT: Requirement = {
|
|
43
|
+
id: 'UM-001',
|
|
44
|
+
cap: 'user-management',
|
|
45
|
+
requirement: 'List',
|
|
46
|
+
actors: '-',
|
|
47
|
+
objects: '-',
|
|
48
|
+
rules: '-',
|
|
49
|
+
origin: 'intended',
|
|
50
|
+
confidence: { kind: 'confirmed' },
|
|
51
|
+
citations: [{ kind: 'ledger', id: 'route-a' }],
|
|
52
|
+
parity: { kind: 'rubric', level: 'high' },
|
|
53
|
+
batch: 'b-1',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LENS_CENSUS: Census = {
|
|
57
|
+
kind: 'lens',
|
|
58
|
+
surface: 'routes',
|
|
59
|
+
phase: 'enumerate',
|
|
60
|
+
directions: {
|
|
61
|
+
code: { count: 1, evidence: 'rg -n "app.(get|post)" app.js' },
|
|
62
|
+
nav: { count: 1, evidence: 'manual review of route registrations' },
|
|
63
|
+
},
|
|
64
|
+
total: 1,
|
|
65
|
+
in_ledger: 1,
|
|
66
|
+
added: 0,
|
|
67
|
+
skipped: [],
|
|
68
|
+
queued: [],
|
|
69
|
+
batch: 'b-1',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ATTR_CENSUS: Census = {
|
|
73
|
+
kind: 'attribute',
|
|
74
|
+
surface: 'tables',
|
|
75
|
+
subject: 'table-a',
|
|
76
|
+
phase: 'enumerate',
|
|
77
|
+
directions: {
|
|
78
|
+
ddl: { count: 3, evidence: 'rg -n "CREATE TABLE" schema.sql' },
|
|
79
|
+
code: { count: 3, evidence: 'rg -n "table-a" app.js' },
|
|
80
|
+
},
|
|
81
|
+
total: 3,
|
|
82
|
+
behavioral: 1,
|
|
83
|
+
explained: 1,
|
|
84
|
+
queued: [],
|
|
85
|
+
batch: 'b-2',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CAPABILITY: Capability = {
|
|
89
|
+
slug: 'user-management',
|
|
90
|
+
title: 'User management',
|
|
91
|
+
ns: 'UM',
|
|
92
|
+
elements: ['route-a'],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DELTA: Delta = {
|
|
96
|
+
id: 'delta-1',
|
|
97
|
+
scope: 'user-management',
|
|
98
|
+
rationale: 'legacy quirk not worth replicating',
|
|
99
|
+
parity_exclusion: 'silent 500 on double-submit',
|
|
100
|
+
validation: 'manual review',
|
|
101
|
+
owner_signed: 'jane@example.com',
|
|
102
|
+
batch: 'b-1',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const QUEUE_ITEM = `---
|
|
106
|
+
id: q-invoice-batch-scope
|
|
107
|
+
severity: moderate
|
|
108
|
+
status: open
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Evidence
|
|
112
|
+
|
|
113
|
+
Route POST /api/invoice/batch found in InvoiceController.cs:215-240.
|
|
114
|
+
|
|
115
|
+
## Options
|
|
116
|
+
|
|
117
|
+
(a) Replicate as-is. (b) Harden it. (c) Mark out of scope.
|
|
118
|
+
|
|
119
|
+
## Recommendation
|
|
120
|
+
|
|
121
|
+
Recommend (c); three invocations in six months.
|
|
122
|
+
`
|
|
123
|
+
|
|
124
|
+
async function writeQueueItem(root: string): Promise<string> {
|
|
125
|
+
const path = join(storePaths(root).queueDir, 'q-invoice-batch-scope.md')
|
|
126
|
+
await writeFile(path, QUEUE_ITEM)
|
|
127
|
+
return path
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test('reset extract clears requirements and returns dispositions to unaccounted', async () => {
|
|
131
|
+
const p = storePaths(root)
|
|
132
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
133
|
+
await writeRows(p.requirements, [REQUIREMENT], source)
|
|
134
|
+
await writeRows(p.census, [LENS_CENSUS, ATTR_CENSUS], source)
|
|
135
|
+
await setPhaseStatus(root, 'extract', 'done', source)
|
|
136
|
+
|
|
137
|
+
expect(await runReset({ root, phase: 'extract' })).toBe(0)
|
|
138
|
+
|
|
139
|
+
expect(await readRows<Requirement>(p.requirements)).toEqual([])
|
|
140
|
+
const elements = await readRows<Element>(p.elements)
|
|
141
|
+
expect(elements[0]?.disposition).toEqual({ kind: 'unaccounted' })
|
|
142
|
+
const census = await readRows<Census>(p.census)
|
|
143
|
+
expect(census.map((c) => c.kind)).toEqual(['lens'])
|
|
144
|
+
expect((await loadPhases(root)).extract.status).toBe('pending')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('reset enumerate clears elements and lens census but leaves attribute census', async () => {
|
|
148
|
+
const p = storePaths(root)
|
|
149
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
150
|
+
await writeRows(p.census, [LENS_CENSUS, ATTR_CENSUS], source)
|
|
151
|
+
await recordBatch(root, 'enumerate', { id: 'b-1', count: 1 }, source)
|
|
152
|
+
|
|
153
|
+
expect(await runReset({ root, phase: 'enumerate' })).toBe(0)
|
|
154
|
+
|
|
155
|
+
expect(await readRows(p.elements)).toEqual([])
|
|
156
|
+
expect((await readRows<Census>(p.census)).map((c) => c.kind)).toEqual(['attribute'])
|
|
157
|
+
const phases = await loadPhases(root)
|
|
158
|
+
expect(phases.enumerate.status).toBe('pending')
|
|
159
|
+
expect(phases.enumerate.batches).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('reset rejects an unknown phase', async () => {
|
|
163
|
+
expect(await runReset({ root, phase: 'seams' })).toBe(2)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('reset seam clears capabilities and seam files but leaves elements and requirements alone', async () => {
|
|
167
|
+
const p = storePaths(root)
|
|
168
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
169
|
+
await writeRows(p.requirements, [REQUIREMENT], source)
|
|
170
|
+
await writeRows(p.capabilities, [CAPABILITY], source)
|
|
171
|
+
await writeFile(p.seamJson, '{"capabilities":[]}\n')
|
|
172
|
+
await writeFile(p.seamMd, '# Seam\n')
|
|
173
|
+
await setPhaseStatus(root, 'seam', 'done', source)
|
|
174
|
+
|
|
175
|
+
expect(await runReset({ root, phase: 'seam' })).toBe(0)
|
|
176
|
+
|
|
177
|
+
expect(await readRows<Capability>(p.capabilities)).toEqual([])
|
|
178
|
+
expect(existsSync(p.seamJson)).toBe(false)
|
|
179
|
+
expect(existsSync(p.seamMd)).toBe(false)
|
|
180
|
+
expect(await readRows<Element>(p.elements)).toEqual([ELEMENT])
|
|
181
|
+
expect(await readRows<Requirement>(p.requirements)).toEqual([REQUIREMENT])
|
|
182
|
+
expect((await loadPhases(root)).seam.status).toBe('pending')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('reset parity clears deltas and nulls every requirement parity but leaves elements alone', async () => {
|
|
186
|
+
const p = storePaths(root)
|
|
187
|
+
const other: Requirement = {
|
|
188
|
+
...REQUIREMENT,
|
|
189
|
+
id: 'UM-002',
|
|
190
|
+
parity: { kind: 'golden-master', ref: 'gm-1' },
|
|
191
|
+
}
|
|
192
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
193
|
+
await writeRows(p.requirements, [REQUIREMENT, other], source)
|
|
194
|
+
await writeRows(p.deltas, [DELTA], source)
|
|
195
|
+
await setPhaseStatus(root, 'parity', 'done', source)
|
|
196
|
+
|
|
197
|
+
expect(await runReset({ root, phase: 'parity' })).toBe(0)
|
|
198
|
+
|
|
199
|
+
expect(await readRows<Delta>(p.deltas)).toEqual([])
|
|
200
|
+
const reqs = await readRows<Requirement>(p.requirements)
|
|
201
|
+
expect(reqs.map((r) => r.parity)).toEqual([null, null])
|
|
202
|
+
expect(await readRows<Element>(p.elements)).toEqual([ELEMENT])
|
|
203
|
+
expect((await loadPhases(root)).parity.status).toBe('pending')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('reset on a phase that owns no rows resets only its state, touching no store file', async () => {
|
|
207
|
+
const p = storePaths(root)
|
|
208
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
209
|
+
await writeRows(p.requirements, [REQUIREMENT], source)
|
|
210
|
+
await writeRows(p.capabilities, [CAPABILITY], source)
|
|
211
|
+
await writeRows(p.deltas, [DELTA], source)
|
|
212
|
+
await writeRows(p.census, [LENS_CENSUS, ATTR_CENSUS], source)
|
|
213
|
+
await recordBatch(root, 'probe', { id: 'b-1', count: 1 }, source)
|
|
214
|
+
|
|
215
|
+
expect(await runReset({ root, phase: 'probe' })).toBe(0)
|
|
216
|
+
|
|
217
|
+
expect(await readRows<Element>(p.elements)).toEqual([ELEMENT])
|
|
218
|
+
expect(await readRows<Requirement>(p.requirements)).toEqual([REQUIREMENT])
|
|
219
|
+
expect(await readRows<Capability>(p.capabilities)).toEqual([CAPABILITY])
|
|
220
|
+
expect(await readRows<Delta>(p.deltas)).toEqual([DELTA])
|
|
221
|
+
expect((await readRows<Census>(p.census)).map((c) => c.kind)).toEqual(['lens', 'attribute'])
|
|
222
|
+
const phases = await loadPhases(root)
|
|
223
|
+
expect(phases.probe.status).toBe('pending')
|
|
224
|
+
expect(phases.probe.batches).toEqual([])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('reset never touches queue items, regardless of which phase is reset', async () => {
|
|
228
|
+
const queuePath = await writeQueueItem(root)
|
|
229
|
+
const p = storePaths(root)
|
|
230
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
231
|
+
await writeRows(p.requirements, [REQUIREMENT], source)
|
|
232
|
+
await writeRows(p.capabilities, [CAPABILITY], source)
|
|
233
|
+
await writeRows(p.deltas, [DELTA], source)
|
|
234
|
+
await writeRows(p.census, [LENS_CENSUS, ATTR_CENSUS], source)
|
|
235
|
+
|
|
236
|
+
for (const phase of ['enumerate', 'seam', 'extract', 'parity', 'probe'] as const) {
|
|
237
|
+
expect(await runReset({ root, phase })).toBe(0)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expect(existsSync(queuePath)).toBe(true)
|
|
241
|
+
const { items, errors } = await loadQueue(p.queueDir)
|
|
242
|
+
expect(errors).toEqual([])
|
|
243
|
+
expect(items.map((i) => i.id)).toEqual(['q-invoice-batch-scope'])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
async function captureStdout(fn: () => Promise<number>): Promise<{ code: number; text: string }> {
|
|
247
|
+
const out: string[] = []
|
|
248
|
+
const original = process.stdout.write.bind(process.stdout)
|
|
249
|
+
process.stdout.write = ((s: string) => {
|
|
250
|
+
out.push(s)
|
|
251
|
+
return true
|
|
252
|
+
}) as typeof process.stdout.write
|
|
253
|
+
try {
|
|
254
|
+
const code = await fn()
|
|
255
|
+
return { code, text: out.join('') }
|
|
256
|
+
} finally {
|
|
257
|
+
process.stdout.write = original
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test('status names the first non-done phase and its last batch as the resume pointer', async () => {
|
|
262
|
+
const p = storePaths(root)
|
|
263
|
+
await writeRows(p.elements, [ELEMENT], source)
|
|
264
|
+
await writeRows(p.requirements, [REQUIREMENT], source)
|
|
265
|
+
await setPhaseStatus(root, 'probe', 'done', source)
|
|
266
|
+
await recordBatch(root, 'enumerate', { id: 'b-1', count: 1 }, source)
|
|
267
|
+
await setPhaseStatus(root, 'enumerate', 'done', source)
|
|
268
|
+
await recordBatch(root, 'seam', { id: 'b-2', count: 2 }, source)
|
|
269
|
+
|
|
270
|
+
const { code, text } = await captureStdout(() => runStatus({ root }))
|
|
271
|
+
|
|
272
|
+
expect(code).toBe(0)
|
|
273
|
+
expect(text).toContain('probe done')
|
|
274
|
+
expect(text).toContain('enumerate done (1 batch(es))')
|
|
275
|
+
expect(text).toContain('seam running (1 batch(es))')
|
|
276
|
+
expect(text).toContain('1 requirement(s), 1 element(s)')
|
|
277
|
+
expect(text).toContain('resume: seam, last batch b-2 (2 row(s))')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('status reports all phases done when nothing is left to resume', async () => {
|
|
281
|
+
const phases = await loadPhases(root)
|
|
282
|
+
for (const phase of Object.keys(phases) as (keyof typeof phases)[]) {
|
|
283
|
+
await setPhaseStatus(root, phase, 'done', source)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { code, text } = await captureStdout(() => runStatus({ root }))
|
|
287
|
+
|
|
288
|
+
expect(code).toBe(0)
|
|
289
|
+
expect(text).toContain('resume: all phases done')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
async function captureStderr(fn: () => Promise<number>): Promise<{ code: number; text: string }> {
|
|
293
|
+
const out: string[] = []
|
|
294
|
+
const original = process.stderr.write.bind(process.stderr)
|
|
295
|
+
process.stderr.write = ((s: string) => {
|
|
296
|
+
out.push(s)
|
|
297
|
+
return true
|
|
298
|
+
}) as typeof process.stderr.write
|
|
299
|
+
try {
|
|
300
|
+
const code = await fn()
|
|
301
|
+
return { code, text: out.join('') }
|
|
302
|
+
} finally {
|
|
303
|
+
process.stderr.write = original
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Important finding 3: `check` and `queue list` already flag a malformed
|
|
308
|
+
// queue item and exit 1 for the identical condition; status used to print
|
|
309
|
+
// its counts at exit 0 with no mention of it at all, which reads as "the
|
|
310
|
+
// store is fine" when part of it could not even be read.
|
|
311
|
+
test('status surfaces a malformed queue item on stderr and exits 1, not silently 0', async () => {
|
|
312
|
+
await writeFile(join(storePaths(root).queueDir, 'q-broken.md'), 'not a queue item at all')
|
|
313
|
+
|
|
314
|
+
const { code, text } = await captureStderr(() => runStatus({ root }))
|
|
315
|
+
|
|
316
|
+
expect(code).toBe(1)
|
|
317
|
+
expect(text).toContain('q-broken.md')
|
|
318
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { readRawRows, readRows, upsertRows, writeRows } from '../store.ts'
|
|
6
|
+
|
|
7
|
+
let root: string
|
|
8
|
+
const NO_SOURCE = '/nonexistent-source'
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-store-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(root, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('readRows returns empty for a missing file', async () => {
|
|
19
|
+
expect(await readRows(join(root, 'nope.jsonl'))).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('write then read round-trips and ignores blank lines', async () => {
|
|
23
|
+
const path = join(root, 'x.jsonl')
|
|
24
|
+
await writeRows(
|
|
25
|
+
path,
|
|
26
|
+
[
|
|
27
|
+
{ id: 'a', n: 1 },
|
|
28
|
+
{ id: 'b', n: 2 },
|
|
29
|
+
],
|
|
30
|
+
NO_SOURCE,
|
|
31
|
+
)
|
|
32
|
+
const text = await readFile(path, 'utf8')
|
|
33
|
+
expect(text.endsWith('\n')).toBe(true)
|
|
34
|
+
expect(await readRows(path)).toEqual([
|
|
35
|
+
{ id: 'a', n: 1 },
|
|
36
|
+
{ id: 'b', n: 2 },
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('readRows names the offending line on malformed JSON', async () => {
|
|
41
|
+
const path = join(root, 'bad.jsonl')
|
|
42
|
+
await writeFile(path, '{"id":"a"}\nnot json\n')
|
|
43
|
+
await expect(readRows(path)).rejects.toThrow(/line 2/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// readRawRows is readRows's own foundation now, but check.ts is the only
|
|
47
|
+
// caller that needs the line number rather than just the parsed value, so
|
|
48
|
+
// this pins that contract directly instead of leaving it to be exercised
|
|
49
|
+
// only indirectly through check.ts's gate tests. A blank line shifts every
|
|
50
|
+
// line number after it, which is exactly the case a 1-based index computed
|
|
51
|
+
// from the filtered array (rather than the original split) would get wrong.
|
|
52
|
+
test('readRawRows pairs each parsed value with its 1-based line number, including across a blank line', async () => {
|
|
53
|
+
const path = join(root, 'y.jsonl')
|
|
54
|
+
await writeFile(path, '{"id":"a"}\n\n{"id":"b"}\n')
|
|
55
|
+
expect(await readRawRows(path)).toEqual([
|
|
56
|
+
{ line: 1, raw: { id: 'a' } },
|
|
57
|
+
{ line: 3, raw: { id: 'b' } },
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('readRawRows returns empty for a missing file', async () => {
|
|
62
|
+
expect(await readRawRows(join(root, 'nope.jsonl'))).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('upsertRows replaces by id in place and counts', () => {
|
|
66
|
+
const existing = [
|
|
67
|
+
{ id: 'a', n: 1 },
|
|
68
|
+
{ id: 'b', n: 2 },
|
|
69
|
+
]
|
|
70
|
+
const result = upsertRows(existing, [
|
|
71
|
+
{ id: 'b', n: 99 },
|
|
72
|
+
{ id: 'c', n: 3 },
|
|
73
|
+
])
|
|
74
|
+
expect(result.rows).toEqual([
|
|
75
|
+
{ id: 'a', n: 1 },
|
|
76
|
+
{ id: 'b', n: 99 },
|
|
77
|
+
{ id: 'c', n: 3 },
|
|
78
|
+
])
|
|
79
|
+
expect(result.added).toBe(1)
|
|
80
|
+
expect(result.updated).toBe(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('re-importing an identical batch is idempotent', () => {
|
|
84
|
+
const batch = [{ id: 'a', n: 1 }]
|
|
85
|
+
const once = upsertRows([], batch)
|
|
86
|
+
const twice = upsertRows(once.rows, batch)
|
|
87
|
+
expect(twice.rows).toEqual(once.rows)
|
|
88
|
+
expect(twice.added).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('writeRows refuses a path inside the source tree', async () => {
|
|
92
|
+
const source = join(root, 'legacy')
|
|
93
|
+
await expect(writeRows(join(source, 'x.jsonl'), [], source)).rejects.toThrow(/source/)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('upsertRows handles repeated ids in incoming batch', () => {
|
|
97
|
+
const existing = [{ id: 'a', n: 0 }]
|
|
98
|
+
const result = upsertRows(existing, [
|
|
99
|
+
{ id: 'a', n: 1 },
|
|
100
|
+
{ id: 'a', n: 2 },
|
|
101
|
+
])
|
|
102
|
+
expect(result.rows).toEqual([{ id: 'a', n: 2 }])
|
|
103
|
+
expect(result.added).toBe(0)
|
|
104
|
+
expect(result.updated).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('updated count is insensitive to key order', () => {
|
|
108
|
+
const existing = [{ id: 'a', x: 1, y: 2 }]
|
|
109
|
+
const result = upsertRows(existing, [{ id: 'a', y: 2, x: 1 }])
|
|
110
|
+
expect(result.rows).toEqual([{ id: 'a', y: 2, x: 1 }])
|
|
111
|
+
expect(result.added).toBe(0)
|
|
112
|
+
expect(result.updated).toBe(0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('concurrent writes to same path do not collide', async () => {
|
|
116
|
+
const path = join(root, 'concurrent.jsonl')
|
|
117
|
+
await Promise.all([
|
|
118
|
+
writeRows(path, [{ id: 'a', n: 1 }], NO_SOURCE),
|
|
119
|
+
writeRows(path, [{ id: 'b', n: 2 }], NO_SOURCE),
|
|
120
|
+
])
|
|
121
|
+
const rows = await readRows(path)
|
|
122
|
+
expect(rows.length).toBe(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('writeRows cleans up temp file on failure', async () => {
|
|
126
|
+
const dir = join(root, 'target-dir')
|
|
127
|
+
await mkdir(dir)
|
|
128
|
+
await expect(writeRows(dir, [{ id: 'a', n: 1 }], NO_SOURCE)).rejects.toThrow()
|
|
129
|
+
const files = await readdir(root)
|
|
130
|
+
const tmpFiles = files.filter((f) => f.endsWith('.tmp'))
|
|
131
|
+
expect(tmpFiles.length).toBe(0)
|
|
132
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import type { Config } from '../config.ts'
|
|
3
|
+
import { validateDelta, validateElement, validateRequirement } from '../validate.ts'
|
|
4
|
+
|
|
5
|
+
const CFG: Config = {
|
|
6
|
+
source: {
|
|
7
|
+
path: '/tmp/legacy',
|
|
8
|
+
scope: 'all',
|
|
9
|
+
stack: 'unknown',
|
|
10
|
+
vcs: 'none',
|
|
11
|
+
basis: 'source-only',
|
|
12
|
+
},
|
|
13
|
+
target: { name: 'newapp', stack: 'unknown', parity_test_path: 'x', layout: {}, commands: {} },
|
|
14
|
+
surfaces: ['routes'],
|
|
15
|
+
surfaceSingular: {},
|
|
16
|
+
closers: [],
|
|
17
|
+
handoff: { adapter: 'markdown' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('validateElement rejects a null row instead of throwing', () => {
|
|
21
|
+
const result = validateElement(null, CFG)
|
|
22
|
+
expect(result.ok).toBe(false)
|
|
23
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('validateElement rejects a non-object row instead of throwing', () => {
|
|
27
|
+
const result = validateElement('not a row', CFG)
|
|
28
|
+
expect(result.ok).toBe(false)
|
|
29
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('validateRequirement rejects a null row instead of throwing', () => {
|
|
33
|
+
const result = validateRequirement(null, CFG)
|
|
34
|
+
expect(result.ok).toBe(false)
|
|
35
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('validateRequirement rejects a non-object row instead of throwing', () => {
|
|
39
|
+
const result = validateRequirement(42, CFG)
|
|
40
|
+
expect(result.ok).toBe(false)
|
|
41
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('validateDelta rejects a null row instead of throwing', () => {
|
|
45
|
+
const result = validateDelta(null, CFG)
|
|
46
|
+
expect(result.ok).toBe(false)
|
|
47
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('validateDelta rejects a non-object row instead of throwing', () => {
|
|
51
|
+
const result = validateDelta(['not', 'a', 'row'], CFG)
|
|
52
|
+
expect(result.ok).toBe(false)
|
|
53
|
+
if (!result.ok) expect(result.errors.join(' ')).toContain('object')
|
|
54
|
+
})
|