@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,183 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { errorMessage } from '../../bin/migrate.ts'
|
|
6
|
+
|
|
7
|
+
const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
|
|
8
|
+
|
|
9
|
+
async function run(
|
|
10
|
+
args: string[],
|
|
11
|
+
opts: { cwd?: string } = {},
|
|
12
|
+
): Promise<{ code: number; out: string; err: string }> {
|
|
13
|
+
const proc = Bun.spawn(['bun', CLI, ...args], {
|
|
14
|
+
stdout: 'pipe',
|
|
15
|
+
stderr: 'pipe',
|
|
16
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
17
|
+
})
|
|
18
|
+
const [out, err] = await Promise.all([
|
|
19
|
+
new Response(proc.stdout).text(),
|
|
20
|
+
new Response(proc.stderr).text(),
|
|
21
|
+
])
|
|
22
|
+
await proc.exited
|
|
23
|
+
return { code: proc.exitCode ?? -1, out, err }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('--help exits 0 and lists the subcommands', async () => {
|
|
27
|
+
const { code, out } = await run(['--help'])
|
|
28
|
+
expect(code).toBe(0)
|
|
29
|
+
for (const verb of ['init', 'import', 'census', 'queue', 'check', 'status', 'reset', 'report']) {
|
|
30
|
+
expect(out).toContain(verb)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('no subcommand is a usage error', async () => {
|
|
35
|
+
const { code, err } = await run([])
|
|
36
|
+
expect(code).toBe(2)
|
|
37
|
+
expect(err).toContain('Usage')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('unknown subcommand is a usage error naming the input', async () => {
|
|
41
|
+
const { code, err } = await run(['frobnicate'])
|
|
42
|
+
expect(code).toBe(2)
|
|
43
|
+
expect(err).toContain('frobnicate')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('--version prints the package version', async () => {
|
|
47
|
+
const { code, out } = await run(['--version'])
|
|
48
|
+
expect(code).toBe(0)
|
|
49
|
+
expect(out.trim()).toMatch(/^migrate \d+\.\d+\.\d+$/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// The central guard in main() (bin/migrate.ts): every handler calls
|
|
53
|
+
// loadConfig without a try/catch of its own, so a missing or malformed
|
|
54
|
+
// config.toml throws an Error that none of them classifies. Before the
|
|
55
|
+
// guard, that Error escaped as a raw Bun stack trace (source snippet, "at
|
|
56
|
+
// ..." frames, a non-deliberate exit code). The guard must turn that into
|
|
57
|
+
// the same one-line, no-stack-trace diagnostic every handler already
|
|
58
|
+
// produces for its own recognized failures, with a deliberate exit code:
|
|
59
|
+
// 2, because the request could not be serviced at all (same class as "no
|
|
60
|
+
// .migrate store found above the cwd"), not 1 (a well-formed request with
|
|
61
|
+
// a bad answer).
|
|
62
|
+
|
|
63
|
+
let guardRoot: string | undefined
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
if (guardRoot) await rm(guardRoot, { recursive: true, force: true })
|
|
67
|
+
guardRoot = undefined
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
function looksLikeStackTrace(text: string): boolean {
|
|
71
|
+
return /\bat\s+\S+\s*\(?.*:\d+:\d+/.test(text) || text.includes('Bun v') || text.includes('^')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('a missing config.toml is a clean diagnostic and exit 2, not a stack trace', async () => {
|
|
75
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-guard-')))
|
|
76
|
+
await mkdir(join(guardRoot, '.migrate'), { recursive: true })
|
|
77
|
+
const { code, out, err } = await run(['check'], { cwd: guardRoot })
|
|
78
|
+
expect(code).toBe(2)
|
|
79
|
+
expect(out).toBe('')
|
|
80
|
+
expect(err).toBe(
|
|
81
|
+
`check: config.toml not found at ${join(guardRoot, '.migrate', 'config.toml')}; run 'migrate init' first\n`,
|
|
82
|
+
)
|
|
83
|
+
expect(looksLikeStackTrace(err)).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('a malformed config.toml is a clean diagnostic and exit 2, not a stack trace', async () => {
|
|
87
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-guard-')))
|
|
88
|
+
await mkdir(join(guardRoot, '.migrate'), { recursive: true })
|
|
89
|
+
await writeFile(join(guardRoot, '.migrate', 'config.toml'), '[source]\npath = "/tmp/legacy"\n')
|
|
90
|
+
const { code, err } = await run(['check'], { cwd: guardRoot })
|
|
91
|
+
expect(code).toBe(2)
|
|
92
|
+
expect(err).toBe('check: config.toml: missing or empty source.scope\n')
|
|
93
|
+
expect(looksLikeStackTrace(err)).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('the guard is central: it also catches a bad config.toml for queue, not just check', async () => {
|
|
97
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-guard-')))
|
|
98
|
+
await mkdir(join(guardRoot, '.migrate'), { recursive: true })
|
|
99
|
+
const { code, err } = await run(['queue', 'list'], { cwd: guardRoot })
|
|
100
|
+
expect(code).toBe(2)
|
|
101
|
+
expect(err).toContain('queue: config.toml not found')
|
|
102
|
+
expect(looksLikeStackTrace(err)).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("the guard does not mask a well-formed request's own classified failure", async () => {
|
|
106
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-guard-')))
|
|
107
|
+
const { code, err } = await run(['import', 'elements', 'nope.json'], { cwd: guardRoot })
|
|
108
|
+
// No .migrate store at all: findStoreRoot's own usage error fires before
|
|
109
|
+
// loadConfig is ever reached, so this exercises the pre-existing guard
|
|
110
|
+
// (findStoreRoot), not the new one, confirming the new guard did not
|
|
111
|
+
// change behaviour for a case already handled.
|
|
112
|
+
expect(code).toBe(2)
|
|
113
|
+
expect(err).toContain('import: no .migrate store found above the cwd')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Minor finding 5: init, report and reset each parse a `--flag <value>`
|
|
117
|
+
// pair, and used to disagree on what happens when the value slot is empty
|
|
118
|
+
// or is itself another flag. `init --scope --name newapp` used to read
|
|
119
|
+
// scope as the literal string '--name' and exit 0; `report --out` with
|
|
120
|
+
// nothing after it used to fall back to the default directory in silence;
|
|
121
|
+
// only `reset --phase` with no value at all was already a usage error. All
|
|
122
|
+
// three now share one rule, reset's own shape: either shape of a bad value
|
|
123
|
+
// is a usage error (2) naming the flag.
|
|
124
|
+
|
|
125
|
+
test('init treats a flag value that is itself another flag as a usage error, not a swallowed value', async () => {
|
|
126
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-flags-')))
|
|
127
|
+
const { code, err } = await run(['init', '--source', guardRoot, '--scope', '--name', 'newapp'], {
|
|
128
|
+
cwd: guardRoot,
|
|
129
|
+
})
|
|
130
|
+
expect(code).toBe(2)
|
|
131
|
+
expect(err).toContain('--scope needs a value')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('report --out with no value is a usage error, not a silent fallback to the default directory', async () => {
|
|
135
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-flags-')))
|
|
136
|
+
const { code, err } = await run(['report', '--out'], { cwd: guardRoot })
|
|
137
|
+
expect(code).toBe(2)
|
|
138
|
+
expect(err).toContain('--out needs a value')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('reset --phase whose value is itself another flag is a usage error, matching a missing value', async () => {
|
|
142
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-flags-')))
|
|
143
|
+
const { code, err } = await run(['reset', '--phase', '--out'], { cwd: guardRoot })
|
|
144
|
+
expect(code).toBe(2)
|
|
145
|
+
expect(err).toContain('--phase needs a value')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('reset --phase with no value at all is still a usage error (unchanged)', async () => {
|
|
149
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-flags-')))
|
|
150
|
+
const { code, err } = await run(['reset', '--phase'], { cwd: guardRoot })
|
|
151
|
+
expect(code).toBe(2)
|
|
152
|
+
expect(err).toContain('--phase needs a value')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('reset with no --phase flag at all is a usage error naming the missing flag', async () => {
|
|
156
|
+
guardRoot = await realpath(await mkdtemp(join(tmpdir(), 'migrate-cli-flags-')))
|
|
157
|
+
const { code, err } = await run(['reset'], { cwd: guardRoot })
|
|
158
|
+
expect(code).toBe(2)
|
|
159
|
+
expect(err).toContain('missing --phase')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Minor finding 6: the central guard's `(e as Error).message` printed the
|
|
163
|
+
// useless "sub: undefined" for a non-Error rejection, and would have thrown
|
|
164
|
+
// a TypeError from inside the catch itself for a rejected `null` or
|
|
165
|
+
// `undefined` -- the one shape that would still have escaped uncaught.
|
|
166
|
+
// `errorMessage` is the exact expression the guard now calls; exercised
|
|
167
|
+
// directly here since nothing in this CLI's own handlers currently rejects
|
|
168
|
+
// with anything but a real Error, so the failure mode this closes cannot
|
|
169
|
+
// be reproduced by driving the CLI end-to-end.
|
|
170
|
+
|
|
171
|
+
test('errorMessage returns the message of an Error instance', () => {
|
|
172
|
+
expect(errorMessage(new Error('boom'))).toBe('boom')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('errorMessage converts a non-Error rejection to a string instead of printing undefined', () => {
|
|
176
|
+
expect(errorMessage('boom')).toBe('boom')
|
|
177
|
+
expect(errorMessage({ code: 'EBOOM' })).toBe('[object Object]')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('errorMessage converts a null or undefined rejection without throwing a second error', () => {
|
|
181
|
+
expect(errorMessage(null)).toBe('null')
|
|
182
|
+
expect(errorMessage(undefined)).toBe('undefined')
|
|
183
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
|
|
6
|
+
const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
|
|
7
|
+
|
|
8
|
+
const SURFACES = [
|
|
9
|
+
'routes',
|
|
10
|
+
'tables',
|
|
11
|
+
'jobs',
|
|
12
|
+
'reports',
|
|
13
|
+
'screens',
|
|
14
|
+
'integrations',
|
|
15
|
+
'workflows',
|
|
16
|
+
'settings',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
let target: string
|
|
20
|
+
let source: string
|
|
21
|
+
|
|
22
|
+
async function migrate(args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
23
|
+
const proc = Bun.spawn(['bun', CLI, ...args], { cwd: target, stdout: 'pipe', stderr: 'pipe' })
|
|
24
|
+
const [out, err] = await Promise.all([
|
|
25
|
+
new Response(proc.stdout).text(),
|
|
26
|
+
new Response(proc.stderr).text(),
|
|
27
|
+
])
|
|
28
|
+
await proc.exited
|
|
29
|
+
return { code: proc.exitCode ?? -1, out, err }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
target = await mkdtemp(join(tmpdir(), 'migrate-conc-'))
|
|
34
|
+
source = join(target, 'legacy')
|
|
35
|
+
await Bun.write(join(source, 'app.js'), '// legacy\n')
|
|
36
|
+
const init = await migrate([
|
|
37
|
+
'init',
|
|
38
|
+
'--source',
|
|
39
|
+
source,
|
|
40
|
+
'--scope',
|
|
41
|
+
'concurrency fixture',
|
|
42
|
+
'--name',
|
|
43
|
+
'target',
|
|
44
|
+
])
|
|
45
|
+
expect(init.code).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(target, { recursive: true, force: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Eight concurrent importers, one per default surface type, because
|
|
53
|
+
// (surface x lens) is exactly the fan-out unit the orchestration rules
|
|
54
|
+
// mandate for phase 1. Before the store lock, each importer read the same
|
|
55
|
+
// base elements.jsonl and whichever renamed last discarded every other
|
|
56
|
+
// writer's rows, and each recordBatch clobbered the batch list the same way.
|
|
57
|
+
test('eight concurrent imports lose no rows and no batch entries', async () => {
|
|
58
|
+
const files = await Promise.all(
|
|
59
|
+
SURFACES.map(async (surface, i) => {
|
|
60
|
+
const batch = `b-${surface}-code-00${i + 1}`
|
|
61
|
+
const path = join(target, `${batch}.json`)
|
|
62
|
+
await writeFile(
|
|
63
|
+
path,
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
batch,
|
|
66
|
+
phase: 'enumerate',
|
|
67
|
+
rows: [
|
|
68
|
+
{
|
|
69
|
+
id: `${surface.replace(/s$/, '')}-one`,
|
|
70
|
+
surface,
|
|
71
|
+
element: `${surface} one`,
|
|
72
|
+
found_by: ['code'],
|
|
73
|
+
disposition: { kind: 'unaccounted' },
|
|
74
|
+
refs: [{ kind: 'src', path: 'app.js' }],
|
|
75
|
+
lens: 'code',
|
|
76
|
+
notes: '',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: `${surface.replace(/s$/, '')}-two`,
|
|
80
|
+
surface,
|
|
81
|
+
element: `${surface} two`,
|
|
82
|
+
found_by: ['code'],
|
|
83
|
+
disposition: { kind: 'unaccounted' },
|
|
84
|
+
refs: [{ kind: 'src', path: 'app.js' }],
|
|
85
|
+
lens: 'code',
|
|
86
|
+
notes: '',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
return { batch, path }
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const results = await Promise.all(files.map((f) => migrate(['import', 'elements', f.path])))
|
|
96
|
+
for (const r of results) expect(r.code).toBe(0)
|
|
97
|
+
|
|
98
|
+
const elements = (await readFile(join(target, '.migrate', 'elements.jsonl'), 'utf8'))
|
|
99
|
+
.split('\n')
|
|
100
|
+
.filter((l) => l.trim().length > 0)
|
|
101
|
+
expect(elements.length).toBe(SURFACES.length * 2)
|
|
102
|
+
|
|
103
|
+
const phases = JSON.parse(await readFile(join(target, '.migrate', 'phases.json'), 'utf8'))
|
|
104
|
+
const recorded = new Set((phases.phases.enumerate.batches as { id: string }[]).map((b) => b.id))
|
|
105
|
+
for (const f of files) expect(recorded.has(f.batch)).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a dead holder is refused with exit 3 and cleared by --force-unlock', async () => {
|
|
109
|
+
await writeFile(
|
|
110
|
+
join(target, '.migrate', '.lock'),
|
|
111
|
+
JSON.stringify({ pid: 999999, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
|
|
112
|
+
)
|
|
113
|
+
const batchPath = join(target, 'b-routes-code-001.json')
|
|
114
|
+
await writeFile(
|
|
115
|
+
batchPath,
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
batch: 'b-routes-code-001',
|
|
118
|
+
phase: 'enumerate',
|
|
119
|
+
rows: [
|
|
120
|
+
{
|
|
121
|
+
id: 'route-one',
|
|
122
|
+
surface: 'routes',
|
|
123
|
+
element: 'GET /one',
|
|
124
|
+
found_by: ['code'],
|
|
125
|
+
disposition: { kind: 'unaccounted' },
|
|
126
|
+
refs: [{ kind: 'src', path: 'app.js' }],
|
|
127
|
+
lens: 'code',
|
|
128
|
+
notes: '',
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const blocked = await migrate(['import', 'elements', batchPath])
|
|
135
|
+
expect(blocked.code).toBe(3)
|
|
136
|
+
expect(blocked.err).toContain('999999')
|
|
137
|
+
expect(blocked.err).toContain('--force-unlock')
|
|
138
|
+
|
|
139
|
+
const forced = await migrate(['import', 'elements', batchPath, '--force-unlock'])
|
|
140
|
+
expect(forced.code).toBe(0)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Final-review finding, Important 3: `reset` does the same read-modify-write
|
|
144
|
+
// over whole store files that import and census do -- it rewrites
|
|
145
|
+
// elements.jsonl, census.jsonl and phases.json from what it read -- and was
|
|
146
|
+
// the one such command with no lock and no lock.ts import at all. A command
|
|
147
|
+
// that does not take the lock cannot report that it could not get it, so exit
|
|
148
|
+
// 3 here is the whole assertion: before the fix both invocations below exited
|
|
149
|
+
// 0 and cleared the store out from under whatever held the lock.
|
|
150
|
+
test('reset refuses a dead holder with exit 3 and clears it with --force-unlock', async () => {
|
|
151
|
+
await writeFile(
|
|
152
|
+
join(target, '.migrate', '.lock'),
|
|
153
|
+
JSON.stringify({ pid: 999999, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const blocked = await migrate(['reset', '--phase', 'enumerate'])
|
|
157
|
+
expect(blocked.code).toBe(3)
|
|
158
|
+
expect(blocked.err).toContain('999999')
|
|
159
|
+
expect(blocked.err).toContain('--force-unlock')
|
|
160
|
+
|
|
161
|
+
const forced = await migrate(['reset', '--phase', 'enumerate', '--force-unlock'])
|
|
162
|
+
expect(forced.code).toBe(0)
|
|
163
|
+
expect(forced.out).toContain('reset enumerate')
|
|
164
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { DEFAULT_CLOSERS, DEFAULT_SURFACES, loadConfig, writeConfig } from '../config.ts'
|
|
6
|
+
import { storePaths } from '../paths.ts'
|
|
7
|
+
|
|
8
|
+
let root: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
root = await mkdtemp(join(tmpdir(), 'migrate-config-'))
|
|
12
|
+
await mkdir(join(root, '.migrate'), { recursive: true })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(root, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const MINIMAL = `[source]
|
|
20
|
+
path = "/tmp/legacy"
|
|
21
|
+
scope = "the whole thing"
|
|
22
|
+
stack = "unknown"
|
|
23
|
+
vcs = "none"
|
|
24
|
+
basis = "source-only"
|
|
25
|
+
|
|
26
|
+
[target]
|
|
27
|
+
name = "newapp"
|
|
28
|
+
stack = "bun + react"
|
|
29
|
+
parity_test_path = "tests/parity/{capability}/{fr_slug}.test.ts"
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
test('loadConfig fills the default surface and closer sets', async () => {
|
|
33
|
+
await writeFile(storePaths(root).config, MINIMAL)
|
|
34
|
+
const cfg = await loadConfig(root)
|
|
35
|
+
expect(cfg.surfaces).toEqual([...DEFAULT_SURFACES])
|
|
36
|
+
expect(cfg.closers).toEqual([...DEFAULT_CLOSERS])
|
|
37
|
+
expect(cfg.source.path).toBe('/tmp/legacy')
|
|
38
|
+
expect(cfg.source.basis).toBe('source-only')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('loadConfig honours a declared surface set', async () => {
|
|
42
|
+
await writeFile(
|
|
43
|
+
storePaths(root).config,
|
|
44
|
+
`${MINIMAL}\n[surfaces]\ntypes = ["programs", "copybooks"]\nsingular = { copybooks = "copybook" }\n`,
|
|
45
|
+
)
|
|
46
|
+
const cfg = await loadConfig(root)
|
|
47
|
+
expect(cfg.surfaces).toEqual(['programs', 'copybooks'])
|
|
48
|
+
expect(cfg.surfaceSingular.copybooks).toBe('copybook')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('loadConfig fails with a diagnostic when a required key is missing', async () => {
|
|
52
|
+
await writeFile(storePaths(root).config, '[source]\npath = "/tmp/legacy"\n')
|
|
53
|
+
await expect(loadConfig(root)).rejects.toThrow(/source.scope/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('loadConfig fails with a diagnostic when the file is absent', async () => {
|
|
57
|
+
await expect(loadConfig(root)).rejects.toThrow(/config.toml/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('writeConfig then loadConfig round-trips', async () => {
|
|
61
|
+
await writeConfig(root, {
|
|
62
|
+
sourcePath: '/tmp/legacy',
|
|
63
|
+
scope: 'billing only',
|
|
64
|
+
targetName: 'newapp',
|
|
65
|
+
})
|
|
66
|
+
const cfg = await loadConfig(root)
|
|
67
|
+
expect(cfg.source.scope).toBe('billing only')
|
|
68
|
+
expect(cfg.target.name).toBe('newapp')
|
|
69
|
+
expect(cfg.source.stack).toBe('unknown')
|
|
70
|
+
expect(cfg.handoff.adapter).toBe('markdown')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// A hostile scope is operator-typed free text, not TOML syntax: writeConfig
|
|
74
|
+
// must escape it so loadConfig reads back the exact bytes that were passed in,
|
|
75
|
+
// rather than truncating the value, injecting a key, or leaving config.toml
|
|
76
|
+
// unparseable for every later command.
|
|
77
|
+
const HOSTILE_SCOPES: Array<[string, string]> = [
|
|
78
|
+
['a quote that would otherwise close the string and inject a key', 'billing" evil = true #'],
|
|
79
|
+
['a backslash that would otherwise start a TOML escape', 'billing\\team'],
|
|
80
|
+
['a newline that would otherwise leave the string unterminated', 'billing\nrogue'],
|
|
81
|
+
['a tab', 'billing\trogue'],
|
|
82
|
+
['several hostile characters combined', 'billing" \\evil\t\n#more'],
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
for (const [label, scope] of HOSTILE_SCOPES) {
|
|
86
|
+
test(`writeConfig then loadConfig round-trips ${label} byte-identically`, async () => {
|
|
87
|
+
await writeConfig(root, { sourcePath: '/tmp/legacy', scope, targetName: 'newapp' })
|
|
88
|
+
const cfg = await loadConfig(root)
|
|
89
|
+
expect(cfg.source.scope).toBe(scope)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('writeConfig round-trips a value containing backspace, form feed and delete', async () => {
|
|
94
|
+
const scope = `billing${String.fromCharCode(0x08)}${String.fromCharCode(0x0c)}${String.fromCharCode(0x7f)}rogue`
|
|
95
|
+
await writeConfig(root, { sourcePath: '/tmp/legacy', scope, targetName: 'newapp' })
|
|
96
|
+
const cfg = await loadConfig(root)
|
|
97
|
+
expect(cfg.source.scope).toBe(scope)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('writeConfig refuses a value containing a control character it cannot represent', async () => {
|
|
101
|
+
const scope = `billing${String.fromCharCode(0x1b)}esc`
|
|
102
|
+
await expect(
|
|
103
|
+
writeConfig(root, { sourcePath: '/tmp/legacy', scope, targetName: 'newapp' }),
|
|
104
|
+
).rejects.toThrow(/U\+001b/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('writeConfig does not fall prey to $-pattern replacement corruption', async () => {
|
|
108
|
+
const scope = 'cost$&$$center'
|
|
109
|
+
await writeConfig(root, { sourcePath: '/tmp/legacy', scope, targetName: 'newapp' })
|
|
110
|
+
const cfg = await loadConfig(root)
|
|
111
|
+
expect(cfg.source.scope).toBe(scope)
|
|
112
|
+
})
|