@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,475 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { existsSync } from 'node:fs'
3
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { writeConfig } from '../config.ts'
7
+ import { isMissingFrontmatter, loadQueue, parseQueueItem } from '../queue.ts'
8
+ import { runQueue } from '../queue-cmd.ts'
9
+
10
+ let root: string
11
+ let queueDir: string
12
+
13
+ beforeEach(async () => {
14
+ root = await mkdtemp(join(tmpdir(), 'migrate-queue-'))
15
+ queueDir = join(root, '.migrate', 'queue')
16
+ await mkdir(queueDir, { recursive: true })
17
+ await writeConfig(root, { sourcePath: join(root, 'legacy'), scope: 'all', targetName: 'newapp' })
18
+ })
19
+
20
+ afterEach(async () => {
21
+ await rm(root, { recursive: true, force: true })
22
+ })
23
+
24
+ const GOOD = `---
25
+ id: q-invoice-batch-scope
26
+ severity: moderate
27
+ status: open
28
+ ---
29
+
30
+ ## Evidence
31
+
32
+ Route POST /api/invoice/batch found in InvoiceController.cs:215-240.
33
+
34
+ ## Options
35
+
36
+ (a) Replicate as-is. (b) Harden it. (c) Mark out of scope.
37
+
38
+ ## Recommendation
39
+
40
+ Recommend (c); three invocations in six months.
41
+ `
42
+
43
+ test('a well-formed item parses', () => {
44
+ const result = parseQueueItem(GOOD, join(queueDir, 'q-invoice-batch-scope.md'))
45
+ expect(result.ok).toBe(true)
46
+ if (result.ok) {
47
+ expect(result.value.severity).toBe('moderate')
48
+ expect(result.value.status).toBe('open')
49
+ expect(result.value.evidence).toContain('InvoiceController.cs')
50
+ }
51
+ })
52
+
53
+ test('a filename that does not match the id is rejected', () => {
54
+ const result = parseQueueItem(GOOD, join(queueDir, 'other.md'))
55
+ expect(result.ok).toBe(false)
56
+ if (!result.ok) expect(result.errors.join(' ')).toContain('filename')
57
+ })
58
+
59
+ test('an empty section is rejected', () => {
60
+ const text = GOOD.replace('Recommend (c); three invocations in six months.', '')
61
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
62
+ expect(result.ok).toBe(false)
63
+ if (!result.ok) expect(result.errors.join(' ')).toContain('Recommendation')
64
+ })
65
+
66
+ test('a missing section is rejected', () => {
67
+ const text = GOOD.replace(/## Options[\s\S]*?(?=## Recommendation)/, '')
68
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
69
+ expect(result.ok).toBe(false)
70
+ if (!result.ok) expect(result.errors.join(' ')).toContain('Options')
71
+ })
72
+
73
+ test('an unknown severity is rejected', () => {
74
+ const text = GOOD.replace('severity: moderate', 'severity: urgent')
75
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
76
+ expect(result.ok).toBe(false)
77
+ if (!result.ok) expect(result.errors.join(' ')).toContain('severity')
78
+ })
79
+
80
+ test('loadQueue sorts critical first and reports per-file errors', async () => {
81
+ await writeFile(join(queueDir, 'q-invoice-batch-scope.md'), GOOD)
82
+ await writeFile(
83
+ join(queueDir, 'q-zulu.md'),
84
+ GOOD.replace('q-invoice-batch-scope', 'q-zulu').replace(
85
+ 'severity: moderate',
86
+ 'severity: critical',
87
+ ),
88
+ )
89
+ await writeFile(join(queueDir, 'q-broken.md'), '---\nid: q-broken\n---\n')
90
+ const { items, errors } = await loadQueue(queueDir)
91
+ expect(items.map((i) => i.id)).toEqual(['q-zulu', 'q-invoice-batch-scope'])
92
+ expect(errors.join(' ')).toContain('q-broken')
93
+ })
94
+
95
+ // An adjudicated item requires a ruling: status alone is not enough to
96
+ // close out a decision, since 'adjudicated' with no ruling text records
97
+ // that a call was made without recording what it was.
98
+
99
+ test('an adjudicated item with no ruling is rejected', () => {
100
+ const text = GOOD.replace('status: open', 'status: adjudicated')
101
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
102
+ expect(result.ok).toBe(false)
103
+ if (!result.ok) expect(result.errors.join(' ')).toContain('ruling')
104
+ })
105
+
106
+ test('an adjudicated item with a ruling parses', () => {
107
+ const text = GOOD.replace('status: open', 'status: adjudicated\nruling: out of scope, delta-003')
108
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
109
+ expect(result.ok).toBe(true)
110
+ if (result.ok) {
111
+ expect(result.value.status).toBe('adjudicated')
112
+ expect(result.value.ruling).toBe('out of scope, delta-003')
113
+ }
114
+ })
115
+
116
+ // Correction 1: no uncaught throws from the CLI, ever. runQueue reads files
117
+ // with readFile; a missing file, a directory passed as a file, or an
118
+ // unreadable file must all produce a clean diagnostic and a deliberate exit
119
+ // code instead of a raw stack trace escaping the process.
120
+
121
+ test('queue add reports a missing file as a clean diagnostic, not a thrown stack trace', async () => {
122
+ const code = await runQueue({ root, args: ['add', join(root, 'does-not-exist.md')] })
123
+ expect(code).toBe(2)
124
+ })
125
+
126
+ test('queue add reports a directory passed as a file as a clean diagnostic', async () => {
127
+ const dir = join(root, 'a-directory.md')
128
+ await mkdir(dir)
129
+ const code = await runQueue({ root, args: ['add', dir] })
130
+ expect(code).toBe(2)
131
+ })
132
+
133
+ test('queue add reports an unreadable file as a clean diagnostic', async () => {
134
+ const path = join(root, 'no-access.md')
135
+ await writeFile(path, GOOD)
136
+ await chmod(path, 0o000)
137
+ try {
138
+ const code = await runQueue({ root, args: ['add', path] })
139
+ expect(code).toBe(2)
140
+ } finally {
141
+ await chmod(path, 0o644)
142
+ }
143
+ })
144
+
145
+ test('loadQueue reports a directory named *.md without abandoning the rest of the directory', async () => {
146
+ await writeFile(join(queueDir, 'q-invoice-batch-scope.md'), GOOD)
147
+ await mkdir(join(queueDir, 'q-oops.md'))
148
+ const { items, errors } = await loadQueue(queueDir)
149
+ expect(items.map((i) => i.id)).toEqual(['q-invoice-batch-scope'])
150
+ expect(errors.join(' ')).toContain('q-oops.md')
151
+ })
152
+
153
+ test('loadQueue reports an unreadable file without abandoning the rest of the directory', async () => {
154
+ await writeFile(join(queueDir, 'q-invoice-batch-scope.md'), GOOD)
155
+ const lockedPath = join(queueDir, 'q-locked.md')
156
+ await writeFile(
157
+ lockedPath,
158
+ GOOD.replace('q-invoice-batch-scope', 'q-locked').replace(
159
+ 'severity: moderate',
160
+ 'severity: critical',
161
+ ),
162
+ )
163
+ await chmod(lockedPath, 0o000)
164
+ try {
165
+ const { items, errors } = await loadQueue(queueDir)
166
+ expect(items.map((i) => i.id)).toEqual(['q-invoice-batch-scope'])
167
+ expect(errors.join(' ')).toContain('q-locked.md')
168
+ } finally {
169
+ await chmod(lockedPath, 0o644)
170
+ }
171
+ })
172
+
173
+ test('loadQueue reports a queue path that is a file, not a directory, without throwing', async () => {
174
+ const notADir = join(root, '.migrate', 'not-a-dir')
175
+ await writeFile(notADir, 'oops')
176
+ const { items, errors } = await loadQueue(notADir)
177
+ expect(items).toEqual([])
178
+ expect(errors.join(' ')).toContain(notADir)
179
+ })
180
+
181
+ // Correction 2: exit-code convention. A queue file that cannot be read or
182
+ // has no frontmatter at all is a usage error (2); a queue file that parses
183
+ // but violates the grammar is a content failure (1).
184
+
185
+ test('isMissingFrontmatter is true only for the no-frontmatter case', () => {
186
+ const noFm = parseQueueItem('not frontmatter at all\n', join(queueDir, 'x.md'))
187
+ const badSeverity = parseQueueItem(
188
+ GOOD.replace('severity: moderate', 'severity: urgent'),
189
+ join(queueDir, 'q-invoice-batch-scope.md'),
190
+ )
191
+ if (!noFm.ok) expect(isMissingFrontmatter(noFm.errors)).toBe(true)
192
+ if (!badSeverity.ok) expect(isMissingFrontmatter(badSeverity.errors)).toBe(false)
193
+ })
194
+
195
+ test('queue add rejects a file with no frontmatter at all as a usage error (2)', async () => {
196
+ const path = join(root, 'no-frontmatter.md')
197
+ await writeFile(path, '# Just some markdown\n\nNo frontmatter here.\n')
198
+ const code = await runQueue({ root, args: ['add', path] })
199
+ expect(code).toBe(2)
200
+ })
201
+
202
+ test('queue add rejects a well-formed-but-invalid item as a content failure (1)', async () => {
203
+ const path = join(root, 'q-invoice-batch-scope.md')
204
+ await writeFile(path, GOOD.replace('severity: moderate', 'severity: urgent'))
205
+ const code = await runQueue({ root, args: ['add', path] })
206
+ expect(code).toBe(1)
207
+ })
208
+
209
+ test('queue add rejects an empty section as a content failure (1)', async () => {
210
+ const path = join(root, 'q-invoice-batch-scope.md')
211
+ await writeFile(path, GOOD.replace('Recommend (c); three invocations in six months.', ''))
212
+ const code = await runQueue({ root, args: ['add', path] })
213
+ expect(code).toBe(1)
214
+ })
215
+
216
+ test('queue add accepts a well-formed item and copies it into the store (0)', async () => {
217
+ const path = join(root, 'q-invoice-batch-scope.md')
218
+ await writeFile(path, GOOD)
219
+ const code = await runQueue({ root, args: ['add', path] })
220
+ expect(code).toBe(0)
221
+ expect(existsSync(join(queueDir, 'q-invoice-batch-scope.md'))).toBe(true)
222
+ })
223
+
224
+ test('queue add with no file argument is a usage error (2)', async () => {
225
+ const code = await runQueue({ root, args: ['add'] })
226
+ expect(code).toBe(2)
227
+ })
228
+
229
+ test('queue list reports items and returns 0 when the queue is clean', async () => {
230
+ const path = join(root, 'q-invoice-batch-scope.md')
231
+ await writeFile(path, GOOD)
232
+ await runQueue({ root, args: ['add', path] })
233
+ const code = await runQueue({ root, args: ['list'] })
234
+ expect(code).toBe(0)
235
+ })
236
+
237
+ test('queue list returns 1 when a queue file is broken, without abandoning the listing', async () => {
238
+ await writeFile(join(queueDir, 'q-broken.md'), '---\nid: q-broken\n---\n')
239
+ const code = await runQueue({ root, args: ['list'] })
240
+ expect(code).toBe(1)
241
+ })
242
+
243
+ test('queue show prints the raw file for a known id', async () => {
244
+ await writeFile(join(queueDir, 'q-invoice-batch-scope.md'), GOOD)
245
+ const code = await runQueue({ root, args: ['show', 'q-invoice-batch-scope'] })
246
+ expect(code).toBe(0)
247
+ })
248
+
249
+ test('queue show reports an unknown id as a content failure (1)', async () => {
250
+ const code = await runQueue({ root, args: ['show', 'q-does-not-exist'] })
251
+ expect(code).toBe(1)
252
+ })
253
+
254
+ test('queue show with no id is a usage error (2)', async () => {
255
+ const code = await runQueue({ root, args: ['show'] })
256
+ expect(code).toBe(2)
257
+ })
258
+
259
+ test('queue with an unknown verb is a usage error (2)', async () => {
260
+ const code = await runQueue({ root, args: ['frobnicate'] })
261
+ expect(code).toBe(2)
262
+ })
263
+
264
+ // Critical finding 1: a store configured so its own path resolves inside
265
+ // its configured source tree (e.g. source.path pointing at the project
266
+ // root itself, which the store also lives under) must not crash.
267
+ // assertNotUnderSource throws by design (writeRows's own contract, see
268
+ // store.test.ts); queue-cmd.ts must catch that throw and report a clean
269
+ // diagnostic with a deliberate exit code instead of letting it escape as
270
+ // an uncaught stack trace. Chosen exit code: 2 -- a malformed/unusable
271
+ // setup that fails identically no matter what valid file content is
272
+ // supplied, not a property of this file's own data (see the report for
273
+ // the full argument against exit code 1).
274
+
275
+ test('queue add refuses to write when the store sits inside its own configured source tree, as a clean usage error (2), not a crash', async () => {
276
+ await writeConfig(root, { sourcePath: root, scope: 'all', targetName: 'newapp' })
277
+ const path = join(root, 'q-invoice-batch-scope.md')
278
+ await writeFile(path, GOOD)
279
+ const code = await runQueue({ root, args: ['add', path] })
280
+ expect(code).toBe(2)
281
+ expect(existsSync(join(queueDir, 'q-invoice-batch-scope.md'))).toBe(false)
282
+ })
283
+
284
+ // Critical finding 2: sectionBody must match headings at a line boundary
285
+ // and at exactly level two. Before the fix, '### Options' (one hash too
286
+ // many) was invisible to the next-boundary search but still matched by a
287
+ // plain indexOf('## Options') substring lookup, so Evidence silently
288
+ // absorbed the whole mislabeled Options block (including its own heading)
289
+ // while Options was separately (mis)populated from that same text --
290
+ // ok:true, corrupted and duplicated, no diagnostic. A duplicated
291
+ // '## Evidence' heading similarly truncated Evidence to its first block
292
+ // and dropped the second entirely, again ok:true with no diagnostic.
293
+
294
+ test('a heading one level too deep (### Options) is reported as a missing section, not silently accepted with corrupted content', () => {
295
+ const text = GOOD.replace('## Options', '### Options')
296
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
297
+ expect(result.ok).toBe(false)
298
+ if (!result.ok) expect(result.errors.join(' ')).toContain('Options')
299
+ })
300
+
301
+ test('a duplicated ## Evidence heading is rejected as a duplicate section, not silently truncated to the first block', () => {
302
+ const text = GOOD.replace(
303
+ '## Evidence\n\nRoute POST /api/invoice/batch found in InvoiceController.cs:215-240.\n',
304
+ '## Evidence\n\nFirst evidence block.\n\n## Evidence\n\nSecond evidence block, silently dropped by the pre-fix code.\n',
305
+ )
306
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
307
+ expect(result.ok).toBe(false)
308
+ if (!result.ok) {
309
+ const msg = result.errors.join(' ')
310
+ expect(msg).toContain('Evidence')
311
+ expect(msg.toLowerCase()).toContain('duplicate')
312
+ }
313
+ })
314
+
315
+ test('a level-three sub-heading inside a section is kept as ordinary content, not treated as a boundary', () => {
316
+ const text = GOOD.replace(
317
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
318
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.\n\n### Extra detail\n\nAlso seen in the audit log.',
319
+ )
320
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
321
+ expect(result.ok).toBe(true)
322
+ if (result.ok) {
323
+ expect(result.value.evidence).toContain('### Extra detail')
324
+ expect(result.value.evidence).toContain('Also seen in the audit log.')
325
+ expect(result.value.options).not.toContain('Extra detail')
326
+ }
327
+ })
328
+
329
+ // Important finding 3: a leading BOM and/or CRLF line endings must not
330
+ // reject an otherwise well-formed item wholesale. Both are common
331
+ // byproducts of authoring on Windows; neither changes the file's actual
332
+ // content, so both are stripped/normalized before any grammar check runs.
333
+
334
+ test('a leading BOM does not cause a well-formed item to be rejected', () => {
335
+ const result = parseQueueItem(`\uFEFF${GOOD}`, join(queueDir, 'q-invoice-batch-scope.md'))
336
+ expect(result.ok).toBe(true)
337
+ })
338
+
339
+ test('CRLF line endings do not cause a well-formed item to be rejected', () => {
340
+ const crlf = GOOD.replace(/\n/g, '\r\n')
341
+ const result = parseQueueItem(crlf, join(queueDir, 'q-invoice-batch-scope.md'))
342
+ expect(result.ok).toBe(true)
343
+ if (result.ok) {
344
+ expect(result.value.evidence).toContain('InvoiceController.cs')
345
+ expect(result.value.evidence).not.toContain('\r')
346
+ }
347
+ })
348
+
349
+ test('a BOM plus CRLF together do not cause a well-formed item to be rejected', () => {
350
+ const text = `\uFEFF${GOOD.replace(/\n/g, '\r\n')}`
351
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
352
+ expect(result.ok).toBe(true)
353
+ })
354
+
355
+ // Minor finding 4: a wrong-case heading (e.g. '## options') reports
356
+ // "missing ## Options section" with no hint that case is the problem,
357
+ // identical to the message for a heading that's absent entirely. State
358
+ // the grammar so the author can tell the two apart.
359
+
360
+ test('a wrong-case heading names the exact grammar required, not just "missing"', () => {
361
+ const text = GOOD.replace('## Options', '## options')
362
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
363
+ expect(result.ok).toBe(false)
364
+ if (!result.ok) {
365
+ const msg = result.errors.join(' ')
366
+ expect(msg).toContain('Options')
367
+ expect(msg).toContain('case-sensitive')
368
+ }
369
+ })
370
+
371
+ // Round-1 confirmation: an indented, tab-indented, blockquoted, or
372
+ // starts-with heading must still be treated as ordinary body text (not a
373
+ // section boundary), exactly as before the fence fix. These are
374
+ // regression checks, not fail-before/pass-after tests for this round --
375
+ // the fence-scanning rewrite only changed how a line is classified while
376
+ // inside a fence; the underlying line-anchored, exact-name heading test
377
+ // applied to a non-fenced line is unchanged.
378
+
379
+ test('an indented ## Options heading is not recognized (still missing)', () => {
380
+ const text = GOOD.replace('## Options', ' ## Options')
381
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
382
+ expect(result.ok).toBe(false)
383
+ if (!result.ok) expect(result.errors.join(' ')).toContain('missing ## Options')
384
+ })
385
+
386
+ test('a tab-indented ## Options heading is not recognized (still missing)', () => {
387
+ const text = GOOD.replace('## Options', '\t## Options')
388
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
389
+ expect(result.ok).toBe(false)
390
+ if (!result.ok) expect(result.errors.join(' ')).toContain('missing ## Options')
391
+ })
392
+
393
+ test('a blockquoted ## Options heading is not recognized (still missing)', () => {
394
+ const text = GOOD.replace('## Options', '> ## Options')
395
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
396
+ expect(result.ok).toBe(false)
397
+ if (!result.ok) expect(result.errors.join(' ')).toContain('missing ## Options')
398
+ })
399
+
400
+ test('a heading whose name only starts with the required text (## Options considered) does not match Options', () => {
401
+ const text = GOOD.replace('## Options', '## Options considered')
402
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
403
+ expect(result.ok).toBe(false)
404
+ if (!result.ok) expect(result.errors.join(' ')).toContain('missing ## Options')
405
+ })
406
+
407
+ // Critical finding (round 2): a fenced code block quoting a '##'-prefixed
408
+ // line (a shell script's own comments, a quoted markdown file, a diff)
409
+ // must not be mistaken for real document structure. Before this fix, a
410
+ // fenced '## Options' inside Evidence was rejected as "duplicate ##
411
+ // Options" (colliding with the real Options heading later in the
412
+ // document) -- a false rejection of well-formed input pointing the author
413
+ // at entirely the wrong problem.
414
+
415
+ const FENCE = '```'
416
+
417
+ test('a fenced code block in Evidence containing ## Options parses cleanly, with the fence content preserved verbatim', () => {
418
+ const text = GOOD.replace(
419
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
420
+ [
421
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
422
+ '',
423
+ FENCE,
424
+ '## Options',
425
+ 'echo "quoted shell output, not a real heading"',
426
+ FENCE,
427
+ ].join('\n'),
428
+ )
429
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
430
+ expect(result.ok).toBe(true)
431
+ if (result.ok) {
432
+ expect(result.value.evidence).toContain('## Options')
433
+ expect(result.value.evidence).toContain('echo "quoted shell output, not a real heading"')
434
+ expect(result.value.options).not.toContain('echo')
435
+ }
436
+ })
437
+
438
+ test('a fenced code block containing ## Evidence does not trigger duplicate-section detection', () => {
439
+ const text = GOOD.replace(
440
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
441
+ [
442
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
443
+ '',
444
+ FENCE,
445
+ '## Evidence',
446
+ 'quoted from another document, not a real heading',
447
+ FENCE,
448
+ ].join('\n'),
449
+ )
450
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
451
+ expect(result.ok).toBe(true)
452
+ if (result.ok) {
453
+ expect(result.value.evidence).toContain('## Evidence')
454
+ expect(result.value.evidence).toContain('quoted from another document, not a real heading')
455
+ }
456
+ })
457
+
458
+ test('an unclosed fence produces one clear, loud error naming the fence, not a silently truncated or corrupted document', () => {
459
+ const text = GOOD.replace(
460
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
461
+ [
462
+ 'Route POST /api/invoice/batch found in InvoiceController.cs:215-240.',
463
+ '',
464
+ FENCE,
465
+ 'echo "this fence is never closed"',
466
+ ].join('\n'),
467
+ )
468
+ const result = parseQueueItem(text, join(queueDir, 'q-invoice-batch-scope.md'))
469
+ expect(result.ok).toBe(false)
470
+ if (!result.ok) {
471
+ const msg = result.errors.join(' ')
472
+ expect(msg).toContain('unclosed')
473
+ expect(msg).toContain('fence')
474
+ }
475
+ })
@@ -0,0 +1,150 @@
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 { writeConfig } from '../config.ts'
6
+ import { storePaths } from '../paths.ts'
7
+ import { renderLedger, renderRequirements } from '../report.ts'
8
+ import { runReport } from '../report-cmd.ts'
9
+ import type { Element, Requirement } from '../types.ts'
10
+
11
+ let root: string
12
+
13
+ beforeEach(async () => {
14
+ root = await mkdtemp(join(tmpdir(), 'migrate-report-'))
15
+ await mkdir(join(root, '.migrate'), { recursive: true })
16
+ })
17
+
18
+ afterEach(async () => {
19
+ await rm(root, { recursive: true, force: true })
20
+ })
21
+
22
+ const ELEMENT: Element = {
23
+ id: 'route-get-api-users',
24
+ surface: 'routes',
25
+ element: 'GET /api/users',
26
+ found_by: ['code', 'nav'],
27
+ disposition: { kind: 'mapped', fr: 'UM-001' },
28
+ refs: [{ kind: 'src', path: 'Controllers/Users.cs', lines: [42, 58] }],
29
+ lens: 'code',
30
+ batch: 'b-1',
31
+ notes: 'first',
32
+ }
33
+
34
+ const REQUIREMENT: Requirement = {
35
+ id: 'UM-001',
36
+ cap: 'user-management',
37
+ requirement: 'List users | paginated',
38
+ actors: 'User',
39
+ objects: 'User',
40
+ rules: '-',
41
+ origin: 'intended',
42
+ confidence: { kind: 'confirmed' },
43
+ citations: [{ kind: 'ledger', id: 'route-get-api-users' }],
44
+ parity: { kind: 'rubric', level: 'high' },
45
+ batch: 'b-1',
46
+ }
47
+
48
+ test('the ledger renders one section per surface with a header banner', () => {
49
+ const md = renderLedger([ELEMENT])
50
+ expect(md).toContain('generated by `migrate report`')
51
+ expect(md).toContain('## routes')
52
+ expect(md).toContain('route-get-api-users')
53
+ expect(md).toContain('mapped:UM-001')
54
+ expect(md).toContain('code,nav')
55
+ })
56
+
57
+ test('a pipe inside a cell is escaped so the table survives', () => {
58
+ const md = renderRequirements([REQUIREMENT])
59
+ expect(md).toContain('List users \\| paginated')
60
+ // Split on unescaped pipes only: a plain split('|') would also split on the
61
+ // \| this test exists to verify, and would always agree with itself.
62
+ const columns = (line: string): number => line.split(/(?<!\\)\|/).length
63
+ const header = md.split('\n').find((l) => l.startsWith('| id '))
64
+ const row = md.split('\n').find((l) => l.includes('UM-001'))
65
+ expect(header).toBeDefined()
66
+ expect(row).toBeDefined()
67
+ expect(columns(row as string)).toBe(columns(header as string))
68
+ })
69
+
70
+ // The assertion above is only worth having if it can actually fail. This
71
+ // reproduces, by hand, exactly what renderRequirements would emit if `cell()`
72
+ // regressed to not escaping '|' at all: the requirement cell's literal pipe
73
+ // stays raw instead of becoming '\|'. A plain line.split('|') would count
74
+ // that raw pipe as a column break in BOTH the correctly-escaped and the
75
+ // broken case identically (it does not look at the preceding character),
76
+ // so it can never distinguish them -- which is why the brief's original
77
+ // `line.split('|')` version always agreed with itself regardless of what
78
+ // the code did. The unescaped-pipe split used above does not have that
79
+ // blind spot: it must report the broken row as having one more column than
80
+ // the header, which is exactly what a bug caught by this test looks like.
81
+ test('the corrected column-count assertion genuinely fails when pipe-escaping is missing', () => {
82
+ const header =
83
+ '| id | requirement | actors | objects | rules | confidence | origin | citations | parity |'
84
+ const brokenRow =
85
+ '| UM-001 | List users | paginated | User | User | - | confirmed | intended | route-get-api-users | rubric:high |'
86
+ const columns = (line: string): number => line.split(/(?<!\\)\|/).length
87
+ expect(columns(brokenRow)).not.toBe(columns(header))
88
+ })
89
+
90
+ test('confidence and parity render in their grammar form', () => {
91
+ const md = renderRequirements([
92
+ {
93
+ ...REQUIREMENT,
94
+ confidence: { kind: 'queued', queue: 'q-a' },
95
+ parity: { kind: 'rubric', level: 'moderate', queue: 'q-a' },
96
+ },
97
+ ])
98
+ expect(md).toContain('queued:q-a')
99
+ expect(md).toContain('rubric:moderate:q-a')
100
+ })
101
+
102
+ test('an empty store still renders a valid document', () => {
103
+ const md = renderLedger([])
104
+ expect(md).toContain('No surface elements recorded')
105
+ })
106
+
107
+ // report writes generated views next to the store by default, never inside
108
+ // the read-only source checkout. assertNotUnderSource throws by design (see
109
+ // paths.test.ts and store.test.ts); report-cmd.ts deliberately does not
110
+ // catch that throw itself (unlike census-cmd.ts, import-cmd.ts and
111
+ // queue-cmd.ts), relying instead on bin/migrate.ts's central error guard to
112
+ // turn it into a clean exit-2 diagnostic -- so at this layer the contract
113
+ // under test is that runReport rejects, not that it returns 2.
114
+ test('report refuses to write when the output directory sits inside the configured source tree', async () => {
115
+ await writeConfig(root, { sourcePath: root, scope: 'all', targetName: 'newapp' })
116
+ await expect(runReport({ root })).rejects.toThrow(/source/)
117
+ })
118
+
119
+ async function captureStderr(fn: () => Promise<number>): Promise<{ code: number; text: string }> {
120
+ const out: string[] = []
121
+ const original = process.stderr.write.bind(process.stderr)
122
+ process.stderr.write = ((s: string) => {
123
+ out.push(s)
124
+ return true
125
+ }) as typeof process.stderr.write
126
+ try {
127
+ const code = await fn()
128
+ return { code, text: out.join('') }
129
+ } finally {
130
+ process.stderr.write = original
131
+ }
132
+ }
133
+
134
+ // Important finding 3: a malformed queue item used to be dropped from
135
+ // queue.md with no trace at all -- the file still carried its "generated by
136
+ // `migrate report`" banner, exit was 0, and an owner handed queue.md alone
137
+ // had no way to learn a queue item existed that this run could not read.
138
+ // `check` and `queue list` already flag the identical condition and exit 1;
139
+ // report must not be the one command in the CLI that hides it.
140
+ test('report surfaces a malformed queue item on stderr and exits 1, not silently 0', async () => {
141
+ await writeConfig(root, { sourcePath: join(root, 'legacy'), scope: 'all', targetName: 'newapp' })
142
+ const p = storePaths(root)
143
+ await mkdir(p.queueDir, { recursive: true })
144
+ await writeFile(join(p.queueDir, 'q-broken.md'), 'not a queue item at all')
145
+
146
+ const { code, text } = await captureStderr(() => runReport({ root }))
147
+
148
+ expect(code).toBe(1)
149
+ expect(text).toContain('q-broken.md')
150
+ })