@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,1093 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const CLI = join(import.meta.dir, '..', '..', 'bin', 'migrate.ts')
7
+ const FIXTURE = join(import.meta.dir, '..', '..', 'fixtures', 'tiny-express')
8
+
9
+ let target: string
10
+ let source: string
11
+
12
+ async function migrate(args: string[]): Promise<{ code: number; out: string; err: string }> {
13
+ const proc = Bun.spawn(['bun', CLI, ...args], { cwd: target, stdout: 'pipe', stderr: 'pipe' })
14
+ const [out, err] = await Promise.all([
15
+ new Response(proc.stdout).text(),
16
+ new Response(proc.stderr).text(),
17
+ ])
18
+ await proc.exited
19
+ return { code: proc.exitCode ?? -1, out, err }
20
+ }
21
+
22
+ async function write(name: string, value: unknown): Promise<string> {
23
+ const path = join(target, name)
24
+ await writeFile(path, JSON.stringify(value))
25
+ return path
26
+ }
27
+
28
+ 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-express-'))
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. Reading
45
+ // and parsing it here, rather than hand-copying its rows into this file, is
46
+ // the point: a row added to the fixture without a matching update here (or
47
+ // vice versa) 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
+ // Express has no recipe pack in references/recipes/ (only aspnet.md exists),
83
+ // so enumerate.md's contract-only mode applies: two independently-derived
84
+ // directions per surface, supplied by the agent instead of a recipe file.
85
+ // Both directions below find exactly the fixture's own elements, since this
86
+ // is a fixture small enough that a real agent's two directions would too.
87
+ // Every evidence string below names a command actually re-run against the
88
+ // fixture (not merely a plausible-sounding description) to confirm it
89
+ // produces the count this surface's rows carry; nothing downstream executes
90
+ // these strings, so an unverified one would sit undetected the same way a
91
+ // stale src citation's line range does.
92
+ //
93
+ // Six of the eight (routes, jobs, reports, screens, integrations, workflows)
94
+ // have both directions read the same single file; only tables and settings
95
+ // cross-reference a second one. That is a property of a fixture this small,
96
+ // not evidence of stronger independent triangulation than it actually
97
+ // carries, and nobody should cite it as the latter.
98
+ const DIRECTIONS: Record<string, [string, string]> = {
99
+ routes: ['grep "app.(get|post)" across app.js', 'manual walk of route registrations in app.js'],
100
+ tables: [
101
+ 'grep "CREATE TABLE" across schema.sql',
102
+ 'manual review of table names referenced in app.js, cron.js and reports/',
103
+ ],
104
+ jobs: ['grep "cron.schedule" across cron.js', 'manual review of cron.js'],
105
+ reports: ['ls reports/*.json', 'manual review of report definitions under reports/'],
106
+ screens: ['ls views/*.html', 'manual review of templates under views/'],
107
+ integrations: ['grep "fetch(" across app.js', 'manual review of outbound HTTP calls in app.js'],
108
+ workflows: [
109
+ 'manual review of state stored and later consumed across app.js handlers',
110
+ 'grep "pendingWelcomes" across app.js',
111
+ ],
112
+ settings: [
113
+ 'grep "settings\\." across app.js, excluding the require line',
114
+ 'manual review of settings.json keys',
115
+ ],
116
+ }
117
+
118
+ // Ledger refs a lens would record per enumerate.md's Procedure, step 4:
119
+ // an element that touches one already in the ledger gets a
120
+ // {"kind": "ledger", "id": ...} entry naming it. See GROUND-TRUTH.md's
121
+ // "Element-to-element touches" section, which this map matches by hand,
122
+ // the same relationship DIRECTIONS above has to the fixture's source
123
+ // files: this is fixture metadata the test asserts against, not something
124
+ // parsed off disk.
125
+ type LedgerRef = { kind: 'ledger'; id: string }
126
+ const REFS: Record<string, LedgerRef[]> = {
127
+ 'route-get-api-users': [{ kind: 'ledger', id: 'table-users' }],
128
+ 'route-post-api-users': [{ kind: 'ledger', id: 'table-users' }],
129
+ 'job-purge-audit-log': [{ kind: 'ledger', id: 'table-audit-log' }],
130
+ 'report-daily-users': [{ kind: 'ledger', id: 'table-users' }],
131
+ 'workflow-welcome-email': [
132
+ { kind: 'ledger', id: 'route-post-api-users' },
133
+ { kind: 'ledger', id: 'route-get-api-users-id-welcome' },
134
+ { kind: 'ledger', id: 'setting-welcome-email-enabled' },
135
+ ],
136
+ }
137
+
138
+ // Builds the same graph seam.md's surface-affinity clustering builds: one
139
+ // edge per {"kind": "ledger", ...} ref on any element, connecting it to the
140
+ // id it names. An edge is an unordered pair, stored as its two ids sorted
141
+ // and joined, so a-touches-b and b-touches-a (were both ever recorded)
142
+ // would collapse to one edge rather than count twice.
143
+ type StoredElement = { id: string; refs: { kind: string; id?: string }[] }
144
+
145
+ function buildAffinityEdges(elements: StoredElement[]): Set<string> {
146
+ const edges = new Set<string>()
147
+ for (const el of elements) {
148
+ for (const ref of el.refs) {
149
+ if (ref.kind === 'ledger' && ref.id) {
150
+ edges.add([el.id, ref.id].sort().join('|'))
151
+ }
152
+ }
153
+ }
154
+ return edges
155
+ }
156
+
157
+ const QUEUE_ID = 'q-tiny-express-enumerate-scaffold'
158
+
159
+ const QUEUE_ITEM = `---
160
+ id: ${QUEUE_ID}
161
+ severity: minor
162
+ status: open
163
+ ---
164
+
165
+ ## Evidence
166
+
167
+ This run asserts a clean \`check --phase enumerate\` before extract has run,
168
+ and coverage is a whole-store gate: it reads every element regardless of
169
+ \`--phase\`, so an element with no terminal disposition fails it at any
170
+ phase. No requirement exists yet at that point for an element to map to.
171
+
172
+ ## Options
173
+
174
+ (a) Leave every element unaccounted and give up on asserting the enumerate
175
+ slice clean at all. (b) Give every element a placeholder out-of-scope
176
+ disposition citing this item, which extract then replaces with a real
177
+ \`mapped\` disposition once there is a requirement to map to.
178
+
179
+ ## Recommendation
180
+
181
+ Recommend (b); it isolates the census and run-state assertions the
182
+ enumerate step exists to make from the coverage gate, and citing this item
183
+ keeps the placeholder auditable rather than silent. The item stays open
184
+ after extract supersedes it, because nothing in this milestone adjudicates
185
+ a queue item.
186
+ `
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Phase 2, seam. The partition surface-affinity clustering produces from the
190
+ // REFS above, following references/phases/seam.md's procedure rather than
191
+ // choosing capabilities by hand. Re-derived by running that manual's own
192
+ // formula over exactly the seven edges e2e asserts in step 6b:
193
+ //
194
+ // component 1 (n=2): job-purge-audit-log, table-audit-log
195
+ // component 2 (n=7): report-daily-users, route-get-api-users,
196
+ // route-get-api-users-id-welcome, route-post-api-users,
197
+ // setting-welcome-email-enabled, table-users, workflow-welcome-email
198
+ // edgeless singletons (n=3): integration-mailer, screen-users,
199
+ // setting-max-users-per-page
200
+ // m (total edges) = 7
201
+ // components-only Q = 0.245
202
+ //
203
+ // 0.245 is below the 0.3 floor, so the greedy refinement seam.md specifies
204
+ // runs on the seven-node component (every node in it starting as its own
205
+ // group, the two-node component left whole):
206
+ //
207
+ // starting Q (component exploded to singletons) = -0.010
208
+ // merge 1: {report-daily-users} + {table-users} -> Q = 0.102
209
+ // merge 2: {route-get-api-users-id-welcome} + {workflow-welcome-email} -> Q = 0.214
210
+ // merge 3: {route-get-api-users} + {report-daily-users, table-users} -> Q = 0.316
211
+ // merge 4: {setting-welcome-email-enabled} + {route-get-api-users-id-welcome,
212
+ // workflow-welcome-email} -> Q = 0.418
213
+ // merge 5: {route-post-api-users} + {report-daily-users, route-get-api-users,
214
+ // table-users} -> Q = 0.459
215
+ // no further join raises Q; stopping
216
+ //
217
+ // Q = 0.459 >= 0.3. Express has no schema this run can cluster, no statically
218
+ // parseable call graph, and the copied fixture carries no VCS history, so
219
+ // surface-affinity is the only validator that could run at all: the
220
+ // one-validator exception, not the two-agree rule, is what licenses accepting
221
+ // this split, exactly as seam.md's first worked example describes.
222
+ //
223
+ // The three edgeless elements are assigned by hand, per seam.md's edgeless
224
+ // rule, each on the source proximity that rule names, because the graph has
225
+ // nothing to say about a node with no edges:
226
+ // screen-users -> user-directory (views/users.html renders the
227
+ // list that capability's route serves)
228
+ // setting-max-users-per-page-> user-directory (app.js:8 is the only line
229
+ // that reads it, inside that capability's route)
230
+ // integration-mailer -> welcome-notification (app.js:21 is inside
231
+ // that capability's welcome handler)
232
+ type CapabilityRow = { slug: string; title: string; ns: string; elements: string[] }
233
+
234
+ const CAPABILITIES: CapabilityRow[] = [
235
+ {
236
+ slug: 'audit-retention',
237
+ title: 'Audit Retention',
238
+ ns: 'AR',
239
+ elements: ['job-purge-audit-log', 'table-audit-log'],
240
+ },
241
+ {
242
+ slug: 'user-directory',
243
+ title: 'User Directory',
244
+ ns: 'UD',
245
+ elements: [
246
+ 'report-daily-users',
247
+ 'route-get-api-users',
248
+ 'route-post-api-users',
249
+ 'table-users',
250
+ 'screen-users',
251
+ 'setting-max-users-per-page',
252
+ ],
253
+ },
254
+ {
255
+ slug: 'welcome-notification',
256
+ title: 'Welcome Notification',
257
+ ns: 'WN',
258
+ elements: [
259
+ 'route-get-api-users-id-welcome',
260
+ 'setting-welcome-email-enabled',
261
+ 'workflow-welcome-email',
262
+ 'integration-mailer',
263
+ ],
264
+ },
265
+ ]
266
+
267
+ const SEAM_JSON = {
268
+ validators: {
269
+ 'schema-clustering': { ran: false, reason: 'no relational schema this run can cluster' },
270
+ 'call-graph': { ran: false, reason: 'no static call-graph tooling in this environment' },
271
+ 'change-coupling': { ran: false, reason: 'the copied fixture carries no VCS history' },
272
+ 'surface-affinity': { ran: true, modularity: 0.459 },
273
+ },
274
+ agreement: ['surface-affinity'],
275
+ modularity: 0.459,
276
+ status: 'accepted',
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Phase 3, extract. One requirement per distinct behavior, at least one
281
+ // citation each, every citation's src path and line range real: the citations
282
+ // gate runs by default and resolves each one against the copied fixture, so a
283
+ // wrong line range here fails the run rather than sitting undetected.
284
+ // `parity` is null on every row, because assigning an oracle is phase 4's job;
285
+ // extract.md is explicit that the parity gate exempts nothing at confirmed or
286
+ // inferred, only at queued.
287
+ type Citation =
288
+ | { kind: 'ledger'; id: string }
289
+ | { kind: 'src'; path: string; lines: [number, number] }
290
+
291
+ type RequirementRow = {
292
+ id: string
293
+ cap: string
294
+ requirement: string
295
+ actors: string
296
+ objects: string
297
+ rules: string
298
+ origin: string
299
+ confidence: { kind: string; queue?: string }
300
+ citations: Citation[]
301
+ parity: null
302
+ }
303
+
304
+ const REQUIREMENTS: RequirementRow[] = [
305
+ {
306
+ id: 'UD-001',
307
+ cap: 'user-directory',
308
+ requirement: 'Listing users returns at most the configured maximum number of users per page',
309
+ actors: 'API client',
310
+ objects: 'User list',
311
+ rules: 'The response is sliced to settings.maxUsersPerPage entries',
312
+ origin: 'intended',
313
+ confidence: { kind: 'confirmed' },
314
+ citations: [
315
+ { kind: 'ledger', id: 'route-get-api-users' },
316
+ { kind: 'src', path: 'app.js', lines: [8, 8] },
317
+ { kind: 'src', path: 'settings.json', lines: [1, 4] },
318
+ ],
319
+ parity: null,
320
+ },
321
+ {
322
+ id: 'UD-002',
323
+ cap: 'user-directory',
324
+ requirement: 'Creating a user returns a generated id and records a pending welcome against it',
325
+ actors: 'API client',
326
+ objects: 'User',
327
+ rules: 'The id is minted from the current clock; the submitted email is held against it',
328
+ origin: 'intended',
329
+ confidence: { kind: 'confirmed' },
330
+ citations: [
331
+ { kind: 'ledger', id: 'route-post-api-users' },
332
+ { kind: 'src', path: 'app.js', lines: [10, 14] },
333
+ ],
334
+ parity: null,
335
+ },
336
+ {
337
+ id: 'UD-003',
338
+ cap: 'user-directory',
339
+ requirement:
340
+ 'The users table stores an id, a required email, and an active flag defaulting to set',
341
+ actors: '-',
342
+ objects: 'User row',
343
+ rules: 'email is NOT NULL; is_active defaults to 1',
344
+ origin: 'intended',
345
+ confidence: { kind: 'confirmed' },
346
+ citations: [
347
+ { kind: 'ledger', id: 'table-users' },
348
+ { kind: 'src', path: 'schema.sql', lines: [1, 5] },
349
+ ],
350
+ parity: null,
351
+ },
352
+ {
353
+ id: 'UD-004',
354
+ cap: 'user-directory',
355
+ requirement: 'The daily-users report counts only users whose active flag is set',
356
+ actors: 'Report consumer',
357
+ objects: 'Active user count',
358
+ rules: 'The query filters on is_active = 1 and runs once a day',
359
+ origin: 'intended',
360
+ confidence: { kind: 'confirmed' },
361
+ citations: [
362
+ { kind: 'ledger', id: 'report-daily-users' },
363
+ { kind: 'src', path: 'reports/daily-users.json', lines: [1, 6] },
364
+ ],
365
+ parity: null,
366
+ },
367
+ {
368
+ id: 'UD-005',
369
+ cap: 'user-directory',
370
+ requirement: 'The users screen renders one list entry per user email',
371
+ actors: 'Operator',
372
+ objects: 'User list screen',
373
+ rules: 'Each entry is bound to the email field',
374
+ origin: 'intended',
375
+ confidence: { kind: 'confirmed' },
376
+ citations: [
377
+ { kind: 'ledger', id: 'screen-users' },
378
+ { kind: 'src', path: 'views/users.html', lines: [1, 9] },
379
+ ],
380
+ parity: null,
381
+ },
382
+ {
383
+ // The one queued requirement in this run, and the reason it is queued is
384
+ // visible in the source rather than invented: app.js:8 answers the list
385
+ // route with a literal empty array sliced to the page cap. Nothing in the
386
+ // checkout shows the users table ever being read, so whether this endpoint
387
+ // is a stub or genuinely always empty is not decidable from here.
388
+ // A queued requirement is the one case the parity gate exempts, which is
389
+ // why this row keeps `parity: null` through phase 4 while every other row
390
+ // gets a plan.
391
+ id: 'UD-006',
392
+ cap: 'user-directory',
393
+ requirement: 'The users list endpoint reads the rows it returns from the users table',
394
+ actors: 'API client',
395
+ objects: 'User list',
396
+ rules: '-',
397
+ origin: 'intended',
398
+ confidence: { kind: 'queued', queue: 'q-express-user-list-source' },
399
+ citations: [
400
+ { kind: 'ledger', id: 'route-get-api-users' },
401
+ { kind: 'src', path: 'app.js', lines: [8, 8] },
402
+ { kind: 'src', path: 'schema.sql', lines: [1, 5] },
403
+ ],
404
+ parity: null,
405
+ },
406
+ {
407
+ id: 'WN-001',
408
+ cap: 'welcome-notification',
409
+ requirement:
410
+ 'The welcome route reports a send only when the welcome setting is enabled and a pending welcome exists for that id',
411
+ actors: 'API client',
412
+ objects: 'Pending welcome',
413
+ rules: 'Both settings.welcomeEmailEnabled and a stored email must hold, or nothing is sent',
414
+ origin: 'intended',
415
+ confidence: { kind: 'confirmed' },
416
+ citations: [
417
+ { kind: 'ledger', id: 'route-get-api-users-id-welcome' },
418
+ { kind: 'src', path: 'app.js', lines: [18, 27] },
419
+ { kind: 'src', path: 'settings.json', lines: [1, 4] },
420
+ ],
421
+ parity: null,
422
+ },
423
+ {
424
+ id: 'WN-002',
425
+ cap: 'welcome-notification',
426
+ requirement:
427
+ 'Signup and welcome are one two-step workflow carrying the pending email in process memory between the two requests',
428
+ actors: 'API client',
429
+ objects: 'Pending welcome state',
430
+ rules: 'Step one writes the map entry; step two consumes it',
431
+ origin: 'intended',
432
+ confidence: { kind: 'confirmed' },
433
+ citations: [
434
+ { kind: 'ledger', id: 'workflow-welcome-email' },
435
+ { kind: 'src', path: 'app.js', lines: [5, 6] },
436
+ { kind: 'src', path: 'app.js', lines: [16, 19] },
437
+ ],
438
+ parity: null,
439
+ },
440
+ {
441
+ id: 'WN-003',
442
+ cap: 'welcome-notification',
443
+ requirement: 'The welcome email is sent by an outbound POST to the mailer service',
444
+ actors: 'System',
445
+ objects: 'Welcome email',
446
+ rules: 'The recipient address is the email held against the pending welcome',
447
+ origin: 'intended',
448
+ // Inferred, not confirmed: the source shows the request being made, and
449
+ // nothing citable here shows what the mailer does with it. That is
450
+ // extract.md's own definition of inferred, a piece genuinely unobservable
451
+ // from a call to something outside the citable source.
452
+ confidence: { kind: 'inferred' },
453
+ citations: [
454
+ { kind: 'ledger', id: 'integration-mailer' },
455
+ { kind: 'src', path: 'app.js', lines: [21, 24] },
456
+ ],
457
+ parity: null,
458
+ },
459
+ {
460
+ id: 'AR-001',
461
+ cap: 'audit-retention',
462
+ requirement: 'Audit log rows older than thirty days are purged on a nightly schedule',
463
+ actors: 'System',
464
+ objects: 'Audit log rows',
465
+ rules: 'Runs at 02:00 daily; the cutoff is thirty days',
466
+ origin: 'intended',
467
+ confidence: { kind: 'confirmed' },
468
+ citations: [
469
+ { kind: 'ledger', id: 'job-purge-audit-log' },
470
+ { kind: 'src', path: 'cron.js', lines: [3, 6] },
471
+ ],
472
+ parity: null,
473
+ },
474
+ {
475
+ id: 'AR-002',
476
+ cap: 'audit-retention',
477
+ requirement: 'The audit log stores one row per user action with a creation timestamp',
478
+ actors: '-',
479
+ objects: 'Audit log row',
480
+ rules: 'user_id, action and created_at are all NOT NULL',
481
+ origin: 'intended',
482
+ confidence: { kind: 'confirmed' },
483
+ citations: [
484
+ { kind: 'ledger', id: 'table-audit-log' },
485
+ { kind: 'src', path: 'schema.sql', lines: [7, 12] },
486
+ ],
487
+ parity: null,
488
+ },
489
+ ]
490
+
491
+ // Every ground-truth element maps to one requirement. Asserted against the
492
+ // parsed GROUND-TRUTH.md rows below rather than trusted, so an element added
493
+ // to the fixture with no home here fails loudly instead of quietly ending the
494
+ // run unaccounted.
495
+ const DISPOSITIONS: Record<string, { kind: 'mapped'; fr: string }> = {
496
+ 'route-get-api-users': { kind: 'mapped', fr: 'UD-001' },
497
+ 'setting-max-users-per-page': { kind: 'mapped', fr: 'UD-001' },
498
+ 'route-post-api-users': { kind: 'mapped', fr: 'UD-002' },
499
+ 'table-users': { kind: 'mapped', fr: 'UD-003' },
500
+ 'report-daily-users': { kind: 'mapped', fr: 'UD-004' },
501
+ 'screen-users': { kind: 'mapped', fr: 'UD-005' },
502
+ 'route-get-api-users-id-welcome': { kind: 'mapped', fr: 'WN-001' },
503
+ 'setting-welcome-email-enabled': { kind: 'mapped', fr: 'WN-001' },
504
+ 'workflow-welcome-email': { kind: 'mapped', fr: 'WN-002' },
505
+ 'integration-mailer': { kind: 'mapped', fr: 'WN-003' },
506
+ 'job-purge-audit-log': { kind: 'mapped', fr: 'AR-001' },
507
+ 'table-audit-log': { kind: 'mapped', fr: 'AR-002' },
508
+ }
509
+
510
+ // One rule-sweep per capability, one attribute census for the only table with
511
+ // columns worth explaining, and one record per declared closer. extract.md is
512
+ // explicit that nothing gates the first two for completeness (only lens
513
+ // against [surfaces].types and closer against [closers].set), so they are here
514
+ // as the manual's discipline, not because a gate would catch their absence.
515
+ const EXTRACT_CENSUS: Record<string, unknown>[] = [
516
+ {
517
+ kind: 'rule-sweep',
518
+ subject: 'user-directory',
519
+ phase: 'extract',
520
+ probes: 2,
521
+ found: 1,
522
+ as_requirements: 1,
523
+ queued: [],
524
+ batch: 'b-rules-user-directory-001',
525
+ },
526
+ {
527
+ kind: 'rule-sweep',
528
+ subject: 'welcome-notification',
529
+ phase: 'extract',
530
+ probes: 2,
531
+ found: 1,
532
+ as_requirements: 1,
533
+ queued: [],
534
+ batch: 'b-rules-welcome-notification-001',
535
+ },
536
+ {
537
+ kind: 'rule-sweep',
538
+ subject: 'audit-retention',
539
+ phase: 'extract',
540
+ probes: 2,
541
+ found: 1,
542
+ as_requirements: 1,
543
+ queued: [],
544
+ batch: 'b-rules-audit-retention-001',
545
+ },
546
+ {
547
+ // schema.sql:1-5 declares id, email and is_active; the report query at
548
+ // reports/daily-users.json:4 names one of them. `id` is an identity key,
549
+ // which extract.md's exemption list keeps out of `behavioral`, leaving
550
+ // email (UD-003) and is_active (UD-004) as the two that are explained.
551
+ kind: 'attribute',
552
+ surface: 'tables',
553
+ subject: 'table-users',
554
+ phase: 'extract',
555
+ directions: {
556
+ ddl: { count: 3, evidence: 'column list from CREATE TABLE users in schema.sql' },
557
+ report_query: {
558
+ count: 1,
559
+ evidence: 'columns named in the query in reports/daily-users.json',
560
+ },
561
+ },
562
+ total: 3,
563
+ behavioral: 2,
564
+ explained: 2,
565
+ queued: [],
566
+ batch: 'b-attr-table-users-001',
567
+ },
568
+ {
569
+ // workflow-welcome-email spans route-post-api-users (user-directory) and
570
+ // both route-get-api-users-id-welcome and setting-welcome-email-enabled
571
+ // (welcome-notification): a genuine cross-capability seam, found and
572
+ // covered on the spot by WN-002, which describes the whole journey rather
573
+ // than either half.
574
+ kind: 'closer',
575
+ closer: 'cross-capability-workflow',
576
+ phase: 'extract',
577
+ checked: 1,
578
+ findings: 1,
579
+ fixed: 1,
580
+ queued: [],
581
+ batch: 'b-closer-cross-capability-001',
582
+ },
583
+ {
584
+ kind: 'closer',
585
+ closer: 'scope-injection',
586
+ phase: 'extract',
587
+ checked: 11,
588
+ findings: 0,
589
+ fixed: 0,
590
+ queued: [],
591
+ batch: 'b-closer-scope-injection-001',
592
+ },
593
+ {
594
+ // A real finding this pass cannot resolve: app.js writes nothing to the
595
+ // users table and reads nothing from it, so the only read on record is the
596
+ // report's query and there is no write anywhere at all.
597
+ kind: 'closer',
598
+ closer: 'read-write-symmetry',
599
+ phase: 'extract',
600
+ checked: 11,
601
+ findings: 1,
602
+ fixed: 0,
603
+ queued: ['q-express-users-table-unwired'],
604
+ batch: 'b-closer-read-write-symmetry-001',
605
+ },
606
+ ]
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Phase 4, parity. source.basis is source-only (init's default, and the honest
610
+ // call for a copied fixture with no package.json and no installed
611
+ // dependencies), so `differential` is unavailable: it needs a live legacy
612
+ // system to diff against, and there is none. `golden-master` survives only
613
+ // where the source itself ships the artifact that determines the output, which
614
+ // parity.md names as the exception -- the DDL and the report query are exactly
615
+ // that. Everything else is `rubric`, and every rubric below `high` carries the
616
+ // queue id the refs gate checks for.
617
+ //
618
+ // Each `ref` is built from target.parity_test_path read out of config.toml at
619
+ // run time, never from the default hardcoded here, since probe.md says an
620
+ // operator may have edited it and parity.md says to read it rather than assume.
621
+ type ParityPlan =
622
+ | { kind: 'golden-master'; capability: string; frSlug: string }
623
+ | { kind: 'rubric'; level: string; queue?: string }
624
+
625
+ const PARITY: Record<string, ParityPlan | null> = {
626
+ 'UD-001': { kind: 'rubric', level: 'high' },
627
+ 'UD-002': { kind: 'rubric', level: 'high' },
628
+ 'UD-003': { kind: 'golden-master', capability: 'user-directory', frSlug: 'users-table' },
629
+ 'UD-004': { kind: 'golden-master', capability: 'user-directory', frSlug: 'daily-users-report' },
630
+ 'UD-005': { kind: 'rubric', level: 'high' },
631
+ 'UD-006': null,
632
+ 'WN-001': { kind: 'rubric', level: 'high' },
633
+ 'WN-002': { kind: 'rubric', level: 'high' },
634
+ 'WN-003': { kind: 'rubric', level: 'moderate', queue: 'q-express-mailer-delivery-unobservable' },
635
+ 'AR-001': { kind: 'rubric', level: 'high' },
636
+ 'AR-002': { kind: 'golden-master', capability: 'audit-retention', frSlug: 'audit-log-table' },
637
+ }
638
+
639
+ const DELTA = {
640
+ id: 'delta-mailer-provider-swap',
641
+ scope: 'Outbound welcome email delivery (WN-003)',
642
+ rationale:
643
+ 'The legacy app posts straight to mailer.example.com from inside the request handler. The target sends through its own provider, so the outbound request differs by destination and headers for reasons that have nothing to do with whether a welcome was owed.',
644
+ parity_exclusion:
645
+ 'The WN-003 parity check must not assert on the mailer endpoint or the shape of the request sent to it, only that a send was attempted for an enabled, pending welcome.',
646
+ validation:
647
+ 'A greenfield-only test asserts the target provider is called with the right recipient; the parity suite does not re-prove the transport.',
648
+ owner_signed: null as string | null,
649
+ }
650
+
651
+ // Every queue item this run files beyond the enumerate scaffold, each one named
652
+ // by something real: two by a field the refs gate checks (UD-006's
653
+ // confidence.queue and WN-003's parity.queue), one by a closer census's own
654
+ // `queued` array, which queue.md is explicit that no gate ever cross-checks.
655
+ // Filed anyway, because that is the manual's discipline.
656
+ const QUEUE_ITEMS: Record<string, string> = {
657
+ 'q-express-user-list-source': `---
658
+ id: q-express-user-list-source
659
+ severity: moderate
660
+ status: open
661
+ ---
662
+
663
+ ## Evidence
664
+
665
+ \`app.js:8\` answers \`GET /api/users\` with \`[].slice(0, settings.maxUsersPerPage)\`:
666
+ a literal empty array, capped at the configured page size. Nothing anywhere
667
+ in the checkout opens a database connection or reads the \`users\` table that
668
+ \`schema.sql:1-5\` declares.
669
+
670
+ ## Options
671
+
672
+ (a) Treat the endpoint as a stub and write the requirement against the table
673
+ the schema declares. (b) Treat the empty list as the real behavior and
674
+ record that the table is unread. (c) Ask the operator which one production
675
+ actually served.
676
+
677
+ ## Recommendation
678
+
679
+ Recommend (c); the route and the schema disagree about whether this endpoint
680
+ has a data source, and nothing in the source settles it either way.
681
+ `,
682
+ 'q-express-mailer-delivery-unobservable': `---
683
+ id: q-express-mailer-delivery-unobservable
684
+ severity: moderate
685
+ status: open
686
+ ---
687
+
688
+ ## Evidence
689
+
690
+ \`WN-003\` describes the outbound POST at \`app.js:21-24\` to
691
+ \`https://mailer.example.com/send\`. The basis for this run is source-only, so
692
+ there is no live legacy system to diff against and nothing to capture a
693
+ golden master from, and the call crosses a boundary neither could reach in
694
+ any case.
695
+
696
+ ## Options
697
+
698
+ (a) Ship \`rubric:low\` and revisit if a runnable environment appears.
699
+ (b) Block parity on this requirement until the mailer can be observed.
700
+ (c) Ship \`rubric:moderate\`: the guard conditions and the recipient are both
701
+ readable from the source, the delivery itself is not.
702
+
703
+ ## Recommendation
704
+
705
+ Recommend (c); \`rubric:moderate\` matches exactly what is observable today.
706
+ `,
707
+ 'q-express-users-table-unwired': `---
708
+ id: q-express-users-table-unwired
709
+ severity: moderate
710
+ status: open
711
+ ---
712
+
713
+ ## Evidence
714
+
715
+ The \`read-write-symmetry\` closer checked every write path against a matching
716
+ read path. \`schema.sql:1-5\` declares a \`users\` table;
717
+ \`reports/daily-users.json:4\` reads it; nothing in \`app.js\` or \`cron.js\`
718
+ writes to it, and \`POST /api/users\` at \`app.js:10-14\` persists nothing at
719
+ all beyond an in-memory map entry.
720
+
721
+ ## Options
722
+
723
+ (a) Treat the table as write-only-by-something-outside-this-checkout and
724
+ widen the search. (b) Treat it as a real gap for the target to fix rather
725
+ than replicate. (c) Ask the operator whether user rows were ever written by
726
+ this application.
727
+
728
+ ## Recommendation
729
+
730
+ Recommend (c); a table with a reader and no writer anywhere in the checkout
731
+ is exactly what this closer exists to surface, and only the operator can say
732
+ whether the writer is missing or merely elsewhere.
733
+ `,
734
+ }
735
+
736
+ // Substitutes a parity plan into the row shape `migrate import reqs` accepts.
737
+ // `{capability}` and `{fr_slug}` come from target.parity_test_path as read off
738
+ // config.toml; parity.md is explicit that nothing in the CLI derives an
739
+ // fr_slug or checks a ref against the template, so this substitution is the
740
+ // whole of the convention.
741
+ function renderParity(plan: ParityPlan | null, template: string): Record<string, unknown> | null {
742
+ if (plan === null) return null
743
+ if (plan.kind === 'rubric') {
744
+ return { kind: 'rubric', level: plan.level, ...(plan.queue ? { queue: plan.queue } : {}) }
745
+ }
746
+ const ref = template
747
+ .replaceAll('{capability}', plan.capability)
748
+ .replaceAll('{fr_slug}', plan.frSlug)
749
+ return { kind: 'golden-master', ref }
750
+ }
751
+
752
+ function readTomlString(text: string, key: string): string {
753
+ const match = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'))
754
+ if (!match?.[1]) throw new Error(`config.toml has no ${key}`)
755
+ return match[1]
756
+ }
757
+
758
+ test('contract-only run driven probe through queue, ending green at check --phase queue', async () => {
759
+ const groundTruthPath = join(source, 'GROUND-TRUTH.md')
760
+ const rows = await parseGroundTruth(groundTruthPath)
761
+ expect(rows.length).toBeGreaterThan(0)
762
+ const groups = groupBySurface(rows)
763
+
764
+ // 1. migrate init.
765
+ const init = await migrate([
766
+ 'init',
767
+ '--source',
768
+ source,
769
+ '--scope',
770
+ 'the whole tiny app',
771
+ '--name',
772
+ 'tiny-next',
773
+ '--source-stack',
774
+ 'express',
775
+ ])
776
+ expect(init.code).toBe(0)
777
+
778
+ // Both defaults init wrote are left exactly as they are: the eight surface
779
+ // types, because exercising all eight is this fixture's whole point, and the
780
+ // three closers, because this run reaches extract and therefore owes a
781
+ // closer census record for each one. parity_test_path is read back rather
782
+ // than assumed, per probe.md and parity.md, since an operator may have
783
+ // edited it and nothing downstream would notice.
784
+ const cfgPath = join(target, '.migrate', 'config.toml')
785
+ const cfg = await readFile(cfgPath, 'utf8')
786
+ const parityTemplate = readTomlString(cfg, 'parity_test_path')
787
+ expect(parityTemplate).toContain('{capability}')
788
+ expect(parityTemplate).toContain('{fr_slug}')
789
+
790
+ // 2. migrate phase probe --status done.
791
+ expect((await migrate(['phase', 'probe', '--status', 'done'])).code).toBe(0)
792
+
793
+ // File the queue item every element's placeholder disposition below cites.
794
+ // See QUEUE_ITEM for why: coverage is a whole-store gate, so a clean check
795
+ // bound at --phase enumerate (step 6) still needs every element disposed,
796
+ // even though disposing elements is ordinarily extract's job.
797
+ const queuePath = await writeText(`${QUEUE_ID}.md`, QUEUE_ITEM)
798
+ expect((await migrate(['queue', 'add', queuePath])).code).toBe(0)
799
+
800
+ // 3. migrate import elements, one batch per surface, rows from GROUND-TRUTH.md.
801
+ for (const [surface, surfaceRows] of groups) {
802
+ const batch = await write(`elements-${surface}.json`, {
803
+ batch: `b-${surface}-elements-001`,
804
+ phase: 'enumerate',
805
+ rows: surfaceRows.map((row) => ({
806
+ id: row.id,
807
+ surface: row.surface,
808
+ element: row.element,
809
+ found_by: ['code'],
810
+ disposition: { kind: 'out-of-scope', queue: QUEUE_ID },
811
+ refs: REFS[row.id] ?? [],
812
+ lens: 'code',
813
+ notes: '',
814
+ })),
815
+ })
816
+ const result = await migrate(['import', 'elements', batch])
817
+ expect(result.code).toBe(0)
818
+ }
819
+
820
+ // 4. migrate census, one balanced lens record per surface, two directions each.
821
+ for (const [surface, surfaceRows] of groups) {
822
+ const count = surfaceRows.length
823
+ const [a, b] = DIRECTIONS[surface] ?? ['direction a', 'direction b']
824
+ const record = await write(`census-${surface}.json`, {
825
+ kind: 'lens',
826
+ surface,
827
+ phase: 'enumerate',
828
+ directions: {
829
+ grep: { count, evidence: a },
830
+ manual: { count, evidence: b },
831
+ },
832
+ total: count,
833
+ in_ledger: 0,
834
+ added: count,
835
+ skipped: [],
836
+ queued: [],
837
+ batch: `b-${surface}-census-001`,
838
+ })
839
+ const result = await migrate(['census', record])
840
+ expect(result.code).toBe(0)
841
+ expect(result.out).toContain(`census: recorded lens:${surface}`)
842
+ }
843
+
844
+ // 5. migrate phase enumerate --status done.
845
+ expect((await migrate(['phase', 'enumerate', '--status', 'done'])).code).toBe(0)
846
+
847
+ // 6. migrate check --phase enumerate. Every gate this phase owns is clean,
848
+ // and the only violations left are the three declared closers, whose
849
+ // records belong to extract and do not exist yet: exactly the mid-run
850
+ // posture enumerate.md describes, where the census gate reads the whole
851
+ // store regardless of --phase. Asserting the count pins that down harder
852
+ // than asserting cleanliness would, because it fails if anything else
853
+ // starts failing too.
854
+ //
855
+ // Note this holds despite the workflows batch (imported before the settings
856
+ // batch, in GROUND-TRUTH.md's row order) citing setting-welcome-email-enabled
857
+ // before that element exists yet: nothing in `import elements` or `check`
858
+ // resolves a `ledger` ref against the ledger, so a dangling one mid-run is
859
+ // silently tolerated, exactly as enumerate.md's step 4 says. It has resolved
860
+ // by the time this check runs, since every surface has imported by now.
861
+ const enumerated = await migrate(['check', '--phase', 'enumerate'])
862
+ expect(enumerated.out).toContain(
863
+ `0/${rows.length} mapped, ${rows.length} out-of-scope, 0 unaccounted`,
864
+ )
865
+ expect(enumerated.out).toContain('Violations (3):')
866
+ expect(enumerated.out).toContain(' census:')
867
+ for (const closer of ['cross-capability-workflow', 'scope-injection', 'read-write-symmetry']) {
868
+ expect(enumerated.out).toContain(` declared closer ${closer} has no census record`)
869
+ }
870
+ expect(enumerated.code).toBe(1)
871
+
872
+ // 6b. Surface-affinity clustering's graph, built straight from the store
873
+ // the same way seam.md's worked example builds it. This is the assertion
874
+ // that would fail if enumerate's ref-recording step were skipped: an
875
+ // empty REFS map above would leave every element's refs empty, and this
876
+ // graph would have zero edges, which is exactly the defect this task
877
+ // exists to close.
878
+ const elementsForAffinity = (await readFile(join(target, '.migrate', 'elements.jsonl'), 'utf8'))
879
+ .split('\n')
880
+ .filter((line) => line.trim().length > 0)
881
+ .map((line) => JSON.parse(line) as StoredElement)
882
+ const edges = buildAffinityEdges(elementsForAffinity)
883
+ expect(edges.size).toBeGreaterThan(0)
884
+ const expectedEdges = [
885
+ ['route-get-api-users', 'table-users'],
886
+ ['route-post-api-users', 'table-users'],
887
+ ['job-purge-audit-log', 'table-audit-log'],
888
+ ['report-daily-users', 'table-users'],
889
+ ['workflow-welcome-email', 'route-post-api-users'],
890
+ ['workflow-welcome-email', 'route-get-api-users-id-welcome'],
891
+ ['workflow-welcome-email', 'setting-welcome-email-enabled'],
892
+ ].map(([a, b]) => [a, b].sort().join('|'))
893
+ for (const edge of expectedEdges) {
894
+ expect(edges.has(edge)).toBe(true)
895
+ }
896
+ expect(edges.size).toBe(expectedEdges.length)
897
+
898
+ // 7. Phase 2, seam. No CLI verb authors any of the three artifacts, so all
899
+ // three are written by hand here exactly as seam.md says a real run writes
900
+ // them. The partition is asserted to be a partition of the ground truth
901
+ // first: every element in exactly one capability, nothing invented, nothing
902
+ // dropped. No gate checks that (nothing reads capabilities.elements at all),
903
+ // which is precisely why the assertion is here.
904
+ const assigned = CAPABILITIES.flatMap((c) => c.elements)
905
+ expect([...assigned].sort()).toEqual(rows.map((r) => r.id).sort())
906
+ expect(new Set(assigned).size).toBe(assigned.length)
907
+
908
+ const storeDir = join(target, '.migrate')
909
+ await writeFile(
910
+ join(storeDir, 'capabilities.jsonl'),
911
+ `${CAPABILITIES.map((c) => JSON.stringify(c)).join('\n')}\n`,
912
+ )
913
+ await writeFile(join(storeDir, 'seam.json'), `${JSON.stringify(SEAM_JSON, null, 2)}\n`)
914
+ await writeFile(
915
+ join(storeDir, 'seam.md'),
916
+ '# Seam evidence\n\nsurface-affinity clustering over the ledger refs, connected components then the\ngreedy modularity refinement, Q = 0.459. The other three validators could not\nrun: no relational schema, no static call graph, no VCS history in the copied\nfixture.\n',
917
+ )
918
+ expect((await migrate(['phase', 'seam', '--status', 'done'])).code).toBe(0)
919
+
920
+ // 8. Phase 3, extract. Requirements first, then the disposition write-back
921
+ // as its own elements batch, which extract.md calls out as the only writer
922
+ // of a resolved disposition.
923
+ for (const id of Object.keys(DISPOSITIONS)) {
924
+ expect(rows.some((r) => r.id === id)).toBe(true)
925
+ }
926
+ expect(Object.keys(DISPOSITIONS).length).toBe(rows.length)
927
+
928
+ const reqBatch = await write('reqs.json', {
929
+ batch: 'b-reqs-extract-001',
930
+ phase: 'extract',
931
+ rows: REQUIREMENTS,
932
+ })
933
+ const reqImport = await migrate(['import', 'reqs', reqBatch])
934
+ expect(reqImport.code).toBe(0)
935
+ expect(reqImport.out).toContain(
936
+ `import reqs: ${REQUIREMENTS.length} added, 0 updated, batch b-reqs-extract-001`,
937
+ )
938
+
939
+ // The queue items UD-006's confidence and the read-write-symmetry closer
940
+ // name, filed before anything checks them: a queue id with no file behind it
941
+ // is a present violation the moment `check` runs, not a future one.
942
+ for (const [id, body] of Object.entries(QUEUE_ITEMS)) {
943
+ const path = await writeText(`${id}.md`, body)
944
+ const added = await migrate(['queue', 'add', path])
945
+ expect(added.code).toBe(0)
946
+ expect(added.out).toContain(`queue add: ${id}`)
947
+ }
948
+
949
+ const disposed = await write('elements-disposed.json', {
950
+ batch: 'b-elements-disposition-001',
951
+ phase: 'extract',
952
+ rows: rows.map((row) => ({
953
+ id: row.id,
954
+ surface: row.surface,
955
+ element: row.element,
956
+ found_by: ['code'],
957
+ disposition: DISPOSITIONS[row.id],
958
+ refs: REFS[row.id] ?? [],
959
+ lens: 'code',
960
+ notes: '',
961
+ })),
962
+ })
963
+ const disposeImport = await migrate(['import', 'elements', disposed])
964
+ expect(disposeImport.code).toBe(0)
965
+ expect(disposeImport.out).toContain(`import elements: 0 added, ${rows.length} updated`)
966
+
967
+ for (const record of EXTRACT_CENSUS) {
968
+ const path = await write(`census-${record.batch}.json`, record)
969
+ const result = await migrate(['census', path])
970
+ expect(result.code).toBe(0)
971
+ }
972
+ expect((await migrate(['phase', 'extract', '--status', 'done'])).code).toBe(0)
973
+
974
+ // 9. Phase 4, parity. The delta goes in unsigned first, so the deltas gate
975
+ // is shown failing on it before it is shown clean: an unsigned exclusion is
976
+ // exactly what that gate exists to stop from accreting silently.
977
+ const unsigned = await write('deltas.json', {
978
+ batch: 'b-deltas-parity-001',
979
+ phase: 'parity',
980
+ rows: [DELTA],
981
+ })
982
+ expect((await migrate(['import', 'deltas', unsigned])).code).toBe(0)
983
+ const withUnsignedDelta = await migrate(['check', '--phase', 'parity'])
984
+ expect(withUnsignedDelta.code).toBe(1)
985
+ expect(withUnsignedDelta.out).toContain(' deltas:')
986
+ expect(withUnsignedDelta.out).toContain(` ${DELTA.id} is not owner-signed`)
987
+
988
+ const signed = await write('deltas-signed.json', {
989
+ batch: 'b-deltas-parity-002',
990
+ phase: 'parity',
991
+ rows: [{ ...DELTA, owner_signed: '2026-08-08' }],
992
+ })
993
+ expect((await migrate(['import', 'deltas', signed])).code).toBe(0)
994
+
995
+ const parityBatch = await write('reqs-parity.json', {
996
+ batch: 'b-reqs-parity-001',
997
+ phase: 'parity',
998
+ rows: REQUIREMENTS.map((r) => ({
999
+ ...r,
1000
+ parity: renderParity(PARITY[r.id] ?? null, parityTemplate),
1001
+ })),
1002
+ })
1003
+ const parityImport = await migrate(['import', 'reqs', parityBatch])
1004
+ expect(parityImport.code).toBe(0)
1005
+ // Every row counts as updated, including UD-006, whose parity stays null:
1006
+ // the importer stamps its own batch id onto every row it writes, so a row
1007
+ // whose content is otherwise unchanged still differs from the one on disk.
1008
+ expect(parityImport.out).toContain(
1009
+ `import reqs: 0 added, ${REQUIREMENTS.length} updated, batch b-reqs-parity-001`,
1010
+ )
1011
+ expect((await migrate(['phase', 'parity', '--status', 'done'])).code).toBe(0)
1012
+
1013
+ // 10. Phase 5, queue. Every item this run owed was filed in the pass that
1014
+ // named it, so this phase closes on the status flip, exactly as queue.md
1015
+ // says: closing is not "the queue is empty", since nothing in this milestone
1016
+ // adjudicates an item.
1017
+ const listed = await migrate(['queue', 'list', '--open'])
1018
+ expect(listed.code).toBe(0)
1019
+ expect(listed.out).toContain(`${Object.keys(QUEUE_ITEMS).length + 1} item(s)`)
1020
+ expect((await migrate(['phase', 'queue', '--status', 'done'])).code).toBe(0)
1021
+
1022
+ // 11. The terminus. Every element mapped, every gate clean, exit 0.
1023
+ const green = await migrate(['check', '--phase', 'queue'])
1024
+ expect(green.out).toContain(`${rows.length}/${rows.length} mapped, 0 out-of-scope, 0 unaccounted`)
1025
+ expect(green.out).not.toContain('Violations')
1026
+ expect(green.code).toBe(0)
1027
+
1028
+ // 12. Plain `migrate check` gates every phase through handoff, and fails on
1029
+ // exactly the two that have no verb in this milestone. The violation count
1030
+ // is what makes "exactly" an assertion rather than a hope.
1031
+ const full = await migrate(['check'])
1032
+ expect(full.code).toBe(1)
1033
+ expect(full.out).toContain('Violations (2):')
1034
+ expect(full.out).toContain(' run-state:')
1035
+ expect(full.out).toContain(
1036
+ ' phase adjudicate is pending; every phase through handoff must be done',
1037
+ )
1038
+ expect(full.out).toContain(
1039
+ ' phase handoff is pending; every phase through handoff must be done',
1040
+ )
1041
+
1042
+ // 13. The terminus assertion in step 11 is load-bearing, shown by mutation
1043
+ // rather than asserted: nulling every requirement's parity plan is a phase-4
1044
+ // regression and nothing else, and it must break the green check. Restoring
1045
+ // the file returns it to green, which is what proves the mutation was the
1046
+ // only cause.
1047
+ const requirementsPath = join(storeDir, 'requirements.jsonl')
1048
+ const requirementsText = await readFile(requirementsPath, 'utf8')
1049
+ const stripped = requirementsText
1050
+ .split('\n')
1051
+ .filter((line) => line.trim().length > 0)
1052
+ .map((line) => JSON.stringify({ ...(JSON.parse(line) as object), parity: null }))
1053
+ await writeFile(requirementsPath, `${stripped.join('\n')}\n`)
1054
+
1055
+ const noParity = await migrate(['check', '--phase', 'queue'])
1056
+ expect(noParity.code).toBe(1)
1057
+ expect(noParity.out).toContain(' parity:')
1058
+ expect(noParity.out).toContain(' UD-001 has no parity plan')
1059
+ // UD-006 is `queued`, the one confidence the parity gate exempts, so it must
1060
+ // not appear even now that its plan is null like everyone else's.
1061
+ expect(noParity.out).not.toContain('UD-006 has no parity plan')
1062
+
1063
+ await writeFile(requirementsPath, requirementsText)
1064
+ expect((await migrate(['check', '--phase', 'queue'])).code).toBe(0)
1065
+
1066
+ // 14. Removing one element row breaks the census reconciliation for its
1067
+ // surface, and only that: `in_ledger + added` on the surviving census
1068
+ // record still claims the pre-mutation count, but elements.jsonl now has
1069
+ // one fewer row for that surface. This is what makes the enumerate-phase
1070
+ // arithmetic load-bearing rather than vacuous: if the reconciliation check
1071
+ // were missing or broken, this mutation would leave the check clean too.
1072
+ const mutatedSurface = 'tables'
1073
+ const mutatedRows = groups.get(mutatedSurface)
1074
+ if (!mutatedRows || mutatedRows.length < 2) {
1075
+ throw new Error(`fixture must declare at least two ${mutatedSurface} rows to mutate one away`)
1076
+ }
1077
+ const removedId = mutatedRows[mutatedRows.length - 1]?.id
1078
+ const elementsPath = join(target, '.migrate', 'elements.jsonl')
1079
+ const elementsText = await readFile(elementsPath, 'utf8')
1080
+ const survivingLines = elementsText
1081
+ .split('\n')
1082
+ .filter((line) => line.trim().length > 0)
1083
+ .filter((line) => (JSON.parse(line) as { id: string }).id !== removedId)
1084
+ await writeFile(elementsPath, `${survivingLines.join('\n')}\n`)
1085
+
1086
+ const afterRemoval = await migrate(['check', '--phase', 'enumerate'])
1087
+ expect(afterRemoval.code).not.toBe(0)
1088
+ const claimed = mutatedRows.length
1089
+ const actual = mutatedRows.length - 1
1090
+ expect(afterRemoval.out).toContain(
1091
+ `lens census for ${mutatedSurface} claims in_ledger 0 + added ${claimed} = ${claimed} element(s) in the ledger, but elements.jsonl has ${actual}`,
1092
+ )
1093
+ })