spring-runtime-mcp 0.2.0__py3-none-any.whl

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 (118) hide show
  1. spring_runtime_mcp/__init__.py +3 -0
  2. spring_runtime_mcp/adapters/__init__.py +1 -0
  3. spring_runtime_mcp/adapters/beans.py +159 -0
  4. spring_runtime_mcp/adapters/conditions.py +333 -0
  5. spring_runtime_mcp/adapters/config.py +295 -0
  6. spring_runtime_mcp/adapters/drift.py +790 -0
  7. spring_runtime_mcp/adapters/health.py +164 -0
  8. spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
  9. spring_runtime_mcp/adapters/mappings.py +294 -0
  10. spring_runtime_mcp/adapters/migrations.py +373 -0
  11. spring_runtime_mcp/adapters/openapi.py +683 -0
  12. spring_runtime_mcp/adapters/source_catalog.py +119 -0
  13. spring_runtime_mcp/adapters/startup.py +202 -0
  14. spring_runtime_mcp/analyzers/__init__.py +1 -0
  15. spring_runtime_mcp/analyzers/bean_graph.py +172 -0
  16. spring_runtime_mcp/analyzers/condition_failures.py +359 -0
  17. spring_runtime_mcp/cli.py +248 -0
  18. spring_runtime_mcp/contracts/__init__.py +1 -0
  19. spring_runtime_mcp/contracts/definitions.py +425 -0
  20. spring_runtime_mcp/diagnostics.py +254 -0
  21. spring_runtime_mcp/models/__init__.py +1 -0
  22. spring_runtime_mcp/models/bean_graph.py +77 -0
  23. spring_runtime_mcp/models/common.py +99 -0
  24. spring_runtime_mcp/models/conditions.py +56 -0
  25. spring_runtime_mcp/models/queries.py +140 -0
  26. spring_runtime_mcp/models/source_catalog.py +35 -0
  27. spring_runtime_mcp/models/timestamps.py +71 -0
  28. spring_runtime_mcp/policy/__init__.py +1 -0
  29. spring_runtime_mcp/policy/configuration.py +32 -0
  30. spring_runtime_mcp/policy/output_guard.py +117 -0
  31. spring_runtime_mcp/policy/sanitization.py +616 -0
  32. spring_runtime_mcp/py.typed +0 -0
  33. spring_runtime_mcp/replay.py +220 -0
  34. spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
  35. spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
  36. spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
  37. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
  38. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
  39. spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
  40. spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
  41. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
  42. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
  43. spring_runtime_mcp/replay_data/conformance/README.md +41 -0
  44. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
  45. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
  46. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
  47. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
  48. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
  49. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
  50. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
  51. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
  52. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
  53. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
  54. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
  55. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
  56. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
  57. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
  58. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
  59. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
  60. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
  61. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
  62. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
  63. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
  64. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
  65. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
  66. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
  67. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
  68. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
  69. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
  70. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
  71. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
  72. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
  73. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
  74. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
  75. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
  76. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
  77. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
  78. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
  79. spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
  80. spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
  81. spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
  82. spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
  83. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
  84. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
  85. spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
  86. spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
  87. spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
  88. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
  89. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
  90. spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
  91. spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
  92. spring_runtime_mcp/schemas/tools-contract.json +1902 -0
  93. spring_runtime_mcp/server.py +322 -0
  94. spring_runtime_mcp/services.py +44 -0
  95. spring_runtime_mcp/snapshots.py +340 -0
  96. spring_runtime_mcp/tools/__init__.py +1 -0
  97. spring_runtime_mcp/tools/analyze_startup.py +79 -0
  98. spring_runtime_mcp/tools/check_migrations.py +99 -0
  99. spring_runtime_mcp/tools/detect_drift.py +323 -0
  100. spring_runtime_mcp/tools/diagnose_health.py +88 -0
  101. spring_runtime_mcp/tools/inspect_beans.py +479 -0
  102. spring_runtime_mcp/tools/inspect_config.py +102 -0
  103. spring_runtime_mcp/tools/registry.py +48 -0
  104. spring_runtime_mcp/tools/search_openapi.py +79 -0
  105. spring_runtime_mcp/tools/support.py +166 -0
  106. spring_runtime_mcp/tools/verify_mappings.py +62 -0
  107. spring_runtime_mcp/transport/__init__.py +1 -0
  108. spring_runtime_mcp/transport/actuator_http.py +459 -0
  109. spring_runtime_mcp/transport/bounded_json.py +144 -0
  110. spring_runtime_mcp/transport/replay_files.py +71 -0
  111. spring_runtime_mcp/transport/response_gate.py +186 -0
  112. spring_runtime_mcp/transport/responses.py +82 -0
  113. spring_runtime_mcp/transport/stdio.py +44 -0
  114. spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
  115. spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
  116. spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
  117. spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  118. spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,373 @@
