@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,1276 @@
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-webforms')
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
+ async function writeText(name: string, text: string): Promise<string> {
29
+ const path = join(target, name)
30
+ await writeFile(path, text)
31
+ return path
32
+ }
33
+
34
+ beforeEach(async () => {
35
+ target = await mkdtemp(join(tmpdir(), 'migrate-e2e-webforms-'))
36
+ source = join(target, 'legacy')
37
+ await cp(FIXTURE, source, { recursive: true })
38
+ })
39
+
40
+ afterEach(async () => {
41
+ await rm(target, { recursive: true, force: true })
42
+ })
43
+
44
+ // GROUND-TRUTH.md is the single source of what this fixture contains, parsed
45
+ // here rather than hand-copied, for the same reason e2e-express.test.ts parses
46
+ // it: a row added to the fixture without a matching update here (or the
47
+ // reverse) cannot silently drift, because the batches below are built from
48
+ // whatever the table on disk actually says.
49
+ type GroundTruthRow = { surface: string; id: string; element: string }
50
+
51
+ async function parseGroundTruth(path: string): Promise<GroundTruthRow[]> {
52
+ const text = await readFile(path, 'utf8')
53
+ const rows: GroundTruthRow[] = []
54
+ for (const line of text.split('\n')) {
55
+ const trimmed = line.trim()
56
+ if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue
57
+ const cells = trimmed
58
+ .slice(1, -1)
59
+ .split('|')
60
+ .map((c) => c.trim())
61
+ if (cells.length !== 3) continue
62
+ const surface = cells[0] ?? ''
63
+ const id = cells[1] ?? ''
64
+ const element = cells[2] ?? ''
65
+ if (surface === 'surface' && id === 'id') continue // header row
66
+ if (cells.every((c) => /^-+$/.test(c))) continue // separator row
67
+ rows.push({ surface, id, element })
68
+ }
69
+ return rows
70
+ }
71
+
72
+ function groupBySurface(rows: GroundTruthRow[]): Map<string, GroundTruthRow[]> {
73
+ const groups = new Map<string, GroundTruthRow[]>()
74
+ for (const row of rows) {
75
+ const list = groups.get(row.surface) ?? []
76
+ list.push(row)
77
+ groups.set(row.surface, list)
78
+ }
79
+ return groups
80
+ }
81
+
82
+ // This fixture's whole point is exercising the shipped aspnet recipe
83
+ // (references/recipes/aspnet.md), the one pack that ships at v1, instead of
84
+ // contract-only mode: `--source-stack aspnet-webforms` below is what routes
85
+ // enumerate.md's Inputs section at that file instead of deriving directions
86
+ // by hand. Every evidence string is the recipe's own literal `Probe:` (or
87
+ // disclosed supporting-probe) command, copied verbatim from aspnet.md, not a
88
+ // paraphrase of it: that is what ties this test to the recipe rather than to
89
+ // a generic enumeration, and it is also what a reviewer re-checks the count
90
+ // against. Every count below was produced by running that exact command
91
+ // against this fixture, not assumed from reading the recipe, and every raw
92
+ // match count that differs from the reported one is disclosed in a comment
93
+ // beside it rather than left for a reader to rediscover.
94
+ //
95
+ // **All twenty-one of aspnet.md's named directions are here**, not a chosen
96
+ // subset. Several surfaces therefore carry more than the two-direction floor,
97
+ // which is aspnet.md's own preamble ("several surfaces below name more than
98
+ // the two-direction floor") made concrete. Site.master gives both nav probes
99
+ // (reports and screens) a real master page to find real content in, not an
100
+ // absence to report against: a real WebForms app ships a master page, and this
101
+ // fixture's screens/nav direction has to discriminate a real screen link from
102
+ // a real report link sitting in the same file, not merely report zero because
103
+ // none exist.
104
+ //
105
+ // Seven directions find nothing here, and every one is recorded as a real
106
+ // zero rather than omitted, per enumerate.md's zero-findings rule: jobs'
107
+ // hangfire and osconfig, tables' orm, reports' builder, integrations' wcf and
108
+ // configured, and workflows' multistep. This fixture simply uses none of
109
+ // Hangfire, Windows Task Scheduler, EF, an .rdlc catalog, WCF, configured
110
+ // service endpoints, or MVC-style wizard actions. A zero costs one line and
111
+ // says the direction ran; leaving it out would say only that somebody chose
112
+ // not to look.
113
+ //
114
+ // A zero-count direction cannot break a census either way: the bound is
115
+ // max(directions) <= total <= sum(directions), and adding a zero moves
116
+ // neither end of it.
117
+ const DIRECTIONS: Record<string, Record<string, { count: number; evidence: string }>> = {
118
+ routes: {
119
+ // Raw match count is 7 (1 RoutePrefix + 2 lines per action x 3 actions,
120
+ // since every action here uses the split verb-attribute-then-Route-attribute
121
+ // form): excluding the RoutePrefix line (it annotates no action) leaves 6,
122
+ // and deduping each action's two lines down to the one action they both
123
+ // annotate leaves 3, the real count this direction reports.
124
+ attribute: {
125
+ count: 3,
126
+ evidence:
127
+ "rg -n -g '*.cs' '[\\[,]\\s*(RoutePrefix|Route|HttpGet|HttpPost|HttpPut|HttpDelete|HttpPatch|HttpHead|HttpOptions)\\b' <source>",
128
+ },
129
+ convention: {
130
+ count: 3,
131
+ evidence: "rg -n -g '*Controller*.cs' 'public\\s+\\S+\\s+\\w+\\s*\\(' <source>",
132
+ },
133
+ webforms: {
134
+ count: 2,
135
+ evidence: "find <source> -type f \\( -name '*.aspx' -o -name '*.ashx' \\)",
136
+ },
137
+ },
138
+ tables: {
139
+ ddl: { count: 2, evidence: "rg -n -g '*.sql' -i '^\\s*CREATE TABLE' <source>" },
140
+ // Raw match count is 4: a bracket-quoted [dbo].[Users] read, a plain
141
+ // INSERT INTO Users, a schema-qualified DELETE FROM dbo.AuditLog, and one
142
+ // block-comment continuation line naming a table that does not exist
143
+ // (LegacyUsers) which survives the probe's // -only comment filter (it
144
+ // starts with " * ", not "//") and must be excluded by classification,
145
+ // not by the regex. 2 real tables, both already known from DDL.
146
+ raw_ado: {
147
+ count: 2,
148
+ evidence:
149
+ "rg -n -g '*.cs' '(FROM|INTO|UPDATE)\\s+((\\[[^]]+\\]|\\w+)\\.)*(\\[[^]]+\\]|\\w+)' <source> | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//'",
150
+ },
151
+ // Zero: this fixture uses raw ADO throughout and has no DbContext and no
152
+ // [Table(...)] attribute anywhere. The recipe pairs a second probe with
153
+ // this direction's DDL half, for migrationBuilder.CreateTable, which finds
154
+ // nothing here for the same reason.
155
+ orm: { count: 0, evidence: "rg -n -g '*.cs' 'DbSet<|\\[Table\\(' <source>: no matches" },
156
+ },
157
+ jobs: {
158
+ // Raw match count is 3, all in Jobs/NightlyDigestJob.cs and all naming the
159
+ // same single job: the IJob class declaration at line 9, the
160
+ // JobBuilder.Create that builds it at 29, and the ScheduleJob that
161
+ // registers it at 30. Deduped to the one job they describe, that is 1.
162
+ quartz: {
163
+ count: 1,
164
+ evidence: "rg -n -g '*.cs' 'IJob\\b|JobBuilder\\.Create|ScheduleJob\\(' <source>",
165
+ },
166
+ hangfire: {
167
+ count: 0,
168
+ evidence: "rg -n -g '*.cs' 'RecurringJob\\.|BackgroundJob\\.' <source>: no matches",
169
+ },
170
+ osconfig: {
171
+ count: 0,
172
+ evidence:
173
+ "rg -n -g '*.xml' -g '*.config' -i 'cron|schedule|<Task version' <source>: no matches",
174
+ },
175
+ },
176
+ reports: {
177
+ disk: {
178
+ count: 1,
179
+ evidence: "find <source> -type f \\( -name '*.rdl' -o -name '*.rdlc' \\)",
180
+ },
181
+ // Site.master's nav carries a real "Daily active users report" link: 1
182
+ // real match, already known from the disk direction above.
183
+ nav: {
184
+ count: 1,
185
+ evidence:
186
+ "rg -n -g '*.sitemap' -g '_Layout.cshtml' -g '*.master' -g '*Nav*.cshtml' -g '*Menu*.cshtml' -i 'report' <source>",
187
+ },
188
+ // Zero: nothing in this fixture registers a report through code. The .rdl
189
+ // is picked up off disk and linked from the master page, which is the whole
190
+ // of how this app knows about it.
191
+ builder: { count: 0, evidence: "rg -n -g '*.cs' '\\.rdlc?\"' <source>: no matches" },
192
+ },
193
+ screens: {
194
+ filesystem: {
195
+ count: 2,
196
+ evidence: "find <source> -type f \\( -name '*.aspx' -o -name '*.cshtml' \\)",
197
+ },
198
+ // Raw match count is 3: Site.master's Home and Users NavigateUrl links
199
+ // (2 real screens, both already known from the filesystem direction) and
200
+ // one href pointing at the reports nav entry above, which names a report
201
+ // path, not a screen, and must be excluded by classification. 2 real
202
+ // screens.
203
+ nav: {
204
+ count: 2,
205
+ evidence:
206
+ "rg -n -g '*.sitemap' -g '_Layout.cshtml' -g '*.master' -g '*Nav*.cshtml' -g '*Menu*.cshtml' '(url|href|NavigateUrl)=\"~/|Html\\.ActionLink\\(|Url\\.Action\\(' <source>",
207
+ },
208
+ },
209
+ integrations: {
210
+ httpclient: {
211
+ count: 1,
212
+ evidence: "rg -n -g '*.cs' 'new HttpClient\\(|new WebClient\\(' <source>",
213
+ },
214
+ wcf: {
215
+ count: 0,
216
+ evidence:
217
+ "rg -n -g '*.cs' 'ClientBase<|ChannelFactory<|\\[ServiceContract\\]' <source>: no matches",
218
+ },
219
+ // Zero: web.config declares appSettings and connectionStrings, and no
220
+ // <endpoint> element at all. The one outbound call this app makes is
221
+ // hardcoded in BillingClient.cs, not configured.
222
+ configured: { count: 0, evidence: "rg -n -g '*.config' '<endpoint\\b' <source>: no matches" },
223
+ },
224
+ workflows: {
225
+ multistep: {
226
+ count: 0,
227
+ evidence: "rg -n -g '*.cs' 'ActionResult Step[0-9]+' <source>: no matches",
228
+ },
229
+ // Raw match count is 2, both in Controllers/UsersController.cs and both on
230
+ // the same session key: the write at line 49 and the read at 57. A write
231
+ // and the read that consumes it are the two ends of one workflow, not two
232
+ // workflows, so this reports 1.
233
+ statecarriers: {
234
+ count: 1,
235
+ evidence: "rg -n -g '*.cs' 'Session\\[|TempData\\[' <source>",
236
+ },
237
+ },
238
+ settings: {
239
+ storage: { count: 3, evidence: "rg -n -g '*.config' '<add (key|name)=' <source>" },
240
+ // Raw match count is 4: UsersController.cs:17 and NightlyDigestJob.cs:14
241
+ // both read DefaultConnection, UsersController.cs:58 reads
242
+ // WelcomeEmailEnabled, and NightlyDigestJob.cs:13 reads
243
+ // NightlyDigestCutoffDays. Deduped by the setting named rather than by the
244
+ // read site, that is 3 distinct settings, matching what storage found.
245
+ readsites: {
246
+ count: 3,
247
+ evidence: "rg -n -g '*.cs' 'ConfigurationManager\\.(AppSettings|ConnectionStrings)' <source>",
248
+ },
249
+ },
250
+ }
251
+
252
+ // Ledger refs a lens would record per enumerate.md's Procedure, step 4: an
253
+ // element that touches one already in the ledger gets a
254
+ // {"kind": "ledger", "id": ...} entry naming it. See GROUND-TRUTH.md's
255
+ // "Element-to-element touches" section, which this map matches by hand, the
256
+ // same relationship DIRECTIONS above has to aspnet.md's probes: this is
257
+ // fixture metadata the test asserts against, not something parsed off disk.
258
+ type LedgerRef = { kind: 'ledger'; id: string }
259
+ const REFS: Record<string, LedgerRef[]> = {
260
+ 'route-get-api-users': [{ kind: 'ledger', id: 'table-users' }],
261
+ 'route-post-api-users': [{ kind: 'ledger', id: 'table-users' }],
262
+ 'route-get-api-users-id-welcome': [{ kind: 'ledger', id: 'integration-billing-sync' }],
263
+ 'job-nightly-digest': [{ kind: 'ledger', id: 'table-audit-log' }],
264
+ 'report-daily-users': [{ kind: 'ledger', id: 'table-users' }],
265
+ 'workflow-signup-welcome': [
266
+ { kind: 'ledger', id: 'route-post-api-users' },
267
+ { kind: 'ledger', id: 'route-get-api-users-id-welcome' },
268
+ { kind: 'ledger', id: 'setting-welcome-email-enabled' },
269
+ ],
270
+ // enumerate.md step 4's own canonical example: "a screen that posts to a
271
+ // route" (route-post-api-users, wired for real via Default.aspx.cs calling
272
+ // UsersController.CreateUser directly) and the analogous read-side case
273
+ // (route-get-api-users-id-welcome, wired via Users.aspx.cs calling
274
+ // UsersController.GetWelcomeStatus directly).
275
+ 'screen-default': [{ kind: 'ledger', id: 'route-post-api-users' }],
276
+ 'screen-users': [{ kind: 'ledger', id: 'route-get-api-users-id-welcome' }],
277
+ }
278
+
279
+ // Builds the same graph seam.md's surface-affinity clustering builds: one
280
+ // edge per {"kind": "ledger", ...} ref on any element, connecting it to the
281
+ // id it names. An edge is an unordered pair, stored as its two ids sorted and
282
+ // joined, so a-touches-b and b-touches-a (were both ever recorded) would
283
+ // collapse to one edge rather than count twice.
284
+ type StoredElement = { id: string; refs: { kind: string; id?: string }[] }
285
+
286
+ function buildAffinityEdges(elements: StoredElement[]): Set<string> {
287
+ const edges = new Set<string>()
288
+ for (const el of elements) {
289
+ for (const ref of el.refs) {
290
+ if (ref.kind === 'ledger' && ref.id) {
291
+ edges.add([el.id, ref.id].sort().join('|'))
292
+ }
293
+ }
294
+ }
295
+ return edges
296
+ }
297
+
298
+ const QUEUE_ID = 'q-tiny-webforms-enumerate-scaffold'
299
+
300
+ const QUEUE_ITEM = `---
301
+ id: ${QUEUE_ID}
302
+ severity: minor
303
+ status: open
304
+ ---
305
+
306
+ ## Evidence
307
+
308
+ This run asserts the enumerate slice before extract has run, and coverage is
309
+ a whole-store gate: it reads every element regardless of \`--phase\`, so an
310
+ element with no terminal disposition fails it at any phase. No requirement
311
+ exists yet at that point for an element to map to.
312
+
313
+ ## Options
314
+
315
+ (a) Leave every element unaccounted and give up on asserting the enumerate
316
+ slice at all. (b) Give every element a placeholder out-of-scope disposition
317
+ citing this item, which extract then replaces with a real disposition once
318
+ there is a requirement to map to.
319
+
320
+ ## Recommendation
321
+
322
+ Recommend (b); it isolates the census and run-state assertions the enumerate
323
+ step exists to make from the coverage gate, and citing this item keeps the
324
+ placeholder auditable rather than silent. The item stays open after extract
325
+ supersedes it, because nothing in this milestone adjudicates a queue item.
326
+ `
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Phase 2, seam. The partition is the one references/phases/seam.md's second
330
+ // worked example derives and prints in full, from exactly the ten edges step
331
+ // 7b asserts below: components-only Q = 0.180, under the 0.3 floor, so the
332
+ // greedy refinement runs on the ten-node component and reaches Q = 0.505. The
333
+ // manual carries the merge ladder; it is not repeated here, because a second
334
+ // copy of it is a second thing to keep true.
335
+ //
336
+ // The four edgeless elements are assigned by hand per seam.md's edgeless rule,
337
+ // each on the source proximity that rule names:
338
+ // route-default-aspx -> signup (it is Default.aspx, and
339
+ // screen-default is that file's
340
+ // rendered half)
341
+ // route-users-aspx -> welcome-activation (same, for
342
+ // Users.aspx and screen-users)
343
+ // setting-nightly-digest-cutoff-days -> audit-retention
344
+ // (Jobs/NightlyDigestJob.cs:13 is the
345
+ // only line that reads it)
346
+ // and the fourth is seam.md's own named exception: setting-default-connection
347
+ // is read at Controllers/UsersController.cs:17 and Jobs/NightlyDigestJob.cs:14,
348
+ // two lines that between them serve three of the four capabilities, so no
349
+ // capability has a claim the others do not. It joins none of them on purpose,
350
+ // gets a queue item, and extract disposes of it out-of-scope citing that item.
351
+ type CapabilityRow = { slug: string; title: string; ns: string; elements: string[] }
352
+
353
+ const CAPABILITIES: CapabilityRow[] = [
354
+ {
355
+ slug: 'welcome-activation',
356
+ title: 'Welcome Activation',
357
+ ns: 'WA',
358
+ elements: [
359
+ 'integration-billing-sync',
360
+ 'route-get-api-users-id-welcome',
361
+ 'screen-users',
362
+ 'route-users-aspx',
363
+ ],
364
+ },
365
+ {
366
+ slug: 'audit-retention',
367
+ title: 'Audit Retention',
368
+ ns: 'AR',
369
+ elements: ['job-nightly-digest', 'table-audit-log', 'setting-nightly-digest-cutoff-days'],
370
+ },
371
+ {
372
+ slug: 'user-directory',
373
+ title: 'User Directory',
374
+ ns: 'UD',
375
+ elements: ['report-daily-users', 'route-get-api-users', 'table-users'],
376
+ },
377
+ {
378
+ slug: 'signup',
379
+ title: 'Signup',
380
+ ns: 'SU',
381
+ elements: [
382
+ 'route-post-api-users',
383
+ 'screen-default',
384
+ 'setting-welcome-email-enabled',
385
+ 'workflow-signup-welcome',
386
+ 'route-default-aspx',
387
+ ],
388
+ },
389
+ ]
390
+
391
+ // Deliberately in no capability, per seam.md's exception. Held as its own
392
+ // constant so the partition assertion below has to name it explicitly rather
393
+ // than a missing element slipping through as an off-by-one.
394
+ const UNASSIGNED = ['setting-default-connection']
395
+
396
+ const SEAM_JSON = {
397
+ validators: {
398
+ 'schema-clustering': { ran: false, reason: 'no live database to read foreign keys from' },
399
+ 'call-graph': { ran: false, reason: 'no static C# call-graph tooling in this environment' },
400
+ 'change-coupling': { ran: false, reason: 'the copied fixture carries no VCS history' },
401
+ 'surface-affinity': { ran: true, modularity: 0.505 },
402
+ },
403
+ agreement: ['surface-affinity'],
404
+ modularity: 0.505,
405
+ status: 'accepted',
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Phase 3, extract. Every src citation's path and line range is real: the
410
+ // citations gate runs by default and resolves each one against the copied
411
+ // fixture, so a wrong range fails the run rather than sitting undetected.
412
+ type Citation =
413
+ | { kind: 'ledger'; id: string }
414
+ | { kind: 'src'; path: string; lines: [number, number] }
415
+
416
+ type RequirementRow = {
417
+ id: string
418
+ cap: string
419
+ requirement: string
420
+ actors: string
421
+ objects: string
422
+ rules: string
423
+ origin: string
424
+ confidence: { kind: string; queue?: string }
425
+ citations: Citation[]
426
+ parity: null
427
+ }
428
+
429
+ const REQUIREMENTS: RequirementRow[] = [
430
+ {
431
+ id: 'SU-001',
432
+ cap: 'signup',
433
+ requirement:
434
+ 'The signup screen submits an email address to the user-creation route in process and redirects to the users page',
435
+ actors: 'Visitor',
436
+ objects: 'Signup form',
437
+ rules: 'The redirect target is ~/Users.aspx',
438
+ origin: 'intended',
439
+ confidence: { kind: 'confirmed' },
440
+ citations: [
441
+ { kind: 'ledger', id: 'screen-default' },
442
+ { kind: 'src', path: 'Default.aspx.cs', lines: [8, 12] },
443
+ { kind: 'src', path: 'Default.aspx', lines: [1, 7] },
444
+ ],
445
+ parity: null,
446
+ },
447
+ {
448
+ id: 'SU-002',
449
+ cap: 'signup',
450
+ requirement:
451
+ 'Creating a user inserts an active row and stores a pending welcome in session keyed by the new id',
452
+ actors: 'API client',
453
+ objects: 'User',
454
+ rules: 'IsActive is set to 1 on insert; the id is minted from the current clock',
455
+ origin: 'intended',
456
+ confidence: { kind: 'confirmed' },
457
+ citations: [
458
+ { kind: 'ledger', id: 'route-post-api-users' },
459
+ { kind: 'src', path: 'Controllers/UsersController.cs', lines: [38, 51] },
460
+ ],
461
+ parity: null,
462
+ },
463
+ {
464
+ id: 'SU-003',
465
+ cap: 'signup',
466
+ requirement:
467
+ 'Signup then welcome is a two-page workflow carrying the pending email in session between the two pages',
468
+ actors: 'Visitor',
469
+ objects: 'Pending welcome state',
470
+ rules: 'The session key is PendingWelcome_ plus the new user id',
471
+ origin: 'intended',
472
+ confidence: { kind: 'confirmed' },
473
+ citations: [
474
+ { kind: 'ledger', id: 'workflow-signup-welcome' },
475
+ { kind: 'src', path: 'Controllers/UsersController.cs', lines: [49, 49] },
476
+ { kind: 'src', path: 'Users.aspx.cs', lines: [8, 12] },
477
+ ],
478
+ parity: null,
479
+ },
480
+ {
481
+ id: 'SU-004',
482
+ cap: 'signup',
483
+ requirement: 'WelcomeEmailEnabled decides whether a welcome check notifies anything at all',
484
+ actors: '-',
485
+ objects: 'Welcome setting',
486
+ rules: 'The value is compared against the literal string "true"',
487
+ origin: 'intended',
488
+ confidence: { kind: 'confirmed' },
489
+ citations: [
490
+ { kind: 'ledger', id: 'setting-welcome-email-enabled' },
491
+ { kind: 'src', path: 'web.config', lines: [3, 6] },
492
+ { kind: 'src', path: 'Controllers/UsersController.cs', lines: [58, 59] },
493
+ ],
494
+ parity: null,
495
+ },
496
+ {
497
+ id: 'WA-001',
498
+ cap: 'welcome-activation',
499
+ requirement:
500
+ 'Checking a welcome status notifies the billing system when the welcome setting is enabled and a pending welcome exists for that id',
501
+ actors: 'API client',
502
+ objects: 'Activation notification',
503
+ rules: 'Both the setting and a stored email must hold, or nothing is notified',
504
+ origin: 'intended',
505
+ // Inferred rather than confirmed: the source shows the POST being made and
506
+ // nothing citable here shows what the billing system does with it, which
507
+ // is extract.md's own definition of a piece unobservable from the source.
508
+ confidence: { kind: 'inferred' },
509
+ citations: [
510
+ { kind: 'ledger', id: 'route-get-api-users-id-welcome' },
511
+ { kind: 'src', path: 'Controllers/UsersController.cs', lines: [53, 61] },
512
+ { kind: 'src', path: 'Integrations/BillingClient.cs', lines: [10, 14] },
513
+ ],
514
+ parity: null,
515
+ },
516
+ {
517
+ id: 'WA-002',
518
+ cap: 'welcome-activation',
519
+ requirement: 'The users page checks the welcome status of the id in its query string on load',
520
+ actors: 'Visitor',
521
+ objects: 'Users page',
522
+ rules: 'A missing id parameter is read as 0',
523
+ origin: 'intended',
524
+ confidence: { kind: 'confirmed' },
525
+ citations: [
526
+ { kind: 'ledger', id: 'screen-users' },
527
+ { kind: 'src', path: 'Users.aspx.cs', lines: [8, 12] },
528
+ { kind: 'src', path: 'Users.aspx', lines: [1, 8] },
529
+ ],
530
+ parity: null,
531
+ },
532
+ {
533
+ // The one queued requirement in this run, and the ambiguity is in the
534
+ // source rather than invented: GetUsers returns the reader itself out of a
535
+ // `using` block that disposes the connection on the way out, so whether
536
+ // this endpoint ever served rows is not decidable from the checkout.
537
+ // A queued requirement is the one case the parity gate exempts, which is
538
+ // why this row keeps `parity: null` through phase 4.
539
+ id: 'UD-001',
540
+ cap: 'user-directory',
541
+ requirement: 'Listing users returns the id and email of active users only',
542
+ actors: 'API client',
543
+ objects: 'User list',
544
+ rules: 'The query filters on IsActive = 1',
545
+ origin: 'intended',
546
+ confidence: { kind: 'queued', queue: 'q-webforms-getusers-reader-lifetime' },
547
+ citations: [
548
+ { kind: 'ledger', id: 'route-get-api-users' },
549
+ { kind: 'src', path: 'Controllers/UsersController.cs', lines: [23, 32] },
550
+ ],
551
+ parity: null,
552
+ },
553
+ {
554
+ id: 'UD-002',
555
+ cap: 'user-directory',
556
+ requirement:
557
+ 'The Users table stores an id, a required email, and an active flag defaulting to set',
558
+ actors: '-',
559
+ objects: 'User row',
560
+ rules: 'Email is NOT NULL; IsActive defaults to 1',
561
+ origin: 'intended',
562
+ confidence: { kind: 'confirmed' },
563
+ citations: [
564
+ { kind: 'ledger', id: 'table-users' },
565
+ { kind: 'src', path: 'Schema.sql', lines: [1, 5] },
566
+ ],
567
+ parity: null,
568
+ },
569
+ {
570
+ id: 'UD-003',
571
+ cap: 'user-directory',
572
+ requirement: 'The daily users report counts only users whose active flag is set',
573
+ actors: 'Report consumer',
574
+ objects: 'Active user count',
575
+ rules: 'The dataset query filters on IsActive = 1',
576
+ origin: 'intended',
577
+ confidence: { kind: 'confirmed' },
578
+ citations: [
579
+ { kind: 'ledger', id: 'report-daily-users' },
580
+ { kind: 'src', path: 'Reports/DailyUsers.rdl', lines: [3, 9] },
581
+ ],
582
+ parity: null,
583
+ },
584
+ {
585
+ id: 'AR-001',
586
+ cap: 'audit-retention',
587
+ requirement:
588
+ 'Audit log rows older than the configured cutoff are deleted on a nightly schedule',
589
+ actors: 'System',
590
+ objects: 'Audit log rows',
591
+ rules: 'The Quartz trigger fires at 02:00 daily; the cutoff comes from configuration',
592
+ origin: 'intended',
593
+ confidence: { kind: 'confirmed' },
594
+ citations: [
595
+ { kind: 'ledger', id: 'job-nightly-digest' },
596
+ { kind: 'src', path: 'Jobs/NightlyDigestJob.cs', lines: [11, 22] },
597
+ { kind: 'src', path: 'Jobs/NightlyDigestJob.cs', lines: [27, 31] },
598
+ ],
599
+ parity: null,
600
+ },
601
+ {
602
+ id: 'AR-002',
603
+ cap: 'audit-retention',
604
+ requirement: 'The AuditLog table stores one row per user action with a creation timestamp',
605
+ actors: '-',
606
+ objects: 'Audit log row',
607
+ rules: 'UserId, Action and CreatedAt are all NOT NULL',
608
+ origin: 'intended',
609
+ confidence: { kind: 'confirmed' },
610
+ citations: [
611
+ { kind: 'ledger', id: 'table-audit-log' },
612
+ { kind: 'src', path: 'Schema.sql', lines: [7, 12] },
613
+ ],
614
+ parity: null,
615
+ },
616
+ {
617
+ id: 'AR-003',
618
+ cap: 'audit-retention',
619
+ requirement:
620
+ 'NightlyDigestCutoffDays sets how old an audit row must be before the nightly purge deletes it',
621
+ actors: '-',
622
+ objects: 'Retention cutoff',
623
+ rules: 'The value is parsed as an integer number of days and subtracted from the current time',
624
+ origin: 'intended',
625
+ confidence: { kind: 'confirmed' },
626
+ citations: [
627
+ { kind: 'ledger', id: 'setting-nightly-digest-cutoff-days' },
628
+ { kind: 'src', path: 'web.config', lines: [3, 6] },
629
+ { kind: 'src', path: 'Jobs/NightlyDigestJob.cs', lines: [13, 13] },
630
+ ],
631
+ parity: null,
632
+ },
633
+ ]
634
+
635
+ // Fifteen elements map to a requirement; the sixteenth is the seam exception,
636
+ // carried forward unmapped on purpose with the queue id the refs gate checks.
637
+ type Disposition = { kind: 'mapped'; fr: string } | { kind: 'out-of-scope'; queue: string }
638
+
639
+ const DISPOSITIONS: Record<string, Disposition> = {
640
+ 'screen-default': { kind: 'mapped', fr: 'SU-001' },
641
+ 'route-default-aspx': { kind: 'mapped', fr: 'SU-001' },
642
+ 'route-post-api-users': { kind: 'mapped', fr: 'SU-002' },
643
+ 'workflow-signup-welcome': { kind: 'mapped', fr: 'SU-003' },
644
+ 'setting-welcome-email-enabled': { kind: 'mapped', fr: 'SU-004' },
645
+ 'route-get-api-users-id-welcome': { kind: 'mapped', fr: 'WA-001' },
646
+ 'integration-billing-sync': { kind: 'mapped', fr: 'WA-001' },
647
+ 'screen-users': { kind: 'mapped', fr: 'WA-002' },
648
+ 'route-users-aspx': { kind: 'mapped', fr: 'WA-002' },
649
+ 'route-get-api-users': { kind: 'mapped', fr: 'UD-001' },
650
+ 'table-users': { kind: 'mapped', fr: 'UD-002' },
651
+ 'report-daily-users': { kind: 'mapped', fr: 'UD-003' },
652
+ 'job-nightly-digest': { kind: 'mapped', fr: 'AR-001' },
653
+ 'table-audit-log': { kind: 'mapped', fr: 'AR-002' },
654
+ 'setting-nightly-digest-cutoff-days': { kind: 'mapped', fr: 'AR-003' },
655
+ 'setting-default-connection': {
656
+ kind: 'out-of-scope',
657
+ queue: 'q-webforms-default-connection-ownership',
658
+ },
659
+ }
660
+
661
+ const EXTRACT_CENSUS: Record<string, unknown>[] = [
662
+ {
663
+ kind: 'rule-sweep',
664
+ subject: 'signup',
665
+ phase: 'extract',
666
+ probes: 2,
667
+ found: 1,
668
+ as_requirements: 1,
669
+ queued: [],
670
+ batch: 'b-rules-signup-001',
671
+ },
672
+ {
673
+ kind: 'rule-sweep',
674
+ subject: 'welcome-activation',
675
+ phase: 'extract',
676
+ probes: 2,
677
+ found: 1,
678
+ as_requirements: 1,
679
+ queued: [],
680
+ batch: 'b-rules-welcome-activation-001',
681
+ },
682
+ {
683
+ kind: 'rule-sweep',
684
+ subject: 'user-directory',
685
+ phase: 'extract',
686
+ probes: 2,
687
+ found: 1,
688
+ as_requirements: 1,
689
+ queued: [],
690
+ batch: 'b-rules-user-directory-001',
691
+ },
692
+ {
693
+ kind: 'rule-sweep',
694
+ subject: 'audit-retention',
695
+ phase: 'extract',
696
+ probes: 2,
697
+ found: 1,
698
+ as_requirements: 1,
699
+ queued: [],
700
+ batch: 'b-rules-audit-retention-001',
701
+ },
702
+ {
703
+ // Schema.sql:1-5 declares Id, Email and IsActive; the SELECT at
704
+ // Controllers/UsersController.cs:29 names two of them. `Id` is an identity
705
+ // key, which extract.md's exemption list keeps out of `behavioral`, leaving
706
+ // Email (UD-002) and IsActive (UD-003) as the two that are explained.
707
+ kind: 'attribute',
708
+ surface: 'tables',
709
+ subject: 'table-users',
710
+ phase: 'extract',
711
+ directions: {
712
+ ddl: { count: 3, evidence: 'column list from CREATE TABLE Users in Schema.sql' },
713
+ reader: {
714
+ count: 2,
715
+ evidence: 'columns named in the SELECT at Controllers/UsersController.cs:29',
716
+ },
717
+ },
718
+ total: 3,
719
+ behavioral: 2,
720
+ explained: 2,
721
+ queued: [],
722
+ batch: 'b-attr-table-users-001',
723
+ },
724
+ {
725
+ // workflow-signup-welcome spans signup (route-post-api-users,
726
+ // setting-welcome-email-enabled) and welcome-activation
727
+ // (route-get-api-users-id-welcome): a genuine cross-capability seam, found
728
+ // and covered on the spot by SU-003, which describes the whole journey
729
+ // rather than either half of it.
730
+ kind: 'closer',
731
+ closer: 'cross-capability-workflow',
732
+ phase: 'extract',
733
+ checked: 1,
734
+ findings: 1,
735
+ fixed: 1,
736
+ queued: [],
737
+ batch: 'b-closer-cross-capability-001',
738
+ },
739
+ {
740
+ kind: 'closer',
741
+ closer: 'scope-injection',
742
+ phase: 'extract',
743
+ checked: 12,
744
+ findings: 0,
745
+ fixed: 0,
746
+ queued: [],
747
+ batch: 'b-closer-scope-injection-001',
748
+ },
749
+ {
750
+ // A real finding this pass cannot resolve: NightlyDigestJob deletes
751
+ // AuditLog rows, and nothing anywhere in the checkout ever inserts one.
752
+ kind: 'closer',
753
+ closer: 'read-write-symmetry',
754
+ phase: 'extract',
755
+ checked: 12,
756
+ findings: 1,
757
+ fixed: 0,
758
+ queued: ['q-webforms-auditlog-never-written'],
759
+ batch: 'b-closer-read-write-symmetry-001',
760
+ },
761
+ ]
762
+
763
+ // ---------------------------------------------------------------------------
764
+ // Phase 4, parity. source.basis is source-only (init's default, and the honest
765
+ // call for a checkout with no .csproj and no SDK to build it), so
766
+ // `differential` is unavailable: it needs a live legacy system to diff
767
+ // against. `golden-master` survives only where the source itself ships the
768
+ // artifact that determines the output, which is parity.md's stated exception
769
+ // and covers the DDL and the report definition. Everything else is `rubric`,
770
+ // and every rubric below `high` carries the queue id the refs gate checks.
771
+ type ParityPlan =
772
+ | { kind: 'golden-master'; capability: string; frSlug: string }
773
+ | { kind: 'rubric'; level: string; queue?: string }
774
+
775
+ const PARITY: Record<string, ParityPlan | null> = {
776
+ 'SU-001': { kind: 'rubric', level: 'high' },
777
+ 'SU-002': { kind: 'rubric', level: 'high' },
778
+ 'SU-003': { kind: 'rubric', level: 'high' },
779
+ 'SU-004': { kind: 'rubric', level: 'high' },
780
+ 'WA-001': { kind: 'rubric', level: 'moderate', queue: 'q-webforms-billing-notify-unobservable' },
781
+ 'WA-002': { kind: 'rubric', level: 'high' },
782
+ 'UD-001': null,
783
+ 'UD-002': { kind: 'golden-master', capability: 'user-directory', frSlug: 'users-table' },
784
+ 'UD-003': { kind: 'golden-master', capability: 'user-directory', frSlug: 'daily-users-report' },
785
+ 'AR-001': { kind: 'rubric', level: 'high' },
786
+ 'AR-002': { kind: 'golden-master', capability: 'audit-retention', frSlug: 'audit-log-table' },
787
+ 'AR-003': { kind: 'rubric', level: 'high' },
788
+ }
789
+
790
+ const DELTA = {
791
+ id: 'delta-in-process-controller-call',
792
+ scope: 'The signup screen reaching the user-creation route (SU-001)',
793
+ rationale:
794
+ 'Default.aspx.cs:10 constructs UsersController directly and calls CreateUser in process, with no HTTP request between the two. The target reaches the same route over HTTP, so status codes, model binding and error surfacing all differ for reasons that have nothing to do with whether a user was created.',
795
+ parity_exclusion:
796
+ 'The SU-001 parity check must not assert on transport, status code or model-binding behavior, only on the user row that results and the redirect that follows it.',
797
+ validation:
798
+ "A greenfield-only test covers the target's HTTP error surface for this route; the parity suite does not re-prove it.",
799
+ owner_signed: null as string | null,
800
+ }
801
+
802
+ // Four queue items beyond the enumerate scaffold. Three are named by a field
803
+ // the refs gate actually checks (UD-001's confidence.queue, WA-001's
804
+ // parity.queue, setting-default-connection's disposition.queue); the fourth is
805
+ // named only by a closer census's own `queued` array, which queue.md is
806
+ // explicit no gate ever cross-checks. Filed anyway, as the manual's discipline.
807
+ const QUEUE_ITEMS: Record<string, string> = {
808
+ 'q-webforms-getusers-reader-lifetime': `---
809
+ id: q-webforms-getusers-reader-lifetime
810
+ severity: moderate
811
+ status: open
812
+ ---
813
+
814
+ ## Evidence
815
+
816
+ \`Controllers/UsersController.cs:25-32\` returns \`Ok(cmd.ExecuteReader())\` from
817
+ inside a \`using (var conn = OpenConnection())\` block. The connection is
818
+ disposed as the method returns, before anything can read from the reader it
819
+ just handed back.
820
+
821
+ ## Options
822
+
823
+ (a) Write the requirement against the query's evident intent, active users
824
+ only, and treat the lifetime bug as a defect the target should not
825
+ replicate. (b) Treat the endpoint as non-functional and record that. (c) Ask
826
+ the operator whether this endpoint ever returned rows in production.
827
+
828
+ ## Recommendation
829
+
830
+ Recommend (c); the query says one thing and the disposal order says another,
831
+ and only production behavior settles which one this run should carry forward.
832
+ `,
833
+ 'q-webforms-billing-notify-unobservable': `---
834
+ id: q-webforms-billing-notify-unobservable
835
+ severity: moderate
836
+ status: open
837
+ ---
838
+
839
+ ## Evidence
840
+
841
+ \`WA-001\` describes the outbound POST at \`Integrations/BillingClient.cs:10-14\`
842
+ to \`https://billing.example.com/activations\`. The basis for this run is
843
+ source-only, so there is no live legacy system to diff against and nothing to
844
+ capture a golden master from, and the call crosses a boundary neither could
845
+ reach in any case.
846
+
847
+ ## Options
848
+
849
+ (a) Ship \`rubric:low\` and revisit if a runnable environment appears.
850
+ (b) Block parity on this requirement until the billing system can be
851
+ observed. (c) Ship \`rubric:moderate\`: the guard conditions and the payload
852
+ are both readable from the source, the delivery itself is not.
853
+
854
+ ## Recommendation
855
+
856
+ Recommend (c); \`rubric:moderate\` matches exactly what is observable today.
857
+ `,
858
+ 'q-webforms-default-connection-ownership': `---
859
+ id: q-webforms-default-connection-ownership
860
+ severity: moderate
861
+ status: open
862
+ ---
863
+
864
+ ## Evidence
865
+
866
+ \`setting-default-connection\` (\`web.config:8\`) is read at
867
+ \`Controllers/UsersController.cs:17\` and \`Jobs/NightlyDigestJob.cs:14\`. Those
868
+ two lines serve three of this run's four capabilities between them, so no
869
+ capability has a claim on it the others do not, and surface-affinity
870
+ clustering has nothing to say about it either: it carries no ledger ref and
871
+ is named by none, so it is an edgeless singleton.
872
+
873
+ ## Options
874
+
875
+ (a) File it under \`user-directory\`, which owns the users table it mostly
876
+ reaches. (b) Declare a fifth, infrastructure-shaped capability to hold shared
877
+ configuration. (c) Leave it out of the partition and carry it forward
878
+ out-of-scope until an owner decides where shared infrastructure belongs in
879
+ the target.
880
+
881
+ ## Recommendation
882
+
883
+ Recommend (c) for now; (a) would hide a shared dependency inside one
884
+ capability's fanout, and (b) is a partition decision worth an owner's
885
+ signature rather than this pass's guess.
886
+ `,
887
+ 'q-webforms-auditlog-never-written': `---
888
+ id: q-webforms-auditlog-never-written
889
+ severity: moderate
890
+ status: open
891
+ ---
892
+
893
+ ## Evidence
894
+
895
+ The \`read-write-symmetry\` closer checked every write path against a matching
896
+ read path. \`Jobs/NightlyDigestJob.cs:17\` deletes from \`dbo.AuditLog\`, and
897
+ \`Schema.sql:7-12\` declares the table, but nothing anywhere in the checkout
898
+ ever inserts a row into it: the only two SQL writes in the source are the
899
+ \`INSERT INTO Users\` at \`Controllers/UsersController.cs:44\` and that delete.
900
+
901
+ ## Options
902
+
903
+ (a) Widen the search; the writer may live outside this checkout, in a trigger
904
+ or a separate service. (b) Treat it as a real gap for the target to fix
905
+ rather than replicate. (c) Ask the operator whether audit rows were ever
906
+ written by this application.
907
+
908
+ ## Recommendation
909
+
910
+ Recommend (c); a table with a purge job and no writer anywhere is exactly
911
+ what this closer exists to surface, and only the operator can say whether the
912
+ writer is missing or merely elsewhere.
913
+ `,
914
+ }
915
+
916
+ // Substitutes a parity plan into the row shape `migrate import reqs` accepts.
917
+ // `{capability}` and `{fr_slug}` come from target.parity_test_path as read off
918
+ // config.toml; parity.md is explicit that nothing in the CLI derives an
919
+ // fr_slug or checks a ref against the template, so this substitution is the
920
+ // whole of the convention.
921
+ function renderParity(plan: ParityPlan | null, template: string): Record<string, unknown> | null {
922
+ if (plan === null) return null
923
+ if (plan.kind === 'rubric') {
924
+ return { kind: 'rubric', level: plan.level, ...(plan.queue ? { queue: plan.queue } : {}) }
925
+ }
926
+ const ref = template
927
+ .replaceAll('{capability}', plan.capability)
928
+ .replaceAll('{fr_slug}', plan.frSlug)
929
+ return { kind: 'golden-master', ref }
930
+ }
931
+
932
+ function readTomlString(text: string, key: string): string {
933
+ const match = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'))
934
+ if (!match?.[1]) throw new Error(`config.toml has no ${key}`)
935
+ return match[1]
936
+ }
937
+
938
+ test('aspnet recipe run driven probe through queue, ending green at check --phase queue', async () => {
939
+ const groundTruthPath = join(source, 'GROUND-TRUTH.md')
940
+ const rows = await parseGroundTruth(groundTruthPath)
941
+ expect(rows.length).toBeGreaterThan(0)
942
+ const groups = groupBySurface(rows)
943
+
944
+ // 1. migrate init, with --source-stack aspnet-webforms so enumerate.md
945
+ // reads references/recipes/aspnet.md instead of falling into contract-only
946
+ // mode: that recipe path, never exercised against a committed fixture
947
+ // before this test, is the one this whole fixture exists to prove out.
948
+ const init = await migrate([
949
+ 'init',
950
+ '--source',
951
+ source,
952
+ '--scope',
953
+ 'the whole tiny webforms app',
954
+ '--name',
955
+ 'webforms-next',
956
+ '--source-stack',
957
+ 'aspnet-webforms',
958
+ ])
959
+ expect(init.code).toBe(0)
960
+
961
+ // Both defaults init wrote are left exactly as they are: the eight surface
962
+ // types, because exercising all eight is this fixture's whole point, and the
963
+ // three closers, because this run reaches extract and therefore owes a
964
+ // closer census record for each one. parity_test_path is read back rather
965
+ // than assumed, per probe.md and parity.md, since an operator may have
966
+ // edited it and nothing downstream would notice.
967
+ const cfgPath = join(target, '.migrate', 'config.toml')
968
+ const cfg = await readFile(cfgPath, 'utf8')
969
+ const parityTemplate = readTomlString(cfg, 'parity_test_path')
970
+ expect(parityTemplate).toContain('{capability}')
971
+ expect(parityTemplate).toContain('{fr_slug}')
972
+
973
+ // 2. migrate phase probe --status done.
974
+ expect((await migrate(['phase', 'probe', '--status', 'done'])).code).toBe(0)
975
+
976
+ // File the queue item every element's placeholder disposition below cites.
977
+ const queuePath = await writeText(`${QUEUE_ID}.md`, QUEUE_ITEM)
978
+ expect((await migrate(['queue', 'add', queuePath])).code).toBe(0)
979
+
980
+ // 4. migrate import elements, one batch per surface, rows from GROUND-TRUTH.md.
981
+ for (const [surface, surfaceRows] of groups) {
982
+ const batch = await write(`elements-${surface}.json`, {
983
+ batch: `b-${surface}-elements-001`,
984
+ phase: 'enumerate',
985
+ rows: surfaceRows.map((row) => ({
986
+ id: row.id,
987
+ surface: row.surface,
988
+ element: row.element,
989
+ found_by: ['code'],
990
+ disposition: { kind: 'out-of-scope', queue: QUEUE_ID },
991
+ refs: REFS[row.id] ?? [],
992
+ lens: 'code',
993
+ notes: '',
994
+ })),
995
+ })
996
+ const result = await migrate(['import', 'elements', batch])
997
+ expect(result.code).toBe(0)
998
+ }
999
+
1000
+ // aspnet.md names twenty-one directions across these eight surfaces, and
1001
+ // seven of them find nothing on this fixture. Both numbers are counted here
1002
+ // rather than claimed in a comment, so dropping a direction or quietly
1003
+ // omitting a zero is a test failure rather than something a reader has to
1004
+ // notice.
1005
+ const declaredDirections = Object.values(DIRECTIONS).flatMap((d) => Object.values(d))
1006
+ expect(declaredDirections.length).toBe(21)
1007
+ expect(declaredDirections.filter((d) => d.count === 0).length).toBe(7)
1008
+
1009
+ // 5. migrate census, one balanced lens record per surface, directions named
1010
+ // by aspnet.md for that surface, evidence set to the recipe's own probe
1011
+ // command, total within the bounds those directions imply.
1012
+ for (const [surface, surfaceRows] of groups) {
1013
+ const count = surfaceRows.length
1014
+ const directions = DIRECTIONS[surface]
1015
+ if (!directions) throw new Error(`no aspnet.md directions declared for surface ${surface}`)
1016
+ const record = await write(`census-${surface}.json`, {
1017
+ kind: 'lens',
1018
+ surface,
1019
+ phase: 'enumerate',
1020
+ directions,
1021
+ total: count,
1022
+ in_ledger: 0,
1023
+ added: count,
1024
+ skipped: [],
1025
+ queued: [],
1026
+ batch: `b-${surface}-census-001`,
1027
+ })
1028
+ const result = await migrate(['census', record])
1029
+ expect(result.code).toBe(0)
1030
+ expect(result.out).toContain(`census: recorded lens:${surface}`)
1031
+ }
1032
+
1033
+ // 6. migrate phase enumerate --status done.
1034
+ expect((await migrate(['phase', 'enumerate', '--status', 'done'])).code).toBe(0)
1035
+
1036
+ // 7. migrate check --phase enumerate. Every gate this phase owns is clean,
1037
+ // and the only violations left are the three declared closers, whose records
1038
+ // belong to extract and do not exist yet: exactly the mid-run posture
1039
+ // enumerate.md describes, where the census gate reads the whole store
1040
+ // regardless of --phase. Asserting the count pins that down harder than
1041
+ // asserting cleanliness would, because it fails if anything else starts
1042
+ // failing too.
1043
+ const enumerated = await migrate(['check', '--phase', 'enumerate'])
1044
+ expect(enumerated.out).toContain(
1045
+ `0/${rows.length} mapped, ${rows.length} out-of-scope, 0 unaccounted`,
1046
+ )
1047
+ expect(enumerated.out).toContain('Violations (3):')
1048
+ expect(enumerated.out).toContain(' census:')
1049
+ for (const closer of ['cross-capability-workflow', 'scope-injection', 'read-write-symmetry']) {
1050
+ expect(enumerated.out).toContain(` declared closer ${closer} has no census record`)
1051
+ }
1052
+ expect(enumerated.code).toBe(1)
1053
+
1054
+ // 7b. Surface-affinity clustering's graph, built straight from the store
1055
+ // the same way seam.md's worked example builds it. This is the assertion
1056
+ // that would fail if enumerate's ref-recording step (step 4) were skipped:
1057
+ // an empty REFS map above would leave every element's refs empty, and this
1058
+ // graph would have zero edges, which is exactly the defect this task
1059
+ // exists to close.
1060
+ const elementsForAffinity = (await readFile(join(target, '.migrate', 'elements.jsonl'), 'utf8'))
1061
+ .split('\n')
1062
+ .filter((line) => line.trim().length > 0)
1063
+ .map((line) => JSON.parse(line) as StoredElement)
1064
+ const edges = buildAffinityEdges(elementsForAffinity)
1065
+ expect(edges.size).toBeGreaterThan(0)
1066
+ const expectedEdges = [
1067
+ ['route-get-api-users', 'table-users'],
1068
+ ['route-post-api-users', 'table-users'],
1069
+ ['route-get-api-users-id-welcome', 'integration-billing-sync'],
1070
+ ['job-nightly-digest', 'table-audit-log'],
1071
+ ['report-daily-users', 'table-users'],
1072
+ ['workflow-signup-welcome', 'route-post-api-users'],
1073
+ ['workflow-signup-welcome', 'route-get-api-users-id-welcome'],
1074
+ ['workflow-signup-welcome', 'setting-welcome-email-enabled'],
1075
+ ['screen-default', 'route-post-api-users'],
1076
+ ['screen-users', 'route-get-api-users-id-welcome'],
1077
+ ].map(([a, b]) => [a, b].sort().join('|'))
1078
+ for (const edge of expectedEdges) {
1079
+ expect(edges.has(edge)).toBe(true)
1080
+ }
1081
+ expect(edges.size).toBe(expectedEdges.length)
1082
+
1083
+ // 8. Phase 2, seam. No CLI verb authors any of the three artifacts, so all
1084
+ // three are written by hand here exactly as seam.md says a real run writes
1085
+ // them. The partition is checked against the ground truth first: fifteen
1086
+ // elements in exactly one capability each, and the sixteenth deliberately in
1087
+ // none. No gate checks any of that (nothing reads capabilities.elements at
1088
+ // all), which is precisely why the assertion is here.
1089
+ const assigned = CAPABILITIES.flatMap((c) => c.elements)
1090
+ expect(new Set(assigned).size).toBe(assigned.length)
1091
+ expect([...assigned, ...UNASSIGNED].sort()).toEqual(rows.map((r) => r.id).sort())
1092
+ expect(UNASSIGNED).toEqual(['setting-default-connection'])
1093
+
1094
+ const storeDir = join(target, '.migrate')
1095
+ await writeFile(
1096
+ join(storeDir, 'capabilities.jsonl'),
1097
+ `${CAPABILITIES.map((c) => JSON.stringify(c)).join('\n')}\n`,
1098
+ )
1099
+ await writeFile(join(storeDir, 'seam.json'), `${JSON.stringify(SEAM_JSON, null, 2)}\n`)
1100
+ await writeFile(
1101
+ join(storeDir, 'seam.md'),
1102
+ '# Seam evidence\n\nsurface-affinity clustering over the ledger refs, connected components then the\ngreedy modularity refinement, Q = 0.505. The other three validators could not\nrun: no live database, no static C# call graph, no VCS history in the copied\nfixture. setting-default-connection is left out of every capability on purpose;\nsee q-webforms-default-connection-ownership.\n',
1103
+ )
1104
+ expect((await migrate(['phase', 'seam', '--status', 'done'])).code).toBe(0)
1105
+
1106
+ // 9. Phase 3, extract.
1107
+ for (const id of Object.keys(DISPOSITIONS)) {
1108
+ expect(rows.some((r) => r.id === id)).toBe(true)
1109
+ }
1110
+ expect(Object.keys(DISPOSITIONS).length).toBe(rows.length)
1111
+
1112
+ const reqBatch = await write('reqs.json', {
1113
+ batch: 'b-reqs-extract-001',
1114
+ phase: 'extract',
1115
+ rows: REQUIREMENTS,
1116
+ })
1117
+ const reqImport = await migrate(['import', 'reqs', reqBatch])
1118
+ expect(reqImport.code).toBe(0)
1119
+ expect(reqImport.out).toContain(
1120
+ `import reqs: ${REQUIREMENTS.length} added, 0 updated, batch b-reqs-extract-001`,
1121
+ )
1122
+
1123
+ for (const [id, body] of Object.entries(QUEUE_ITEMS)) {
1124
+ const path = await writeText(`${id}.md`, body)
1125
+ const added = await migrate(['queue', 'add', path])
1126
+ expect(added.code).toBe(0)
1127
+ expect(added.out).toContain(`queue add: ${id}`)
1128
+ }
1129
+
1130
+ const disposed = await write('elements-disposed.json', {
1131
+ batch: 'b-elements-disposition-001',
1132
+ phase: 'extract',
1133
+ rows: rows.map((row) => ({
1134
+ id: row.id,
1135
+ surface: row.surface,
1136
+ element: row.element,
1137
+ found_by: ['code'],
1138
+ disposition: DISPOSITIONS[row.id],
1139
+ refs: REFS[row.id] ?? [],
1140
+ lens: 'code',
1141
+ notes: '',
1142
+ })),
1143
+ })
1144
+ const disposeImport = await migrate(['import', 'elements', disposed])
1145
+ expect(disposeImport.code).toBe(0)
1146
+ expect(disposeImport.out).toContain(`import elements: 0 added, ${rows.length} updated`)
1147
+
1148
+ for (const record of EXTRACT_CENSUS) {
1149
+ const path = await write(`census-${record.batch}.json`, record)
1150
+ const result = await migrate(['census', path])
1151
+ expect(result.code).toBe(0)
1152
+ }
1153
+ expect((await migrate(['phase', 'extract', '--status', 'done'])).code).toBe(0)
1154
+
1155
+ // 10. Phase 4, parity. The delta goes in unsigned first, so the deltas gate
1156
+ // is shown failing on it before it is shown clean.
1157
+ const unsigned = await write('deltas.json', {
1158
+ batch: 'b-deltas-parity-001',
1159
+ phase: 'parity',
1160
+ rows: [DELTA],
1161
+ })
1162
+ expect((await migrate(['import', 'deltas', unsigned])).code).toBe(0)
1163
+ const withUnsignedDelta = await migrate(['check', '--phase', 'parity'])
1164
+ expect(withUnsignedDelta.code).toBe(1)
1165
+ expect(withUnsignedDelta.out).toContain(' deltas:')
1166
+ expect(withUnsignedDelta.out).toContain(` ${DELTA.id} is not owner-signed`)
1167
+
1168
+ const signed = await write('deltas-signed.json', {
1169
+ batch: 'b-deltas-parity-002',
1170
+ phase: 'parity',
1171
+ rows: [{ ...DELTA, owner_signed: '2026-08-08' }],
1172
+ })
1173
+ expect((await migrate(['import', 'deltas', signed])).code).toBe(0)
1174
+
1175
+ const parityBatch = await write('reqs-parity.json', {
1176
+ batch: 'b-reqs-parity-001',
1177
+ phase: 'parity',
1178
+ rows: REQUIREMENTS.map((r) => ({
1179
+ ...r,
1180
+ parity: renderParity(PARITY[r.id] ?? null, parityTemplate),
1181
+ })),
1182
+ })
1183
+ const parityImport = await migrate(['import', 'reqs', parityBatch])
1184
+ expect(parityImport.code).toBe(0)
1185
+ // Every row counts as updated, including UD-001, whose parity stays null:
1186
+ // the importer stamps its own batch id onto every row it writes, so a row
1187
+ // whose content is otherwise unchanged still differs from the one on disk.
1188
+ expect(parityImport.out).toContain(
1189
+ `import reqs: 0 added, ${REQUIREMENTS.length} updated, batch b-reqs-parity-001`,
1190
+ )
1191
+ expect((await migrate(['phase', 'parity', '--status', 'done'])).code).toBe(0)
1192
+
1193
+ // 11. Phase 5, queue. Every item this run owed was filed in the pass that
1194
+ // named it, so this phase closes on the status flip, exactly as queue.md
1195
+ // says: closing is not "the queue is empty", since nothing in this milestone
1196
+ // adjudicates an item.
1197
+ const listed = await migrate(['queue', 'list', '--open'])
1198
+ expect(listed.code).toBe(0)
1199
+ expect(listed.out).toContain(`${Object.keys(QUEUE_ITEMS).length + 1} item(s)`)
1200
+ expect((await migrate(['phase', 'queue', '--status', 'done'])).code).toBe(0)
1201
+
1202
+ // 12. The terminus. Fifteen of sixteen mapped, the sixteenth out-of-scope by
1203
+ // the seam decision, no violations, exit 0.
1204
+ const green = await migrate(['check', '--phase', 'queue'])
1205
+ expect(green.out).toContain(
1206
+ `${rows.length - UNASSIGNED.length}/${rows.length} mapped, ${UNASSIGNED.length} out-of-scope, 0 unaccounted`,
1207
+ )
1208
+ expect(green.out).not.toContain('Violations')
1209
+ expect(green.code).toBe(0)
1210
+
1211
+ // 13. Plain `migrate check` gates every phase through handoff, and fails on
1212
+ // exactly the two that have no verb in this milestone. The violation count
1213
+ // is what makes "exactly" an assertion rather than a hope.
1214
+ const full = await migrate(['check'])
1215
+ expect(full.code).toBe(1)
1216
+ expect(full.out).toContain('Violations (2):')
1217
+ expect(full.out).toContain(' run-state:')
1218
+ expect(full.out).toContain(
1219
+ ' phase adjudicate is pending; every phase through handoff must be done',
1220
+ )
1221
+ expect(full.out).toContain(
1222
+ ' phase handoff is pending; every phase through handoff must be done',
1223
+ )
1224
+
1225
+ // 14. The terminus assertion in step 12 is load-bearing, shown by mutation
1226
+ // rather than asserted: nulling every requirement's parity plan is a phase-4
1227
+ // regression and nothing else, and it must break the green check. Restoring
1228
+ // the file returns it to green, which is what proves the mutation was the
1229
+ // only cause.
1230
+ const requirementsPath = join(storeDir, 'requirements.jsonl')
1231
+ const requirementsText = await readFile(requirementsPath, 'utf8')
1232
+ const stripped = requirementsText
1233
+ .split('\n')
1234
+ .filter((line) => line.trim().length > 0)
1235
+ .map((line) => JSON.stringify({ ...(JSON.parse(line) as object), parity: null }))
1236
+ await writeFile(requirementsPath, `${stripped.join('\n')}\n`)
1237
+
1238
+ const noParity = await migrate(['check', '--phase', 'queue'])
1239
+ expect(noParity.code).toBe(1)
1240
+ expect(noParity.out).toContain(' parity:')
1241
+ expect(noParity.out).toContain(' SU-001 has no parity plan')
1242
+ // UD-001 is `queued`, the one confidence the parity gate exempts, so it must
1243
+ // not appear even now that its plan is null like everyone else's.
1244
+ expect(noParity.out).not.toContain('UD-001 has no parity plan')
1245
+
1246
+ await writeFile(requirementsPath, requirementsText)
1247
+ expect((await migrate(['check', '--phase', 'queue'])).code).toBe(0)
1248
+
1249
+ // 15. Removing one element row breaks the census reconciliation for its
1250
+ // surface, and only that: `in_ledger + added` on the surviving census
1251
+ // record still claims the pre-mutation count, but elements.jsonl now has
1252
+ // one fewer row for that surface. This is what makes the enumerate-phase
1253
+ // arithmetic load-bearing rather than vacuous: if the reconciliation check
1254
+ // were missing or broken, this mutation would leave the check clean too.
1255
+ const mutatedSurface = 'tables'
1256
+ const mutatedRows = groups.get(mutatedSurface)
1257
+ if (!mutatedRows || mutatedRows.length < 2) {
1258
+ throw new Error(`fixture must declare at least two ${mutatedSurface} rows to mutate one away`)
1259
+ }
1260
+ const removedId = mutatedRows[mutatedRows.length - 1]?.id
1261
+ const elementsPath = join(target, '.migrate', 'elements.jsonl')
1262
+ const elementsText = await readFile(elementsPath, 'utf8')
1263
+ const survivingLines = elementsText
1264
+ .split('\n')
1265
+ .filter((line) => line.trim().length > 0)
1266
+ .filter((line) => (JSON.parse(line) as { id: string }).id !== removedId)
1267
+ await writeFile(elementsPath, `${survivingLines.join('\n')}\n`)
1268
+
1269
+ const afterRemoval = await migrate(['check', '--phase', 'enumerate'])
1270
+ expect(afterRemoval.code).not.toBe(0)
1271
+ const claimed = mutatedRows.length
1272
+ const actual = mutatedRows.length - 1
1273
+ expect(afterRemoval.out).toContain(
1274
+ `lens census for ${mutatedSurface} claims in_ledger 0 + added ${claimed} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
1275
+ )
1276
+ })