@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.
Files changed (148) hide show
  1. package/README.md +18 -2
  2. package/dist/cli/index.js +105 -28
  3. package/package.json +1 -1
  4. package/skills/index.json +14 -2
  5. package/skills/magpie/README.md +2 -1
  6. package/skills/magpie/SKILL.md +138 -45
  7. package/skills/magpie/bin/magpie.ts +43 -0
  8. package/skills/magpie/fixtures/example-pr/brief.json +18 -0
  9. package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
  10. package/skills/magpie/fixtures/fake-gh.sh +14 -0
  11. package/skills/magpie/package.json +1 -1
  12. package/skills/magpie/references/peer-review.md +7 -2
  13. package/skills/magpie/references/scout.md +90 -0
  14. package/skills/magpie/references/specialists.md +117 -6
  15. package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
  16. package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
  17. package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
  18. package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
  19. package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
  20. package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
  21. package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
  22. package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
  23. package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
  24. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
  25. package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
  26. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
  27. package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
  28. package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
  29. package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
  30. package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
  31. package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
  32. package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
  33. package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
  34. package/skills/magpie/scripts/diff-chunks.ts +28 -0
  35. package/skills/magpie/scripts/findings-files.ts +32 -0
  36. package/skills/magpie/scripts/gh.ts +68 -14
  37. package/skills/magpie/scripts/git-diff.ts +111 -0
  38. package/skills/magpie/scripts/path-filter.ts +9 -5
  39. package/skills/magpie/scripts/preview-cmd.ts +11 -1
  40. package/skills/magpie/scripts/refresh.ts +32 -3
  41. package/skills/magpie/scripts/render-cmd.ts +35 -11
  42. package/skills/magpie/scripts/render-findings.ts +78 -2
  43. package/skills/magpie/scripts/render-progress.ts +6 -1
  44. package/skills/magpie/scripts/setup-cmd.ts +38 -1
  45. package/skills/magpie/scripts/shard.ts +171 -0
  46. package/skills/magpie/scripts/status-cmd.ts +7 -4
  47. package/skills/magpie/scripts/types.ts +50 -0
  48. package/skills/magpie/skill.json +2 -2
  49. package/skills/magpie/templates/styles.css +66 -0
  50. package/skills/migrate/README.md +194 -0
  51. package/skills/migrate/SKILL.md +197 -0
  52. package/skills/migrate/bin/migrate +15 -0
  53. package/skills/migrate/bin/migrate.ts +309 -0
  54. package/skills/migrate/biome.json +35 -0
  55. package/skills/migrate/bun.lock +24 -0
  56. package/skills/migrate/docs/architecture.md +294 -0
  57. package/skills/migrate/docs/reference.md +590 -0
  58. package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
  59. package/skills/migrate/fixtures/tiny-express/app.js +29 -0
  60. package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
  61. package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
  62. package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
  63. package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
  64. package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
  65. package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
  66. package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
  67. package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
  68. package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
  69. package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
  70. package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
  71. package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
  72. package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
  73. package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
  74. package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
  75. package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
  76. package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
  77. package/skills/migrate/install.sh +68 -0
  78. package/skills/migrate/package.json +17 -0
  79. package/skills/migrate/references/phases/enumerate.md +291 -0
  80. package/skills/migrate/references/phases/extract.md +652 -0
  81. package/skills/migrate/references/phases/parity.md +275 -0
  82. package/skills/migrate/references/phases/probe.md +135 -0
  83. package/skills/migrate/references/phases/queue.md +242 -0
  84. package/skills/migrate/references/phases/seam.md +416 -0
  85. package/skills/migrate/references/recipes/README.md +116 -0
  86. package/skills/migrate/references/recipes/aspnet.md +287 -0
  87. package/skills/migrate/references/run-ops.md +280 -0
  88. package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
  89. package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
  90. package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
  91. package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
  92. package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
  93. package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
  94. package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
  95. package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
  96. package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
  97. package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
  98. package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
  99. package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
  100. package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
  101. package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
  102. package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
  103. package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
  104. package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
  105. package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
  106. package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
  107. package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
  108. package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
  109. package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
  110. package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
  111. package/skills/migrate/scripts/census-cmd.ts +109 -0
  112. package/skills/migrate/scripts/census.ts +342 -0
  113. package/skills/migrate/scripts/check-cmd.ts +24 -0
  114. package/skills/migrate/scripts/check.ts +376 -0
  115. package/skills/migrate/scripts/citations.ts +92 -0
  116. package/skills/migrate/scripts/config.ts +237 -0
  117. package/skills/migrate/scripts/ids.ts +31 -0
  118. package/skills/migrate/scripts/import-cmd.ts +141 -0
  119. package/skills/migrate/scripts/init-cmd.ts +118 -0
  120. package/skills/migrate/scripts/leaks.ts +184 -0
  121. package/skills/migrate/scripts/lock.ts +188 -0
  122. package/skills/migrate/scripts/paths.ts +103 -0
  123. package/skills/migrate/scripts/phase-cmd.ts +63 -0
  124. package/skills/migrate/scripts/phases.ts +113 -0
  125. package/skills/migrate/scripts/queue-cmd.ts +98 -0
  126. package/skills/migrate/scripts/queue.ts +258 -0
  127. package/skills/migrate/scripts/report-cmd.ts +47 -0
  128. package/skills/migrate/scripts/report.ts +131 -0
  129. package/skills/migrate/scripts/reset-cmd.ts +120 -0
  130. package/skills/migrate/scripts/status-cmd.ts +52 -0
  131. package/skills/migrate/scripts/store.ts +159 -0
  132. package/skills/migrate/scripts/types.ts +137 -0
  133. package/skills/migrate/scripts/validate.ts +221 -0
  134. package/skills/migrate/skill.json +33 -0
  135. package/skills/migrate/templates/config.toml +27 -0
  136. package/skills/migrate/templates/queue-item.md +17 -0
  137. package/skills/migrate/tsconfig.json +18 -0
  138. package/skills/migrate/uninstall.sh +31 -0
  139. package/skills/sluice/SKILL.md +82 -0
  140. package/skills/sluice/references/deep-channel.md +94 -0
  141. package/skills/sluice/references/finish.md +35 -0
  142. package/skills/sluice/references/intent.md +29 -0
  143. package/skills/sluice/references/review.md +42 -0
  144. package/skills/sluice/references/root-cause.md +38 -0
  145. package/skills/sluice/references/show-or-say.md +36 -0
  146. package/skills/sluice/references/test-first.md +35 -0
  147. package/skills/sluice/references/verify.md +26 -0
  148. 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
+ })