1
+ """Normalize Flyway and Liquibase actuator beans without executing migrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import replace
7
+ from typing import Any
8
+
9
+ from ..models.common import AnalysisBudget, AnalysisLimit
10
+
11
+ from ..models.queries import MigrationQuery
12
+
13
+ _FLYWAY = {
14
+ "SUCCESS": "APPLIED",
15
+ "FAILED": "FAILED",
16
+ "PENDING": "PENDING",
17
+ "OUT_OF_ORDER": "OUT_OF_ORDER",
18
+ "MISSING_FAILED": "MISSING_FAILED",
19
+ "MISSING_SUCCESS": "APPLIED",
20
+ "FUTURE_SUCCESS": "APPLIED",
21
+ "FUTURE_FAILED": "FAILED",
22
+ }
23
+ _LIQUIBASE = {
24
+ "EXECUTED": "APPLIED",
25
+ "FAILED": "FAILED",
26
+ "MARK_RAN": "APPLIED",
27
+ "RERAN": "APPLIED",
28
+ "SKIPPED": "PENDING",
29
+ }
30
+ _ALGORITHM = re.compile(r"(\d+):")
31
+
32
+
33
+ def _checksum(value: Any) -> tuple[str | None, str]:
34
+ if isinstance(value, bool) or value is None:
35
+ return None, "absent"
36
+ if isinstance(value, int):
37
+ return str(value), "number"
38
+ if isinstance(value, str) and value:
39
+ return value[:128], "string"
40
+ return None, "absent"
41
+
42
+
43
+ def _state(raw: str | None, table: dict[str, str]) -> tuple[str, str]:
44
+ if raw is None:
45
+ return "UNKNOWN", "unknown"
46
+ mapped = table.get(raw)
47
+ if mapped is None:
48
+ return "UNKNOWN", "reported"
49
+ return mapped, "reported"
50
+
51
+
52
+ def _keep(query: MigrationQuery, row: dict[str, Any]) -> bool:
53
+ if query.context_id and row["contextId"] != query.context_id:
54
+ return False
55
+ if query.migration_bean and row["migrationBean"] != query.migration_bean:
56
+ return False
57
+ if query.states and row["state"] not in query.states:
58
+ return False
59
+ return not (query.version is not None and row["version"] != query.version)
60
+
61
+
62
+ _ATTENTION_RANK = {"FAILED": 0, "MISSING_FAILED": 1, "OUT_OF_ORDER": 2, "PENDING": 3}
63
+
64
+
65
+ def _migration_order(row: dict[str, Any]) -> tuple[int, int, int, str]:
66
+ """Attention state first, then engine order, then stable identity."""
67
+ order = row["orderExecuted"]
68
+ known = isinstance(order, int)
69
+ return (
70
+ _ATTENTION_RANK.get(row["state"], 4),
71
+ 0 if known else 1,
72
+ order if known else 0,
73
+ row["identity"],
74
+ )
75
+
76
+
77
+ def _from_flyway(body: Any) -> tuple[list[dict[str, Any]], list[str]]:
78
+ if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
79
+ raise ValueError("INVALID_MIGRATIONS_SHAPE")
80
+ rows: list[dict[str, Any]] = []
81
+ gaps: set[str] = set()
82
+ budget = AnalysisBudget()
83
+ for context_name in sorted(body["contexts"]):
84
+ context = body["contexts"][context_name]
85
+ if not isinstance(context, dict) or not isinstance(
86
+ context.get("flywayBeans"), dict
87
+ ):
88
+ gaps.add("FLYWAY_BEANS_UNOBSERVED")
89
+ continue
90
+ parent = (
91
+ context.get("parentId")
92
+ if isinstance(context.get("parentId"), str)
93
+ else None
94
+ )
95
+ for bean_name in sorted(context["flywayBeans"]):
96
+ bean = context["flywayBeans"][bean_name]
97
+ migrations = bean.get("migrations") if isinstance(bean, dict) else None
98
+ if not isinstance(migrations, list):
99
+ gaps.add("FLYWAY_MIGRATIONS_UNOBSERVED")
100
+ continue
101
+ for migration in migrations:
102
+ budget.consume()
103
+ if not isinstance(migration, dict):
104
+ gaps.add("MIGRATION_RECORD_UNREADABLE")
105
+ continue
106
+ raw = (
107
+ migration.get("state")
108
+ if isinstance(migration.get("state"), str)
109
+ else None
110
+ )
111
+ state, evidence = _state(raw, _FLYWAY)
112
+ version = (
113
+ migration.get("version")
114
+ if isinstance(migration.get("version"), str)
115
+ else None
116
+ )
117
+ script = (
118
+ migration.get("script")
119
+ if isinstance(migration.get("script"), str)
120
+ else None
121
+ )
122
+ checksum, checksum_type = _checksum(migration.get("checksum"))
123
+ rows.append(
124
+ {
125
+ "endpointId": "flyway",
126
+ "contextId": context_name
127
+ if isinstance(context_name, str)
128
+ else None,
129
+ "parentContextId": parent,
130
+ "engine": "flyway",
131
+ "migrationBean": str(bean_name)[:256],
132
+ "identity": f"{bean_name}:{version or script or 'unversioned'}"[
133
+ :256
134
+ ],
135
+ "script": script[:256] if script else None,
136
+ "changeLog": None,
137
+ "changeSetId": None,
138
+ "author": None,
139
+ "version": version[:128] if version else None,
140
+ "description": None,
141
+ "checksum": checksum,
142
+ "checksumType": checksum_type,
143
+ "checksumAlgorithm": None,
144
+ "executionTimeMs": migration.get("executionTime")
145
+ if isinstance(migration.get("executionTime"), (int, float))
146
+ and not isinstance(migration.get("executionTime"), bool)
147
+ and migration["executionTime"] >= 0
148
+ else None,
149
+ "orderExecuted": migration.get("installedRank")
150
+ if type(migration.get("installedRank")) is int
151
+ and migration["installedRank"] >= 0
152
+ else None,
153
+ "executedAt": migration.get("installedOn")
154
+ if isinstance(migration.get("installedOn"), str)
155
+ else None,
156
+ "rawState": raw[:64] if raw else None,
157
+ "state": state,
158
+ "stateEvidence": evidence,
159
+ "attentionRequired": state
160
+ in {
161
+ "FAILED",
162
+ "PENDING",
163
+ "OUT_OF_ORDER",
164
+ "MISSING_FAILED",
165
+ "UNKNOWN",
166
+ },
167
+ }
168
+ )
169
+ return rows, sorted(gaps)
170
+
171
+
172
+ def _from_liquibase(body: Any) -> tuple[list[dict[str, Any]], list[str]]:
173
+ if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
174
+ raise ValueError("INVALID_MIGRATIONS_SHAPE")
175
+ rows: list[dict[str, Any]] = []
176
+ gaps: set[str] = set()
177
+ budget = AnalysisBudget()
178
+ for context_name in sorted(body["contexts"]):
179
+ context = body["contexts"][context_name]
180
+ if not isinstance(context, dict) or not isinstance(
181
+ context.get("liquibaseBeans"), dict
182
+ ):
183
+ gaps.add("LIQUIBASE_BEANS_UNOBSERVED")
184
+ continue
185
+ parent = (
186
+ context.get("parentId")
187
+ if isinstance(context.get("parentId"), str)
188
+ else None
189
+ )
190
+ for bean_name in sorted(context["liquibaseBeans"]):
191
+ bean = context["liquibaseBeans"][bean_name]
192
+ changes = bean.get("changeSets") if isinstance(bean, dict) else None
193
+ if not isinstance(changes, list):
194
+ gaps.add("LIQUIBASE_CHANGESETS_UNOBSERVED")
195
+ continue
196
+ for change in changes:
197
+ budget.consume()
198
+ if not isinstance(change, dict):
199
+ gaps.add("MIGRATION_RECORD_UNREADABLE")
200
+ continue
201
+ raw = (
202
+ change.get("execType")
203
+ if isinstance(change.get("execType"), str)
204
+ else None
205
+ )
206
+ state, evidence = _state(raw, _LIQUIBASE)
207
+ raw_id = change.get("id")
208
+ change_id = raw_id if isinstance(raw_id, str) else "unidentified"
209
+ change_log = (
210
+ change.get("changeLog")
211
+ if isinstance(change.get("changeLog"), str)
212
+ else None
213
+ )
214
+ author = (
215
+ change.get("author")
216
+ if isinstance(change.get("author"), str)
217
+ else None
218
+ )
219
+ if change_log is None or author is None:
220
+ gaps.add("MIGRATION_IDENTITY_INCOMPLETE")
221
+ identity = ":".join(
222
+ (change_log or "unknown", change_id, author or "unknown")
223
+ )
224
+ checksum, checksum_type = _checksum(change.get("checksum"))
225
+ algorithm = None
226
+ if checksum and (matched := _ALGORITHM.match(checksum)):
227
+ algorithm = matched.group(1)
228
+ rows.append(
229
+ {
230
+ "endpointId": "liquibase",
231
+ "contextId": context_name
232
+ if isinstance(context_name, str)
233
+ else None,
234
+ "parentContextId": parent,
235
+ "engine": "liquibase",
236
+ "migrationBean": str(bean_name)[:256],
237
+ "identity": identity[:256],
238
+ "script": None,
239
+ "changeLog": change_log,
240
+ "changeSetId": change_id[:256],
241
+ "author": author,
242
+ "version": None,
243
+ "description": None,
244
+ "checksum": checksum,
245
+ "checksumType": checksum_type,
246
+ "checksumAlgorithm": algorithm,
247
+ "executionTimeMs": None,
248
+ "orderExecuted": change.get("orderExecuted")
249
+ if type(change.get("orderExecuted")) is int
250
+ and change["orderExecuted"] >= 0
251
+ else None,
252
+ "executedAt": change.get("dateExecuted")
253
+ if isinstance(change.get("dateExecuted"), str)
254
+ else None,
255
+ "rawState": raw[:64] if raw else None,
256
+ "state": state,
257
+ "stateEvidence": evidence,
258
+ "attentionRequired": state
259
+ in {
260
+ "FAILED",
261
+ "PENDING",
262
+ "OUT_OF_ORDER",
263
+ "MISSING_FAILED",
264
+ "UNKNOWN",
265
+ },
266
+ }
267
+ )
268
+ return rows, sorted(gaps)
269
+
270
+
271
+ def apply_expected(
272
+ rows: list[dict[str, Any]], query: MigrationQuery
273
+ ) -> tuple[list[dict[str, Any]], list[str], str]:
274
+ if not query.expected:
275
+ return rows, [], "unknown"
276
+ observed = {
277
+ (row["engine"], row["contextId"], row["migrationBean"], row["identity"]): row
278
+ for row in rows
279
+ }
280
+ gaps: list[str] = []
281
+ for expected in query.expected:
282
+ key = (
283
+ expected.engine,
284
+ expected.context_id,
285
+ expected.migration_bean,
286
+ expected.identity,
287
+ )
288
+ current = observed.get(key)
289
+ if current is None and query.expected_set_complete:
290
+ rows.append(
291
+ {
292
+ "endpointId": expected.engine,
293
+ "contextId": expected.context_id,
294
+ "parentContextId": None,
295
+ "engine": expected.engine,
296
+ "migrationBean": expected.migration_bean,
297
+ "identity": expected.identity,
298
+ "script": None,
299
+ "changeLog": None,
300
+ "changeSetId": None,
301
+ "author": None,
302
+ "version": expected.version,
303
+ "description": None,
304
+ "checksum": expected.checksum,
305
+ "checksumType": "string" if expected.checksum else "absent",
306
+ "checksumAlgorithm": None,
307
+ "executionTimeMs": None,
308
+ "orderExecuted": None,
309
+ "executedAt": None,
310
+ "rawState": None,
311
+ "state": "PENDING",
312
+ "stateEvidence": "baselineInferred",
313
+ "attentionRequired": True,
314
+ }
315
+ )
316
+ continue
317
+ if current and expected.checksum:
318
+ if current["checksum"] is None:
319
+ gaps.append("CHECKSUM_UNOBSERVED")
320
+ continue
321
+ if expected.engine == "liquibase":
322
+ baseline_algorithm = _ALGORITHM.match(expected.checksum)
323
+ actual_algorithm = current["checksumAlgorithm"]
324
+ if baseline_algorithm is None or actual_algorithm is None:
325
+ gaps.append("CHECKSUM_ALGORITHM_UNOBSERVED")
326
+ continue
327
+ if baseline_algorithm.group(1) != actual_algorithm:
328
+ gaps.append("CHECKSUM_ALGORITHM_INCOMPATIBLE")
329
+ continue
330
+ if current["checksum"] != expected.checksum:
331
+ gaps.append("CHECKSUM_MISMATCH")
332
+ current["attentionRequired"] = True
333
+ return rows, gaps, "baselineInferred"
334
+
335
+
336
+ def normalize_migrations(
337
+ flyway: Any | None, liquibase: Any | None, query: MigrationQuery
338
+ ) -> tuple[list[dict[str, Any]], list[str], str]:
339
+ rows: list[dict[str, Any]] = []
340
+ gaps: list[str] = []
341
+ complete_engines: set[str] = set()
342
+ for engine, body, parser in (
343
+ ("flyway", flyway, _from_flyway),
344
+ ("liquibase", liquibase, _from_liquibase),
345
+ ):
346
+ if body is None:
347
+ continue
348
+ try:
349
+ parsed, parsed_gaps = parser(body)
350
+ except AnalysisLimit:
351
+ # Budget exhaustion is not malformed upstream evidence.
352
+ gaps.append("GRAPH_LIMIT_REACHED")
353
+ continue
354
+ except (ValueError, TypeError, KeyError):
355
+ gaps.append(engine.upper() + "_EVIDENCE_INVALID")
356
+ continue
357
+ rows.extend(parsed)
358
+ gaps.extend(parsed_gaps)
359
+ if not parsed_gaps:
360
+ complete_engines.add(engine)
361
+ comparison = replace(
362
+ query,
363
+ expected=tuple(e for e in query.expected if e.engine in complete_engines),
364
+ )
365
+ rows, expected_gaps, visibility = apply_expected(rows, comparison)
366
+ gaps.extend(expected_gaps)
367
+ kept = [row for row in rows if _keep(query, row)]
368
+ kept.sort(key=_migration_order)
369
+ items = []
370
+ for index, row in enumerate(kept, start=1):
371
+ row["id"] = f"migration_{index:06d}"
372
+ items.append(row)
373
+ return items, sorted(set(gaps)), visibility