redeploy 0.2.61__tar.gz → 0.2.63__tar.gz

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 (241) hide show
  1. {redeploy-0.2.61 → redeploy-0.2.63}/PKG-INFO +7 -7
  2. {redeploy-0.2.61 → redeploy-0.2.63}/README.md +6 -6
  3. {redeploy-0.2.61 → redeploy-0.2.63}/pyproject.toml +1 -1
  4. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.63/redeploy/analyze/__init__.py +6 -0
  6. redeploy-0.2.63/redeploy/analyze/checkers/__init__.py +1 -0
  7. redeploy-0.2.63/redeploy/analyze/spec_analyzer.py +383 -0
  8. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/handlers.py +1 -1
  9. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/__init__.py +2 -0
  10. redeploy-0.2.63/redeploy/cli/commands/lint.py +86 -0
  11. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/plan_apply.py +27 -1
  12. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_cli.py +1 -1
  13. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy.egg-info/PKG-INFO +7 -7
  14. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy.egg-info/SOURCES.txt +4 -0
  15. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_prompt_llm.py +13 -4
  16. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/__init__.py +0 -0
  17. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/exceptions.py +0 -0
  18. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/executor.py +0 -0
  19. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/progress.py +0 -0
  20. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/rollback.py +0 -0
  21. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/state.py +0 -0
  22. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/state_apply.py +0 -0
  23. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/utils/__init__.py +0 -0
  24. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/apply/utils/run_container_build.py +0 -0
  25. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/audit.py +0 -0
  26. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/__init__.py +0 -0
  27. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/extractor.py +0 -0
  28. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/generators/__init__.py +0 -0
  29. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/generators/docker_compose.py +0 -0
  30. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/generators/migration.py +0 -0
  31. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/sources/__init__.py +0 -0
  32. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/sources/compose.py +0 -0
  33. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/sources/hardware.py +0 -0
  34. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/sources/infra.py +0 -0
  35. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/blueprint/sources/migration.py +0 -0
  36. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/__init__.py +0 -0
  37. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/audit.py +0 -0
  38. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/blueprint.py +0 -0
  39. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/bump_fix.py +0 -0
  40. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/detect.py +0 -0
  41. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/device_map.py +0 -0
  42. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/device_map_renderers.py +0 -0
  43. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/devices.py +0 -0
  44. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/diagnose.py +0 -0
  45. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/diff.py +0 -0
  46. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/exec_.py +0 -0
  47. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/export.py +0 -0
  48. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/hardware.py +0 -0
  49. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/import_.py +0 -0
  50. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/init.py +0 -0
  51. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/inspect.py +0 -0
  52. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/mcp_cmd.py +0 -0
  53. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/patterns.py +0 -0
  54. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/plugin.py +0 -0
  55. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/probe.py +0 -0
  56. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/prompt_cmd.py +0 -0
  57. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/push.py +0 -0
  58. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/state.py +0 -0
  59. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/status.py +0 -0
  60. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/target.py +0 -0
  61. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/__init__.py +0 -0
  62. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/commands.py +0 -0
  63. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/helpers.py +0 -0
  64. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/monorepo.py +0 -0
  65. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/release.py +0 -0
  66. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/scanner.py +0 -0
  67. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/utils/__init__.py +0 -0
  68. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/utils/changelog_config.py +0 -0
  69. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/version/utils/git_config.py +0 -0
  70. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/commands/workflow.py +0 -0
  71. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/core.py +0 -0
  72. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/display.py +0 -0
  73. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli/query.py +0 -0
  74. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/cli.py +0 -0
  75. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/config_apply/__init__.py +0 -0
  76. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/config_apply/applier.py +0 -0
  77. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/config_apply/loader.py +0 -0
  78. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/data_sync.py +0 -0
  79. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/__init__.py +0 -0
  80. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/builtin/__init__.py +0 -0
  81. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/detector.py +0 -0
  82. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/hardware.py +0 -0
  83. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/hardware_rules.py +0 -0
  84. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/probes.py +0 -0
  85. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/remote.py +0 -0
  86. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/templates.py +0 -0
  87. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/detect/workflow.py +0 -0
  88. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/discovery.py +0 -0
  89. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl/__init__.py +0 -0
  90. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl/loader.py +0 -0
  91. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl/parser.py +0 -0
  92. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/__init__.py +0 -0
  93. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/context.py +0 -0
  94. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/decorators.py +0 -0
  95. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/docker_steps.py +0 -0
  96. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/exceptions.py +0 -0
  97. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/runner.py +0 -0
  98. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/dsl_python/steps.py +0 -0
  99. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/fleet.py +0 -0
  100. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/__init__.py +0 -0
  101. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/config_txt.py +0 -0
  102. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/data/__init__.py +0 -0
  103. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/data/hyperpixel.py +0 -0
  104. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/data/official.py +0 -0
  105. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/data/waveshare.py +0 -0
  106. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/fixes.py +0 -0
  107. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/kiosk/__init__.py +0 -0
  108. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/kiosk/autostart.py +0 -0
  109. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/kiosk/browsers.py +0 -0
  110. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/kiosk/compositors.py +0 -0
  111. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  112. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/panels.py +0 -0
  113. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/hardware/raspi_config.py +0 -0
  114. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal/__init__.py +0 -0
  115. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal/decider.py +0 -0
  116. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal/hint_provider.py +0 -0
  117. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal/log_writer.py +0 -0
  118. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal/loop_detector.py +0 -0
  119. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal/runner.py +0 -0
  120. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/heal.py +0 -0
  121. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/__init__.py +0 -0
  122. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/base.py +0 -0
  123. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/config_hints.py +0 -0
  124. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/docker_compose.py +0 -0
  125. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/parsers/__init__.py +0 -0
  126. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/parsers/compose.py +0 -0
  127. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/iac/registry.py +0 -0
  128. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/integrations/__init__.py +0 -0
  129. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/integrations/op3_bridge.py +0 -0
  130. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/markpact/__init__.py +0 -0
  131. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/markpact/compiler.py +0 -0
  132. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/markpact/models.py +0 -0
  133. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/markpact/parser.py +0 -0
  134. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/mcp_server.py +0 -0
  135. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/__init__.py +0 -0
  136. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/blueprint.py +0 -0
  137. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/devices.py +0 -0
  138. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/enums.py +0 -0
  139. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/hardware.py +0 -0
  140. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/infra.py +0 -0
  141. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/manifest.py +0 -0
  142. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/persisted.py +0 -0
  143. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/pipeline.py +0 -0
  144. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/plan.py +0 -0
  145. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/models/spec.py +0 -0
  146. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/observe.py +0 -0
  147. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/parse.py +0 -0
  148. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/patterns.py +0 -0
  149. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plan/__init__.py +0 -0
  150. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plan/planner.py +0 -0
  151. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/__init__.py +0 -0
  152. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/builtin/__init__.py +0 -0
  153. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/builtin/browser_reload.py +0 -0
  154. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  155. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/builtin/notify.py +0 -0
  156. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/builtin/process_control.py +0 -0
  157. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  158. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/schema.py +0 -0
  159. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/spec_loader.py +0 -0
  160. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/ssh.py +0 -0
  161. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/steps/__init__.py +0 -0
  162. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/steps/builtins.py +0 -0
  163. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/steps/kiosk.py +0 -0
  164. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/__init__.py +0 -0
  165. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_audit.py +0 -0
  166. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_data_sync.py +0 -0
  167. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_discovery.py +0 -0
  168. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_dsl.py +0 -0
  169. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_examples.py +0 -0
  170. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_executor.py +0 -0
  171. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_fleet.py +0 -0
  172. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_git_integration.py +0 -0
  173. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_heal.py +0 -0
  174. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_heal_decider.py +0 -0
  175. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_hooks.py +0 -0
  176. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_iac.py +0 -0
  177. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_iac_compose_contract.py +0 -0
  178. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_iac_docker_compose_contract.py +0 -0
  179. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_manifest.py +0 -0
  180. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_markpact_compiler.py +0 -0
  181. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_markpact_parser.py +0 -0
  182. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_models.py +0 -0
  183. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_observe.py +0 -0
  184. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_parse.py +0 -0
  185. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_patterns.py +0 -0
  186. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_planner_edge.py +0 -0
  187. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_plugins.py +0 -0
  188. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_probes.py +0 -0
  189. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_public_api.py +0 -0
  190. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_resume.py +0 -0
  191. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_spec_loader.py +0 -0
  192. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_ssh.py +0 -0
  193. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_templates.py +0 -0
  194. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_verify.py +0 -0
  195. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_version.py +0 -0
  196. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_version_changelog.py +0 -0
  197. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_version_cli.py +0 -0
  198. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_version_manifest.py +0 -0
  199. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_version_sources.py +0 -0
  200. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/tests/test_workflow.py +0 -0
  201. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/verify.py +0 -0
  202. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/__init__.py +0 -0
  203. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/bump.py +0 -0
  204. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/changelog.py +0 -0
  205. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/commits.py +0 -0
  206. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/diff.py +0 -0
  207. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/git_integration.py +0 -0
  208. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/git_transaction.py +0 -0
  209. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/manifest.py +0 -0
  210. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/__init__.py +0 -0
  211. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/base.py +0 -0
  212. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/json_.py +0 -0
  213. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/plain.py +0 -0
  214. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/regex.py +0 -0
  215. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/toml_.py +0 -0
  216. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/sources/yaml_.py +0 -0
  217. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version/transaction.py +0 -0
  218. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy/version.py +0 -0
  219. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy.egg-info/dependency_links.txt +0 -0
  220. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy.egg-info/entry_points.txt +0 -0
  221. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy.egg-info/requires.txt +0 -0
  222. {redeploy-0.2.61 → redeploy-0.2.63}/redeploy.egg-info/top_level.txt +0 -0
  223. {redeploy-0.2.61 → redeploy-0.2.63}/setup.cfg +0 -0
  224. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_blueprint_sources.py +0 -0
  225. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_blueprint_sources_compose.py +0 -0
  226. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_cli_query.py +0 -0
  227. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_example_argocd_flux_plugin.py +0 -0
  228. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_example_gitops_ci_plugin.py +0 -0
  229. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_example_helm_kustomize_plugin.py +0 -0
  230. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_hardware_config.py +0 -0
  231. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_hardware_kiosk.py +0 -0
  232. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_hardware_rules.py +0 -0
  233. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_heal_decider.py +0 -0
  234. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_heal_loop_detector.py +0 -0
  235. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_iac.py +0 -0
  236. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_iac_config_hints.py +0 -0
  237. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_iac_plugin_loading.py +0 -0
  238. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_import_plugin_template.py +0 -0
  239. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_parse.py +0 -0
  240. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_redeploy.py +0 -0
  241. {redeploy-0.2.61 → redeploy-0.2.63}/tests/test_steps_kiosk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.61
