redeploy 0.2.60__tar.gz → 0.2.62__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.60 → redeploy-0.2.62}/PKG-INFO +7 -7
  2. {redeploy-0.2.60 → redeploy-0.2.62}/README.md +6 -6
  3. {redeploy-0.2.60 → redeploy-0.2.62}/pyproject.toml +1 -1
  4. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.62/redeploy/analyze/__init__.py +6 -0
  6. redeploy-0.2.62/redeploy/analyze/checkers/__init__.py +1 -0
  7. redeploy-0.2.62/redeploy/analyze/spec_analyzer.py +379 -0
  8. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/__init__.py +2 -0
  9. redeploy-0.2.62/redeploy/cli/commands/lint.py +86 -0
  10. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/plan_apply.py +27 -1
  11. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/prompt_cmd.py +4 -1
  12. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/runner.py +13 -1
  13. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_cli.py +1 -1
  14. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_heal.py +43 -0
  15. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/PKG-INFO +7 -7
  16. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/SOURCES.txt +4 -0
  17. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/__init__.py +0 -0
  18. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/exceptions.py +0 -0
  19. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/executor.py +0 -0
  20. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/handlers.py +0 -0
  21. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/progress.py +0 -0
  22. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/rollback.py +0 -0
  23. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/state.py +0 -0
  24. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/state_apply.py +0 -0
  25. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/utils/__init__.py +0 -0
  26. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/apply/utils/run_container_build.py +0 -0
  27. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/audit.py +0 -0
  28. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/__init__.py +0 -0
  29. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/extractor.py +0 -0
  30. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/generators/__init__.py +0 -0
  31. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/generators/docker_compose.py +0 -0
  32. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/generators/migration.py +0 -0
  33. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/__init__.py +0 -0
  34. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/compose.py +0 -0
  35. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/hardware.py +0 -0
  36. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/infra.py +0 -0
  37. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/blueprint/sources/migration.py +0 -0
  38. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/__init__.py +0 -0
  39. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/audit.py +0 -0
  40. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/blueprint.py +0 -0
  41. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/bump_fix.py +0 -0
  42. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/detect.py +0 -0
  43. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/device_map.py +0 -0
  44. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/device_map_renderers.py +0 -0
  45. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/devices.py +0 -0
  46. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/diagnose.py +0 -0
  47. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/diff.py +0 -0
  48. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/exec_.py +0 -0
  49. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/export.py +0 -0
  50. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/hardware.py +0 -0
  51. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/import_.py +0 -0
  52. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/init.py +0 -0
  53. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/inspect.py +0 -0
  54. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/mcp_cmd.py +0 -0
  55. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/patterns.py +0 -0
  56. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/plugin.py +0 -0
  57. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/probe.py +0 -0
  58. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/push.py +0 -0
  59. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/state.py +0 -0
  60. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/status.py +0 -0
  61. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/target.py +0 -0
  62. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/__init__.py +0 -0
  63. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/commands.py +0 -0
  64. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/helpers.py +0 -0
  65. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/monorepo.py +0 -0
  66. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/release.py +0 -0
  67. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/scanner.py +0 -0
  68. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/utils/__init__.py +0 -0
  69. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/utils/changelog_config.py +0 -0
  70. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/version/utils/git_config.py +0 -0
  71. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/commands/workflow.py +0 -0
  72. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/core.py +0 -0
  73. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/display.py +0 -0
  74. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli/query.py +0 -0
  75. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/cli.py +0 -0
  76. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/config_apply/__init__.py +0 -0
  77. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/config_apply/applier.py +0 -0
  78. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/config_apply/loader.py +0 -0
  79. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/data_sync.py +0 -0
  80. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/__init__.py +0 -0
  81. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/builtin/__init__.py +0 -0
  82. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/detector.py +0 -0
  83. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/hardware.py +0 -0
  84. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/hardware_rules.py +0 -0
  85. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/probes.py +0 -0
  86. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/remote.py +0 -0
  87. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/templates.py +0 -0
  88. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/detect/workflow.py +0 -0
  89. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/discovery.py +0 -0
  90. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl/__init__.py +0 -0
  91. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl/loader.py +0 -0
  92. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl/parser.py +0 -0
  93. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/__init__.py +0 -0
  94. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/context.py +0 -0
  95. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/decorators.py +0 -0
  96. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/docker_steps.py +0 -0
  97. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/exceptions.py +0 -0
  98. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/runner.py +0 -0
  99. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/dsl_python/steps.py +0 -0
  100. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/fleet.py +0 -0
  101. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/__init__.py +0 -0
  102. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/config_txt.py +0 -0
  103. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/__init__.py +0 -0
  104. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/hyperpixel.py +0 -0
  105. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/official.py +0 -0
  106. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/data/waveshare.py +0 -0
  107. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/fixes.py +0 -0
  108. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/__init__.py +0 -0
  109. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/autostart.py +0 -0
  110. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/browsers.py +0 -0
  111. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/compositors.py +0 -0
  112. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  113. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/panels.py +0 -0
  114. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/hardware/raspi_config.py +0 -0
  115. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/__init__.py +0 -0
  116. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/decider.py +0 -0
  117. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/hint_provider.py +0 -0
  118. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/log_writer.py +0 -0
  119. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal/loop_detector.py +0 -0
  120. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/heal.py +0 -0
  121. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/__init__.py +0 -0
  122. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/base.py +0 -0
  123. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/config_hints.py +0 -0
  124. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/docker_compose.py +0 -0
  125. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/parsers/__init__.py +0 -0
  126. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/parsers/compose.py +0 -0
  127. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/iac/registry.py +0 -0
  128. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/integrations/__init__.py +0 -0
  129. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/integrations/op3_bridge.py +0 -0
  130. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/__init__.py +0 -0
  131. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/compiler.py +0 -0
  132. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/models.py +0 -0
  133. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/markpact/parser.py +0 -0
  134. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/mcp_server.py +0 -0
  135. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/__init__.py +0 -0
  136. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/blueprint.py +0 -0
  137. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/devices.py +0 -0
  138. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/enums.py +0 -0
  139. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/hardware.py +0 -0
  140. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/infra.py +0 -0
  141. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/manifest.py +0 -0
  142. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/persisted.py +0 -0
  143. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/pipeline.py +0 -0
  144. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/plan.py +0 -0
  145. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/models/spec.py +0 -0
  146. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/observe.py +0 -0
  147. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/parse.py +0 -0
  148. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/patterns.py +0 -0
  149. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plan/__init__.py +0 -0
  150. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plan/planner.py +0 -0
  151. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/__init__.py +0 -0
  152. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/__init__.py +0 -0
  153. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/browser_reload.py +0 -0
  154. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  155. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/notify.py +0 -0
  156. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/process_control.py +0 -0
  157. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  158. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/schema.py +0 -0
  159. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/spec_loader.py +0 -0
  160. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/ssh.py +0 -0
  161. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/steps/__init__.py +0 -0
  162. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/steps/builtins.py +0 -0
  163. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/steps/kiosk.py +0 -0
  164. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/__init__.py +0 -0
  165. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_audit.py +0 -0
  166. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_data_sync.py +0 -0
  167. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_discovery.py +0 -0
  168. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_dsl.py +0 -0
  169. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_examples.py +0 -0
  170. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_executor.py +0 -0
  171. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_fleet.py +0 -0
  172. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_git_integration.py +0 -0
  173. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_heal_decider.py +0 -0
  174. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_hooks.py +0 -0
  175. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_iac.py +0 -0
  176. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_iac_compose_contract.py +0 -0
  177. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_iac_docker_compose_contract.py +0 -0
  178. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_manifest.py +0 -0
  179. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_markpact_compiler.py +0 -0
  180. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_markpact_parser.py +0 -0
  181. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_models.py +0 -0
  182. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_observe.py +0 -0
  183. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_parse.py +0 -0
  184. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_patterns.py +0 -0
  185. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_planner_edge.py +0 -0
  186. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_plugins.py +0 -0
  187. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_probes.py +0 -0
  188. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_public_api.py +0 -0
  189. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_resume.py +0 -0
  190. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_spec_loader.py +0 -0
  191. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_ssh.py +0 -0
  192. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_templates.py +0 -0
  193. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_verify.py +0 -0
  194. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version.py +0 -0
  195. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_changelog.py +0 -0
  196. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_cli.py +0 -0
  197. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_manifest.py +0 -0
  198. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_version_sources.py +0 -0
  199. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/tests/test_workflow.py +0 -0
  200. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/verify.py +0 -0
  201. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/__init__.py +0 -0
  202. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/bump.py +0 -0
  203. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/changelog.py +0 -0
  204. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/commits.py +0 -0
  205. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/diff.py +0 -0
  206. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/git_integration.py +0 -0
  207. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/git_transaction.py +0 -0
  208. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/manifest.py +0 -0
  209. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/__init__.py +0 -0
  210. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/base.py +0 -0
  211. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/json_.py +0 -0
  212. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/plain.py +0 -0
  213. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/regex.py +0 -0
  214. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/toml_.py +0 -0
  215. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/sources/yaml_.py +0 -0
  216. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version/transaction.py +0 -0
  217. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy/version.py +0 -0
  218. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/dependency_links.txt +0 -0
  219. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/entry_points.txt +0 -0
  220. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/requires.txt +0 -0
  221. {redeploy-0.2.60 → redeploy-0.2.62}/redeploy.egg-info/top_level.txt +0 -0
  222. {redeploy-0.2.60 → redeploy-0.2.62}/setup.cfg +0 -0
  223. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_blueprint_sources.py +0 -0
  224. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_blueprint_sources_compose.py +0 -0
  225. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_cli_query.py +0 -0
  226. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_example_argocd_flux_plugin.py +0 -0
  227. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_example_gitops_ci_plugin.py +0 -0
  228. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_example_helm_kustomize_plugin.py +0 -0
  229. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_hardware_config.py +0 -0
  230. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_hardware_kiosk.py +0 -0
  231. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_hardware_rules.py +0 -0
  232. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_heal_decider.py +0 -0
  233. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_heal_loop_detector.py +0 -0
  234. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_iac.py +0 -0
  235. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_iac_config_hints.py +0 -0
  236. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_iac_plugin_loading.py +0 -0
  237. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_import_plugin_template.py +0 -0
  238. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_parse.py +0 -0
  239. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_prompt_llm.py +0 -0
  240. {redeploy-0.2.60 → redeploy-0.2.62}/tests/test_redeploy.py +0 -0
  241. {redeploy-0.2.60 → redeploy-0.2.62}/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.60
