@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,458 @@
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 { runCheck } from '../check.ts'
6
+ import { writeConfig } from '../config.ts'
7
+ import { storePaths } from '../paths.ts'
8
+ import { savePhases } from '../phases.ts'
9
+ import { writeRows } from '../store.ts'
10
+ import type { Census, Element, Requirement } from '../types.ts'
11
+
12
+ let root: string
13
+ let source: string
14
+
15
+ const SURFACES = ['routes', 'tables']
16
+
17
+ beforeEach(async () => {
18
+ root = await mkdtemp(join(tmpdir(), 'migrate-check-'))
19
+ source = join(root, 'legacy')
20
+ await mkdir(join(root, '.migrate', 'queue'), { recursive: true })
21
+ await mkdir(source, { recursive: true })
22
+ await writeConfig(root, { sourcePath: source, scope: 'all', targetName: 'newapp' })
23
+ // Narrow the declared sets so the fixture only has to satisfy two surfaces
24
+ // and no closers.
25
+ const cfgPath = storePaths(root).config
26
+ const text = await Bun.file(cfgPath).text()
27
+ await writeFile(
28
+ cfgPath,
29
+ text
30
+ .replace(/^types = .*$/m, `types = ${JSON.stringify(SURFACES)}`)
31
+ .replace(/^set = .*$/m, 'set = []'),
32
+ )
33
+ })
34
+
35
+ afterEach(async () => {
36
+ await rm(root, { recursive: true, force: true })
37
+ })
38
+
39
+ const ELEMENT: Element = {
40
+ id: 'route-get-api-users',
41
+ surface: 'routes',
42
+ element: 'GET /api/users',
43
+ found_by: ['code'],
44
+ disposition: { kind: 'mapped', fr: 'UM-001' },
45
+ refs: [],
46
+ lens: 'code',
47
+ batch: 'b-1',
48
+ notes: '',
49
+ }
50
+
51
+ const REQUIREMENT: Requirement = {
52
+ id: 'UM-001',
53
+ cap: 'user-management',
54
+ requirement: 'List users',
55
+ actors: 'User',
56
+ objects: 'User',
57
+ rules: '-',
58
+ origin: 'intended',
59
+ confidence: { kind: 'confirmed' },
60
+ citations: [{ kind: 'ledger', id: 'route-get-api-users' }],
61
+ parity: { kind: 'rubric', level: 'high' },
62
+ batch: 'b-1',
63
+ }
64
+
65
+ function lensCensus(surface: string, total: number, inLedger: number): Census {
66
+ return {
67
+ kind: 'lens',
68
+ surface,
69
+ phase: 'enumerate',
70
+ directions: {
71
+ code: { count: total, evidence: `rg -n "${surface}" app.js | wc -l` },
72
+ nav: { count: total, evidence: 'manual review of route registrations' },
73
+ },
74
+ total,
75
+ in_ledger: inLedger,
76
+ added: 0,
77
+ skipped: [],
78
+ queued: [],
79
+ batch: 'b-1',
80
+ }
81
+ }
82
+
83
+ async function seedClean(): Promise<void> {
84
+ const p = storePaths(root)
85
+ await writeRows(p.elements, [ELEMENT], source)
86
+ await writeRows(p.requirements, [REQUIREMENT], source)
87
+ await writeRows(
88
+ p.capabilities,
89
+ [{ slug: 'user-management', title: 'Users', ns: 'UM', elements: [] }],
90
+ source,
91
+ )
92
+ await writeRows(p.census, [lensCensus('routes', 1, 1), lensCensus('tables', 0, 0)], source)
93
+ }
94
+
95
+ // The run-state gate now checks whether the run actually happened, so a
96
+ // store seeded by writeRows alone (bypassing import/census's recordBatch)
97
+ // carries no evidence that probe or enumerate ran. Advancing phase state
98
+ // directly with savePhases (rather than via the CLI, since this suite talks
99
+ // to runCheck's own API) and bounding the check at --phase enumerate keeps
100
+ // this test about the nine other gates: it only has to prove enough phase
101
+ // history exists for run-state to agree the census batch it seeded was
102
+ // committed, not simulate the rest of a Milestone 2 run this fixture never
103
+ // performs.
104
+ async function markThroughEnumerate(): Promise<void> {
105
+ await savePhases(
106
+ root,
107
+ {
108
+ probe: { status: 'done', batches: [], pending: [] },
109
+ enumerate: { status: 'done', batches: [{ id: 'b-1', count: 1 }], pending: [] },
110
+ seam: { status: 'pending', batches: [], pending: [] },
111
+ extract: { status: 'pending', batches: [], pending: [] },
112
+ parity: { status: 'pending', batches: [], pending: [] },
113
+ queue: { status: 'pending', batches: [], pending: [] },
114
+ adjudicate: { status: 'pending', batches: [], pending: [] },
115
+ handoff: { status: 'pending', batches: [], pending: [] },
116
+ },
117
+ source,
118
+ )
119
+ }
120
+
121
+ test('a clean store passes with the summary line', async () => {
122
+ await seedClean()
123
+ await markThroughEnumerate()
124
+ const result = await runCheck({ root, phase: 'enumerate' })
125
+ expect(result.violations).toEqual([])
126
+ expect(result.summary).toBe('1/1 mapped, 0 out-of-scope, 0 unaccounted')
127
+ })
128
+
129
+ test('an unaccounted element is a coverage violation naming its id', async () => {
130
+ await seedClean()
131
+ await writeRows(
132
+ storePaths(root).elements,
133
+ [{ ...ELEMENT, disposition: { kind: 'unaccounted' } }],
134
+ source,
135
+ )
136
+ const result = await runCheck({ root })
137
+ expect(result.summary).toBe('0/1 mapped, 0 out-of-scope, 1 unaccounted')
138
+ const coverage = result.violations.filter((v) => v.gate === 'coverage')
139
+ expect(coverage).toHaveLength(1)
140
+ expect(coverage[0]?.message).toContain('route-get-api-users')
141
+ })
142
+
143
+ // Important finding 2: balanceOf only checks a census record's own numbers
144
+ // against each other; nothing tied in_ledger + added to elements.jsonl
145
+ // itself, so a lens record could claim any in_ledger/added split it liked
146
+ // as long as it balanced internally. `total` is self-reported and cannot be
147
+ // checked against anything, but in_ledger + added is a concrete claim about
148
+ // how many rows now exist in the ledger for that surface, and that claim is
149
+ // directly countable against elements.jsonl.
150
+ test('a lens census claiming elements that were never added to the ledger is a census violation', async () => {
151
+ await seedClean()
152
+ // Balances on its own (50 = 50 + 0 + 0 + 0) but elements.jsonl holds zero
153
+ // rows for 'tables': the padding surface the report describes verbatim.
154
+ await writeRows(
155
+ storePaths(root).census,
156
+ [lensCensus('routes', 1, 1), { ...lensCensus('tables', 50, 50) }],
157
+ source,
158
+ )
159
+ const result = await runCheck({ root })
160
+ const census = result.violations.filter((v) => v.gate === 'census')
161
+ expect(census).toHaveLength(1)
162
+ expect(census[0]?.message).toContain('tables')
163
+ expect(census[0]?.message).toContain('50')
164
+ })
165
+
166
+ test('a lens census whose in_ledger + added matches the real ledger count passes', async () => {
167
+ await seedClean()
168
+ // routes: in_ledger 1 + added 0 = 1, and exactly one 'routes' element
169
+ // exists in the seeded store, so this must not be flagged.
170
+ const result = await runCheck({ root })
171
+ const census = result.violations.filter((v) => v.gate === 'census')
172
+ expect(census).toEqual([])
173
+ })
174
+
175
+ test('a declared surface with no census record is a census violation', async () => {
176
+ await seedClean()
177
+ await writeRows(storePaths(root).census, [lensCensus('routes', 1, 1)], source)
178
+ const result = await runCheck({ root })
179
+ const census = result.violations.filter((v) => v.gate === 'census')
180
+ expect(census).toHaveLength(1)
181
+ expect(census[0]?.message).toContain('tables')
182
+ })
183
+
184
+ // Review round 1, Finding 1: census.jsonl is a store file readRows only
185
+ // asserts a type onto; nothing checked its shape at gate time before this,
186
+ // so a hand-edited row that never passed through census-cmd.ts's
187
+ // validateCensus call could sit there silently. A single-direction lens
188
+ // record satisfies boundsOf's arithmetic trivially (max == sum == total
189
+ // when there is exactly one direction), and an old-shape record turns
190
+ // boundsOf's max/sum into NaN, against which every comparison is false: both
191
+ // reached exit 0 before this fix. This is the test whose absence let that
192
+ // hole through, so it writes census.jsonl by hand rather than through
193
+ // writeRows, and checks both rows are named by line and by the reason
194
+ // validateCensus rejects each of them.
195
+ //
196
+ // Review round 2: the first version of this test asserted with .some(...),
197
+ // which only proves the expected violations are present and says nothing
198
+ // about extra ones. It missed that the original fix skipped both rows'
199
+ // entire loop iteration, so neither 'routes' nor 'tables' was ever
200
+ // registered in surfacesWithCensus, and gate 2 additionally (and wrongly)
201
+ // reported both as having no lens census record at all -- wrong because the
202
+ // lens did run and did write a record; only its shape is defective. Tightened
203
+ // to assert the exact violation count, so a regression that reintroduces
204
+ // those two extra, misleading violations fails this test instead of passing
205
+ // it.
206
+ test('a hand-edited census.jsonl with a single-direction row and an old-shape row is named by line, not silently passed through', async () => {
207
+ await seedClean()
208
+ const singleDirection = {
209
+ kind: 'lens',
210
+ surface: 'routes',
211
+ phase: 'enumerate',
212
+ directions: { code: { count: 1, evidence: 'rg -n "app.(get|post)" app.js' } },
213
+ total: 1,
214
+ in_ledger: 1,
215
+ added: 0,
216
+ skipped: [],
217
+ queued: [],
218
+ batch: 'b-1',
219
+ }
220
+ const oldShape = {
221
+ kind: 'lens',
222
+ surface: 'tables',
223
+ phase: 'enumerate',
224
+ directions: { ddl: 1, orm: 1 },
225
+ total: 1,
226
+ in_ledger: 1,
227
+ added: 0,
228
+ skipped: [],
229
+ queued: [],
230
+ batch: 'b-1',
231
+ }
232
+ await writeFile(
233
+ storePaths(root).census,
234
+ `${JSON.stringify(singleDirection)}\n${JSON.stringify(oldShape)}\n`,
235
+ )
236
+ const result = await runCheck({ root })
237
+ const census = result.violations.filter((v) => v.gate === 'census')
238
+ // Exactly three: the single-direction row's one shape error, plus the
239
+ // old-shape row's two bare-count errors (one per direction). Nothing else:
240
+ // in particular, no "has no lens census record" for either surface, since
241
+ // both rows named a real surface and a real kind, even though both were
242
+ // otherwise malformed.
243
+ expect(census).toHaveLength(3)
244
+ expect(
245
+ census.some(
246
+ (v) =>
247
+ v.message.includes('line 1') && v.message.includes('at least two independent directions'),
248
+ ),
249
+ ).toBe(true)
250
+ expect(
251
+ census.filter(
252
+ (v) => v.message.includes('line 2') && v.message.includes('old bare-count shape'),
253
+ ),
254
+ ).toHaveLength(2)
255
+ expect(census.some((v) => v.message.includes('has no lens census record'))).toBe(false)
256
+ })
257
+
258
+ // Review round 2: pins the guard on the recovery above. A row with no kind
259
+ // at all (not merely the wrong kind) cannot claim any surface, so unlike the
260
+ // test above, both declared surfaces must still be reported missing here --
261
+ // 'routes' because the one row that names it cannot claim to be its lens
262
+ // census, and 'tables' because no row names it at all.
263
+ test('a census row with no kind cannot claim a surface, so both declared surfaces are still reported missing', async () => {
264
+ await seedClean()
265
+ const noKind = {
266
+ surface: 'routes',
267
+ phase: 'enumerate',
268
+ directions: {
269
+ code: { count: 1, evidence: 'rg -n "app.(get|post)" app.js' },
270
+ nav: { count: 1, evidence: 'manual review of route registrations' },
271
+ },
272
+ total: 1,
273
+ in_ledger: 1,
274
+ added: 0,
275
+ skipped: [],
276
+ queued: [],
277
+ batch: 'b-1',
278
+ }
279
+ await writeFile(storePaths(root).census, `${JSON.stringify(noKind)}\n`)
280
+ const result = await runCheck({ root })
281
+ const census = result.violations.filter((v) => v.gate === 'census')
282
+ expect(
283
+ census.some((v) => v.message.includes('line 1') && v.message.includes('unknown kind')),
284
+ ).toBe(true)
285
+ expect(
286
+ census.some(
287
+ (v) => v.message.includes('routes') && v.message.includes('has no lens census record'),
288
+ ),
289
+ ).toBe(true)
290
+ expect(
291
+ census.some(
292
+ (v) => v.message.includes('tables') && v.message.includes('has no lens census record'),
293
+ ),
294
+ ).toBe(true)
295
+ })
296
+
297
+ test('a mapped disposition pointing at no requirement is a refs violation', async () => {
298
+ await seedClean()
299
+ await writeRows(
300
+ storePaths(root).elements,
301
+ [{ ...ELEMENT, disposition: { kind: 'mapped', fr: 'UM-999' } }],
302
+ source,
303
+ )
304
+ const result = await runCheck({ root })
305
+ const refs = result.violations.filter((v) => v.gate === 'refs')
306
+ expect(refs.some((v) => v.message.includes('UM-999'))).toBe(true)
307
+ })
308
+
309
+ test('a queued confidence with no queue file is a refs violation', async () => {
310
+ await seedClean()
311
+ await writeRows(
312
+ storePaths(root).requirements,
313
+ [{ ...REQUIREMENT, confidence: { kind: 'queued', queue: 'q-missing' }, parity: null }],
314
+ source,
315
+ )
316
+ const result = await runCheck({ root })
317
+ expect(result.violations.some((v) => v.gate === 'refs' && v.message.includes('q-missing'))).toBe(
318
+ true,
319
+ )
320
+ })
321
+
322
+ test('a requirement with no parity plan is a parity violation', async () => {
323
+ await seedClean()
324
+ await writeRows(storePaths(root).requirements, [{ ...REQUIREMENT, parity: null }], source)
325
+ const result = await runCheck({ root })
326
+ expect(result.violations.some((v) => v.gate === 'parity' && v.message.includes('UM-001'))).toBe(
327
+ true,
328
+ )
329
+ })
330
+
331
+ test('an unsigned delta is a deltas violation', async () => {
332
+ await seedClean()
333
+ await writeRows(
334
+ storePaths(root).deltas,
335
+ [
336
+ {
337
+ id: 'delta-multi-tenancy',
338
+ scope: 'all tables',
339
+ rationale: 'SaaS',
340
+ parity_exclusion: 'ignore tenant id',
341
+ validation: 'leak tests',
342
+ owner_signed: null,
343
+ batch: 'b-1',
344
+ },
345
+ ],
346
+ source,
347
+ )
348
+ const result = await runCheck({ root })
349
+ expect(
350
+ result.violations.some((v) => v.gate === 'deltas' && v.message.includes('delta-multi-tenancy')),
351
+ ).toBe(true)
352
+ })
353
+
354
+ test('citations run by default and are only skipped when explicitly disabled', async () => {
355
+ await seedClean()
356
+ await writeRows(
357
+ storePaths(root).requirements,
358
+ [{ ...REQUIREMENT, citations: [{ kind: 'src', path: 'Ghost.cs' }] }],
359
+ source,
360
+ )
361
+ const byDefault = await runCheck({ root })
362
+ expect(byDefault.violations.some((v) => v.gate === 'citations')).toBe(true)
363
+ const disabled = await runCheck({ root, citations: false })
364
+ expect(disabled.violations.filter((v) => v.gate === 'citations')).toEqual([])
365
+ })
366
+
367
+ test('a dirty source checkout is a source violation', async () => {
368
+ await seedClean()
369
+ await Bun.spawn(['git', 'init', '-q'], { cwd: source }).exited
370
+ await writeFile(join(source, 'dirty.txt'), 'uncommitted\n')
371
+ const result = await runCheck({ root })
372
+ expect(result.violations.some((v) => v.gate === 'source')).toBe(true)
373
+ })
374
+
375
+ // Review round: the refs gate must catch duplicate identities on its own,
376
+ // not assume some other gate (or another command) already ruled them out.
377
+ // `capabilities.jsonl` in particular has no import path at all yet, so
378
+ // hand-editing is currently the only way a row lands there, making a
379
+ // duplicate slug directly reachable today, not a hypothetical.
380
+
381
+ test('two requirements sharing an id both survive but are flagged as a refs violation naming the id and count', async () => {
382
+ await seedClean()
383
+ const dup: Requirement = { ...REQUIREMENT, requirement: 'List users, second copy' }
384
+ await writeRows(storePaths(root).requirements, [REQUIREMENT, dup], source)
385
+ const result = await runCheck({ root })
386
+ const refs = result.violations.filter((v) => v.gate === 'refs')
387
+ expect(
388
+ refs.some((v) => v.message.includes('UM-001') && v.message.includes('appears 2 times')),
389
+ ).toBe(true)
390
+ })
391
+
392
+ test('two capabilities sharing a slug are flagged as a refs violation naming the slug and count', async () => {
393
+ await seedClean()
394
+ await writeRows(
395
+ storePaths(root).capabilities,
396
+ [
397
+ { slug: 'user-management', title: 'Users', ns: 'UM', elements: [] },
398
+ { slug: 'user-management', title: 'Users (duplicate)', ns: 'UM', elements: [] },
399
+ ],
400
+ source,
401
+ )
402
+ const result = await runCheck({ root })
403
+ const refs = result.violations.filter((v) => v.gate === 'refs')
404
+ expect(
405
+ refs.some(
406
+ (v) => v.message.includes('user-management') && v.message.includes('appears 2 times'),
407
+ ),
408
+ ).toBe(true)
409
+ })
410
+
411
+ test('two elements sharing an id are flagged as a refs violation naming the id and count', async () => {
412
+ await seedClean()
413
+ const dup: Element = { ...ELEMENT, element: 'GET /api/users, second copy' }
414
+ await writeRows(storePaths(root).elements, [ELEMENT, dup], source)
415
+ const result = await runCheck({ root })
416
+ const refs = result.violations.filter((v) => v.gate === 'refs')
417
+ expect(
418
+ refs.some(
419
+ (v) => v.message.includes('route-get-api-users') && v.message.includes('appears 2 times'),
420
+ ),
421
+ ).toBe(true)
422
+ })
423
+
424
+ test('ids that merely share a prefix are not confused with duplicates', async () => {
425
+ await seedClean()
426
+ const other: Requirement = {
427
+ ...REQUIREMENT,
428
+ id: 'UM-0010',
429
+ citations: [{ kind: 'ledger', id: 'route-get-api-users' }],
430
+ }
431
+ await writeRows(storePaths(root).requirements, [REQUIREMENT, other], source)
432
+ const result = await runCheck({ root })
433
+ expect(result.violations.filter((v) => v.gate === 'refs')).toEqual([])
434
+ })
435
+
436
+ test('two citations of the same missing queue id on one requirement produce two distinguishable refs violations', async () => {
437
+ await seedClean()
438
+ await writeRows(
439
+ storePaths(root).requirements,
440
+ [
441
+ {
442
+ ...REQUIREMENT,
443
+ confidence: { kind: 'queued', queue: 'q-missing' },
444
+ parity: { kind: 'rubric', level: 'moderate', queue: 'q-missing' },
445
+ },
446
+ ],
447
+ source,
448
+ )
449
+ const result = await runCheck({ root })
450
+ const refs = result.violations.filter((v) => v.gate === 'refs' && v.message.includes('q-missing'))
451
+ expect(refs).toHaveLength(2)
452
+ // Not deduplicated: both citations are real and both need fixing, so the
453
+ // two violations must remain distinct entries with distinct messages,
454
+ // each naming which field it came from.
455
+ expect(new Set(refs.map((v) => v.message)).size).toBe(2)
456
+ expect(refs.some((v) => v.message.includes('confidence.queue'))).toBe(true)
457
+ expect(refs.some((v) => v.message.includes('parity.queue'))).toBe(true)
458
+ })
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { resolveCitations } from '../citations.ts'
6
+ import type { Requirement } from '../types.ts'
7
+
8
+ let source: string
9
+
10
+ beforeEach(async () => {
11
+ source = await mkdtemp(join(tmpdir(), 'migrate-src-'))
12
+ await mkdir(join(source, 'Controllers'), { recursive: true })
13
+ await writeFile(
14
+ join(source, 'Controllers', 'Auth.cs'),
15
+ Array.from({ length: 50 }, (_, i) => `line ${i + 1}`).join('\n'),
16
+ )
17
+ })
18
+
19
+ afterEach(async () => {
20
+ await rm(source, { recursive: true, force: true })
21
+ })
22
+
23
+ function req(citations: Requirement['citations']): Requirement {
24
+ return {
25
+ id: 'UM-001',
26
+ cap: 'user-management',
27
+ requirement: 'User logs in',
28
+ actors: 'User',
29
+ objects: 'Credentials',
30
+ rules: '-',
31
+ origin: 'intended',
32
+ confidence: { kind: 'confirmed' },
33
+ citations,
34
+ parity: { kind: 'rubric', level: 'high' },
35
+ batch: 'b-1',
36
+ }
37
+ }
38
+
39
+ test('a src citation inside the file passes', async () => {
40
+ const v = await resolveCitations(
41
+ [req([{ kind: 'src', path: 'Controllers/Auth.cs', lines: [20, 35] }])],
42
+ source,
43
+ )
44
+ expect(v).toEqual([])
45
+ })
46
+
47
+ test('a src citation to a missing file is a violation naming the path', async () => {
48
+ const v = await resolveCitations([req([{ kind: 'src', path: 'Controllers/Ghost.cs' }])], source)
49
+ expect(v).toHaveLength(1)
50
+ expect(v[0]?.message).toContain('Controllers/Ghost.cs')
51
+ expect(v[0]?.gate).toBe('citations')
52
+ })
53
+
54
+ test('a line range past the end of the file is a violation', async () => {
55
+ const v = await resolveCitations(
56
+ [req([{ kind: 'src', path: 'Controllers/Auth.cs', lines: [45, 900] }])],
57
+ source,
58
+ )
59
+ expect(v).toHaveLength(1)
60
+ expect(v[0]?.message).toContain('900')
61
+ expect(v[0]?.message).toContain('50')
62
+ })
63
+
64
+ test('a ledger citation is not resolved against the source tree', async () => {
65
+ const v = await resolveCitations([req([{ kind: 'ledger', id: 'route-post-api-login' }])], source)
66
+ expect(v).toEqual([])
67
+ })
68
+
69
+ test('a citation escaping the source root is a violation', async () => {
70
+ const v = await resolveCitations([req([{ kind: 'src', path: '../outside.cs' }])], source)
71
+ expect(v).toHaveLength(1)
72
+ expect(v[0]?.message).toContain('outside the source tree')
73
+ })
74
+
75
+ test('an inverted line range is a violation', async () => {
76
+ const v = await resolveCitations(
77
+ [req([{ kind: 'src', path: 'Controllers/Auth.cs', lines: [30, 10] }])],
78
+ source,
79
+ )
80
+ expect(v).toHaveLength(1)
81
+ expect(v[0]?.message).toContain('inverted')
82
+ expect(v[0]?.gate).toBe('citations')
83
+ })
84
+
85
+ test('a symlink inside source tree pointing outside is a violation', async () => {
86
+ const outside = await mkdtemp(join(tmpdir(), 'migrate-out-'))
87
+ try {
88
+ const outside_file = join(outside, 'outside.cs')
89
+ await writeFile(outside_file, 'outside content')
90
+ const link_path = join(source, 'link.cs')
91
+ try {
92
+ await symlink(outside_file, link_path)
93
+ } catch {
94
+ // Symlinks not supported on this platform, skip
95
+ return
96
+ }
97
+ const v = await resolveCitations([req([{ kind: 'src', path: 'link.cs' }])], source)
98
+ expect(v).toHaveLength(1)
99
+ expect(v[0]?.message).toContain('outside the source tree')
100
+ } finally {
101
+ await rm(outside, { recursive: true, force: true })
102
+ }
103
+ })
104
+
105
+ test('an absolute path is rejected', async () => {
106
+ const v = await resolveCitations([req([{ kind: 'src', path: '/absolute/path.cs' }])], source)
107
+ expect(v).toHaveLength(1)
108
+ expect(v[0]?.message).toContain('absolute path')
109
+ expect(v[0]?.gate).toBe('citations')
110
+ })
111
+
112
+ test('a file with no trailing newline passes', async () => {
113
+ await writeFile(join(source, 'NoNewline.cs'), 'line 1\nline 2')
114
+ const v = await resolveCitations(
115
+ [req([{ kind: 'src', path: 'NoNewline.cs', lines: [1, 2] }])],
116
+ source,
117
+ )
118
+ expect(v).toEqual([])
119
+ })
120
+
121
+ test('a path with spaces passes', async () => {
122
+ await writeFile(join(source, 'File With Spaces.cs'), 'line 1')
123
+ const v = await resolveCitations(
124
+ [req([{ kind: 'src', path: 'File With Spaces.cs', lines: [1, 1] }])],
125
+ source,
126
+ )
127
+ expect(v).toEqual([])
128
+ })
129
+
130
+ test('a path with unicode passes', async () => {
131
+ await writeFile(join(source, '文件.cs'), 'line 1')
132
+ const v = await resolveCitations([req([{ kind: 'src', path: '文件.cs', lines: [1, 1] }])], source)
133
+ expect(v).toEqual([])
134
+ })
135
+
136
+ test('a path with leading ./ passes', async () => {
137
+ const v = await resolveCitations(
138
+ [req([{ kind: 'src', path: './Controllers/Auth.cs', lines: [1, 10] }])],
139
+ source,
140
+ )
141
+ expect(v).toEqual([])
142
+ })
143
+
144
+ test('a one-line file cited [1,1] passes', async () => {
145
+ await writeFile(join(source, 'OneLine.cs'), 'single line')
146
+ const v = await resolveCitations(
147
+ [req([{ kind: 'src', path: 'OneLine.cs', lines: [1, 1] }])],
148
+ source,
149
+ )
150
+ expect(v).toEqual([])
151
+ })
152
+
153
+ test('a src ref with no lines passes', async () => {
154
+ const v = await resolveCitations([req([{ kind: 'src', path: 'Controllers/Auth.cs' }])], source)
155
+ expect(v).toEqual([])
156
+ })