3
+ Version: 0.2.63
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -32,17 +32,17 @@ Requires-Dist: mcp>=1.0; extra == "mcp"
32
32
 
33
33
  ## AI Cost Tracking
34
34
 
35
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.61-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-29.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
35
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.63-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-31.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
37
37
 
38
- - 🤖 **LLM usage:** $7.5000 (81 commits)
39
- - 👤 **Human dev:** ~$2911 (29.1h @ $100/h, 30min dedup)
38
+ - 🤖 **LLM usage:** $7.5000 (83 commits)
39
+ - 👤 **Human dev:** ~$3183 (31.8h @ $100/h, 30min dedup)
40
40
 
41
- Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
+ Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
42
42
 
43
43
  ---
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.61-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.63-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
46
 
47
47
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
48
48
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.61-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-29.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.63-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-31.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (81 commits)
10
- - 👤 **Human dev:** ~$2911 (29.1h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (83 commits)
10
+ - 👤 **Human dev:** ~$3183 (31.8h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.61-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.63-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.61"
7
+ version = "0.2.63"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
7
7
 
8
8
  Everything not listed in ``__all__`` is internal and may change without notice.
9
9
  """
10
- __version__ = "0.2.61"
10
+ __version__ = "0.2.63"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -0,0 +1,6 @@
1
+ """Static analysis for migration specs — pre-flight checks before plan/apply."""
2
+ from __future__ import annotations
3
+
4
+ from .spec_analyzer import SpecAnalyzer, AnalysisResult, IssueSeverity
5
+
6
+ __all__ = ["SpecAnalyzer", "AnalysisResult", "IssueSeverity"]
@@ -0,0 +1 @@
1
+ """Built-in static checkers for redeploy specs."""
@@ -0,0 +1,383 @@
1
+ """Static analysis engine for migration specs.
2
+
3
+ Scans compiled MigrationSpec + raw markpact blocks for:
4
+ - missing local files/paths referenced in steps (src, dst, command, config_file)
5
+ - broken command_ref / insert_before references
6
+ - docker-compose.yml inconsistencies (missing files, volume paths, build contexts)
7
+ - hardcoded absolute paths to external dependencies in commands
8
+ - .env / config file references
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import subprocess
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+
21
+ from ..markpact.models import MarkpactDocument
22
+ from ..models.plan import MigrationStep
23
+ from ..models.spec import MigrationSpec
24
+
25
+
26
+ class IssueSeverity(str, Enum):
27
+ ERROR = "error"
28
+ WARNING = "warning"
29
+ INFO = "info"
30
+
31
+
32
+ @dataclass
33
+ class Issue:
34
+ severity: IssueSeverity
35
+ category: str
36
+ step_id: str | None
37
+ message: str
38
+ suggestion: str | None = None
39
+ line: int | None = None
40
+
41
+
42
+ @dataclass
43
+ class AnalysisResult:
44
+ issues: list[Issue] = field(default_factory=list)
45
+ passed: bool = True
46
+
47
+ def add(self, severity: IssueSeverity, category: str, message: str,
48
+ step_id: str | None = None, suggestion: str | None = None, line: int | None = None) -> None:
49
+ self.issues.append(Issue(severity, category, step_id, message, suggestion, line))
50
+ if severity == IssueSeverity.ERROR:
51
+ self.passed = False
52
+
53
+ def errors(self) -> list[Issue]:
54
+ return [i for i in self.issues if i.severity == IssueSeverity.ERROR]
55
+
56
+ def warnings(self) -> list[Issue]:
57
+ return [i for i in self.issues if i.severity == IssueSeverity.WARNING]
58
+
59
+
60
+ class _Checker:
61
+ def check(self, spec: MigrationSpec, document: MarkpactDocument | None,
62
+ base_dir: Path, result: AnalysisResult) -> None:
63
+ raise NotImplementedError
64
+
65
+
66
+ class _PathChecker(_Checker):
67
+ """Validate local file paths referenced by steps."""
68
+
69
+ def check(self, spec, document, base_dir, result):
70
+ for step in spec.extra_steps:
71
+ self._check_field(step, "src", base_dir, result)
72
+ self._check_field(step, "dst", base_dir, result)
73
+ self._check_field(step, "config_file", base_dir, result)
74
+ self._check_env_file(step, base_dir, result)
75
+
76
+ def _check_field(self, step: dict, field_name: str, base_dir: Path, result: AnalysisResult):
77
+ val = step.get(field_name)
78
+ if not val:
79
+ return
80
+ # Only check local (non-ssh) paths; remote paths often start with ~ or user@host:...
81
+ if re.search(r"^[\w.-]+@", str(val)):
82
+ return
83
+ # dst pointing to remote home (~/) is not a local path to verify
84
+ if field_name == "dst" and str(val).startswith("~/"):
85
+ return
86
+ path = self._resolve(val, base_dir)
87
+ if path and not path.exists():
88
+ result.add(
89
+ IssueSeverity.ERROR, "paths",
90
+ f"Step '{step.get('id')}' references missing {field_name}: {val}",
91
+ step.get("id"),
92
+ suggestion=f"Create file/dir or correct path: {path}"
93
+ )
94
+
95
+ def _check_env_file(self, step: dict, base_dir: Path, result: AnalysisResult):
96
+ # Some steps may compose env_file indirectly; we also scan raw commands for .env
97
+ pass
98
+
99
+ @staticmethod
100
+ def _resolve(val: str, base_dir: Path) -> Path | None:
101
+ val = val.strip()
102
+ if val.startswith("~/"):
103
+ return Path.home() / val[2:]
104
+ if val.startswith("/"):
105
+ return Path(val)
106
+ # relative -> resolve against base_dir
107
+ return base_dir / val
108
+
109
+
110
+ class _CommandPathChecker(_Checker):
111
+ """Scan command strings for hardcoded absolute paths outside the project."""
112
+
113
+ EXTERNAL_RE = re.compile(
114
+ r"(?:^|\s)(/home/\w+/[^\s'\"]+|~/[^\s'\"]+)"
115
+ )
116
+
117
+ def check(self, spec, document, base_dir, result):
118
+ for step in spec.extra_steps:
119
+ cmd = step.get("command") or ""
120
+ for match in self.EXTERNAL_RE.finditer(cmd):
121
+ path_str = match.group(1)
122
+ # Skip remote target paths (ssh_cmd / inline_script running remotely)
123
+ if path_str.startswith("~/"):
124
+ continue
125
+ # Ignore paths inside the project tree
126
+ resolved = self._resolve(path_str, base_dir)
127
+ if resolved and self._is_inside(resolved, base_dir):
128
+ continue
129
+ # Check if exists locally
130
+ if resolved and not resolved.exists():
131
+ result.add(
132
+ IssueSeverity.ERROR, "commands",
133
+ f"Step '{step.get('id')}' command references missing external path: {path_str}",
134
+ step.get("id"),
135
+ suggestion="Add rsync/scp step to sync this dependency, or correct the path."
136
+ )
137
+ else:
138
+ result.add(
139
+ IssueSeverity.WARNING, "commands",
140
+ f"Step '{step.get('id')}' command references external path: {path_str}",
141
+ step.get("id"),
142
+ suggestion="Consider adding an explicit sync step for this dependency."
143
+ )
144
+
145
+ @staticmethod
146
+ def _resolve(val: str, base_dir: Path) -> Path | None:
147
+ val = val.strip().rstrip("/")
148
+ if val.startswith("~/"):
149
+ return Path.home() / val[2:]
150
+ if val.startswith("/"):
151
+ return Path(val)
152
+ return base_dir / val
153
+
154
+ @staticmethod
155
+ def _is_inside(path: Path, base: Path) -> bool:
156
+ try:
157
+ path.resolve().relative_to(base.resolve())
158
+ return True
159
+ except ValueError:
160
+ return False
161
+
162
+
163
+ class _ReferenceChecker(_Checker):
164
+ """Ensure command_ref and insert_before point to existing things."""
165
+
166
+ def check(self, spec, document, base_dir, result):
167
+ from redeploy.steps import StepLibrary
168
+ step_ids = {s.get("id") for s in spec.extra_steps if s.get("id")}
169
+ step_ids |= set(StepLibrary.list())
170
+ ref_ids = set()
171
+ if document:
172
+ for block in document.blocks:
173
+ if block.kind == "ref" and block.ref_id:
174
+ ref_ids.add(block.ref_id)
175
+
176
+ for step in spec.extra_steps:
177
+ sid = step.get("id")
178
+ cref = step.get("command_ref")
179
+ if cref and cref not in ref_ids:
180
+ result.add(
181
+ IssueSeverity.ERROR, "references",
182
+ f"Step '{sid}' references unknown command_ref '{cref}'",
183
+ sid,
184
+ suggestion=f"Define a ```bash markpact:ref {cref} block or remove command_ref."
185
+ )
186
+ ib = step.get("insert_before")
187
+ if ib and ib not in step_ids:
188
+ result.add(
189
+ IssueSeverity.WARNING, "references",
190
+ f"Step '{sid}' insert_before points to unknown step '{ib}'",
191
+ sid,
192
+ suggestion="This may be a runtime-generated step; verify the generated plan."
193
+ )
194
+
195
+
196
+ class _ComposeChecker(_Checker):
197
+ """Validate docker-compose files declared in spec or found in project."""
198
+
199
+ def check(self, spec, document, base_dir, result):
200
+ compose_files = list(spec.target.compose_files or [])
201
+ # Also try to discover common names if none explicitly declared
202
+ if not compose_files:
203
+ for candidate in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
204
+ if (base_dir / candidate).exists():
205
+ compose_files.append(candidate)
206
+ break
207
+
208
+ for cf in compose_files:
209
+ path = base_dir / cf
210
+ if not path.exists():
211
+ result.add(
212
+ IssueSeverity.ERROR, "compose",
213
+ f"Declared compose file not found: {cf}",
214
+ suggestion=f"Create {path} or remove from target.compose_files."
215
+ )
216
+ continue
217
+ self._scan_compose(path, base_dir, result)
218
+
219
+ def _scan_compose(self, path: Path, base_dir: Path, result: AnalysisResult):
220
+ try:
221
+ data = yaml.safe_load(path.read_text()) or {}
222
+ except Exception as exc:
223
+ result.add(IssueSeverity.ERROR, "compose", f"Cannot parse {path}: {exc}")
224
+ return
225
+
226
+ services = data.get("services", {})
227
+ for svc_name, svc in services.items():
228
+ if not isinstance(svc, dict):
229
+ continue
230
+ # build.context
231
+ build = svc.get("build")
232
+ if isinstance(build, dict):
233
+ ctx = build.get("context", ".")
234
+ ctx_path = base_dir / ctx if not ctx.startswith("/") else Path(ctx)
235
+ if not ctx_path.exists():
236
+ result.add(
237
+ IssueSeverity.ERROR, "compose",
238
+ f"Service '{svc_name}' build.context missing: {ctx}",
239
+ suggestion=f"Create directory {ctx_path} or fix context."
240
+ )
241
+ dockerfile = build.get("dockerfile")
242
+ if dockerfile:
243
+ df_path = ctx_path / dockerfile
244
+ if not df_path.exists():
245
+ result.add(
246
+ IssueSeverity.ERROR, "compose",
247
+ f"Service '{svc_name}' Dockerfile missing: {df_path}",
248
+ suggestion=f"Create {df_path} or correct dockerfile path."
249
+ )
250
+ # env_file
251
+ env_files = svc.get("env_file", [])
252
+ if isinstance(env_files, str):
253
+ env_files = [env_files]
254
+ for ef in env_files:
255
+ ef_path = base_dir / ef if not str(ef).startswith("/") else Path(ef)
256
+ if not ef_path.exists():
257
+ result.add(
258
+ IssueSeverity.WARNING, "compose",
259
+ f"Service '{svc_name}' env_file missing: {ef}",
260
+ suggestion=f"Create {ef_path} or remove env_file entry."
261
+ )
262
+ # volumes
263
+ for vol in svc.get("volumes", []):
264
+ if isinstance(vol, str):
265
+ if ":" in vol:
266
+ host_part = vol.split(":", 1)[0]
267
+ # Skip named volumes and relative paths that exist
268
+ if host_part.startswith("/"):
269
+ if not Path(host_part).exists():
270
+ result.add(
271
+ IssueSeverity.WARNING, "compose",
272
+ f"Service '{svc_name}' volume host path missing: {host_part}",
273
+ suggestion=f"Ensure host path exists before deployment."
274
+ )
275
+ elif host_part != "." and "/" in host_part:
276
+ hp = base_dir / host_part
277
+ if not hp.exists():
278
+ result.add(
279
+ IssueSeverity.WARNING, "compose",
280
+ f"Service '{svc_name}' relative volume path missing: {hp}",
281
+ suggestion=f"Create {hp} or mount as named volume."
282
+ )
283
+
284
+
285
+ class _EnvFileChecker(_Checker):
286
+ """Check that .env referenced by target.env_file exists."""
287
+
288
+ def check(self, spec, document, base_dir, result):
289
+ ef = spec.target.env_file or spec.source.env_file
290
+ if ef:
291
+ path = base_dir / ef if not ef.startswith("/") else Path(ef)
292
+ if not path.exists():
293
+ result.add(
294
+ IssueSeverity.ERROR, "env",
295
+ f"env_file not found: {ef}",
296
+ suggestion=f"Create {path} or correct env_file path."
297
+ )
298
+
299
+
300
+ class _BinaryChecker(_Checker):
301
+ """Warn if commands reference binaries not available locally (best-effort)."""
302
+
303
+ def check(self, spec, document, base_dir, result):
304
+ for step in spec.extra_steps:
305
+ cmd = step.get("command") or ""
306
+ for word in cmd.split():
307
+ word = word.strip(";|&`'\"()")
308
+ if "/" in word or word in ("if", "then", "else", "fi", "for", "do", "done", "echo", "true", "false"):
309
+ continue
310
+ if not self._which(word):
311
+ # Only warn for likely commands (simple words, not variable names)
312
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9_-]+$", word):
313
+ result.add(
314
+ IssueSeverity.WARNING, "binaries",
315
+ f"Step '{step.get('id')}' command uses binary not found locally: '{word}'",
316
+ step.get("id"),
317
+ suggestion=f"Install '{word}' or ensure it exists on target host."
318
+ )
319
+
320
+ @staticmethod
321
+ def _which(cmd: str) -> bool:
322
+ try:
323
+ subprocess.run(["which", cmd], capture_output=True, check=True)
324
+ return True
325
+ except Exception:
326
+ return False
327
+
328
+
329
+ class SpecAnalyzer:
330
+ """Run static checks against a compiled MigrationSpec (and optional raw MarkpactDocument)."""
331
+
332
+ DEFAULT_CHECKERS: list[_Checker] = [
333
+ _PathChecker(),
334
+ _CommandPathChecker(),
335
+ _ReferenceChecker(),
336
+ _ComposeChecker(),
337
+ _EnvFileChecker(),
338
+ _BinaryChecker(),
339
+ ]
340
+
341
+ def __init__(self, base_dir: Path | None = None,
342
+ checkers: list[_Checker] | None = None):
343
+ self.base_dir = base_dir or Path.cwd()
344
+ self.checkers = checkers or list(self.DEFAULT_CHECKERS)
345
+
346
+ def analyze(self, spec: MigrationSpec,
347
+ document: MarkpactDocument | None = None) -> AnalysisResult:
348
+ result = AnalysisResult()
349
+ for checker in self.checkers:
350
+ checker.check(spec, document, self.base_dir, result)
351
+ return result
352
+
353
+ def analyze_file(self, spec_path: Path) -> tuple[MigrationSpec | None, AnalysisResult]:
354
+ """Load spec from file (YAML or markpact) and analyze."""
355
+ from ..markpact.parser import parse_markpact_file
356
+ from ..markpact.compiler import compile_markpact_document, MarkpactCompileError
357
+ from ..models.spec import MigrationSpec as MS
358
+
359
+ document = None
360
+ spec = None
361
+ result = AnalysisResult()
362
+
363
+ # Try markpact first
364
+ if spec_path.suffix == ".md":
365
+ try:
366
+ document = parse_markpact_file(spec_path)
367
+ spec = compile_markpact_document(document)
368
+ except MarkpactCompileError as exc:
369
+ result.add(IssueSeverity.ERROR, "compile", str(exc))
370
+ return None, result
371
+ except Exception as exc:
372
+ result.add(IssueSeverity.ERROR, "compile", f"Failed to parse markpact: {exc}")
373
+ return None, result
374
+ else:
375
+ try:
376
+ spec = MS.from_file(spec_path)
377
+ except Exception as exc:
378
+ result.add(IssueSeverity.ERROR, "load", f"Failed to load spec: {exc}")
379
+ return None, result
380
+
381
+ if spec:
382
+ result = self.analyze(spec, document)
383
+ return spec, result
@@ -112,7 +112,7 @@ def run_docker_health_wait(
112
112
  status_str = ", ".join(f"{n}:{s}" for n, s in statuses)
113
113
 
114
114
  if status_str != last_status:
115
- logger.debug(f" [{elapsed}s] containers: {status_str}")
115
+ logger.info(f" [{elapsed}s] containers: {status_str}")
116
116
  last_status = status_str
117
117
 
118
118
  if _all_containers_healthy(statuses):
@@ -57,10 +57,12 @@ from .commands.status import status
57
57
  from .commands.bump_fix import bump_cmd, fix_cmd
58
58
  from .commands.prompt_cmd import prompt_cmd
59
59
  from .commands.mcp_cmd import mcp_cmd
60
+ from .commands.lint import lint
60
61
 
61
62
  # Register commands
62
63
  cli.add_command(audit)
63
64
  cli.add_command(detect)
65
+ cli.add_command(lint)
64
66
  cli.add_command(devices)
65
67
  cli.add_command(scan)
66
68
  cli.add_command(device_add)
@@ -0,0 +1,86 @@
1
+ """lint command — static analysis of migration specs before deployment."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ...analyze import SpecAnalyzer
12
+
13
+
14
+ @click.command()
15
+ @click.argument("spec_file", default=None, required=False, type=click.Path(), metavar="SPEC")
16
+ @click.option("--env", "env_name", default="", help="Named environment from redeploy.yaml")
17
+ @click.option("--json", "as_json", is_flag=True, help="Output raw JSON")
18
+ @click.option("--exit-zero", is_flag=True, help="Exit 0 even if errors found")
19
+ @click.pass_context
20
+ def lint(ctx, spec_file, env_name, as_json, exit_zero):
21
+ """Static analysis of a migration spec (YAML or markpact .md).
22
+
23
+ Detects missing files, broken references, missing command_ref blocks,
24
+ docker-compose inconsistencies, and hardcoded external paths.
25
+ """
26
+ console = Console()
27
+ from ...cli.core import load_spec_or_exit
28
+ from ...models import ProjectManifest
29
+
30
+ manifest = ProjectManifest.find_and_load(Path.cwd())
31
+ resolved = spec_file or (manifest.spec if manifest else "migration.yaml")
32
+
33
+ if not Path(resolved).exists():
34
+ console.print(f"[red]✗ spec file not found: {resolved}[/red]")
35
+ sys.exit(1)
36
+
37
+ spec = load_spec_or_exit(console, resolved)
38
+
39
+ if manifest and env_name:
40
+ if env_name not in manifest.environments:
41
+ console.print(f"[yellow]⚠ env '{env_name}' not in redeploy.yaml[/yellow]")
42
+ manifest.apply_to_spec(spec, env_name=env_name)
43
+
44
+ analyzer = SpecAnalyzer(base_dir=Path.cwd())
45
+ _, result = analyzer.analyze_file(Path(resolved))
46
+
47
+ if as_json:
48
+ import json
49
+ out = {
50
+ "passed": result.passed,
51
+ "errors": len(result.errors()),
52
+ "warnings": len(result.warnings()),
53
+ "issues": [
54
+ {
55
+ "severity": i.severity.value,
56
+ "category": i.category,
57
+ "step_id": i.step_id,
58
+ "message": i.message,
59
+ "suggestion": i.suggestion,
60
+ }
61
+ for i in result.issues
62
+ ],
63
+ }
64
+ console.print(json.dumps(out, indent=2))
65
+ else:
66
+ if result.passed and not result.issues:
67
+ console.print("[green]✓ No issues found[/green]")
68
+ else:
69
+ if result.errors():
70
+ console.print(f"[red]✗ {len(result.errors())} error(s)[/red]")
71
+ if result.warnings():
72
+ console.print(f"[yellow]⚠ {len(result.warnings())} warning(s)[/yellow]")
73
+
74
+ t = Table(show_header=True, box=None, padding=(0, 2))
75
+ t.add_column("Severity", width=8)
76
+ t.add_column("Category", width=12)
77
+ t.add_column("Step", width=20)
78
+ t.add_column("Message")
79
+ t.add_column("Suggestion", style="dim")
80
+ for i in result.issues:
81
+ sev = f"[red]{i.severity.value}[/red]" if i.severity.value == "error" else f"[yellow]{i.severity.value}[/yellow]"
82
+ t.add_row(sev, i.category, i.step_id or "—", i.message, i.suggestion or "")
83
+ console.print(t)
84
+
85
+ if not exit_zero and not result.passed:
86
+ sys.exit(1)
@@ -264,10 +264,14 @@ def migrate(ctx, host, app, domain, target, strategy, target_version,
264
264
  "--max-heal-retries", default=3, show_default=True,
265
265
  help="Maximum number of LLM self-healing attempts."
266
266
  )
267
+ @click.option(
268
+ "--lint/--no-lint", default=True, show_default=True,
269
+ help="Run static analysis before deployment to catch missing files, broken references, and external path issues."
270
+ )
267
271
  @click.pass_context
268
272
  def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
269
273
  env_name, progress_yaml, resume, from_step, state_file, no_state,
270
- heal, fix_hint, max_heal_retries):
274
+ heal, fix_hint, max_heal_retries, lint):
271
275
  """Execute migration from a single YAML spec (source + target in one file).
272
276
 
273
277
  SPEC defaults to migration.yaml (or value from redeploy.yaml manifest).
@@ -305,6 +309,28 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
305
309
  _apply_manifest_to_spec(console, manifest, spec, env_name)
306
310
  _print_spec_summary(console, spec)
307
311
 
312
+ # Static analysis (lint)
313
+ if lint:
314
+ from ...analyze import SpecAnalyzer, IssueSeverity
315
+ analyzer = SpecAnalyzer(base_dir=Path.cwd())
316
+ _, lint_result = analyzer.analyze_file(Path(resolved_spec))
317
+ if lint_result.issues:
318
+ console.print(f"\n[bold]lint[/bold]")
319
+ for i in lint_result.issues:
320
+ color = "red" if i.severity == IssueSeverity.ERROR else "yellow"
321
+ prefix = f"[{color}]{i.severity.value}[/{color}]"
322
+ step = f" ({i.step_id})" if i.step_id else ""
323
+ console.print(f" {prefix}{step} {i.category}: {i.message}")
324
+ if i.suggestion:
325
+ console.print(f" [dim]→ {i.suggestion}[/dim]")
326
+ if not lint_result.passed:
327
+ console.print("\n[red]✗ lint failed — fix errors above or use --no-lint to skip.[/red]")
328
+ sys.exit(1)
329
+ else:
330
+ console.print(f"[yellow]⚠ lint passed with {len(lint_result.warnings())} warning(s)[/yellow]")
331
+ else:
332
+ console.print("[green] ✓ lint passed[/green]")
333
+
308
334
  # Optional live detect
309
335
  if do_detect:
310
336
  planner = _perform_live_detect(console, spec)
@@ -163,7 +163,7 @@ class TestRunPlanOnly:
163
163
  spec = tmp_path / "migration.md"
164
164
  spec.write_text(_migration_markdown_supported(), encoding="utf-8")
165
165
 
166
- result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
166
+ result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--no-lint"])
167
167
 
168
168
  assert result.exit_code == 0, result.output
169
169
  assert "markdown migration" in result.output