3
+ Version: 0.2.62
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.60-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.62-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-30.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
37
37
 
38
- - 🤖 **LLM usage:** $7.5000 (80 commits)
39
- - 👤 **Human dev:** ~$2906 (29.1h @ $100/h, 30min dedup)
38
+ - 🤖 **LLM usage:** $7.5000 (82 commits)
39
+ - 👤 **Human dev:** ~$3083 (30.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.60-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.62-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.60-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.62-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-30.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (80 commits)
10
- - 👤 **Human dev:** ~$2906 (29.1h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (82 commits)
10
+ - 👤 **Human dev:** ~$3083 (30.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.60-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.62-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.60"
7
+ version = "0.2.62"
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.60"
10
+ __version__ = "0.2.62"
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,379 @@
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
+ # Ignore paths inside the project tree
123
+ resolved = self._resolve(path_str, base_dir)
124
+ if resolved and self._is_inside(resolved, base_dir):
125
+ continue
126
+ # Check if exists locally
127
+ if resolved and not resolved.exists():
128
+ result.add(
129
+ IssueSeverity.ERROR, "commands",
130
+ f"Step '{step.get('id')}' command references missing external path: {path_str}",
131
+ step.get("id"),
132
+ suggestion="Add rsync/scp step to sync this dependency, or correct the path."
133
+ )
134
+ else:
135
+ result.add(
136
+ IssueSeverity.WARNING, "commands",
137
+ f"Step '{step.get('id')}' command references external path: {path_str}",
138
+ step.get("id"),
139
+ suggestion="Consider adding an explicit sync step for this dependency."
140
+ )
141
+
142
+ @staticmethod
143
+ def _resolve(val: str, base_dir: Path) -> Path | None:
144
+ val = val.strip().rstrip("/")
145
+ if val.startswith("~/"):
146
+ return Path.home() / val[2:]
147
+ if val.startswith("/"):
148
+ return Path(val)
149
+ return base_dir / val
150
+
151
+ @staticmethod
152
+ def _is_inside(path: Path, base: Path) -> bool:
153
+ try:
154
+ path.resolve().relative_to(base.resolve())
155
+ return True
156
+ except ValueError:
157
+ return False
158
+
159
+
160
+ class _ReferenceChecker(_Checker):
161
+ """Ensure command_ref and insert_before point to existing things."""
162
+
163
+ def check(self, spec, document, base_dir, result):
164
+ step_ids = {s.get("id") for s in spec.extra_steps if s.get("id")}
165
+ ref_ids = set()
166
+ if document:
167
+ for block in document.blocks:
168
+ if block.kind == "ref" and block.ref_id:
169
+ ref_ids.add(block.ref_id)
170
+
171
+ for step in spec.extra_steps:
172
+ sid = step.get("id")
173
+ cref = step.get("command_ref")
174
+ if cref and cref not in ref_ids:
175
+ result.add(
176
+ IssueSeverity.ERROR, "references",
177
+ f"Step '{sid}' references unknown command_ref '{cref}'",
178
+ sid,
179
+ suggestion=f"Define a ```bash markpact:ref {cref} block or remove command_ref."
180
+ )
181
+ ib = step.get("insert_before")
182
+ if ib and ib not in step_ids:
183
+ result.add(
184
+ IssueSeverity.ERROR, "references",
185
+ f"Step '{sid}' insert_before points to unknown step '{ib}'",
186
+ sid,
187
+ suggestion=f"Available step ids: {', '.join(sorted(step_ids)) or 'none'}"
188
+ )
189
+
190
+
191
+ class _ComposeChecker(_Checker):
192
+ """Validate docker-compose files declared in spec or found in project."""
193
+
194
+ def check(self, spec, document, base_dir, result):
195
+ compose_files = list(spec.target.compose_files or [])
196
+ # Also try to discover common names if none explicitly declared
197
+ if not compose_files:
198
+ for candidate in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
199
+ if (base_dir / candidate).exists():
200
+ compose_files.append(candidate)
201
+ break
202
+
203
+ for cf in compose_files:
204
+ path = base_dir / cf
205
+ if not path.exists():
206
+ result.add(
207
+ IssueSeverity.ERROR, "compose",
208
+ f"Declared compose file not found: {cf}",
209
+ suggestion=f"Create {path} or remove from target.compose_files."
210
+ )
211
+ continue
212
+ self._scan_compose(path, base_dir, result)
213
+
214
+ def _scan_compose(self, path: Path, base_dir: Path, result: AnalysisResult):
215
+ try:
216
+ data = yaml.safe_load(path.read_text()) or {}
217
+ except Exception as exc:
218
+ result.add(IssueSeverity.ERROR, "compose", f"Cannot parse {path}: {exc}")
219
+ return
220
+
221
+ services = data.get("services", {})
222
+ for svc_name, svc in services.items():
223
+ if not isinstance(svc, dict):
224
+ continue
225
+ # build.context
226
+ build = svc.get("build")
227
+ if isinstance(build, dict):
228
+ ctx = build.get("context", ".")
229
+ ctx_path = base_dir / ctx if not ctx.startswith("/") else Path(ctx)
230
+ if not ctx_path.exists():
231
+ result.add(
232
+ IssueSeverity.ERROR, "compose",
233
+ f"Service '{svc_name}' build.context missing: {ctx}",
234
+ suggestion=f"Create directory {ctx_path} or fix context."
235
+ )
236
+ dockerfile = build.get("dockerfile")
237
+ if dockerfile:
238
+ df_path = ctx_path / dockerfile
239
+ if not df_path.exists():
240
+ result.add(
241
+ IssueSeverity.ERROR, "compose",
242
+ f"Service '{svc_name}' Dockerfile missing: {df_path}",
243
+ suggestion=f"Create {df_path} or correct dockerfile path."
244
+ )
245
+ # env_file
246
+ env_files = svc.get("env_file", [])
247
+ if isinstance(env_files, str):
248
+ env_files = [env_files]
249
+ for ef in env_files:
250
+ ef_path = base_dir / ef if not str(ef).startswith("/") else Path(ef)
251
+ if not ef_path.exists():
252
+ result.add(
253
+ IssueSeverity.WARNING, "compose",
254
+ f"Service '{svc_name}' env_file missing: {ef}",
255
+ suggestion=f"Create {ef_path} or remove env_file entry."
256
+ )
257
+ # volumes
258
+ for vol in svc.get("volumes", []):
259
+ if isinstance(vol, str):
260
+ if ":" in vol:
261
+ host_part = vol.split(":", 1)[0]
262
+ # Skip named volumes and relative paths that exist
263
+ if host_part.startswith("/"):
264
+ if not Path(host_part).exists():
265
+ result.add(
266
+ IssueSeverity.WARNING, "compose",
267
+ f"Service '{svc_name}' volume host path missing: {host_part}",
268
+ suggestion=f"Ensure host path exists before deployment."
269
+ )
270
+ elif host_part != "." and "/" in host_part:
271
+ hp = base_dir / host_part
272
+ if not hp.exists():
273
+ result.add(
274
+ IssueSeverity.WARNING, "compose",
275
+ f"Service '{svc_name}' relative volume path missing: {hp}",
276
+ suggestion=f"Create {hp} or mount as named volume."
277
+ )
278
+
279
+
280
+ class _EnvFileChecker(_Checker):
281
+ """Check that .env referenced by target.env_file exists."""
282
+
283
+ def check(self, spec, document, base_dir, result):
284
+ ef = spec.target.env_file or spec.source.env_file
285
+ if ef:
286
+ path = base_dir / ef if not ef.startswith("/") else Path(ef)
287
+ if not path.exists():
288
+ result.add(
289
+ IssueSeverity.ERROR, "env",
290
+ f"env_file not found: {ef}",
291
+ suggestion=f"Create {path} or correct env_file path."
292
+ )
293
+
294
+
295
+ class _BinaryChecker(_Checker):
296
+ """Warn if commands reference binaries not available locally (best-effort)."""
297
+
298
+ def check(self, spec, document, base_dir, result):
299
+ for step in spec.extra_steps:
300
+ cmd = step.get("command") or ""
301
+ for word in cmd.split():
302
+ word = word.strip(";|&`'\"()")
303
+ if "/" in word or word in ("if", "then", "else", "fi", "for", "do", "done", "echo", "true", "false"):
304
+ continue
305
+ if not self._which(word):
306
+ # Only warn for likely commands (simple words, not variable names)
307
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9_-]+$", word):
308
+ result.add(
309
+ IssueSeverity.WARNING, "binaries",
310
+ f"Step '{step.get('id')}' command uses binary not found locally: '{word}'",
311
+ step.get("id"),
312
+ suggestion=f"Install '{word}' or ensure it exists on target host."
313
+ )
314
+
315
+ @staticmethod
316
+ def _which(cmd: str) -> bool:
317
+ try:
318
+ subprocess.run(["which", cmd], capture_output=True, check=True)
319
+ return True
320
+ except Exception:
321
+ return False
322
+
323
+
324
+ class SpecAnalyzer:
325
+ """Run static checks against a compiled MigrationSpec (and optional raw MarkpactDocument)."""
326
+
327
+ DEFAULT_CHECKERS: list[_Checker] = [
328
+ _PathChecker(),
329
+ _CommandPathChecker(),
330
+ _ReferenceChecker(),
331
+ _ComposeChecker(),
332
+ _EnvFileChecker(),
333
+ _BinaryChecker(),
334
+ ]
335
+
336
+ def __init__(self, base_dir: Path | None = None,
337
+ checkers: list[_Checker] | None = None):
338
+ self.base_dir = base_dir or Path.cwd()
339
+ self.checkers = checkers or list(self.DEFAULT_CHECKERS)
340
+
341
+ def analyze(self, spec: MigrationSpec,
342
+ document: MarkpactDocument | None = None) -> AnalysisResult:
343
+ result = AnalysisResult()
344
+ for checker in self.checkers:
345
+ checker.check(spec, document, self.base_dir, result)
346
+ return result
347
+
348
+ def analyze_file(self, spec_path: Path) -> tuple[MigrationSpec | None, AnalysisResult]:
349
+ """Load spec from file (YAML or markpact) and analyze."""
350
+ from ..markpact.parser import parse_markpact_file
351
+ from ..markpact.compiler import compile_markpact_document, MarkpactCompileError
352
+ from ..models.spec import MigrationSpec as MS
353
+
354
+ document = None
355
+ spec = None
356
+ result = AnalysisResult()
357
+
358
+ # Try markpact first
359
+ if spec_path.suffix == ".md":
360
+ try:
361
+ document = parse_markpact_file(spec_path)
362
+ spec = compile_markpact_document(document)
363
+ except MarkpactCompileError as exc:
364
+ result.add(IssueSeverity.ERROR, "compile", str(exc))
365
+ return None, result
366
+ except Exception as exc:
367
+ result.add(IssueSeverity.ERROR, "compile", f"Failed to parse markpact: {exc}")
368
+ return None, result
369
+ else:
370
+ try:
371
+ spec = MS.from_file(spec_path)
372
+ except Exception as exc:
373
+ result.add(IssueSeverity.ERROR, "load", f"Failed to load spec: {exc}")
374
+ return None, result
375
+
376
+ if spec:
377
+ self.base_dir = spec_path.parent
378
+ result = self.analyze(spec, document)
379
+ return spec, result
@@ -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(resolved).parent)
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(resolved_spec).parent)
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)
@@ -113,7 +113,10 @@ def _call_llm(schema: dict, user_prompt: str) -> str:
113
113
  kwargs["api_key"] = api_key
