@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,192 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { loadConfig } from '../config.ts'
7
+ import { runInit } from '../init-cmd.ts'
8
+ import { storePaths } from '../paths.ts'
9
+
10
+ async function captureStdout(fn: () => Promise<number>): Promise<{ code: number; text: string }> {
11
+ const out: string[] = []
12
+ const original = process.stdout.write.bind(process.stdout)
13
+ process.stdout.write = ((s: string) => {
14
+ out.push(s)
15
+ return true
16
+ }) as typeof process.stdout.write
17
+ try {
18
+ const code = await fn()
19
+ return { code, text: out.join('') }
20
+ } finally {
21
+ process.stdout.write = original
22
+ }
23
+ }
24
+
25
+ let root: string
26
+ let source: string
27
+
28
+ beforeEach(async () => {
29
+ root = await mkdtemp(join(tmpdir(), 'migrate-init-'))
30
+ source = join(root, 'legacy')
31
+ await mkdir(source, { recursive: true })
32
+ })
33
+
34
+ afterEach(async () => {
35
+ await rm(root, { recursive: true, force: true })
36
+ })
37
+
38
+ const BASE = () => ({ root, sourcePath: source, scope: 'billing', targetName: 'newapp' })
39
+
40
+ test('init creates the store and a loadable config', async () => {
41
+ expect(await runInit(BASE())).toBe(0)
42
+ expect(existsSync(storePaths(root).queueDir)).toBe(true)
43
+ const cfg = await loadConfig(root)
44
+ expect(cfg.source.scope).toBe('billing')
45
+ expect(cfg.target.name).toBe('newapp')
46
+ })
47
+
48
+ test('init refuses to overwrite an existing config', async () => {
49
+ await runInit(BASE())
50
+ expect(await runInit(BASE())).toBe(1)
51
+ })
52
+
53
+ test('init refuses a source path that does not exist', async () => {
54
+ expect(await runInit({ ...BASE(), sourcePath: join(root, 'nope') })).toBe(2)
55
+ })
56
+
57
+ test('init appends the env file to an existing .gitignore exactly once', async () => {
58
+ await writeFile(join(root, '.gitignore'), 'node_modules/\n')
59
+ await runInit(BASE())
60
+ const text = await readFile(join(root, '.gitignore'), 'utf8')
61
+ expect(text).toContain('.migrate/.env')
62
+ expect(text.match(/\.migrate\/\.env/g)).toHaveLength(1)
63
+ })
64
+
65
+ test('init records an explicit basis', async () => {
66
+ await runInit({ ...BASE(), basis: 'runnable' })
67
+ expect((await loadConfig(root)).source.basis).toBe('runnable')
68
+ })
69
+
70
+ test('init appends the env file to a .gitignore that lacks a trailing newline', async () => {
71
+ await writeFile(join(root, '.gitignore'), 'node_modules/')
72
+ await runInit(BASE())
73
+ const text = await readFile(join(root, '.gitignore'), 'utf8')
74
+ expect(text).toBe('node_modules/\n.migrate/.env\n')
75
+ })
76
+
77
+ test('init detects vcs as git when the source path contains a .git directory', async () => {
78
+ await mkdir(join(source, '.git'), { recursive: true })
79
+ await runInit(BASE())
80
+ expect((await loadConfig(root)).source.vcs).toBe('git')
81
+ })
82
+
83
+ test('init detects vcs as none when the source path has no .git directory', async () => {
84
+ await runInit(BASE())
85
+ expect((await loadConfig(root)).source.vcs).toBe('none')
86
+ })
87
+
88
+ test('init refuses a source path that is a file, not a directory', async () => {
89
+ const filePath = join(root, 'not-a-dir.txt')
90
+ await writeFile(filePath, 'hi')
91
+ expect(await runInit({ ...BASE(), sourcePath: filePath })).toBe(2)
92
+ })
93
+
94
+ test('init does not mistake an unrelated deeper path for a real .gitignore entry', async () => {
95
+ await writeFile(join(root, '.gitignore'), 'node_modules/\nfoo/.migrate/.env\n')
96
+ await runInit(BASE())
97
+ const text = await readFile(join(root, '.gitignore'), 'utf8')
98
+ const lines = text.split('\n').map((l) => l.trim())
99
+ expect(lines).toContain('.migrate/.env')
100
+ expect(lines.filter((l) => l === '.migrate/.env')).toHaveLength(1)
101
+ })
102
+
103
+ test('init does not mistake a commented-out mention for a real .gitignore entry', async () => {
104
+ await writeFile(join(root, '.gitignore'), 'node_modules/\n# .migrate/.env (handled elsewhere)\n')
105
+ await runInit(BASE())
106
+ const text = await readFile(join(root, '.gitignore'), 'utf8')
107
+ const lines = text.split('\n').map((l) => l.trim())
108
+ expect(lines).toContain('.migrate/.env')
109
+ expect(lines.filter((l) => l === '.migrate/.env')).toHaveLength(1)
110
+ })
111
+
112
+ // Final-review finding, Important 1: init was the one writer with no
113
+ // containment guard. `migrate init --source .` inside a source tree created
114
+ // config.toml, made queue/, and appended to the source's own .gitignore; every
115
+ // later command then exited 2 on the guard those writers do have, leaving a
116
+ // store nothing could write to. SKILL.md, docs/reference.md and
117
+ // docs/architecture.md all claimed this was impossible.
118
+
119
+ test('init refuses to build a store inside the read-only source tree, writing nothing', async () => {
120
+ const result = await runInit({ ...BASE(), sourcePath: root })
121
+ expect(result).toBe(2)
122
+ expect(existsSync(storePaths(root).dir)).toBe(false)
123
+ expect(existsSync(join(root, '.gitignore'))).toBe(false)
124
+ })
125
+
126
+ // The three write targets init checks share a parent for an ordinary target
127
+ // root, so a plain path comparison can never tell them apart. A symlink can:
128
+ // here .migrate resolves into the source tree while the target root itself is
129
+ // nowhere near it, so only the store-path legs of the check fire. The guard
130
+ // resolves real paths, which is what makes this reachable at all.
131
+ test('init refuses when only the store directory resolves into the source tree', async () => {
132
+ const stash = join(source, 'stash')
133
+ await mkdir(stash, { recursive: true })
134
+ await symlink(stash, storePaths(root).dir)
135
+ expect(await runInit(BASE())).toBe(2)
136
+ expect(existsSync(join(stash, 'config.toml'))).toBe(false)
137
+ expect(existsSync(join(stash, 'queue'))).toBe(false)
138
+ })
139
+
140
+ // The mirror image, and the leg the store-path checks alone would miss: the
141
+ // store is fine, but the target's .gitignore is a symlink into the source, so
142
+ // the append would edit a file in the read-only checkout. Refused before
143
+ // anything is created, so the planted file is byte-identical afterwards.
144
+ test('init refuses when only the .gitignore resolves into the source tree', async () => {
145
+ const planted = join(source, '.gitignore')
146
+ await writeFile(planted, 'node_modules/\n')
147
+ await symlink(planted, join(root, '.gitignore'))
148
+ expect(await runInit(BASE())).toBe(2)
149
+ expect(await readFile(planted, 'utf8')).toBe('node_modules/\n')
150
+ expect(existsSync(storePaths(root).dir)).toBe(false)
151
+ })
152
+
153
+ // Final-review finding, Important 2: the append was guarded by
154
+ // `if (existsSync(gitignore))`, so a target repo with no .gitignore of its own
155
+ // got no ignore entry at all and `git add -A` staged .migrate/.env on the
156
+ // first commit of the run. The `leaks` gate that would catch the committed
157
+ // result is opt-in, so nothing else stood behind this.
158
+
159
+ test('init creates a .gitignore listing the env file when the target has none, and says so', async () => {
160
+ const { code, text } = await captureStdout(() => runInit(BASE()))
161
+ expect(code).toBe(0)
162
+ const gitignore = join(root, '.gitignore')
163
+ expect(await readFile(gitignore, 'utf8')).toBe('.migrate/.env\n')
164
+ expect(text).toContain(`init: created ${gitignore} with .migrate/.env`)
165
+ })
166
+
167
+ test('init reports the append when the target already has a .gitignore', async () => {
168
+ const gitignore = join(root, '.gitignore')
169
+ await writeFile(gitignore, 'node_modules/\n')
170
+ const { code, text } = await captureStdout(() => runInit(BASE()))
171
+ expect(code).toBe(0)
172
+ expect(text).toContain(`init: appended .migrate/.env to ${gitignore}`)
173
+ })
174
+
175
+ // The claim the two tests above exist to make is not really about file
176
+ // content, it is that git will not stage the env file. Asserted against real
177
+ // git rather than by reading the ignore text, since the ignore text being
178
+ // right and git still staging the file is precisely the failure that shipped.
179
+ test('git does not stage .migrate/.env after init, on a target that had no .gitignore', async () => {
180
+ await Bun.spawn(['git', 'init', '-q'], { cwd: root, stdout: 'ignore', stderr: 'ignore' }).exited
181
+ expect(await runInit(BASE())).toBe(0)
182
+ await writeFile(storePaths(root).env, 'API_TOKEN=super-secret\n')
183
+ const proc = Bun.spawn(['git', 'check-ignore', '.migrate/.env'], {
184
+ cwd: root,
185
+ stdout: 'pipe',
186
+ stderr: 'ignore',
187
+ })
188
+ const listed = await new Response(proc.stdout).text()
189
+ await proc.exited
190
+ expect(proc.exitCode).toBe(0)
191
+ expect(listed.trim()).toBe('.migrate/.env')
192
+ })
@@ -0,0 +1,176 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { parseEnv, scanLeaks, secretValues } from '../leaks.ts'
6
+
7
+ let root: string
8
+
9
+ beforeEach(async () => {
10
+ root = await mkdtemp(join(tmpdir(), 'migrate-leaks-'))
11
+ await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await rm(root, { recursive: true, force: true })
16
+ })
17
+
18
+ async function git(args: string[], cwd: string): Promise<void> {
19
+ const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'ignore', stderr: 'ignore' })
20
+ const code = await proc.exited
21
+ if (code !== 0) throw new Error(`git ${args.join(' ')} failed with exit code ${code}`)
22
+ }
23
+
24
+ async function initRepo(cwd: string): Promise<void> {
25
+ await git(['init', '-q'], cwd)
26
+ await git(['config', 'user.email', 'test@example.com'], cwd)
27
+ await git(['config', 'user.name', 'Test'], cwd)
28
+ await git(['config', 'commit.gpgsign', 'false'], cwd)
29
+ }
30
+
31
+ test('parseEnv handles quotes, export and comments', () => {
32
+ const env = parseEnv('# a comment\nexport A="one two"\nB=plain\nC=\n')
33
+ expect(env.A).toBe('one two')
34
+ expect(env.B).toBe('plain')
35
+ expect(env.C).toBe('')
36
+ })
37
+
38
+ test('secretValues drops empties, short values and urls', () => {
39
+ const values = secretValues({
40
+ MIGRATE_SOURCE_URL: 'https://legacy.example.com',
41
+ MIGRATE_SOURCE_PASSWORD: 'hunter2-longer-secret',
42
+ MIGRATE_SOURCE_READONLY: 'true',
43
+ SHORT: 'abc',
44
+ EMPTY: '',
45
+ })
46
+ expect(values).toEqual(['hunter2-longer-secret'])
47
+ })
48
+
49
+ test('scanLeaks finds a secret pasted into an artifact and names the variable', async () => {
50
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
51
+ await writeFile(
52
+ join(root, '.migrate', 'queue', 'q-a.md'),
53
+ 'logged in with hunter2-longer-secret\n',
54
+ )
55
+ const violations = await scanLeaks({ root })
56
+ expect(violations).toHaveLength(1)
57
+ expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
58
+ expect(violations[0]?.message).toContain('q-a.md')
59
+ expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
60
+ })
61
+
62
+ test('scanLeaks passes on a clean store', async () => {
63
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
64
+ await writeFile(join(root, '.migrate', 'queue', 'q-a.md'), 'observed at legacy.example.com\n')
65
+ expect(await scanLeaks({ root })).toEqual([])
66
+ })
67
+
68
+ test('scanLeaks is a no-op with no .env file', async () => {
69
+ expect(await scanLeaks({ root })).toEqual([])
70
+ })
71
+
72
+ test('scanLeaks reports an unreadable artifact as a violation instead of skipping it', async () => {
73
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
74
+ const target = join(root, '.migrate', 'queue', 'q-a.md')
75
+ await writeFile(target, 'nothing secret here\n')
76
+ await chmod(target, 0o000)
77
+ const violations = await scanLeaks({ root })
78
+ expect(violations).toHaveLength(1)
79
+ expect(violations[0]?.gate).toBe('leaks')
80
+ expect(violations[0]?.message).toContain('q-a.md')
81
+ expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
82
+ })
83
+
84
+ test('scanLeaks reports one violation per file for a value shared by two variables, naming both', async () => {
85
+ await writeFile(
86
+ join(root, '.migrate', '.env'),
87
+ 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\nMIGRATE_SOURCE_PASSWORD_RO=hunter2-longer-secret\n',
88
+ )
89
+ await writeFile(
90
+ join(root, '.migrate', 'queue', 'q-a.md'),
91
+ 'logged in with hunter2-longer-secret\n',
92
+ )
93
+ const violations = await scanLeaks({ root })
94
+ expect(violations).toHaveLength(1)
95
+ expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD_RO')
96
+ expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
97
+ expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
98
+ })
99
+
100
+ test('scanLeaks never puts a secret value on the git subprocess argument list', async () => {
101
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
102
+ await mkdir(join(root, '.git'))
103
+ const capturePath = join(root, 'argv-capture.log')
104
+ const stubPath = join(root, 'git-stub.sh')
105
+ await writeFile(stubPath, `#!/bin/sh\necho "$@" >> ${JSON.stringify(capturePath)}\n`)
106
+ await chmod(stubPath, 0o755)
107
+
108
+ await scanLeaks({ root, gitBin: stubPath })
109
+
110
+ const captured = await readFile(capturePath, 'utf8').catch(() => '')
111
+ expect(captured.length).toBeGreaterThan(0)
112
+ expect(captured).not.toContain('hunter2-longer-secret')
113
+ })
114
+
115
+ test('scanLeaks finds a secret spanning a chunk boundary in the git stub output', async () => {
116
+ // The stub writes the secret in two separate stdout writes with a pause in
117
+ // between, so the parent's stream reads them as two separate chunks with
118
+ // the secret split across the boundary. This exercises the carry-over
119
+ // logic in findInStream rather than relying on how a real git process
120
+ // happens to buffer its output.
121
+ const secret = 'hunter2-longer-secret'
122
+ await writeFile(join(root, '.migrate', '.env'), `MIGRATE_SOURCE_PASSWORD=${secret}\n`)
123
+ await mkdir(join(root, '.git'))
124
+ const stubPath = join(root, 'git-stub-split.ts')
125
+ const mid = Math.floor(secret.length / 2)
126
+ await writeFile(
127
+ stubPath,
128
+ [
129
+ '#!/usr/bin/env bun',
130
+ `process.stdout.write(${JSON.stringify(secret.slice(0, mid))})`,
131
+ 'await new Promise((resolve) => setTimeout(resolve, 50))',
132
+ `process.stdout.write(${JSON.stringify(secret.slice(mid))})`,
133
+ '',
134
+ ].join('\n'),
135
+ )
136
+ await chmod(stubPath, 0o755)
137
+
138
+ const violations = await scanLeaks({ root, gitBin: stubPath })
139
+ expect(violations).toHaveLength(1)
140
+ expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
141
+ expect(violations[0]?.message).toContain('git history')
142
+ })
143
+
144
+ test('scanLeaks finds a secret that exists only in git history, not in the working tree', async () => {
145
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
146
+ await initRepo(root)
147
+ const notes = join(root, 'notes.txt')
148
+ await writeFile(notes, 'logged in with hunter2-longer-secret\n')
149
+ await git(['add', 'notes.txt'], root)
150
+ await git(['commit', '-q', '-m', 'add notes'], root)
151
+ await writeFile(notes, 'logged in fine\n')
152
+ await git(['add', 'notes.txt'], root)
153
+ await git(['commit', '-q', '-m', 'redact notes'], root)
154
+
155
+ const violations = await scanLeaks({ root })
156
+ expect(violations).toHaveLength(1)
157
+ expect(violations[0]?.gate).toBe('leaks')
158
+ expect(violations[0]?.message).toContain('MIGRATE_SOURCE_PASSWORD')
159
+ expect(violations[0]?.message).toContain('git history')
160
+ expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
161
+ })
162
+
163
+ test('scanLeaks does not attempt to run git when there is no .git directory', async () => {
164
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
165
+ const violations = await scanLeaks({ root, gitBin: join(root, 'no-such-git-binary') })
166
+ expect(violations).toEqual([])
167
+ })
168
+
169
+ test('scanLeaks reports a violation when git cannot be run, instead of crashing', async () => {
170
+ await writeFile(join(root, '.migrate', '.env'), 'MIGRATE_SOURCE_PASSWORD=hunter2-longer-secret\n')
171
+ await mkdir(join(root, '.git'))
172
+ const violations = await scanLeaks({ root, gitBin: join(root, 'no-such-git-binary') })
173
+ expect(violations).toHaveLength(1)
174
+ expect(violations[0]?.gate).toBe('leaks')
175
+ expect(violations[0]?.message).not.toContain('hunter2-longer-secret')
176
+ })
@@ -0,0 +1,183 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { existsSync, unlinkSync } from 'node:fs'
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { LockError, lockPath, processIsAlive, withStoreLock } from '../lock.ts'
7
+
8
+ let root: string
9
+
10
+ beforeEach(async () => {
11
+ root = await mkdtemp(join(tmpdir(), 'migrate-lock-'))
12
+ await mkdir(join(root, '.migrate'), { recursive: true })
13
+ })
14
+
15
+ afterEach(async () => {
16
+ await rm(root, { recursive: true, force: true })
17
+ })
18
+
19
+ test('runs the body, returns its value, and removes the lock afterwards', async () => {
20
+ const result = await withStoreLock(root, async () => 'done', { cmd: 'import' })
21
+ expect(result).toBe('done')
22
+ expect(existsSync(lockPath(root))).toBe(false)
23
+ })
24
+
25
+ test('the lock file names the holder while the body runs', async () => {
26
+ let holder: { pid: number; cmd: string; startedAt: string } | null = null
27
+ await withStoreLock(
28
+ root,
29
+ async () => {
30
+ holder = JSON.parse(await readFile(lockPath(root), 'utf8'))
31
+ },
32
+ { cmd: 'census' },
33
+ )
34
+ expect(holder).not.toBeNull()
35
+ expect((holder as unknown as { pid: number }).pid).toBe(process.pid)
36
+ expect((holder as unknown as { cmd: string }).cmd).toBe('census')
37
+ })
38
+
39
+ test('releases the lock when the body throws, and propagates the error', async () => {
40
+ const boom = withStoreLock(
41
+ root,
42
+ async () => {
43
+ throw new Error('body failed')
44
+ },
45
+ { cmd: 'import' },
46
+ )
47
+ await expect(boom).rejects.toThrow('body failed')
48
+ expect(existsSync(lockPath(root))).toBe(false)
49
+ })
50
+
51
+ test('refuses immediately when the holder pid is not running, naming --force-unlock', async () => {
52
+ await writeFile(
53
+ lockPath(root),
54
+ JSON.stringify({ pid: 999999, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
55
+ )
56
+ const attempt = withStoreLock(root, async () => 'unreachable', {
57
+ cmd: 'import',
58
+ alive: () => false,
59
+ timeoutMs: 30_000,
60
+ })
61
+ await expect(attempt).rejects.toThrow(/--force-unlock/)
62
+ await attempt.catch((e) => {
63
+ expect(e).toBeInstanceOf(LockError)
64
+ expect((e as LockError).kind).toBe('stale')
65
+ })
66
+ })
67
+
68
+ // Regression: readHolder and alive() are separated by an await boundary, so
69
+ // a holder that finishes, unlinks the lock, and exits in that gap used to
70
+ // make alive() return false for a pid that no longer matters, reported as a
71
+ // stale lock even though the lock was actually free. The alive hook's own
72
+ // side effect stands in for that race deterministically: it removes the lock
73
+ // file at the exact moment withStoreLock asks whether the holder it just read
74
+ // is still alive, so the confirming re-read must see the lock gone and retry
75
+ // rather than concluding pid 4242 is a dead holder.
76
+ test('a holder that releases the lock in the gap between the read and the liveness check is retried, not reported stale', async () => {
77
+ await writeFile(
78
+ lockPath(root),
79
+ JSON.stringify({ pid: 4242, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
80
+ )
81
+ let calls = 0
82
+ const result = await withStoreLock(root, async () => 'ran', {
83
+ cmd: 'import',
84
+ alive: (pid) => {
85
+ calls += 1
86
+ if (pid === 4242) unlinkSync(lockPath(root))
87
+ return false
88
+ },
89
+ })
90
+ expect(result).toBe('ran')
91
+ // Called exactly once: the confirming re-read found the lock gone and the
92
+ // retry went straight to acquiring it, never asking alive() about a second
93
+ // holder.
94
+ expect(calls).toBe(1)
95
+ })
96
+
97
+ test('times out while a live holder keeps the lock, naming the holder', async () => {
98
+ await writeFile(
99
+ lockPath(root),
100
+ JSON.stringify({ pid: 4412, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
101
+ )
102
+ const waits: string[] = []
103
+ const attempt = withStoreLock(root, async () => 'unreachable', {
104
+ cmd: 'import',
105
+ alive: () => true,
106
+ timeoutMs: 120,
107
+ onWait: (m) => waits.push(m),
108
+ })
109
+ await attempt.catch((e) => {
110
+ expect(e).toBeInstanceOf(LockError)
111
+ expect((e as LockError).kind).toBe('timeout')
112
+ expect((e as LockError).message).toContain('4412')
113
+ })
114
+ expect(waits.length).toBe(1)
115
+ expect(waits[0]).toContain('4412')
116
+ expect(waits[0]).toContain('2026-08-07T02:58:03.000Z')
117
+ })
118
+
119
+ test('force breaks a lock held by a live holder and runs the body', async () => {
120
+ await writeFile(
121
+ lockPath(root),
122
+ JSON.stringify({ pid: 4412, startedAt: '2026-08-07T02:58:03.000Z', cmd: 'import' }),
123
+ )
124
+ const result = await withStoreLock(root, async () => 'ran', {
125
+ cmd: 'import',
126
+ alive: () => true,
127
+ force: true,
128
+ })
129
+ expect(result).toBe('ran')
130
+ expect(existsSync(lockPath(root))).toBe(false)
131
+ })
132
+
133
+ test('a lock file that never becomes readable is reported as stale, not waited out', async () => {
134
+ await writeFile(lockPath(root), 'not json at all')
135
+ const attempt = withStoreLock(root, async () => 'unreachable', {
136
+ cmd: 'import',
137
+ timeoutMs: 30_000,
138
+ })
139
+ await attempt.catch((e) => {
140
+ expect(e).toBeInstanceOf(LockError)
141
+ expect((e as LockError).kind).toBe('stale')
142
+ expect((e as LockError).message).toContain('unreadable')
143
+ })
144
+ })
145
+
146
+ // A lock file left empty forever (its creator crashed after the O_EXCL
147
+ // create but before it ever wrote a holder record) must not hang: an empty
148
+ // read is deliberately excluded from UNREADABLE_TOLERANCE (see readHolder),
149
+ // since counting it there is what caused real contention to misreport a
150
+ // live handoff as corruption, so the only remaining backstop is the overall
151
+ // deadline. This confirms that backstop actually fires instead of looping.
152
+ test('a lock file that stays empty forever times out rather than looping', async () => {
153
+ await writeFile(lockPath(root), '')
154
+ const attempt = withStoreLock(root, async () => 'unreachable', {
155
+ cmd: 'import',
156
+ timeoutMs: 120,
157
+ })
158
+ await attempt.catch((e) => {
159
+ expect(e).toBeInstanceOf(LockError)
160
+ expect((e as LockError).kind).toBe('timeout')
161
+ })
162
+ })
163
+
164
+ test('two concurrent bodies in one process do not overlap', async () => {
165
+ const events: string[] = []
166
+ const body = (name: string) => async () => {
167
+ events.push(`${name}:enter`)
168
+ await Bun.sleep(20)
169
+ events.push(`${name}:exit`)
170
+ }
171
+ await Promise.all([
172
+ withStoreLock(root, body('a'), { cmd: 'import' }),
173
+ withStoreLock(root, body('b'), { cmd: 'import' }),
174
+ ])
175
+ expect(events.length).toBe(4)
176
+ expect(events[1]).toBe(events[0]?.replace(':enter', ':exit'))
177
+ expect(events[3]).toBe(events[2]?.replace(':enter', ':exit'))
178
+ })
179
+
180
+ test('processIsAlive is true for this process and false for an unused pid', () => {
181
+ expect(processIsAlive(process.pid)).toBe(true)
182
+ expect(processIsAlive(999999)).toBe(false)
183
+ })
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
3
+ import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { assertNotUnderSource, findStoreRoot, storePaths } from '../paths.ts'
7
+
8
+ let root: string
9
+
10
+ beforeEach(async () => {
11
+ root = await mkdtemp(join(tmpdir(), 'migrate-paths-'))
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await rm(root, { recursive: true, force: true })
16
+ })
17
+
18
+ // Platform support is detected once, synchronously, at module load, so
19
+ // test.skipIf's condition is known before any test runs rather than
20
+ // discovered mid-test.
21
+
22
+ function detectSymlinkSupport(): boolean {
23
+ const dir = mkdtempSync(join(tmpdir(), 'migrate-symlink-check-'))
24
+ try {
25
+ symlinkSync(join(dir, 'target'), join(dir, 'link'))
26
+ return true
27
+ } catch {
28
+ return false
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true })
31
+ }
32
+ }
33
+
34
+ function detectCaseInsensitiveFs(): boolean {
35
+ const dir = mkdtempSync(join(tmpdir(), 'migrate-case-check-'))
36
+ try {
37
+ mkdirSync(join(dir, 'src'))
38
+ return existsSync(join(dir, 'SRC'))
39
+ } finally {
40
+ rmSync(dir, { recursive: true, force: true })
41
+ }
42
+ }
43
+
44
+ const SYMLINKS_SUPPORTED = detectSymlinkSupport()
45
+ const CASE_INSENSITIVE_FS = detectCaseInsensitiveFs()
46
+
47
+ test('findStoreRoot walks up to the directory holding .migrate', async () => {
48
+ await mkdir(join(root, '.migrate'), { recursive: true })
49
+ await mkdir(join(root, 'a', 'b'), { recursive: true })
50
+ expect(await findStoreRoot(join(root, 'a', 'b'))).toBe(root)
51
+ })
52
+
53
+ test('findStoreRoot returns null when there is no store', async () => {
54
+ await mkdir(join(root, 'a'), { recursive: true })
55
+ expect(await findStoreRoot(join(root, 'a'))).toBeNull()
56
+ })
57
+
58
+ test('storePaths puts every artifact under .migrate', () => {
59
+ const p = storePaths(root)
60
+ expect(p.config).toBe(join(root, '.migrate', 'config.toml'))
61
+ expect(p.elements).toBe(join(root, '.migrate', 'elements.jsonl'))
62
+ expect(p.queueDir).toBe(join(root, '.migrate', 'queue'))
63
+ expect(p.phases).toBe(join(root, '.migrate', 'phases.json'))
64
+ })
65
+
66
+ test('assertNotUnderSource rejects a path inside the source tree', async () => {
67
+ const source = join(root, 'legacy')
68
+ await expect(assertNotUnderSource(join(source, 'x.md'), source)).rejects.toThrow(/source/)
69
+ await expect(assertNotUnderSource(join(source, '..', 'out.md'), source)).resolves.toBeUndefined()
70
+ })
71
+
72
+ test('assertNotUnderSource is not fooled by a sibling with a shared prefix', async () => {
73
+ const source = join(root, 'legacy')
74
+ await expect(
75
+ assertNotUnderSource(join(root, 'legacy-notes', 'x.md'), source),
76
+ ).resolves.toBeUndefined()
77
+ })
78
+
79
+ test('assertNotUnderSource allows writes to the source parent directory', async () => {
80
+ const source = join(root, 'legacy')
81
+ await expect(assertNotUnderSource(root, source)).resolves.toBeUndefined()
82
+ })
83
+
84
+ // Important finding 1, bypass 1: a lexical-only comparison never resolves
85
+ // symlinks, so a target reached only through a symlinked directory can
86
+ // really land inside the source tree while comparing as "outside" on raw
87
+ // path text. Here `.migrate` itself -- the exact shape named in the report
88
+ // -- is a symlink into the source tree; storePaths' own path construction
89
+ // (`join(root, '.migrate', 'elements.jsonl')`) is used verbatim so this
90
+ // reproduces the real write path, not a synthetic one.
91
+ test.skipIf(!SYMLINKS_SUPPORTED)(
92
+ 'assertNotUnderSource follows a .migrate directory symlinked into the source tree',
93
+ async () => {
94
+ const source = join(root, 'legacy')
95
+ const insideSource = join(source, 'landing-zone')
96
+ await mkdir(insideSource, { recursive: true })
97
+ await symlink(insideSource, join(root, '.migrate'))
98
+ const target = storePaths(root).elements
99
+ await expect(assertNotUnderSource(target, source)).rejects.toThrow(/source/)
100
+ },
101
+ )
102
+
103
+ // Important finding 1, bypass 2: on a case-insensitive volume, a config
104
+ // declaring the source at one case spelling and a store computed from a
105
+ // project root spelled with a different case name the same physical
106
+ // directory, but plain string comparison sees two unrelated paths. Skipped
107
+ // cleanly (not failed) on a case-sensitive filesystem, where this scenario
108
+ // cannot arise at all.
109
+ test.skipIf(!CASE_INSENSITIVE_FS)(
110
+ 'assertNotUnderSource is not fooled by a case-variant spelling of the source root',
111
+ async () => {
112
+ const declaredSource = join(root, 'ci', 'src')
113
+ await mkdir(declaredSource, { recursive: true })
114
+ const target = join(root, 'ci', 'SRC', '.migrate', 'elements.jsonl')
115
+ await expect(assertNotUnderSource(target, declaredSource)).rejects.toThrow(/source/)
116
+ },
117
+ )
118
+
119
+ // A write target need not exist yet -- that is the normal case for a store
120
+ // file about to be created for the first time -- so the fix must resolve
121
+ // containment from the nearest existing ancestor rather than requiring the
122
+ // target itself to exist.
123
+ test('assertNotUnderSource resolves containment even when the target does not exist yet', async () => {
124
+ const source = join(root, 'legacy')
125
+ await mkdir(source, { recursive: true })
126
+ const target = join(root, 'project', '.migrate', 'elements.jsonl')
127
+ await mkdir(join(root, 'project'), { recursive: true })
128
+ await expect(assertNotUnderSource(target, source)).resolves.toBeUndefined()
129
+ })