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