@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,775 @@
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 { balanceOf, boundsOf, validateCensus } from '../census.ts'
6
+ import { runCensus } from '../census-cmd.ts'
7
+ import { writeConfig } from '../config.ts'
8
+ import { storePaths } from '../paths.ts'
9
+ import { readRows } from '../store.ts'
10
+ import type { Census } from '../types.ts'
11
+
12
+ let root: string
13
+
14
+ beforeEach(async () => {
15
+ root = await mkdtemp(join(tmpdir(), 'migrate-census-'))
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
+ const LENS: Census = {
25
+ kind: 'lens',
26
+ surface: 'tables',
27
+ phase: 'enumerate',
28
+ directions: {
29
+ ddl: { count: 43, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
30
+ orm: { count: 40, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
31
+ },
32
+ total: 45,
33
+ in_ledger: 44,
34
+ added: 1,
35
+ skipped: [],
36
+ queued: [],
37
+ batch: 'b-1',
38
+ }
39
+
40
+ test('a balanced lens census passes', () => {
41
+ expect(balanceOf(LENS)).toBeNull()
42
+ })
43
+
44
+ test('an unbalanced lens census names the arithmetic', () => {
45
+ const msg = balanceOf({ ...LENS, total: 46 })
46
+ expect(msg).toContain('46')
47
+ expect(msg).toContain('tables')
48
+ })
49
+
50
+ test('skipped and queued count toward the lens total', () => {
51
+ const balanced: Census = {
52
+ ...LENS,
53
+ total: 47,
54
+ skipped: [{ element: '__EFMigrationsHistory', reason: 'framework-owned' }],
55
+ queued: ['q-table-ownership'],
56
+ }
57
+ expect(balanceOf(balanced)).toBeNull()
58
+ })
59
+
60
+ test('attribute balance is explained plus queued equals behavioral', () => {
61
+ const base: Census = {
62
+ kind: 'attribute',
63
+ surface: 'tables',
64
+ subject: 'table-roster-days',
65
+ phase: 'enumerate',
66
+ directions: {
67
+ ddl: { count: 14, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
68
+ entity: { count: 13, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
69
+ },
70
+ total: 15,
71
+ behavioral: 7,
72
+ explained: 6,
73
+ queued: ['q-ros-007'],
74
+ batch: 'b-2',
75
+ }
76
+ expect(balanceOf(base)).toBeNull()
77
+ expect(balanceOf({ ...base, explained: 5 })).toContain('behavioral')
78
+ })
79
+
80
+ test('rule-sweep balance is found equals requirements plus queued', () => {
81
+ const base: Census = {
82
+ kind: 'rule-sweep',
83
+ subject: 'user-management',
84
+ phase: 'extract',
85
+ probes: 4,
86
+ found: 2,
87
+ as_requirements: 2,
88
+ queued: [],
89
+ batch: 'b-3',
90
+ }
91
+ expect(balanceOf(base)).toBeNull()
92
+ expect(balanceOf({ ...base, found: 3 })).toContain('found')
93
+ })
94
+
95
+ test('closer balance is findings equals fixed plus queued', () => {
96
+ const base: Census = {
97
+ kind: 'closer',
98
+ closer: 'read-write-symmetry',
99
+ phase: 'extract',
100
+ checked: 34,
101
+ findings: 3,
102
+ fixed: 2,
103
+ queued: ['q-sym-001'],
104
+ batch: 'b-4',
105
+ }
106
+ expect(balanceOf(base)).toBeNull()
107
+ expect(balanceOf({ ...base, fixed: 1 })).toContain('findings')
108
+ })
109
+
110
+ test('runCensus refuses to record an unbalanced record', async () => {
111
+ const file = join(root, 'c.json')
112
+ await writeFile(file, JSON.stringify({ ...LENS, total: 99 }))
113
+ expect(await runCensus({ root, file })).toBe(1)
114
+ expect(await readRows(storePaths(root).census)).toEqual([])
115
+ })
116
+
117
+ test('runCensus records a balanced record', async () => {
118
+ const file = join(root, 'c.json')
119
+ await writeFile(file, JSON.stringify(LENS))
120
+ expect(await runCensus({ root, file })).toBe(0)
121
+ expect(await readRows<Census>(storePaths(root).census)).toHaveLength(1)
122
+ })
123
+
124
+ test('re-recording the same subject replaces its record rather than stacking', async () => {
125
+ const file = join(root, 'c.json')
126
+ await writeFile(file, JSON.stringify(LENS))
127
+ expect(await runCensus({ root, file })).toBe(0)
128
+ const updated: Census = { ...LENS, in_ledger: 43, added: 2 }
129
+ await writeFile(file, JSON.stringify(updated))
130
+ expect(await runCensus({ root, file })).toBe(0)
131
+ const rows = await readRows<Census>(storePaths(root).census)
132
+ expect(rows).toHaveLength(1)
133
+ expect(rows[0]).toEqual(updated)
134
+ })
135
+
136
+ // Correction 1: a null, non-object, string, number or array row must come
137
+ // back from validateCensus as a normal { ok: false, errors } result and must
138
+ // never throw. Task 7's validate.ts hit this exact defect with a null row
139
+ // dumping a raw stack trace; census.ts must not repeat it.
140
+
141
+ test('validateCensus rejects a null row instead of throwing', () => {
142
+ const result = validateCensus(null)
143
+ expect(result.ok).toBe(false)
144
+ if (!result.ok) expect(result.errors.join(' ')).toContain('object')
145
+ })
146
+
147
+ test('validateCensus rejects a string row instead of throwing', () => {
148
+ const result = validateCensus('not a row')
149
+ expect(result.ok).toBe(false)
150
+ if (!result.ok) expect(result.errors.join(' ')).toContain('object')
151
+ })
152
+
153
+ test('validateCensus rejects a number row instead of throwing', () => {
154
+ const result = validateCensus(42)
155
+ expect(result.ok).toBe(false)
156
+ if (!result.ok) expect(result.errors.join(' ')).toContain('object')
157
+ })
158
+
159
+ test('validateCensus rejects an array row instead of throwing', () => {
160
+ const result = validateCensus([1, 2, 3])
161
+ expect(result.ok).toBe(false)
162
+ if (!result.ok) expect(result.errors.join(' ')).toContain('object')
163
+ })
164
+
165
+ test('runCensus refuses a file whose top-level JSON is null, not thrown', async () => {
166
+ const file = join(root, 'null.json')
167
+ await writeFile(file, 'null')
168
+ // A null row is not shaped like a census record at all (no envelope exists
169
+ // to distinguish this from the balance check the way import-cmd.ts's
170
+ // {batch, phase, rows} envelope does), so this is a usage error (2), not
171
+ // an operation failure (1).
172
+ expect(await runCensus({ root, file })).toBe(2)
173
+ expect(await readRows(storePaths(root).census)).toEqual([])
174
+ })
175
+
176
+ // Correction 2: a missing or unparseable census file must produce a clean
177
+ // diagnostic and exit code 2 via store.ts's readJsonFile, never an uncaught
178
+ // JSON.parse/readFile stack trace.
179
+
180
+ test('runCensus reports a missing file as a clean usage error', async () => {
181
+ const file = join(root, 'does-not-exist.json')
182
+ expect(await runCensus({ root, file })).toBe(2)
183
+ expect(await readRows(storePaths(root).census)).toEqual([])
184
+ })
185
+
186
+ test('runCensus reports a malformed JSON file as a clean usage error', async () => {
187
+ const file = join(root, 'malformed.json')
188
+ await writeFile(file, '{ this is not json')
189
+ expect(await runCensus({ root, file })).toBe(2)
190
+ expect(await readRows(storePaths(root).census)).toEqual([])
191
+ })
192
+
193
+ // Finding 1: skipped and queued are counted by .length, so a duplicate entry
194
+ // pads the balance arithmetic without changing anything real. A duplicate
195
+ // must be refused, not silently deduped, and never allowed to make an
196
+ // otherwise-imbalanced record look balanced.
197
+
198
+ test('a duplicate queued id is rejected even though it would make the sum balance', () => {
199
+ // in_ledger 44 + added 1 + skipped 0 + queued.length 2 = 47 = total: this
200
+ // would pass balanceOf's arithmetic if the duplicate were silently counted.
201
+ const padded: Census = { ...LENS, total: 47, queued: ['q-1', 'q-1'] }
202
+ const result = validateCensus(padded)
203
+ expect(result.ok).toBe(false)
204
+ if (!result.ok) expect(result.errors.join(' ')).toContain('q-1')
205
+ })
206
+
207
+ test('a duplicate skipped element is rejected even though it would make the sum balance', () => {
208
+ const padded: Census = {
209
+ ...LENS,
210
+ total: 47,
211
+ skipped: [
212
+ { element: 'X', reason: 'a' },
213
+ { element: 'X', reason: 'b' },
214
+ ],
215
+ }
216
+ const result = validateCensus(padded)
217
+ expect(result.ok).toBe(false)
218
+ if (!result.ok) expect(result.errors.join(' ')).toContain('X')
219
+ })
220
+
221
+ test('runCensus refuses a record padded with a duplicate queued id', async () => {
222
+ const file = join(root, 'padded.json')
223
+ const padded = { ...LENS, total: 47, queued: ['q-1', 'q-1'] }
224
+ await writeFile(file, JSON.stringify(padded))
225
+ expect(await runCensus({ root, file })).toBe(2)
226
+ expect(await readRows(storePaths(root).census)).toEqual([])
227
+ })
228
+
229
+ // Finding 2: list() only checked that skipped/queued were arrays, never that
230
+ // their elements matched Skipped / string. Each element must be validated,
231
+ // naming the offending index.
232
+
233
+ test('a skipped entry that is not an object is rejected, naming the index', () => {
234
+ const bad = { ...LENS, skipped: ['not-an-object'] }
235
+ const result = validateCensus(bad)
236
+ expect(result.ok).toBe(false)
237
+ if (!result.ok) expect(result.errors.join(' ')).toContain('skipped[0]')
238
+ })
239
+
240
+ test('a skipped entry missing a reason is rejected, naming the index', () => {
241
+ const bad = { ...LENS, skipped: [{ element: 'foo' }] }
242
+ const result = validateCensus(bad)
243
+ expect(result.ok).toBe(false)
244
+ if (!result.ok) expect(result.errors.join(' ')).toContain('skipped[0]')
245
+ })
246
+
247
+ test('a queued entry that is not a string is rejected, naming the index', () => {
248
+ const bad = { ...LENS, queued: [123] }
249
+ const result = validateCensus(bad)
250
+ expect(result.ok).toBe(false)
251
+ if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
252
+ })
253
+
254
+ // Finding 3: a shape failure (missing/mistyped fields, unknown kind, a
255
+ // duplicate) is a usage error (2); only a balance failure on an otherwise
256
+ // well-formed record is an operation failure (1). The two must be
257
+ // distinguishable without parsing stderr text.
258
+
259
+ test('a record missing required fields is a usage error (2), not an operation failure', async () => {
260
+ const file = join(root, 'incomplete.json')
261
+ await writeFile(file, JSON.stringify({ kind: 'lens' }))
262
+ expect(await runCensus({ root, file })).toBe(2)
263
+ expect(await readRows(storePaths(root).census)).toEqual([])
264
+ })
265
+
266
+ test('a well-formed but unbalanced record is an operation failure (1), not a usage error', async () => {
267
+ const file = join(root, 'unbalanced.json')
268
+ await writeFile(file, JSON.stringify({ ...LENS, total: 99 }))
269
+ expect(await runCensus({ root, file })).toBe(1)
270
+ expect(await readRows(storePaths(root).census)).toEqual([])
271
+ })
272
+
273
+ // Finding 4: validateCensus's own error strings must not carry a 'census: '
274
+ // prefix, since runCensus prepends one when printing; a message that was
275
+ // already prefixed produced 'census: census: ...' on a real CLI run.
276
+
277
+ test('validateCensus error messages are not pre-prefixed with census:', () => {
278
+ const result = validateCensus(null)
279
+ expect(result.ok).toBe(false)
280
+ if (!result.ok) {
281
+ for (const e of result.errors) expect(e.startsWith('census:')).toBe(false)
282
+ }
283
+ })
284
+
285
+ // Finding 1, round 2: exact-string uniqueness was defeated by cosmetically
286
+ // distinct but semantically identical entries (wrong case, a trailing
287
+ // space, trailing punctuation), and cross-list padding (the same value
288
+ // reused in both queued and skipped) was never checked at all. Queue ids
289
+ // are now validated against a real format (ids.ts's isValidSlug) instead of
290
+ // merely checked pairwise for uniqueness, so a cosmetic variant is rejected
291
+ // on its own merits rather than needing to be recognized as a duplicate.
292
+
293
+ test('an uppercase queued id is rejected as malformed, not treated as a fresh distinct id', () => {
294
+ const bad = { ...LENS, total: 46, queued: ['Q-1'] }
295
+ const result = validateCensus(bad)
296
+ expect(result.ok).toBe(false)
297
+ if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
298
+ })
299
+
300
+ test('a queued id with trailing whitespace is rejected, not silently trimmed and accepted', () => {
301
+ const bad = { ...LENS, total: 46, queued: ['q-1 '] }
302
+ const result = validateCensus(bad)
303
+ expect(result.ok).toBe(false)
304
+ if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
305
+ })
306
+
307
+ test('a queued id with trailing punctuation is rejected as malformed', () => {
308
+ const bad = { ...LENS, total: 46, queued: ['q-1.'] }
309
+ const result = validateCensus(bad)
310
+ expect(result.ok).toBe(false)
311
+ if (!result.ok) expect(result.errors.join(' ')).toContain('queued[0]')
312
+ })
313
+
314
+ test('a repeated well-formed queued id is rejected as a duplicate', () => {
315
+ const bad = { ...LENS, total: 47, queued: ['q-table-ownership', 'q-table-ownership'] }
316
+ const result = validateCensus(bad)
317
+ expect(result.ok).toBe(false)
318
+ if (!result.ok) expect(result.errors.join(' ')).toContain('q-table-ownership')
319
+ })
320
+
321
+ test('cross-list padding: the same value in both queued and skipped is rejected', () => {
322
+ // in_ledger 44 + added 1 + skipped.length 1 + queued.length 1 = 47 = total:
323
+ // this would balance if the shared value 'q-1' were allowed to pad both
324
+ // lists at once.
325
+ const bad: Census = {
326
+ ...LENS,
327
+ total: 47,
328
+ queued: ['q-1'],
329
+ skipped: [{ element: 'q-1', reason: 'x' }],
330
+ }
331
+ const result = validateCensus(bad)
332
+ expect(result.ok).toBe(false)
333
+ if (!result.ok) expect(result.errors.join(' ')).toContain('q-1')
334
+ })
335
+
336
+ test('queued id format validation applies to attribute, rule-sweep and closer, not only lens', () => {
337
+ const attribute = {
338
+ kind: 'attribute',
339
+ surface: 'tables',
340
+ subject: 'table-roster-days',
341
+ phase: 'enumerate',
342
+ directions: {},
343
+ total: 15,
344
+ behavioral: 7,
345
+ explained: 6,
346
+ queued: ['Q-1'],
347
+ batch: 'b-2',
348
+ }
349
+ const ruleSweep = {
350
+ kind: 'rule-sweep',
351
+ subject: 'user-management',
352
+ phase: 'extract',
353
+ probes: 4,
354
+ found: 2,
355
+ as_requirements: 1,
356
+ queued: ['q-1.'],
357
+ batch: 'b-3',
358
+ }
359
+ const closer = {
360
+ kind: 'closer',
361
+ closer: 'read-write-symmetry',
362
+ phase: 'extract',
363
+ checked: 34,
364
+ findings: 3,
365
+ fixed: 2,
366
+ queued: ['q-1 '],
367
+ batch: 'b-4',
368
+ }
369
+ for (const bad of [attribute, ruleSweep, closer]) {
370
+ const result = validateCensus(bad)
371
+ expect(result.ok).toBe(false)
372
+ }
373
+ })
374
+
375
+ // Round 3: skipped's duplicate check and the cross-list check both compared
376
+ // raw element-name strings, so a case or whitespace variant on the skipped
377
+ // side defeated both (queued was already fixed in round 2, but skipped was
378
+ // left alone). Fixed by normalizing (trim + case-fold) for comparison only;
379
+ // the stored value is always the author's original text.
380
+
381
+ test('a case-variant duplicate skipped element is rejected', () => {
382
+ const bad: Census = {
383
+ ...LENS,
384
+ total: 47,
385
+ skipped: [
386
+ { element: 'orders', reason: 'a' },
387
+ { element: 'ORDERS', reason: 'b' },
388
+ ],
389
+ }
390
+ const result = validateCensus(bad)
391
+ expect(result.ok).toBe(false)
392
+ if (!result.ok) expect(result.errors.join(' ')).toContain('orders')
393
+ })
394
+
395
+ test('a whitespace-variant duplicate skipped element is rejected', () => {
396
+ const bad: Census = {
397
+ ...LENS,
398
+ total: 47,
399
+ skipped: [
400
+ { element: 'orders', reason: 'a' },
401
+ { element: ' orders ', reason: 'b' },
402
+ ],
403
+ }
404
+ const result = validateCensus(bad)
405
+ expect(result.ok).toBe(false)
406
+ if (!result.ok) expect(result.errors.join(' ')).toContain('orders')
407
+ })
408
+
409
+ test('cross-list padding is rejected even with a case-variant on the skipped side', () => {
410
+ // queued is already canonicalized ('q-1'), so this specifically checks
411
+ // that the skipped side is normalized before the two are compared.
412
+ const bad: Census = {
413
+ ...LENS,
414
+ total: 47,
415
+ queued: ['q-1'],
416
+ skipped: [{ element: 'Q-1', reason: 'x' }],
417
+ }
418
+ const result = validateCensus(bad)
419
+ expect(result.ok).toBe(false)
420
+ if (!result.ok) expect(result.errors.join(' ')).toContain('Q-1')
421
+ })
422
+
423
+ // Normalization must stop at trim + case-fold: it must not newly reject
424
+ // legitimate records, including ones whose element names differ only in
425
+ // punctuation (which is left alone deliberately, since punctuation can be
426
+ // load-bearing in a free-text element name).
427
+
428
+ test('punctuation-only variants of an element name are accepted as distinct, not deduped', () => {
429
+ const ok: Census = {
430
+ ...LENS,
431
+ total: 48,
432
+ skipped: [
433
+ { element: 'orders', reason: 'framework-owned' },
434
+ { element: 'orders.', reason: 'legacy typo, kept verbatim' },
435
+ ],
436
+ queued: ['q-table-ownership'],
437
+ }
438
+ const result = validateCensus(ok)
439
+ expect(result.ok).toBe(true)
440
+ if (result.ok) expect(result.value).toEqual(ok)
441
+ })
442
+
443
+ test('legitimate records still validate for all four kinds', () => {
444
+ const lens = validateCensus({
445
+ ...LENS,
446
+ total: 48,
447
+ skipped: [
448
+ { element: 'orders', reason: 'framework-owned' },
449
+ { element: 'orders.', reason: 'legacy typo, kept verbatim' },
450
+ ],
451
+ queued: ['q-table-ownership'],
452
+ })
453
+ expect(lens.ok).toBe(true)
454
+
455
+ const attribute = validateCensus({
456
+ kind: 'attribute',
457
+ surface: 'tables',
458
+ subject: 'table-roster-days',
459
+ phase: 'enumerate',
460
+ directions: {
461
+ ddl: { count: 14, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
462
+ entity: { count: 13, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
463
+ },
464
+ total: 15,
465
+ behavioral: 7,
466
+ explained: 6,
467
+ queued: ['q-ros-007'],
468
+ batch: 'b-2',
469
+ })
470
+ expect(attribute.ok).toBe(true)
471
+
472
+ const ruleSweep = validateCensus({
473
+ kind: 'rule-sweep',
474
+ subject: 'user-management',
475
+ phase: 'extract',
476
+ probes: 4,
477
+ found: 2,
478
+ as_requirements: 2,
479
+ queued: [],
480
+ batch: 'b-3',
481
+ })
482
+ expect(ruleSweep.ok).toBe(true)
483
+
484
+ const closer = validateCensus({
485
+ kind: 'closer',
486
+ closer: 'read-write-symmetry',
487
+ phase: 'extract',
488
+ checked: 34,
489
+ findings: 3,
490
+ fixed: 2,
491
+ queued: ['q-sym-001'],
492
+ batch: 'b-4',
493
+ })
494
+ expect(closer.ok).toBe(true)
495
+ })
496
+
497
+ test('a rule-sweep census without a phase is rejected by name', () => {
498
+ const result = validateCensus({
499
+ kind: 'rule-sweep',
500
+ subject: 'pricing',
501
+ batch: 'b-rules-001',
502
+ probes: 4,
503
+ found: 2,
504
+ as_requirements: 2,
505
+ queued: [],
506
+ })
507
+ expect(result.ok).toBe(false)
508
+ if (!result.ok) expect(result.errors).toContain('phase is required')
509
+ })
510
+
511
+ // Fix round 1, Finding 2: `phase` was free text on every kind, but check.ts's
512
+ // run-state gate only ever looks for a lens census's batch in
513
+ // phases.enumerate.batches and a closer census's batch in
514
+ // phases.extract.batches, both hardcoded, never read off the record's own
515
+ // `phase`. A lens or closer record declaring any other phase could have
516
+ // census-cmd.ts commit its batch under that other phase and the gate would
517
+ // then report it as never committed, even though it was. validateCensus now
518
+ // rejects that mismatch at write time instead of leaving it for the gate to
519
+ // discover after the fact.
520
+
521
+ test('a lens census declaring a phase other than enumerate is rejected by name', () => {
522
+ const result = validateCensus(lens({ phase: 'extract' }))
523
+ expect(result.ok).toBe(false)
524
+ if (!result.ok) {
525
+ const joined = result.errors.join('\n')
526
+ expect(joined).toContain('lens census must declare phase "enumerate"')
527
+ expect(joined).toContain('found "extract"')
528
+ }
529
+ })
530
+
531
+ test('a closer census declaring a phase other than extract is rejected by name', () => {
532
+ const result = validateCensus({
533
+ kind: 'closer',
534
+ closer: 'read-write-symmetry',
535
+ phase: 'enumerate',
536
+ checked: 34,
537
+ findings: 3,
538
+ fixed: 2,
539
+ queued: ['q-sym-001'],
540
+ batch: 'b-4',
541
+ })
542
+ expect(result.ok).toBe(false)
543
+ if (!result.ok) {
544
+ const joined = result.errors.join('\n')
545
+ expect(joined).toContain('closer census must declare phase "extract"')
546
+ expect(joined).toContain('found "enumerate"')
547
+ }
548
+ })
549
+
550
+ test('an attribute census may declare any phase, since the gate never cross-checks it against a batch list', () => {
551
+ const result = validateCensus({
552
+ kind: 'attribute',
553
+ surface: 'tables',
554
+ subject: 'table-roster-days',
555
+ phase: 'seam',
556
+ directions: {
557
+ ddl: { count: 14, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
558
+ entity: { count: 13, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
559
+ },
560
+ total: 15,
561
+ behavioral: 7,
562
+ explained: 6,
563
+ queued: ['q-ros-007'],
564
+ batch: 'b-2',
565
+ })
566
+ expect(result.ok).toBe(true)
567
+ })
568
+
569
+ // Critical finding 1 (shared with import-cmd.ts and queue-cmd.ts): a store
570
+ // whose configured source.path resolves to include the store's own target
571
+ // path must not crash. assertNotUnderSource throws by design (writeRows's
572
+ // own contract, see store.test.ts); census-cmd.ts must catch that throw and
573
+ // report a clean diagnostic with a deliberate exit code instead of letting
574
+ // it escape as an uncaught stack trace. See the report for the full
575
+ // argument for exit code 2 over 1.
576
+
577
+ test('runCensus refuses to write when the store sits inside its own configured source tree, as a clean usage error (2), not a crash', async () => {
578
+ await writeConfig(root, { sourcePath: root, scope: 'all', targetName: 'newapp' })
579
+ const file = join(root, 'c.json')
580
+ await writeFile(file, JSON.stringify(LENS))
581
+ expect(await runCensus({ root, file })).toBe(2)
582
+ expect(await readRows(storePaths(root).census)).toEqual([])
583
+ })
584
+
585
+ function lens(overrides: Record<string, unknown> = {}): Record<string, unknown> {
586
+ return {
587
+ kind: 'lens',
588
+ surface: 'tables',
589
+ phase: 'enumerate',
590
+ directions: {
591
+ ddl: { count: 43, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
592
+ orm: { count: 40, evidence: 'rg -n "\\[Table\\(" --type cs | wc -l' },
593
+ },
594
+ total: 45,
595
+ in_ledger: 45,
596
+ added: 0,
597
+ skipped: [],
598
+ queued: [],
599
+ batch: 'b-tables-census-001',
600
+ ...overrides,
601
+ }
602
+ }
603
+
604
+ test('a well-formed two-direction record with evidence validates', () => {
605
+ const result = validateCensus(lens())
606
+ expect(result.ok).toBe(true)
607
+ })
608
+
609
+ test('a bare-count direction is rejected with the shape it needs', () => {
610
+ const result = validateCensus(lens({ directions: { ddl: 43, orm: 40 } }))
611
+ expect(result.ok).toBe(false)
612
+ if (!result.ok) {
613
+ expect(result.errors.join('\n')).toContain('old bare-count shape')
614
+ expect(result.errors.join('\n')).toContain('"count": 43')
615
+ }
616
+ })
617
+
618
+ test('a direction with an empty evidence string is rejected by name', () => {
619
+ const result = validateCensus(
620
+ lens({
621
+ directions: {
622
+ ddl: { count: 43, evidence: 'rg CREATE TABLE' },
623
+ orm: { count: 40, evidence: ' ' },
624
+ },
625
+ }),
626
+ )
627
+ expect(result.ok).toBe(false)
628
+ if (!result.ok) expect(result.errors.join('\n')).toContain('directions.orm.evidence is required')
629
+ })
630
+
631
+ test('a single direction is rejected against the lens contract', () => {
632
+ const result = validateCensus(
633
+ lens({ directions: { ddl: { count: 45, evidence: 'rg CREATE TABLE' } } }),
634
+ )
635
+ expect(result.ok).toBe(false)
636
+ if (!result.ok) expect(result.errors.join('\n')).toContain('at least two independent directions')
637
+ })
638
+
639
+ test('total below the largest direction count fails bounds', () => {
640
+ const result = validateCensus(lens({ total: 40, in_ledger: 40 }))
641
+ expect(result.ok).toBe(true)
642
+ if (result.ok) {
643
+ const message = boundsOf(result.value)
644
+ expect(message).toContain('below the largest direction count 43')
645
+ }
646
+ })
647
+
648
+ test('total above the sum of direction counts fails bounds', () => {
649
+ const result = validateCensus(lens({ total: 90, in_ledger: 90 }))
650
+ expect(result.ok).toBe(true)
651
+ if (result.ok) {
652
+ const message = boundsOf(result.value)
653
+ expect(message).toContain('exceeds the 83 findings')
654
+ }
655
+ })
656
+
657
+ test('total exactly at each bound passes', () => {
658
+ for (const total of [43, 83]) {
659
+ const result = validateCensus(lens({ total, in_ledger: total }))
660
+ expect(result.ok).toBe(true)
661
+ if (result.ok) expect(boundsOf(result.value)).toBeNull()
662
+ }
663
+ })
664
+
665
+ test('bounds apply to attribute records too', () => {
666
+ const result = validateCensus({
667
+ kind: 'attribute',
668
+ surface: 'tables',
669
+ subject: 'users',
670
+ phase: 'enumerate',
671
+ directions: {
672
+ schema: { count: 12, evidence: 'psql \\d users' },
673
+ code: { count: 9, evidence: 'rg "users\\." --type cs' },
674
+ },
675
+ total: 30,
676
+ behavioral: 8,
677
+ explained: 8,
678
+ queued: [],
679
+ batch: 'b-users-attr-001',
680
+ })
681
+ expect(result.ok).toBe(true)
682
+ if (result.ok) expect(boundsOf(result.value)).toContain('exceeds the 21 findings')
683
+ })
684
+
685
+ test('bounds say nothing about kinds that carry no directions', () => {
686
+ const result = validateCensus({
687
+ kind: 'closer',
688
+ closer: 'scope-injection',
689
+ phase: 'extract',
690
+ batch: 'b-closer-001',
691
+ checked: 3,
692
+ findings: 0,
693
+ fixed: 0,
694
+ queued: [],
695
+ })
696
+ expect(result.ok).toBe(true)
697
+ if (result.ok) expect(boundsOf(result.value)).toBeNull()
698
+ })
699
+
700
+ // Review round 1, Finding 3: the two-direction minimum is counted on the raw
701
+ // keys of the input object, before any entry is filtered out for being
702
+ // malformed. Pinned here so a future refactor that switches to counting the
703
+ // validated output map instead cannot silently regress this: a record with
704
+ // one valid direction and one bare-count direction must still be rejected,
705
+ // and specifically for the bare-count shape, not for a spurious "needs at
706
+ // least two directions" message that would imply the fix is counting the
707
+ // wrong thing.
708
+ test('one valid direction plus one malformed direction is rejected for the malformed one, not treated as short of two', () => {
709
+ const result = validateCensus(
710
+ lens({
711
+ directions: {
712
+ ddl: { count: 43, evidence: 'rg -n "CREATE TABLE" schema.sql | wc -l' },
713
+ orm: 40,
714
+ },
715
+ }),
716
+ )
717
+ expect(result.ok).toBe(false)
718
+ if (!result.ok) {
719
+ const joined = result.errors.join('\n')
720
+ expect(joined).toContain('old bare-count shape')
721
+ expect(joined).not.toContain('at least two independent directions')
722
+ }
723
+ })
724
+
725
+ // Review round 1, Finding 1b: boundsOf's own type signature promises
726
+ // record.directions is well-formed, but a hand-edited census.jsonl can put
727
+ // anything there, and this function is the last line of defense once
728
+ // check.ts's gate-time validation exists (a future caller could always skip
729
+ // that and call boundsOf directly). Object.values(...).map(d => d.count) on
730
+ // a bare number silently produces undefined, and Math.max/reduce on
731
+ // undefined produce NaN, against which every comparison is false, so the
732
+ // bug this pins is a false negative: total 999 must never be judged "in
733
+ // bounds" just because one direction was malformed.
734
+ test('boundsOf names a malformed direction instead of silently treating it as in bounds', () => {
735
+ const malformed = {
736
+ kind: 'lens',
737
+ surface: 'tables',
738
+ phase: 'enumerate',
739
+ directions: { ddl: 43, orm: { count: 40, evidence: 'rg -n "\\[Table\\(" --type cs' } },
740
+ total: 999,
741
+ in_ledger: 999,
742
+ added: 0,
743
+ skipped: [],
744
+ queued: [],
745
+ batch: 'b-1',
746
+ } as unknown as Census
747
+ const message = boundsOf(malformed)
748
+ expect(message).toContain('ddl')
749
+ })
750
+
751
+ // Review round 1, Finding 2: all nine bounds tests above call validateCensus
752
+ // and boundsOf directly; none drives a bounds-violating record through the
753
+ // real command path, so census-cmd.ts's own
754
+ // `balanceOf(result.value) ?? boundsOf(result.value)` line has never been
755
+ // exercised end to end. This closes that gap for the exit code and the
756
+ // stderr message together.
757
+ test('runCensus refuses a record whose total is out of bounds, on stderr, as an operation failure (1)', async () => {
758
+ const file = join(root, 'out-of-bounds.json')
759
+ await writeFile(file, JSON.stringify(lens({ total: 90, in_ledger: 90 })))
760
+ const original = process.stderr.write.bind(process.stderr)
761
+ const out: string[] = []
762
+ process.stderr.write = ((s: string) => {
763
+ out.push(s)
764
+ return true
765
+ }) as typeof process.stderr.write
766
+ let code: number
767
+ try {
768
+ code = await runCensus({ root, file })
769
+ } finally {
770
+ process.stderr.write = original
771
+ }
772
+ expect(code).toBe(1)
773
+ expect(out.join('')).toContain('exceeds the 83 findings')
774
+ expect(await readRows(storePaths(root).census)).toEqual([])
775
+ })