@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,320 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
7
+ const FIXTURE = join(import.meta.dir, '..', '..', 'fixtures', 'tiny-express')
8
+
9
+ let target: string
10
+ let source: string
11
+
12
+ async function migrate(args: string[]): Promise<{ code: number; out: string; err: string }> {
13
+ const proc = Bun.spawn(['bun', CLI, ...args], { cwd: target, stdout: 'pipe', stderr: 'pipe' })
14
+ const [out, err] = await Promise.all([
15
+ new Response(proc.stdout).text(),
16
+ new Response(proc.stderr).text(),
17
+ ])
18
+ await proc.exited
19
+ return { code: proc.exitCode ?? -1, out, err }
20
+ }
21
+
22
+ async function write(name: string, value: unknown): Promise<string> {
23
+ const path = join(target, name)
24
+ await writeFile(path, JSON.stringify(value))
25
+ return path
26
+ }
27
+
28
+ beforeEach(async () => {
29
+ target = await mkdtemp(join(tmpdir(), 'migrate-e2e-'))
30
+ source = join(target, 'legacy')
31
+ await cp(FIXTURE, source, { recursive: true })
32
+ })
33
+
34
+ afterEach(async () => {
35
+ await rm(target, { recursive: true, force: true })
36
+ })
37
+
38
+ test('init, import, census, check, report over the fixture source', async () => {
39
+ const init = await migrate([
40
+ 'init',
41
+ '--source',
42
+ source,
43
+ '--scope',
44
+ 'the whole tiny app',
45
+ '--name',
46
+ 'tiny-next',
47
+ '--source-stack',
48
+ 'express',
49
+ ])
50
+ expect(init.code).toBe(0)
51
+
52
+ // Narrow the declared surfaces to what this fixture actually has, and drop
53
+ // the closers, which belong to the extract phase.
54
+ const cfgPath = join(target, '.migrate', 'config.toml')
55
+ const cfg = await readFile(cfgPath, 'utf8')
56
+ await writeFile(
57
+ cfgPath,
58
+ cfg.replace(/^types = .*$/m, 'types = ["routes", "tables"]').replace(/^set = .*$/m, 'set = []'),
59
+ )
60
+
61
+ const elements = await write('elements.json', {
62
+ batch: 'b-1',
63
+ phase: 'enumerate',
64
+ rows: [
65
+ {
66
+ id: 'route-get-api-users',
67
+ surface: 'routes',
68
+ element: 'GET /api/users',
69
+ found_by: ['code'],
70
+ disposition: { kind: 'unaccounted' },
71
+ refs: [{ kind: 'src', path: 'app.js', lines: [8, 8] }],
72
+ lens: 'code',
73
+ notes: '',
74
+ },
75
+ {
76
+ id: 'route-post-api-users',
77
+ surface: 'routes',
78
+ element: 'POST /api/users',
79
+ found_by: ['code'],
80
+ disposition: { kind: 'unaccounted' },
81
+ refs: [{ kind: 'src', path: 'app.js', lines: [10, 14] }],
82
+ lens: 'code',
83
+ notes: '',
84
+ },
85
+ {
86
+ id: 'table-users',
87
+ surface: 'tables',
88
+ element: 'users',
89
+ found_by: ['code'],
90
+ disposition: { kind: 'unaccounted' },
91
+ refs: [{ kind: 'src', path: 'schema.sql', lines: [1, 5] }],
92
+ lens: 'code',
93
+ notes: '',
94
+ },
95
+ ],
96
+ })
97
+ expect((await migrate(['import', 'elements', elements])).code).toBe(0)
98
+
99
+ // Unaccounted elements mean the gate must fail here.
100
+ const beforeMapping = await migrate(['check'])
101
+ expect(beforeMapping.code).toBe(1)
102
+ expect(beforeMapping.out).toContain('0/3 mapped, 0 out-of-scope, 3 unaccounted')
103
+
104
+ // Every count below was produced by re-running the exact command in its own
105
+ // `evidence` string against fixtures/tiny-express, and every one of them
106
+ // counts the whole fixture rather than the three-row subset this test
107
+ // imports. That difference is the point: this test deliberately imports two
108
+ // of the fixture's three routes and one of its two tables, and the census's
109
+ // own `skipped` list is the mechanism for saying so. Declaring counts that
110
+ // matched the subset instead would mean quoting a probe command that does
111
+ // not produce them, which is the one thing this tool exists to make
112
+ // impossible -- and the balance rule (total == in_ledger + added + skipped +
113
+ // queued) then checks the reconciliation rather than leaving it asserted.
114
+ //
115
+ // The tables `code` direction that used to sit here ran `rg -n "users"
116
+ // app.js` and reported 1. app.js contains no SQL and no database access
117
+ // whatsoever (see the file): every one of its three matches is a `/api/users`
118
+ // URL path segment, so that direction evidenced zero real table references
119
+ // and passed only because the surface set is narrowed here. Replaced with
120
+ // the report definition's own query, which is a real read of a real table.
121
+ for (const record of [
122
+ {
123
+ kind: 'lens',
124
+ surface: 'routes',
125
+ phase: 'enumerate',
126
+ directions: {
127
+ // 3 matches: app.js lines 8, 10 and 18.
128
+ code: { count: 3, evidence: 'rg -n "app.(get|post)" app.js' },
129
+ nav: { count: 3, evidence: 'manual walk of route registrations in app.js' },
130
+ },
131
+ total: 3,
132
+ in_ledger: 2,
133
+ added: 0,
134
+ skipped: [
135
+ {
136
+ element: 'GET /api/users/:id/welcome',
137
+ reason: "out of this test's two-route slice; carried by e2e-express.test.ts",
138
+ },
139
+ ],
140
+ queued: [],
141
+ batch: 'b-1',
142
+ },
143
+ {
144
+ kind: 'lens',
145
+ surface: 'tables',
146
+ phase: 'enumerate',
147
+ directions: {
148
+ // 2 matches: schema.sql lines 1 (users) and 7 (audit_log).
149
+ ddl: { count: 2, evidence: 'rg -n "CREATE TABLE" schema.sql' },
150
+ // 1 match: reports/daily-users.json line 4, SELECT COUNT(*) FROM users.
151
+ query: { count: 1, evidence: 'rg -n "FROM [a-z_]+" reports/*.json' },
152
+ },
153
+ total: 2,
154
+ in_ledger: 1,
155
+ added: 0,
156
+ skipped: [
157
+ {
158
+ element: 'audit_log',
159
+ reason: "out of this test's one-table slice; carried by e2e-express.test.ts",
160
+ },
161
+ ],
162
+ queued: [],
163
+ batch: 'b-1',
164
+ },
165
+ ]) {
166
+ expect((await migrate(['census', await write(`c-${record.surface}.json`, record)])).code).toBe(
167
+ 0,
168
+ )
169
+ }
170
+
171
+ const caps = join(target, '.migrate', 'capabilities.jsonl')
172
+ await writeFile(
173
+ caps,
174
+ `${JSON.stringify({ slug: 'users', title: 'Users', ns: 'US', elements: [] })}\n`,
175
+ )
176
+
177
+ const reqs = await write('reqs.json', {
178
+ batch: 'b-2',
179
+ phase: 'extract',
180
+ rows: [
181
+ {
182
+ id: 'US-001',
183
+ cap: 'users',
184
+ requirement: 'List users',
185
+ actors: 'Client',
186
+ objects: 'User',
187
+ rules: '-',
188
+ origin: 'intended',
189
+ confidence: { kind: 'confirmed' },
190
+ citations: [
191
+ { kind: 'ledger', id: 'route-get-api-users' },
192
+ { kind: 'src', path: 'app.js', lines: [8, 8] },
193
+ ],
194
+ parity: { kind: 'rubric', level: 'high' },
195
+ },
196
+ {
197
+ id: 'US-002',
198
+ cap: 'users',
199
+ requirement: 'Create a user and persist it',
200
+ actors: 'Client',
201
+ objects: 'User',
202
+ rules: 'email is required',
203
+ origin: 'intended',
204
+ confidence: { kind: 'confirmed' },
205
+ citations: [
206
+ { kind: 'ledger', id: 'route-post-api-users' },
207
+ { kind: 'ledger', id: 'table-users' },
208
+ { kind: 'src', path: 'schema.sql', lines: [1, 5] },
209
+ ],
210
+ parity: { kind: 'rubric', level: 'high' },
211
+ },
212
+ ],
213
+ })
214
+ expect((await migrate(['import', 'reqs', reqs])).code).toBe(0)
215
+
216
+ // Map every element by rewriting dispositions through a re-import, which is
217
+ // the same path the extract phase uses.
218
+ const mapped = await write('mapped.json', {
219
+ batch: 'b-3',
220
+ phase: 'extract',
221
+ rows: [
222
+ {
223
+ id: 'route-get-api-users',
224
+ surface: 'routes',
225
+ element: 'GET /api/users',
226
+ found_by: ['code'],
227
+ disposition: { kind: 'mapped', fr: 'US-001' },
228
+ refs: [],
229
+ lens: 'code',
230
+ notes: '',
231
+ },
232
+ {
233
+ id: 'route-post-api-users',
234
+ surface: 'routes',
235
+ element: 'POST /api/users',
236
+ found_by: ['code'],
237
+ disposition: { kind: 'mapped', fr: 'US-002' },
238
+ refs: [],
239
+ lens: 'code',
240
+ notes: '',
241
+ },
242
+ {
243
+ id: 'table-users',
244
+ surface: 'tables',
245
+ element: 'users',
246
+ found_by: ['code'],
247
+ disposition: { kind: 'mapped', fr: 'US-002' },
248
+ refs: [],
249
+ lens: 'code',
250
+ notes: '',
251
+ },
252
+ ],
253
+ })
254
+ expect((await migrate(['import', 'elements', mapped])).code).toBe(0)
255
+
256
+ // Milestone 1 has no verbs for probe, seam, parity or queue: an
257
+ // orchestrator marks each done by hand once it decides there is nothing
258
+ // further to do there, the same way it would for enumerate and extract if
259
+ // recordBatch had not already moved them to 'running'. adjudicate and
260
+ // handoff are left pending on purpose, since neither has a verb at all
261
+ // yet, which is why the clean check below is bound to --phase queue
262
+ // rather than the full run.
263
+ for (const name of ['probe', 'enumerate', 'seam', 'extract', 'parity', 'queue']) {
264
+ expect((await migrate(['phase', name, '--status', 'done'])).code).toBe(0)
265
+ }
266
+
267
+ const clean = await migrate(['check', '--phase', 'queue', '--citations'])
268
+ expect(clean.out).toContain('3/3 mapped, 0 out-of-scope, 0 unaccounted')
269
+ expect(clean.code).toBe(0)
270
+
271
+ const report = await migrate(['report'])
272
+ expect(report.code).toBe(0)
273
+ const ledger = await readFile(join(target, 'docs', 'migrate', 'ledger.md'), 'utf8')
274
+ expect(ledger).toContain('mapped:US-002')
275
+
276
+ const status = await migrate(['status'])
277
+ expect(status.out).toContain('3/3 mapped')
278
+ expect(status.out).toContain('resume:')
279
+ })
280
+
281
+ test('a citation to a file absent from the fixture fails the citations gate', async () => {
282
+ await migrate(['init', '--source', source, '--scope', 'all', '--name', 'tiny-next'])
283
+ const cfgPath = join(target, '.migrate', 'config.toml')
284
+ const cfg = await readFile(cfgPath, 'utf8')
285
+ // Narrow both surfaces and closers to empty: this test imports no elements
286
+ // and records no census, so any declared surface or closer would fail the
287
+ // census gate regardless of the citations violation under test, leaving
288
+ // the exit code unable to discriminate a fixed citations bug from a
289
+ // still-broken one.
290
+ await writeFile(
291
+ cfgPath,
292
+ cfg.replace(/^types = .*$/m, 'types = []').replace(/^set = .*$/m, 'set = []'),
293
+ )
294
+ await writeFile(
295
+ join(target, '.migrate', 'capabilities.jsonl'),
296
+ `${JSON.stringify({ slug: 'users', title: 'Users', ns: 'US', elements: [] })}\n`,
297
+ )
298
+ const reqs = await write('reqs.json', {
299
+ batch: 'b-1',
300
+ phase: 'extract',
301
+ rows: [
302
+ {
303
+ id: 'US-001',
304
+ cap: 'users',
305
+ requirement: 'Invented',
306
+ actors: '-',
307
+ objects: '-',
308
+ rules: '-',
309
+ origin: 'intended',
310
+ confidence: { kind: 'confirmed' },
311
+ citations: [{ kind: 'src', path: 'does-not-exist.js', lines: [1, 2] }],
312
+ parity: { kind: 'rubric', level: 'high' },
313
+ },
314
+ ],
315
+ })
316
+ expect((await migrate(['import', 'reqs', reqs])).code).toBe(0)
317
+ const result = await migrate(['check', '--citations'])
318
+ expect(result.code).toBe(1)
319
+ expect(result.out).toContain('does-not-exist.js')
320
+ })
@@ -0,0 +1,38 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { idPrefixFor, isValidSlug, singularOf, validateElementId } from '../ids.ts'
3
+
4
+ test('singularOf strips a trailing s', () => {
5
+ expect(singularOf('routes', {})).toBe('route')
6
+ expect(singularOf('tables', {})).toBe('table')
7
+ expect(singularOf('jcl-jobs', {})).toBe('jcl-job')
8
+ })
9
+
10
+ test('singularOf leaves a word with no trailing s alone', () => {
11
+ expect(singularOf('data', {})).toBe('data')
12
+ })
13
+
14
+ test('singularOf prefers an explicit override', () => {
15
+ expect(singularOf('indices', { indices: 'index' })).toBe('index')
16
+ })
17
+
18
+ test('isValidSlug accepts lowercase kebab and digits, rejects the rest', () => {
19
+ expect(isValidSlug('get-api-users')).toBe(true)
20
+ expect(isValidSlug('roster-days-2')).toBe(true)
21
+ expect(isValidSlug('Get-Api')).toBe(false)
22
+ expect(isValidSlug('get_api')).toBe(false)
23
+ expect(isValidSlug('')).toBe(false)
24
+ expect(isValidSlug('-leading')).toBe(false)
25
+ })
26
+
27
+ test('validateElementId requires the surface prefix', () => {
28
+ expect(validateElementId('route-get-api-users', 'routes', {})).toBeNull()
29
+ expect(validateElementId('table-users', 'routes', {})).toContain('route-')
30
+ })
31
+
32
+ test('validateElementId rejects a bad slug after a good prefix', () => {
33
+ expect(validateElementId('route-Get_Api', 'routes', {})).toContain('slug')
34
+ })
35
+
36
+ test('idPrefixFor is the singular plus a hyphen', () => {
37
+ expect(idPrefixFor('integrations', {})).toBe('integration-')
38
+ })
@@ -0,0 +1,155 @@
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 { runImport } from '../import-cmd.ts'
7
+ import { storePaths } from '../paths.ts'
8
+ import { loadPhases } from '../phases.ts'
9
+ import { readRows } from '../store.ts'
10
+ import type { Element } from '../types.ts'
11
+
12
+ let root: string
13
+
14
+ beforeEach(async () => {
15
+ root = await mkdtemp(join(tmpdir(), 'migrate-import-'))
16
+ await mkdir(join(root, '.migrate'), { 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
+ async function batch(rows: unknown[], phase = 'enumerate', id = 'b-1'): Promise<string> {
25
+ const path = join(root, 'batch.json')
26
+ await writeFile(path, JSON.stringify({ batch: id, phase, rows }))
27
+ return path
28
+ }
29
+
30
+ const ELEMENT = {
31
+ id: 'route-get-api-users',
32
+ surface: 'routes',
33
+ element: 'GET /api/users',
34
+ found_by: ['code'],
35
+ disposition: { kind: 'unaccounted' },
36
+ refs: [{ kind: 'src', path: 'Controllers/UsersController.cs', lines: [42, 58] }],
37
+ lens: 'code',
38
+ notes: '',
39
+ }
40
+
41
+ test('a valid element batch lands in the store and records the batch', async () => {
42
+ const code = await runImport({ root, kind: 'elements', batchFile: await batch([ELEMENT]) })
43
+ expect(code).toBe(0)
44
+ const rows = await readRows<Element>(storePaths(root).elements)
45
+ expect(rows).toHaveLength(1)
46
+ expect(rows[0]?.batch).toBe('b-1')
47
+ const phases = await loadPhases(root)
48
+ expect(phases.enumerate.batches.map((b) => b.id)).toEqual(['b-1'])
49
+ })
50
+
51
+ test('an element whose id does not match its surface is rejected', async () => {
52
+ const bad = { ...ELEMENT, id: 'table-users' }
53
+ const code = await runImport({ root, kind: 'elements', batchFile: await batch([bad]) })
54
+ expect(code).toBe(1)
55
+ expect(await readRows(storePaths(root).elements)).toEqual([])
56
+ })
57
+
58
+ test('an element on an undeclared surface is rejected', async () => {
59
+ const bad = { ...ELEMENT, id: 'gizmo-a', surface: 'gizmos' }
60
+ const code = await runImport({ root, kind: 'elements', batchFile: await batch([bad]) })
61
+ expect(code).toBe(1)
62
+ })
63
+
64
+ test('a batch is all-or-nothing: one bad row writes none', async () => {
65
+ const bad = { ...ELEMENT, id: 'route-b', found_by: [] }
66
+ const code = await runImport({ root, kind: 'elements', batchFile: await batch([ELEMENT, bad]) })
67
+ expect(code).toBe(1)
68
+ expect(await readRows(storePaths(root).elements)).toEqual([])
69
+ })
70
+
71
+ test('re-importing the same batch does not duplicate rows', async () => {
72
+ await runImport({ root, kind: 'elements', batchFile: await batch([ELEMENT]) })
73
+ await runImport({ root, kind: 'elements', batchFile: await batch([ELEMENT]) })
74
+ expect(await readRows(storePaths(root).elements)).toHaveLength(1)
75
+ })
76
+
77
+ test('a requirement with no citations is rejected', async () => {
78
+ const req = {
79
+ id: 'UM-001',
80
+ cap: 'user-management',
81
+ requirement: 'User logs in',
82
+ actors: 'User',
83
+ objects: 'Credentials',
84
+ rules: '-',
85
+ origin: 'intended',
86
+ confidence: { kind: 'confirmed' },
87
+ citations: [],
88
+ parity: { kind: 'rubric', level: 'high' },
89
+ }
90
+ const code = await runImport({ root, kind: 'reqs', batchFile: await batch([req], 'extract') })
91
+ expect(code).toBe(1)
92
+ })
93
+
94
+ test('a sub-high rubric parity without a queue id is rejected', async () => {
95
+ const req = {
96
+ id: 'UM-002',
97
+ cap: 'user-management',
98
+ requirement: 'User logs out',
99
+ actors: 'User',
100
+ objects: 'Session',
101
+ rules: '-',
102
+ origin: 'intended',
103
+ confidence: { kind: 'confirmed' },
104
+ citations: [{ kind: 'ledger', id: 'route-get-api-users' }],
105
+ parity: { kind: 'rubric', level: 'moderate' },
106
+ }
107
+ const code = await runImport({ root, kind: 'reqs', batchFile: await batch([req], 'parity') })
108
+ expect(code).toBe(1)
109
+ })
110
+
111
+ test('a batch file whose top-level JSON is null is rejected, not thrown', async () => {
112
+ const path = join(root, 'null-batch.json')
113
+ await writeFile(path, 'null')
114
+ const code = await runImport({ root, kind: 'elements', batchFile: path })
115
+ expect(code).toBe(2)
116
+ expect(await readRows(storePaths(root).elements)).toEqual([])
117
+ })
118
+
119
+ test('a duplicate id within one batch is rejected and nothing is written', async () => {
120
+ const dup = { ...ELEMENT, element: 'GET /api/users, second copy' }
121
+ const code = await runImport({ root, kind: 'elements', batchFile: await batch([ELEMENT, dup]) })
122
+ expect(code).toBe(1)
123
+ expect(await readRows(storePaths(root).elements)).toEqual([])
124
+ })
125
+
126
+ test('a syntactically malformed batch file is rejected with a clean diagnostic', async () => {
127
+ const path = join(root, 'malformed.json')
128
+ await writeFile(path, '{ this is not json')
129
+ const code = await runImport({ root, kind: 'elements', batchFile: path })
130
+ expect(code).toBe(2)
131
+ expect(await readRows(storePaths(root).elements)).toEqual([])
132
+ })
133
+
134
+ test('a batch file that does not exist is rejected with a clean diagnostic', async () => {
135
+ const path = join(root, 'does-not-exist.json')
136
+ const code = await runImport({ root, kind: 'elements', batchFile: path })
137
+ expect(code).toBe(2)
138
+ expect(await readRows(storePaths(root).elements)).toEqual([])
139
+ })
140
+
141
+ // Critical finding 1 (shared with census-cmd.ts and queue-cmd.ts): a store
142
+ // whose configured source.path resolves to include the store's own target
143
+ // path (e.g. source.path pointing at the project root itself, which the
144
+ // store also lives under) must not crash. assertNotUnderSource throws by
145
+ // design (writeRows's own contract, see store.test.ts); import-cmd.ts must
146
+ // catch that throw and report a clean diagnostic with a deliberate exit
147
+ // code instead of letting it escape as an uncaught stack trace. See the
148
+ // report for the full argument for exit code 2 over 1.
149
+
150
+ test('import refuses to write when the store sits inside its own configured source tree, as a clean usage error (2), not a crash', async () => {
151
+ await writeConfig(root, { sourcePath: root, scope: 'all', targetName: 'newapp' })
152
+ const code = await runImport({ root, kind: 'elements', batchFile: await batch([ELEMENT]) })
153
+ expect(code).toBe(2)
154
+ expect(await readRows(storePaths(root).elements)).toEqual([])
155
+ })