114
114
 
115
115
  resp = litellm.completion(**kwargs)
116
- return resp.choices[0].message.content.strip()
116
+ content = resp.choices[0].message.content
117
+ if content is None:
118
+ raise ValueError("LLM returned None content")
119
+ return content.strip()
117
120
 
118
121
 
119
122
  def _parse_llm_response(raw: str) -> dict:
@@ -145,12 +145,24 @@ class HealRunner:
145
145
  " [dim]LLM proposal:[/dim]\n"
146
146
  + "\n".join(f" {l}" for l in llm_response.splitlines()[:12])
147
147
  )
148
+ before_patch = self.spec_path.read_text()
148
149
  fixed = apply_fix_to_spec(self.spec_path, failed_step, llm_response)
149
150
  if fixed:
150
151
  desc_m = re.search(r'description:\s*"([^"]+)"', llm_response)
151
152
  summary = desc_m.group(1) if desc_m else llm_response[:60].replace("\n", " ")
152
153
  self.console.print(f" [green]patched spec:[/green] `{failed_step}`")
153
- self._reload_migration()
154
+ try:
155
+ self._reload_migration()
156
+ except Exception as exc:
157
+ # LLM patch changed the file but produced an invalid spec.
158
+ # Restore previous content and continue heal flow safely.
159
+ self.spec_path.write_text(before_patch)
160
+ fixed = False
161
+ summary = f"invalid patch: {exc.__class__.__name__}"
162
+ self.console.print(
163
+ " [yellow]LLM fix invalid for current runtime; "
164
+ "reverted spec patch[/yellow]"
165
+ )
154
166
  else:
155
167
  self.console.print(" [yellow]LLM fix not applicable[/yellow]")
156
168
  else:
@@ -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