periplus-map 0.2.1__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 (272) hide show
  1. periplus/__init__.py +70 -0
  2. periplus/checkmap.py +80 -0
  3. periplus/cli.py +658 -0
  4. periplus/contract/schema/map.schema.json +138 -0
  5. periplus/contract/schema/navigation.schema.json +200 -0
  6. periplus/contract/schema/pack-file.schema.json +1405 -0
  7. periplus/contract/schema/pack-manifest.schema.json +245 -0
  8. periplus/contract/schema/project-settings.schema.json +85 -0
  9. periplus/contract/spec/engine.md +151 -0
  10. periplus/contract/spec/rules.md +352 -0
  11. periplus/detect.py +228 -0
  12. periplus/engine/__init__.py +5 -0
  13. periplus/engine/check.py +481 -0
  14. periplus/engine/emit.py +368 -0
  15. periplus/engine/packload.py +2321 -0
  16. periplus/engine/pattern.py +27 -0
  17. periplus/engine/rules.py +1946 -0
  18. periplus/engine/select.py +491 -0
  19. periplus/engine/source.py +172 -0
  20. periplus/engine/tree.py +424 -0
  21. periplus/errors.py +332 -0
  22. periplus/init.py +358 -0
  23. periplus/layout.py +28 -0
  24. periplus/manifest.py +498 -0
  25. periplus/map.py +364 -0
  26. periplus/navigate/__init__.py +1 -0
  27. periplus/navigate/find.py +143 -0
  28. periplus/navigate/ids.py +30 -0
  29. periplus/navigate/listing.py +42 -0
  30. periplus/navigate/mapfile.py +189 -0
  31. periplus/navigate/path.py +175 -0
  32. periplus/navigate/show.py +175 -0
  33. periplus/navigate/types.py +83 -0
  34. periplus/navigate/walk.py +138 -0
  35. periplus/packs/advancedqueue_basic@0.0.1/pack.yaml +14 -0
  36. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type.yaml +9 -0
  37. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_annotation.yaml +43 -0
  38. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_attribute.yaml +43 -0
  39. periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent.yaml +8 -0
  40. periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent_attribute.yaml +43 -0
  41. periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call.yaml +8 -0
  42. periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call_attribute.yaml +43 -0
  43. periplus/packs/ai_basic@0.0.1/pack.yaml +15 -0
  44. periplus/packs/config_pages_basic@0.0.1/bundle/config_pages_bundle_config.yaml +54 -0
  45. periplus/packs/config_pages_basic@0.0.1/pack.yaml +11 -0
  46. periplus/packs/crop_basic@0.0.1/bundle/crop_bundle_config.yaml +54 -0
  47. periplus/packs/crop_basic@0.0.1/pack.yaml +11 -0
  48. periplus/packs/drupal@0.1.0/block/block_placement.yaml +25 -0
  49. periplus/packs/drupal@0.1.0/block/block_placement_code.yaml +58 -0
  50. periplus/packs/drupal@0.1.0/block/block_placement_config.yaml +53 -0
  51. periplus/packs/drupal@0.1.0/block/block_plugin.yaml +52 -0
  52. periplus/packs/drupal@0.1.0/block/block_plugin_altered.yaml +61 -0
  53. periplus/packs/drupal@0.1.0/block/block_plugin_annotation.yaml +69 -0
  54. periplus/packs/drupal@0.1.0/block/block_plugin_attribute.yaml +56 -0
  55. periplus/packs/drupal@0.1.0/block/block_plugin_content.yaml +46 -0
  56. periplus/packs/drupal@0.1.0/block/block_plugin_derived.yaml +110 -0
  57. periplus/packs/drupal@0.1.0/block/block_plugin_deriver.yaml +30 -0
  58. periplus/packs/drupal@0.1.0/block/block_type.yaml +14 -0
  59. periplus/packs/drupal@0.1.0/block/block_type_code.yaml +43 -0
  60. periplus/packs/drupal@0.1.0/block/block_type_config.yaml +45 -0
  61. periplus/packs/drupal@0.1.0/cache/cache.yaml +18 -0
  62. periplus/packs/drupal@0.1.0/cache/cache_backend.yaml +31 -0
  63. periplus/packs/drupal@0.1.0/component/sdc.yaml +19 -0
  64. periplus/packs/drupal@0.1.0/component/sdc_definition.yaml +25 -0
  65. periplus/packs/drupal@0.1.0/component/sdc_use.yaml +25 -0
  66. periplus/packs/drupal@0.1.0/config/config_object.yaml +65 -0
  67. periplus/packs/drupal@0.1.0/display/display.yaml +32 -0
  68. periplus/packs/drupal@0.1.0/display/display_code.yaml +53 -0
  69. periplus/packs/drupal@0.1.0/display/display_config.yaml +95 -0
  70. periplus/packs/drupal@0.1.0/entity/entity.yaml +33 -0
  71. periplus/packs/drupal@0.1.0/entity/entity_class.yaml +60 -0
  72. periplus/packs/drupal@0.1.0/entity/entity_save.yaml +27 -0
  73. periplus/packs/drupal@0.1.0/entity/entity_type_annotation.yaml +85 -0
  74. periplus/packs/drupal@0.1.0/entity/entity_type_attribute.yaml +76 -0
  75. periplus/packs/drupal@0.1.0/field/field_instance_code.yaml +40 -0
  76. periplus/packs/drupal@0.1.0/field/field_instance_config.yaml +58 -0
  77. periplus/packs/drupal@0.1.0/field/field_storage.yaml +37 -0
  78. periplus/packs/drupal@0.1.0/field/field_storage_code.yaml +39 -0
  79. periplus/packs/drupal@0.1.0/field/field_storage_config.yaml +24 -0
  80. periplus/packs/drupal@0.1.0/form/form.yaml +48 -0
  81. periplus/packs/drupal@0.1.0/form/form_class.yaml +50 -0
  82. periplus/packs/drupal@0.1.0/form/form_id.yaml +48 -0
  83. periplus/packs/drupal@0.1.0/form/form_methods.yaml +39 -0
  84. periplus/packs/drupal@0.1.0/hook/hook.yaml +8 -0
  85. periplus/packs/drupal@0.1.0/hook/hook_attribute.yaml +28 -0
  86. periplus/packs/drupal@0.1.0/module/module.yaml +8 -0
  87. periplus/packs/drupal@0.1.0/module/module_info.yaml +50 -0
  88. periplus/packs/drupal@0.1.0/pack.yaml +184 -0
  89. periplus/packs/drupal@0.1.0/plugin/plugin.yaml +17 -0
  90. periplus/packs/drupal@0.1.0/plugin/plugin_annotation.yaml +120 -0
  91. periplus/packs/drupal@0.1.0/recommendations/recommendations.yaml +14 -0
  92. periplus/packs/drupal@0.1.0/service/service.yaml +19 -0
  93. periplus/packs/drupal@0.1.0/service/service_autowire.yaml +28 -0
  94. periplus/packs/drupal@0.1.0/service/service_definition.yaml +24 -0
  95. periplus/packs/drupal@0.1.0/service/service_use.yaml +43 -0
  96. periplus/packs/drupal_basic@0.3.0/action/action.yaml +9 -0
  97. periplus/packs/drupal_basic@0.3.0/action/action_annotation.yaml +43 -0
  98. periplus/packs/drupal_basic@0.3.0/action/action_attribute.yaml +43 -0
  99. periplus/packs/drupal_basic@0.3.0/block/block_placement.yaml +16 -0
  100. periplus/packs/drupal_basic@0.3.0/block/block_placement_config.yaml +51 -0
  101. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin.yaml +14 -0
  102. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_annotation.yaml +47 -0
  103. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_attribute.yaml +43 -0
  104. periplus/packs/drupal_basic@0.3.0/block_type/block_type.yaml +8 -0
  105. periplus/packs/drupal_basic@0.3.0/block_type/block_type_config.yaml +54 -0
  106. periplus/packs/drupal_basic@0.3.0/boundary/boundary_types.yaml +19 -0
  107. periplus/packs/drupal_basic@0.3.0/bundle/bundle.yaml +20 -0
  108. periplus/packs/drupal_basic@0.3.0/bundle/bundle_config.yaml +393 -0
  109. periplus/packs/drupal_basic@0.3.0/call/call.yaml +349 -0
  110. periplus/packs/drupal_basic@0.3.0/component/component.yaml +30 -0
  111. periplus/packs/drupal_basic@0.3.0/config/config_object.yaml +119 -0
  112. periplus/packs/drupal_basic@0.3.0/constraint/constraint.yaml +8 -0
  113. periplus/packs/drupal_basic@0.3.0/constraint/constraint_annotation.yaml +43 -0
  114. periplus/packs/drupal_basic@0.3.0/constraint/constraint_attribute.yaml +43 -0
  115. periplus/packs/drupal_basic@0.3.0/display/display.yaml +30 -0
  116. periplus/packs/drupal_basic@0.3.0/display/display_config.yaml +203 -0
  117. periplus/packs/drupal_basic@0.3.0/display/display_mode_config.yaml +60 -0
  118. periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_annotation.yaml +46 -0
  119. periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_attribute.yaml +43 -0
  120. periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_annotation.yaml +46 -0
  121. periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_attribute.yaml +43 -0
  122. periplus/packs/drupal_basic@0.3.0/entity_type/entity_type.yaml +16 -0
  123. periplus/packs/drupal_basic@0.3.0/field/field_instance_config.yaml +116 -0
  124. periplus/packs/drupal_basic@0.3.0/field/field_storage.yaml +21 -0
  125. periplus/packs/drupal_basic@0.3.0/field/field_storage_config.yaml +45 -0
  126. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter.yaml +8 -0
  127. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_annotation.yaml +46 -0
  128. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_attribute.yaml +43 -0
  129. periplus/packs/drupal_basic@0.3.0/field_type/field_type_annotation.yaml +46 -0
  130. periplus/packs/drupal_basic@0.3.0/field_type/field_type_attribute.yaml +50 -0
  131. periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_annotation.yaml +46 -0
  132. periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_attribute.yaml +50 -0
  133. periplus/packs/drupal_basic@0.3.0/form/form_id.yaml +80 -0
  134. periplus/packs/drupal_basic@0.3.0/hook/hook.yaml +100 -0
  135. periplus/packs/drupal_basic@0.3.0/hook/hook_attribute.yaml +64 -0
  136. periplus/packs/drupal_basic@0.3.0/hook/hook_theme_include.yaml +34 -0
  137. periplus/packs/drupal_basic@0.3.0/library/library.yaml +12 -0
  138. periplus/packs/drupal_basic@0.3.0/library/library_definition.yaml +58 -0
  139. periplus/packs/drupal_basic@0.3.0/local_action/local_action.yaml +12 -0
  140. periplus/packs/drupal_basic@0.3.0/local_action/local_action_definition.yaml +49 -0
  141. periplus/packs/drupal_basic@0.3.0/mail/mail.yaml +8 -0
  142. periplus/packs/drupal_basic@0.3.0/mail/mail_attribute.yaml +43 -0
  143. periplus/packs/drupal_basic@0.3.0/media_source/media_source.yaml +8 -0
  144. periplus/packs/drupal_basic@0.3.0/media_source/media_source_annotation.yaml +43 -0
  145. periplus/packs/drupal_basic@0.3.0/media_source/media_source_attribute.yaml +79 -0
  146. periplus/packs/drupal_basic@0.3.0/menu_link/menu_link.yaml +18 -0
  147. periplus/packs/drupal_basic@0.3.0/menu_link/menu_link_definition.yaml +48 -0
  148. periplus/packs/drupal_basic@0.3.0/module/module.yaml +7 -0
  149. periplus/packs/drupal_basic@0.3.0/module/module_info.yaml +58 -0
  150. periplus/packs/drupal_basic@0.3.0/pack.yaml +528 -0
  151. periplus/packs/drupal_basic@0.3.0/permission/permission.yaml +7 -0
  152. periplus/packs/drupal_basic@0.3.0/permission/permission_definition.yaml +27 -0
  153. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker.yaml +9 -0
  154. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_annotation.yaml +43 -0
  155. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_attribute.yaml +43 -0
  156. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource.yaml +8 -0
  157. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_annotation.yaml +43 -0
  158. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_attribute.yaml +43 -0
  159. periplus/packs/drupal_basic@0.3.0/role/role.yaml +14 -0
  160. periplus/packs/drupal_basic@0.3.0/role/role_config.yaml +44 -0
  161. periplus/packs/drupal_basic@0.3.0/route/route.yaml +42 -0
  162. periplus/packs/drupal_basic@0.3.0/route/route_definition.yaml +113 -0
  163. periplus/packs/drupal_basic@0.3.0/route/route_options.yaml +55 -0
  164. periplus/packs/drupal_basic@0.3.0/route/route_requirements.yaml +108 -0
  165. periplus/packs/drupal_basic@0.3.0/service/service.yaml +56 -0
  166. periplus/packs/drupal_basic@0.3.0/service/service_definition.yaml +181 -0
  167. periplus/packs/drupal_basic@0.3.0/template/template.yaml +86 -0
  168. periplus/packs/drupal_basic@0.3.0/template/template_route_link.yaml +30 -0
  169. periplus/packs/drupal_basic@0.3.0/theme/theme.yaml +15 -0
  170. periplus/packs/drupal_basic@0.3.0/theme/theme_info.yaml +61 -0
  171. periplus/packs/drupal_basic@0.3.0/theme_hook/template_suggestion.yaml +29 -0
  172. periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook.yaml +87 -0
  173. periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook_render.yaml +20 -0
  174. periplus/packs/drupal_basic@0.3.0/url/url.yaml +88 -0
  175. periplus/packs/drupal_basic@0.3.0/views_plugin/views_field_attribute.yaml +80 -0
  176. periplus/packs/drupal_basic@0.3.0/views_plugin/views_filter_attribute.yaml +80 -0
  177. periplus/packs/drupal_basic@0.3.0/views_plugin/views_plugin.yaml +15 -0
  178. periplus/packs/drupal_basic@0.3.0/views_plugin/views_style_attribute.yaml +80 -0
  179. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment.yaml +12 -0
  180. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php.yaml +15 -0
  181. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php_array.yaml +52 -0
  182. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_twig.yaml +26 -0
  183. periplus/packs/drupal_js_basic@0.0.1/attachment/php_function_holder.yaml +13 -0
  184. periplus/packs/drupal_js_basic@0.0.1/behavior/behavior.yaml +198 -0
  185. periplus/packs/drupal_js_basic@0.0.1/pack.yaml +153 -0
  186. periplus/packs/drupal_js_basic@0.0.1/setting/setting.yaml +13 -0
  187. periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_js.yaml +15 -0
  188. periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_php.yaml +92 -0
  189. periplus/packs/drush_basic@0.0.1/command/command.yaml +15 -0
  190. periplus/packs/drush_basic@0.0.1/command/command_annotation.yaml +60 -0
  191. periplus/packs/drush_basic@0.0.1/command/command_attribute.yaml +88 -0
  192. periplus/packs/drush_basic@0.0.1/pack.yaml +29 -0
  193. periplus/packs/eck_basic@0.0.1/bundle/eck_bundle_config.yaml +56 -0
  194. periplus/packs/eck_basic@0.0.1/entity_type/eck_entity_type_config.yaml +26 -0
  195. periplus/packs/eck_basic@0.0.1/pack.yaml +28 -0
  196. periplus/packs/go@0.0.2/interfaces/satisfies.yaml +47 -0
  197. periplus/packs/go@0.0.2/pack.yaml +51 -0
  198. periplus/packs/go@0.0.2/types/find_declarations.yaml +137 -0
  199. periplus/packs/go@0.0.2/types/node_types.yaml +36 -0
  200. periplus/packs/go_basic@0.0.3/pack.yaml +104 -0
  201. periplus/packs/go_basic@0.0.3/rules/calls.yaml +25 -0
  202. periplus/packs/go_basic@0.0.3/rules/declarations.yaml +40 -0
  203. periplus/packs/go_basic@0.0.3/rules/imports.yaml +16 -0
  204. periplus/packs/go_basic@0.0.3/types/node_types.yaml +15 -0
  205. periplus/packs/js_basic@0.0.1/function/function.yaml +68 -0
  206. periplus/packs/js_basic@0.0.1/pack.yaml +50 -0
  207. periplus/packs/laravel_basic@0.0.5/pack.yaml +313 -0
  208. periplus/packs/laravel_basic@0.0.5/rules/classes.yaml +42 -0
  209. periplus/packs/laravel_basic@0.0.5/rules/config.yaml +229 -0
  210. periplus/packs/laravel_basic@0.0.5/rules/routes.yaml +210 -0
  211. periplus/packs/laravel_basic@0.0.5/rules/types.yaml +50 -0
  212. periplus/packs/laravel_basic@0.0.5/rules/views.yaml +100 -0
  213. periplus/packs/paragraphs_basic@0.0.1/bundle/paragraph_bundle_config.yaml +54 -0
  214. periplus/packs/paragraphs_basic@0.0.1/pack.yaml +12 -0
  215. periplus/packs/php@0.1.0/edges/edge_kinds.yaml +64 -0
  216. periplus/packs/php@0.1.0/edges/find_calls.yaml +34 -0
  217. periplus/packs/php@0.1.0/edges/find_imports.yaml +18 -0
  218. periplus/packs/php@0.1.0/edges/find_member_access.yaml +22 -0
  219. periplus/packs/php@0.1.0/edges/find_structure.yaml +38 -0
  220. periplus/packs/php@0.1.0/embedded/embedded_regions.yaml +19 -0
  221. periplus/packs/php@0.1.0/imports/imports.yaml +20 -0
  222. periplus/packs/php@0.1.0/pack.yaml +117 -0
  223. periplus/packs/php@0.1.0/resolution/excluded_tokens.yaml +23 -0
  224. periplus/packs/php@0.1.0/resolution/resolution.yaml +35 -0
  225. periplus/packs/php@0.1.0/types/find_declarations.yaml +102 -0
  226. periplus/packs/php@0.1.0/types/node_types.yaml +55 -0
  227. periplus/packs/php_basic@0.2.0/edges/edge_kinds.yaml +48 -0
  228. periplus/packs/php_basic@0.2.0/edges/find_structure.yaml +750 -0
  229. periplus/packs/php_basic@0.2.0/imports/imports.yaml +19 -0
  230. periplus/packs/php_basic@0.2.0/pack.yaml +94 -0
  231. periplus/packs/php_basic@0.2.0/types/find_declarations.yaml +267 -0
  232. periplus/packs/php_basic@0.2.0/types/node_types.yaml +55 -0
  233. periplus/packs/profile_basic@0.0.1/bundle/profile_bundle_config.yaml +54 -0
  234. periplus/packs/profile_basic@0.0.1/pack.yaml +12 -0
  235. periplus/packs/salesforce_basic@0.0.1/event/salesforce_event.yaml +36 -0
  236. periplus/packs/salesforce_basic@0.0.1/pack.yaml +31 -0
  237. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping.yaml +19 -0
  238. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping_config.yaml +83 -0
  239. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field.yaml +8 -0
  240. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field_annotation.yaml +43 -0
  241. periplus/packs/twig@0.0.1/pack.yaml +26 -0
  242. periplus/packs/twig@0.0.1/types/template.yaml +57 -0
  243. periplus/packs/twig_basic@0.1.0/pack.yaml +11 -0
  244. periplus/packs/twig_tweak_basic@0.0.1/pack.yaml +27 -0
  245. periplus/packs/twig_tweak_basic@0.0.1/rules/block.yaml +20 -0
  246. periplus/packs/twig_tweak_basic@0.0.1/rules/entity.yaml +35 -0
  247. periplus/packs/twig_tweak_basic@0.0.1/rules/field.yaml +21 -0
  248. periplus/packs/twig_tweak_basic@0.0.1/rules/menu.yaml +21 -0
  249. periplus/packs/webform_basic@0.0.1/bundle/webform_submission_bundle_config.yaml +54 -0
  250. periplus/packs/webform_basic@0.0.1/pack.yaml +20 -0
  251. periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler.yaml +8 -0
  252. periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler_annotation.yaml +43 -0
  253. periplus/packs/yaml@0.0.1/pack.yaml +72 -0
  254. periplus/packs/yaml@0.0.1/types/find_mapping_entry.yaml +21 -0
  255. periplus/packs/yaml@0.0.1/types/yaml_types.yaml +44 -0
  256. periplus/packs/yaml_basic@0.1.0/pack.yaml +10 -0
  257. periplus/packs.py +447 -0
  258. periplus/preflight.py +102 -0
  259. periplus/py.typed +0 -0
  260. periplus/report.py +596 -0
  261. periplus/resolution.py +355 -0
  262. periplus/settings.py +617 -0
  263. periplus/settings.stub.yml +99 -0
  264. periplus/spec.py +774 -0
  265. periplus/update.py +197 -0
  266. periplus/validate.py +655 -0
  267. periplus_map-0.2.1.dist-info/METADATA +566 -0
  268. periplus_map-0.2.1.dist-info/RECORD +272 -0
  269. periplus_map-0.2.1.dist-info/WHEEL +4 -0
  270. periplus_map-0.2.1.dist-info/entry_points.txt +2 -0
  271. periplus_map-0.2.1.dist-info/licenses/LICENSE +202 -0
  272. periplus_map-0.2.1.dist-info/licenses/NOTICE +2 -0
periplus/spec.py ADDED
@@ -0,0 +1,774 @@
1
+ """``periplus spec``: publish the contract a pack is authored against, and stop.
2
+
3
+ The consumer is a model. It types one command and has to come away with everything it needs to
4
+ write a pack: the four schemas that say what a pack file may contain, the two prose documents that
5
+ say what a rule is and what the engine does with it, and an honest list of what the schema language
6
+ cannot yet express. So the whole contract is emitted, not a summary of it and not a pointer to a
7
+ path — a path is a second command a model has to guess at, and a summary is prose nobody reviewed
8
+ standing in for prose somebody did.
9
+
10
+ **Everything published here is read out of the installed package**, through
11
+ ``importlib.resources``, never from a path assembled relative to this file. That is what makes the
12
+ command work from a wheel, a zip install and a source checkout alike, and it is the same route
13
+ ``init`` reaches its stub through and ``packs`` reaches the bundled pack root through.
14
+
15
+ Two things this module deliberately does not do.
16
+
17
+ It does **not** read a pack's node types and edge kinds. ``periplus spec <pack>`` reports what that
18
+ pack's manifest declares and what it inherits *from*; the vocabulary itself needs a reader that
19
+ walks a pack's rule files, and that reader does not exist. Where the answer is knowable without one
20
+ it is given: a pack whose manifest declares no ``depends`` has an empty dependency closure, so the
21
+ union of what it inherits is empty by arithmetic rather than by a stub. Where it is not knowable,
22
+ ``SpecReport.inherited`` is ``None`` and the renderers say the vocabulary has not been read. A
23
+ tuple of nothing and "nothing has been read" are different facts and this module refuses to print
24
+ the first when it means the second.
25
+
26
+ It does **not** validate anything. A manifest that names itself something other than its directory,
27
+ a ``depends`` that is a string rather than a list — all of that loads exactly as ``manifest.py``
28
+ loads it, which is to say permissively, and is the validator's to refuse.
29
+
30
+ One rule decides what goes through ``report._escape`` and what does not, because the two halves of
31
+ this output have different provenance:
32
+
33
+ * **Package data is emitted verbatim.** The contract documents are the artifact being published —
34
+ escaping them would hand a reader a document that is not the document — and a gap's text is a
35
+ slice of the same bytes. They ship inside the wheel; a person's input cannot reach them.
36
+ * **Everything else is escaped.** A pack name comes off the command line, a directory name comes
37
+ off a filesystem where any byte but ``/`` and NUL is legal, and a problem message quotes both.
38
+ Those are attacker-influenced text and go through ``_escape`` exactly as they do in ``report``
39
+ and ``init``.
40
+
41
+ Determinism, which is a stated property of the project rather than a nicety. Three sorts are
42
+ written in this module and they are not worth the same, so which is which is recorded here rather
43
+ than left for a reader to work out from the call sites:
44
+
45
+ * **``read_contract``'s sort over documents is load-bearing.** It is the one place filesystem
46
+ order could reach this output. ``Traversable.iterdir`` declares no order and on ext4 with hashed
47
+ directories it is arbitrary, so without it two machines holding one install print the contract
48
+ in two orders. Sorted after the walk rather than during it, so it is a single statement a test
49
+ can remove; ``test_the_contract_comes_out_in_path_order_whatever_order_the_package_lists_it_in``
50
+ removes it by reading the same tree through a ``Traversable`` that lists in reverse.
51
+ * **``describe_contract``'s sort over problems is load-bearing.** ``resolve_pack_order`` emits
52
+ problems in the order its walk finds them, which is graph order and not code order: a pack whose
53
+ ``depends`` names both a cycle and a pack nobody installed hands back ``DEPENDS_CYCLE`` before
54
+ ``DEPENDS_UNMATCHED``, and this sort is what turns that into ``12`` then ``14``.
55
+ ``test_two_problems_are_printed_in_code_order_and_not_in_the_order_the_walk_found_them``
56
+ builds exactly that pack and fails if the sort goes.
57
+ * **``_describe_pack``'s ``entries = sorted(...)`` is belt and braces, and nothing observes it.**
58
+ Its result feeds ``match_pins``, whose problems are sorted by the bullet above and whose
59
+ directories ``manifest._sorted_directories`` sorts again before either is printed. It is kept
60
+ because a determinism property that holds only because two downstream modules happen to sort is
61
+ a property held by a coincidence, but it is not defended by a test, and a reader should not go
62
+ looking for the one that defends it.
63
+
64
+ Nothing else here iterates a set: the gaps come out in the order their document writes them, which
65
+ is a function of the file's bytes, and the pack names come from ``manifest.resolve_pack_order``,
66
+ which already sorts.
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import importlib.resources
72
+ import json
73
+ import re
74
+ from collections.abc import Callable, Mapping, Sequence
75
+ from dataclasses import dataclass
76
+ from importlib.resources.abc import Traversable
77
+ from pathlib import Path
78
+
79
+ import periplus
80
+ from periplus.errors import ExitCode, Problem
81
+ from periplus.manifest import LoadedPack, resolve_pack_order
82
+ from periplus.packs import PackCandidate, match_pins
83
+ from periplus.preflight import DependencyStatus
84
+ from periplus.report import _escape
85
+ from periplus.resolution import resolve
86
+
87
+ __all__ = [
88
+ "CONTRACT_DIRECTORY",
89
+ "RENDERERS",
90
+ "SUMMARY_RENDERERS",
91
+ "ContractDocument",
92
+ "Gap",
93
+ "InheritedVocabulary",
94
+ "PackSubject",
95
+ "SpecReport",
96
+ "contract_root",
97
+ "describe_contract",
98
+ "read_contract",
99
+ "render_json",
100
+ "render_json_summary",
101
+ "render_text",
102
+ "render_text_summary",
103
+ "spec",
104
+ ]
105
+
106
+ #: The directory inside the package that holds the contract. One name, read by this module and by
107
+ #: nothing else today; ``tests/test_packaging.py`` declares the file list the wheel may carry under
108
+ #: it, which is the bound on what this command can publish.
109
+ CONTRACT_DIRECTORY = "contract"
110
+
111
+ #: A heading that opens a list of things the contract cannot yet express. Two documents write it
112
+ #: two ways — ``## Still open`` in ``rules.md`` and ``## Open, and deliberately not decided here``
113
+ #: in ``engine.md`` — so this matches the shape rather than either literal.
114
+ #:
115
+ #: It is an **address**, not content. The four gaps themselves are not written down anywhere in
116
+ #: this package: they are read out of the shipped Markdown every run, so adding a fifth bullet to
117
+ #: either document publishes a fifth gap with no edit here. That is the whole reason this parser
118
+ #: exists instead of a tuple of strings.
119
+ #:
120
+ #: The shape is ``open`` as the **last word of a clause**: the heading ends there, or a comma,
121
+ #: colon, semicolon or full stop does. ``## Still open`` and ``## Open, and deliberately not
122
+ #: decided here`` are the two written today and both are that shape. What the shape excludes is a
123
+ #: heading where ``open`` is an adjective on some other subject — ``## Open questions for
124
+ #: reviewers``, ``## Open source dependencies``, ``## open-ended`` — every one of which a
125
+ #: ``\b`` after the word accepted. That is not a hypothetical over-match: the day somebody adds an
126
+ #: ``## Open source dependencies`` section to ``engine.md``, every bullet under it would have been
127
+ #: published as a limit of the schema language, with nothing in the output saying so.
128
+ _GAP_HEADING = re.compile(r"^\#{1,2} (?:still )?open\s*(?:[,:;.]|$)", re.IGNORECASE)
129
+
130
+ #: A heading at the level that ends a section. A ``###`` subsection stays inside its ``##`` parent.
131
+ _SECTION_HEADING = re.compile(r"^\#{1,2} ")
132
+
133
+ #: A bullet's bolded lead, as its title. ``- **Cardinality.** Nothing says …`` has the title
134
+ #: ``Cardinality``; the trailing full stop belongs to the sentence and not to the name. A bullet
135
+ #: with no bolded lead has no title, which is a fact about the bullet rather than an error.
136
+ _GAP_TITLE = re.compile(r"^\*\*(?P<title>.+?)\.?\*\*\s*")
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class ContractDocument:
141
+ """One file shipped under ``contract/``, and its bytes as text.
142
+
143
+ ``path`` is relative to ``contract/`` and joined with ``/`` on every platform, because it names
144
+ a resource inside a package and not a location on a disk. ``schema/map.schema.json`` is the
145
+ same string whatever ran the command.
146
+ """
147
+
148
+ path: str
149
+ text: str
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class Gap:
154
+ """One thing the schema language cannot yet express, as the document states it.
155
+
156
+ ``title`` is the bullet's bolded lead, or an empty string when it has none. ``text`` is the
157
+ rest of the bullet with its continuation lines joined into one, so a listing prints one gap per
158
+ line and a name carrying a line break cannot forge a second.
159
+
160
+ Both are shipped package data and are emitted verbatim. See the module docstring.
161
+ """
162
+
163
+ document: str
164
+ title: str
165
+ text: str
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class InheritedVocabulary:
170
+ """The node types and edge kinds a pack may name because something it depends on declares them.
171
+
172
+ Empty tuples mean the vocabulary was worked out and is empty. That is not the same as
173
+ ``SpecReport.inherited`` being ``None``, which means it was not worked out at all — the record
174
+ exists so a consumer has to branch on the difference instead of reading an absence as a zero.
175
+
176
+ **This is the seam the next unit fills.** Resolving a non-empty ``depends`` chain into names
177
+ needs a reader that opens a pack's rule files, and there is none. When there is, it produces
178
+ one of these and the ``None`` branch in ``_describe_pack`` goes away; nothing else in this
179
+ module, and nothing in either renderer, has to change shape.
180
+ """
181
+
182
+ node_types: tuple[str, ...]
183
+ edge_kinds: tuple[str, ...]
184
+
185
+
186
+ @dataclass(frozen=True, slots=True)
187
+ class PackSubject:
188
+ """The pack the command was asked about, as the search path and its own manifest describe it.
189
+
190
+ ``requested`` is the name off the command line, kept whatever became of it, so a report about a
191
+ pack that is not installed still says which one was asked for.
192
+
193
+ ``directory`` is ``None`` when no directory on the search path carries the name, or when the
194
+ one that does has a manifest that could not be read. Both are reported as a problem beside it.
195
+
196
+ ``declares`` and ``version`` are what the manifest says about itself, which is deliberately not
197
+ checked against ``requested``: ``manifest.py`` carries the directory's name and the file's own
198
+ name side by side and lets neither win, because choosing between them is the validator's, and
199
+ this module is a consumer of that record rather than a second place the judgment is made.
200
+
201
+ ``depends`` is the parsed list, sorted and de-duplicated. ``inherits_from`` is every other pack
202
+ the dependency walk actually reached, which is a different fact: a ``depends`` entry naming a
203
+ pack that is not installed appears in the first and not in the second.
204
+ """
205
+
206
+ requested: str
207
+ directory: str | None
208
+ declares: str | None
209
+ version: str | None
210
+ depends: tuple[str, ...]
211
+ inherits_from: tuple[str, ...]
212
+
213
+
214
+ @dataclass(frozen=True, slots=True)
215
+ class SpecReport:
216
+ """One ``spec`` run: the contract, what is open in it, and the pack it was asked about.
217
+
218
+ ``pack`` is ``None`` when no pack was named. ``inherited`` is then the empty vocabulary rather
219
+ than ``None``, and the distinction is the point: nothing was named, so nothing is inherited,
220
+ and that is an answer rather than an absence.
221
+ """
222
+
223
+ tool_version: str
224
+ documents: tuple[ContractDocument, ...]
225
+ gaps: tuple[Gap, ...]
226
+ pack: PackSubject | None
227
+ inherited: InheritedVocabulary | None
228
+ problems: tuple[Problem, ...]
229
+
230
+ @property
231
+ def exit_code(self) -> ExitCode:
232
+ """The lowest code among the problems, or ``OK``.
233
+
234
+ The same expression as ``ResolutionReport.exit_code`` and ``InitReport.exit_code``,
235
+ repeated rather than factored out, for the reason ``init.py`` records: a shared base class
236
+ between records that have nothing else in common is an edge that lets a change to one
237
+ report change another.
238
+ """
239
+ return min((problem.code for problem in self.problems), default=ExitCode.OK)
240
+
241
+
242
+ def contract_root() -> Traversable:
243
+ """The shipped ``contract/`` directory, reached through the package rather than a built path.
244
+
245
+ ``importlib.resources`` and never ``Path(__file__).parent``: the second works in a checkout and
246
+ is wrong everywhere else, and it is the exact defect ``tests/test_contract.py`` installs a real
247
+ wheel to rule out.
248
+ """
249
+ return importlib.resources.files("periplus") / CONTRACT_DIRECTORY
250
+
251
+
252
+ def read_contract(root: Traversable) -> tuple[ContractDocument, ...]:
253
+ """Every file under ``contract/``, recursively, in path order.
254
+
255
+ The walk is unconditional rather than a lookup of six known names. A seventh contract file
256
+ added to the package is published by this command with no edit here, which is the same posture
257
+ ``tests/test_contract.py`` takes when it asserts set equality over what an install exposes
258
+ rather than looking up the names it expects.
259
+
260
+ Sorted **after** the walk and not during it. One sort over the whole result is one statement a
261
+ reader can find and a test can delete, where a sort per directory level is three places the
262
+ property lives; ``Traversable.iterdir`` has no declared order and on ext4 with hashed
263
+ directories it is arbitrary, so without this two machines holding one install would print the
264
+ contract in two orders.
265
+
266
+ Nothing here is caught. A file under ``contract/`` that cannot be read or decoded means the
267
+ install is damaged, which is not a state a person's input can reach and has no remedy this
268
+ command could print; it raises, and the interpreter's own status and traceback say more than a
269
+ ``Problem`` would.
270
+ """
271
+ documents: list[ContractDocument] = []
272
+ _visit(root, "", documents)
273
+ return tuple(sorted(documents, key=lambda document: document.path))
274
+
275
+
276
+ def _visit(node: Traversable, path: str, documents: list[ContractDocument]) -> None:
277
+ """Append every file at or under ``node``, naming each by its path relative to the root."""
278
+ if node.is_dir():
279
+ for child in node.iterdir():
280
+ _visit(child, f"{path}/{child.name}" if path else child.name, documents)
281
+ return
282
+ documents.append(ContractDocument(path=path, text=node.read_text(encoding="utf-8")))
283
+
284
+
285
+ def read_gaps(documents: Sequence[ContractDocument]) -> tuple[Gap, ...]:
286
+ """Everything the contract says it cannot yet express, read out of the contract.
287
+
288
+ A gap is a bullet under a heading that opens an open-questions section — ``## Still open`` in
289
+ ``rules.md``, ``## Open, and deliberately not decided here`` in ``engine.md``. Neither the
290
+ headings' exact wording nor any gap's text is written down in this package: the heading is
291
+ matched by shape and the bullets are read, so **adding a bullet to either document adds a gap
292
+ to this command's output with no change here**. That is the property, and it is why this is a
293
+ parser rather than a tuple of strings that would have to be edited in lockstep with the prose.
294
+
295
+ Every document is offered to the parser, including the schemas. A JSON document has no line
296
+ starting with ``## `` — a JSON string cannot carry a raw newline — so the parser finds nothing
297
+ in one, and that costs a scan of bytes already in memory and saves a rule about which
298
+ extensions are prose. A seventh contract document written in Markdown contributes its own open
299
+ questions the day it ships.
300
+
301
+ Documents come in path order and each document's gaps come out in the order it writes them.
302
+ That order is the document's own and is a function of its bytes, so it is stable across
303
+ machines the way a sort would be, and it keeps a list a person wrote reading the way they wrote
304
+ it — the same call ``report._match_rows`` makes about the order of a ``packs:`` list.
305
+ """
306
+ return tuple(gap for document in documents for gap in _document_gaps(document))
307
+
308
+
309
+ def _document_gaps(document: ContractDocument) -> list[Gap]:
310
+ """One document's open-questions bullets, in the order the document writes them.
311
+
312
+ A section runs from its heading to the next heading at the same level or above, so a ``###``
313
+ subsection inside it is part of it. A bullet runs from its ``- `` to the next bullet, the next
314
+ heading or a blank line, with continuation lines stripped of their indentation and joined by
315
+ single spaces. An indented sub-bullet therefore folds into its parent's text rather than
316
+ becoming a gap of its own; no document does that today, and folding is the harmless reading.
317
+ """
318
+ gaps: list[Gap] = []
319
+ bullet: list[str] = []
320
+ inside = False
321
+
322
+ def flush() -> None:
323
+ if bullet:
324
+ gaps.append(_gap(document.path, " ".join(bullet)))
325
+ bullet.clear()
326
+
327
+ for line in document.text.splitlines():
328
+ if _SECTION_HEADING.match(line):
329
+ flush()
330
+ inside = bool(_GAP_HEADING.match(line))
331
+ continue
332
+ if not inside:
333
+ continue
334
+ if line.startswith("- "):
335
+ flush()
336
+ bullet.append(line[2:].strip())
337
+ elif not line.strip():
338
+ flush()
339
+ elif bullet:
340
+ bullet.append(line.strip())
341
+ flush()
342
+ return gaps
343
+
344
+
345
+ def _gap(document: str, text: str) -> Gap:
346
+ """One bullet split into its bolded lead and the rest of what it says."""
347
+ lead = _GAP_TITLE.match(text)
348
+ if lead is None:
349
+ return Gap(document=document, title="", text=text)
350
+ return Gap(document=document, title=lead.group("title"), text=text[lead.end() :])
351
+
352
+
353
+ def describe_contract(
354
+ contract: Traversable,
355
+ tool_version: str,
356
+ requested: str | None = None,
357
+ candidates: Sequence[PackCandidate] = (),
358
+ problems: Sequence[Problem] = (),
359
+ ) -> SpecReport:
360
+ """Assemble one report from a contract directory and, optionally, a pack to describe.
361
+
362
+ ``contract`` is a ``Traversable`` and not a path this function builds, for the same reason
363
+ ``resolve()`` takes ``start``: a test drives it against a temporary tree with no monkeypatching
364
+ anywhere, which is what makes "add a fifth open question and it appears" a test that edits a
365
+ file rather than a claim in a docstring.
366
+
367
+ ``candidates`` are ignored when no pack was named, and ``spec()`` does not gather them on that
368
+ path at all. See its docstring for why that is a property and not an optimisation.
369
+
370
+ ``problems`` are the ones the caller already found — resolving the search path is the only
371
+ source today — and they are folded in and sorted with this function's own, so the record
372
+ carries every fault of the run and the status is the lowest code among all of them.
373
+ """
374
+ documents = read_contract(contract)
375
+ gaps = read_gaps(documents)
376
+ found: list[Problem] = list(problems)
377
+ subject: PackSubject | None = None
378
+ inherited: InheritedVocabulary | None = InheritedVocabulary(node_types=(), edge_kinds=())
379
+ if requested is not None:
380
+ subject, inherited = _describe_pack(requested, candidates, found)
381
+ return SpecReport(
382
+ tool_version=tool_version,
383
+ documents=documents,
384
+ gaps=gaps,
385
+ pack=subject,
386
+ inherited=inherited,
387
+ # Sorted by code then message, once, at the end — the same key ``resolve()`` sorts its own
388
+ # problems by, so a problem reads the same wherever a person meets it.
389
+ problems=tuple(sorted(found, key=lambda problem: (int(problem.code), problem.message))),
390
+ )
391
+
392
+
393
+ def _describe_pack(
394
+ requested: str,
395
+ candidates: Sequence[PackCandidate],
396
+ problems: list[Problem],
397
+ ) -> tuple[PackSubject, InheritedVocabulary | None]:
398
+ """One pack name resolved to a directory, its manifest, and what it inherits.
399
+
400
+ A name and not a ``<pack>@<version>`` pin: a person authoring a pack knows what their pack is
401
+ called and need not know which version of a dependency is installed. Every ``named`` candidate
402
+ whose pack half equals the name becomes a pin, and ``match_pins`` turns those into matches, so
403
+ the one rule about how a directory name is read lives in ``packs.py`` and is not restated here.
404
+
405
+ Two directories carrying the name are reported and neither is chosen, by
406
+ ``resolve_pack_order``, which keys its graph by pack name and cannot hold one name as two
407
+ packs. A single directory found under two roots is reported twice, once by ``match_pins`` as a
408
+ duplicated pin and once by the walk as a duplicated name; they are two true statements about
409
+ one tree — the pin matched in two places, and the name resolves to two directories — and
410
+ suppressing either would need this module to decide which of a person's faults is the real one.
411
+ """
412
+ entries = sorted(
413
+ {
414
+ candidate.entry
415
+ for candidate in candidates
416
+ if candidate.status == "named"
417
+ and candidate.name is not None
418
+ and candidate.name.pack == requested
419
+ }
420
+ )
421
+ absent = PackSubject(
422
+ requested=requested,
423
+ directory=None,
424
+ declares=None,
425
+ version=None,
426
+ depends=(),
427
+ inherits_from=(),
428
+ )
429
+ if not entries:
430
+ problems.append(_unknown_pack(requested, candidates))
431
+ return absent, None
432
+
433
+ matches, pin_problems = match_pins(entries, candidates)
434
+ order, walk_problems = resolve_pack_order(matches, candidates)
435
+ problems.extend(pin_problems)
436
+ problems.extend(walk_problems)
437
+
438
+ loaded = next((pack for pack in order if pack.name.pack == requested), None)
439
+ if loaded is None:
440
+ # Every reason the walk could not produce it — a duplicated name, an unreadable manifest —
441
+ # has already appended its own problem, so this branch adds none and would be lying if it
442
+ # invented one.
443
+ return absent, None
444
+ subject = PackSubject(
445
+ requested=requested,
446
+ directory=loaded.path,
447
+ declares=loaded.manifest.pack,
448
+ version=loaded.manifest.version,
449
+ depends=tuple(sorted(set(loaded.manifest.depends))),
450
+ inherits_from=tuple(
451
+ sorted({pack.name.pack for pack in order if pack.name.pack != requested})
452
+ ),
453
+ )
454
+ return subject, _inherited(loaded)
455
+
456
+
457
+ def _inherited(loaded: LoadedPack) -> InheritedVocabulary | None:
458
+ """The empty vocabulary for a pack that depends on nothing, and ``None`` for every other pack.
459
+
460
+ A pack whose manifest declares no ``depends`` has an empty dependency closure, so the union of
461
+ the node types and edge kinds it inherits is empty. That is arithmetic over an empty set and
462
+ needs no reader, which is why this one answer can be given today and the other cannot.
463
+
464
+ The question is asked of the **raw** ``depends`` value and not of ``PackManifest.depends``,
465
+ and the difference is the difference between a true and a false statement.
466
+ ``manifest.py`` deliberately parses ``depends: "php"`` to ``()`` — dropping a mistyped value
467
+ rather than reading it as three dependencies named ``p``, ``h`` and ``p`` — so its empty tuple
468
+ covers two files: one that declares no dependencies, and one whose declaration could not be
469
+ read. Only the first inherits nothing. The second is unknown, and unknown is ``None``.
470
+ """
471
+ declared = loaded.manifest.document.get("depends")
472
+ if declared is None or (isinstance(declared, list | tuple) and not declared):
473
+ return InheritedVocabulary(node_types=(), edge_kinds=())
474
+ return None
475
+
476
+
477
+ def _unknown_pack(requested: str, candidates: Sequence[PackCandidate]) -> Problem:
478
+ """A name no directory on the pack search path carries.
479
+
480
+ ``available`` is every pack name discovery found, sorted, rather than a guess at which was
481
+ meant — the same call ``manifest._unmatched`` makes, and for the same reason: a rule that
482
+ decides ``twigg`` meant ``twig`` will one day decide it meant ``php``.
483
+ """
484
+ available = sorted(
485
+ {
486
+ candidate.name.pack
487
+ for candidate in candidates
488
+ if candidate.status == "named" and candidate.name is not None
489
+ }
490
+ )
491
+ return Problem(
492
+ code=ExitCode.PACK_UNKNOWN,
493
+ message=f"no pack on the pack search path is named {requested!r}",
494
+ detail={"pack": requested, "available": ", ".join(available)},
495
+ )
496
+
497
+
498
+ def spec(
499
+ start: Path,
500
+ env: Mapping[str, str],
501
+ dependencies: tuple[DependencyStatus, ...],
502
+ requested: str | None = None,
503
+ ) -> SpecReport:
504
+ """The command's entry point: the contract, and a pack only when one was named.
505
+
506
+ **With no pack named this reads nothing outside the installed package.** No settings file is
507
+ located, no project root is walked to, no pack directory is listed — because there is no
508
+ question that needs them. That is a stated property rather than a saving: the contract is
509
+ package data, and a command that publishes it must work on a machine with no project, no
510
+ configuration and no packs installed, which is the machine a model authoring its first pack is
511
+ sitting at. A test plants an unparseable settings file above the working directory and asserts
512
+ this run still exits 0, which is what makes the property observable rather than argued.
513
+
514
+ With a pack named, the search path is resolved **exactly as ``status`` resolves it**, by
515
+ calling ``resolve()`` — one definition of where packs live, rather than a second one here that
516
+ would drift and would miss a ``pack_paths`` root a settings file added.
517
+
518
+ That means this run inherits every problem resolution found, including ones about pins it did
519
+ not need, and the status is the lowest code among all of them. The alternative is a filter
520
+ deciding which of a project's faults are allowed to matter, and there is no honest rule for
521
+ writing one: a settings file that could not be read is a pack search path the tool cannot vouch
522
+ for, and a report that described a pack found on a path it could not compute would be a report
523
+ that lies about where it looked.
524
+ """
525
+ if requested is None:
526
+ return describe_contract(contract_root(), periplus.__version__)
527
+ resolution = resolve(start=start, env=env, dependencies=dependencies)
528
+ return describe_contract(
529
+ contract_root(),
530
+ periplus.__version__,
531
+ requested,
532
+ resolution.candidates,
533
+ resolution.problems,
534
+ )
535
+
536
+
537
+ # ---------------------------------------------------------------------------------------------
538
+ # The text form
539
+ # ---------------------------------------------------------------------------------------------
540
+
541
+
542
+ def render_text(report: SpecReport) -> str:
543
+ """The contract as the bytes a model reads: the summary, then every document in full.
544
+
545
+ **This is the default form and stays the default form.** The consumer is a model authoring a
546
+ pack, and it needs the schemas themselves; a form that named the documents and stopped would
547
+ cost that consumer a second command and a second round trip to reach what it came for, which
548
+ is a real price paid on every run to save bytes nobody was short of.
549
+
550
+ The summary comes first and the documents last, because the summary is what a person scanning
551
+ the output needs and the documents are what a model reading all of it needs. Neither is
552
+ truncated: a contract published in part is a contract an author writes against in part.
553
+
554
+ Each document is introduced by a ruled line naming its path and is then emitted **byte for
555
+ byte**. It is the artifact, and a transformed artifact is not the artifact — a Markdown fence
556
+ re-indented or a schema re-serialised is a document the author of a pack would then be building
557
+ against by inference.
558
+ """
559
+ text = render_text_summary(report)
560
+ for document in report.documents:
561
+ text += f"\n{'-' * 8} {document.path} {'-' * 8}\n\n{document.text}"
562
+ return text
563
+
564
+
565
+ def render_text_summary(report: SpecReport) -> str:
566
+ """The summary block alone: what was published, the pack, the gaps, the problems.
567
+
568
+ ``--summary``. It is the same bytes ``render_text`` opens with, and that is the whole
569
+ definition — the two forms cannot drift, because there is one function and the whole form is
570
+ this one plus the documents appended to it.
571
+
572
+ Nothing here is a digest of a document. Every path is named in full, so a reader who wants one
573
+ of them knows exactly what to ask for, and no line of this block stands in for prose it
574
+ summarises. What is dropped is the documents' bytes and nothing else.
575
+ """
576
+ lines: list[str] = [f"periplus {_escape(report.tool_version)} pack contract"]
577
+ lines += _section("Contract documents", [document.path for document in report.documents])
578
+ lines += _section("Pack", _pack_lines(report.pack))
579
+ lines += _section("Inherited vocabulary", _vocabulary_lines(report.inherited))
580
+ lines += _section("Not yet expressible", _gap_lines(report.gaps))
581
+ lines += _section("Problems", _problem_lines(report.problems))
582
+ return "\n".join(lines) + "\n"
583
+
584
+
585
+ def _section(heading: str, rows: Sequence[str]) -> list[str]:
586
+ """One block: a blank line, its heading, and its rows indented under it.
587
+
588
+ No column alignment and no width computed from anything. ``report._block`` aligns because a
589
+ resolution report is a table; this output is a list of labelled facts followed by whole
590
+ documents, so borrowing that machinery would be borrowing a decision this form does not make.
591
+ An empty block prints ``none`` rather than nothing, because a section a reader cannot find is
592
+ indistinguishable from a section that does not exist.
593
+ """
594
+ return ["", f"{heading}:", *[f" {row}".rstrip() for row in rows or ["none"]]]
595
+
596
+
597
+ def _labelled(label: str, value: str) -> str:
598
+ """One fact under a heading, its label padded to a fixed width written here and nowhere else."""
599
+ return f"{label:<12}{value}"
600
+
601
+
602
+ def _pack_lines(pack: PackSubject | None) -> list[str]:
603
+ """The pack that was asked about, or that none was.
604
+
605
+ Every value comes off a command line, a filesystem or a manifest, so every value is escaped.
606
+ """
607
+ if pack is None:
608
+ return ["no pack was named"]
609
+ declared = (
610
+ "not declared"
611
+ if pack.declares is None
612
+ else f"{_escape(pack.declares)} {_escape(pack.version or 'no version')}"
613
+ )
614
+ return [
615
+ _labelled("requested", _escape(pack.requested)),
616
+ _labelled("directory", _escape(pack.directory or "not found on the pack search path")),
617
+ _labelled("declares", declared),
618
+ _labelled("depends", _joined(pack.depends) or "none"),
619
+ _labelled("inherits", _joined(pack.inherits_from) or "none"),
620
+ ]
621
+
622
+
623
+ def _vocabulary_lines(inherited: InheritedVocabulary | None) -> list[str]:
624
+ """What the pack may name because a dependency declares it, or that nobody has read it.
625
+
626
+ The ``None`` case says which reader is missing rather than printing an empty list. An empty
627
+ list would be a claim, and the claim would be false.
628
+ """
629
+ if inherited is None:
630
+ return ["not read - no reader opens a pack's rule files for its node types and edge kinds"]
631
+ return [
632
+ _labelled("node types", _joined(inherited.node_types) or "none"),
633
+ _labelled("edge kinds", _joined(inherited.edge_kinds) or "none"),
634
+ ]
635
+
636
+
637
+ def _gap_lines(gaps: Sequence[Gap]) -> list[str]:
638
+ """One line per open question, naming the document that states it.
639
+
640
+ Package data, emitted verbatim. The join in ``_document_gaps`` has already made each one line,
641
+ so there is no line here for a document to forge — and the documents ship inside the wheel,
642
+ which is the reason nothing here is escaped and the pack lines above all are.
643
+ """
644
+ return [
645
+ f"{gap.document} {gap.title}{': ' if gap.title else ''}{gap.text}".rstrip() for gap in gaps
646
+ ]
647
+
648
+
649
+ def _problem_lines(problems: Sequence[Problem]) -> list[str]:
650
+ """One line per problem, its code first, with its named facts under it."""
651
+ lines: list[str] = []
652
+ for problem in problems:
653
+ lines.append(f"{int(problem.code)} {_escape(problem.message)}")
654
+ lines += [
655
+ f" {_escape(key)}: {_escape(problem.detail[key])}" for key in sorted(problem.detail)
656
+ ]
657
+ return lines
658
+
659
+
660
+ def _joined(values: Sequence[str]) -> str:
661
+ """A list of names as one line, each escaped, because each came from outside the package."""
662
+ return ", ".join(_escape(value) for value in values)
663
+
664
+
665
+ # ---------------------------------------------------------------------------------------------
666
+ # The JSON form
667
+ # ---------------------------------------------------------------------------------------------
668
+
669
+
670
+ def render_json(report: SpecReport) -> str:
671
+ """The report as one JSON document, keys sorted, every contract file carried in full.
672
+
673
+ An explicit projection rather than a walk over the dataclasses, for the reason
674
+ ``report.render_json`` gives: adding a field to a record must not silently add a key whose type
675
+ nobody chose. The whole record is JSON primitives already, so nothing here converts a value —
676
+ the projection is here to name the keys, not to rescue them.
677
+
678
+ ``ensure_ascii=True``, matching the other form of every report this tool prints. A pack name
679
+ and a directory name come off a filesystem where a name need not be valid UTF-8, and under
680
+ ``ensure_ascii`` a surrogate escape encodes cleanly instead of raising out of the encoder.
681
+
682
+ Nothing is passed through ``_escape`` here, exactly as in ``report.render_json``: that helper
683
+ stops a name forging a *line*, and this document has no lines to forge. The encoder escapes a
684
+ newline itself, losslessly and reversibly, which is what a consumer of this form needs.
685
+ """
686
+ return _json(
687
+ report, [{"path": document.path, "text": document.text} for document in report.documents]
688
+ )
689
+
690
+
691
+ def render_json_summary(report: SpecReport) -> str:
692
+ """The same document with every ``text`` dropped, and nothing else changed.
693
+
694
+ ``--summary --format json``, and this is the form the flag exists for. A consumer that wants
695
+ the ``gaps`` array today parses some seventy kilobytes of escaped document text to reach it,
696
+ all of which it then discards; here the whole document is the part it wanted.
697
+
698
+ ``documents`` stays a **list of objects**, each carrying its ``path``, rather than collapsing
699
+ to a list of strings. One expression — ``[d["path"] for d in payload["documents"]]`` — then
700
+ reads the paths out of either form, where a type that changed with a flag would make a
701
+ consumer branch on which flag it passed. A consumer that reaches for ``text`` gets a
702
+ ``KeyError``, which is the loud failure and the right one: the bytes are genuinely not here,
703
+ and a key holding an empty string or a null would say they were and were empty.
704
+ """
705
+ return _json(report, [{"path": document.path} for document in report.documents])
706
+
707
+
708
+ def _json(report: SpecReport, documents: list[dict[str, str]]) -> str:
709
+ """One JSON document over a report and whichever projection of its contract files."""
710
+ payload = {
711
+ "tool_version": report.tool_version,
712
+ "documents": documents,
713
+ "gaps": [
714
+ {"document": gap.document, "title": gap.title, "text": gap.text} for gap in report.gaps
715
+ ],
716
+ "pack": _json_pack(report.pack),
717
+ "inherited": _json_vocabulary(report.inherited),
718
+ "problems": [
719
+ {
720
+ "code": int(problem.code),
721
+ "message": problem.message,
722
+ "detail": dict(problem.detail),
723
+ }
724
+ for problem in report.problems
725
+ ],
726
+ }
727
+ return json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=True) + "\n"
728
+
729
+
730
+ def _json_pack(pack: PackSubject | None) -> dict[str, object] | None:
731
+ """The pack that was asked about, or ``null`` when none was named."""
732
+ if pack is None:
733
+ return None
734
+ return {
735
+ "requested": pack.requested,
736
+ "directory": pack.directory,
737
+ "declares": pack.declares,
738
+ "version": pack.version,
739
+ "depends": list(pack.depends),
740
+ "inherits_from": list(pack.inherits_from),
741
+ }
742
+
743
+
744
+ def _json_vocabulary(inherited: InheritedVocabulary | None) -> dict[str, object] | None:
745
+ """The inherited vocabulary, or ``null`` when no reader has worked it out.
746
+
747
+ ``null`` and an object of empty arrays are different facts and both occur: the first means
748
+ nothing read it, the second means it was read and is empty.
749
+ """
750
+ if inherited is None:
751
+ return None
752
+ return {
753
+ "node_types": list(inherited.node_types),
754
+ "edge_kinds": list(inherited.edge_kinds),
755
+ }
756
+
757
+
758
+ #: The formats ``spec --format`` offers, in the order they are offered. ``cli.build_parser`` writes
759
+ #: the same two names as a literal for the reason ``report.RENDERERS`` records — ``cli`` may not
760
+ #: import this module, because it reaches ``ruamel.yaml`` — and a test asserts the two agree.
761
+ RENDERERS: Mapping[str, Callable[[SpecReport], str]] = {
762
+ "text": render_text,
763
+ "json": render_json,
764
+ }
765
+
766
+ #: The same two formats under ``--summary``. A second table keyed by the same names, rather than a
767
+ #: ``summary`` argument threaded through both renderers: ``cli`` picks a table and then looks a
768
+ #: format up in it, so the flag is decided once at the call site and neither renderer carries a
769
+ #: branch. Both names are driven through ``--summary`` by a test, so a format present in one table
770
+ #: and not the other is a ``KeyError`` in the suite rather than in somebody's terminal.
771
+ SUMMARY_RENDERERS: Mapping[str, Callable[[SpecReport], str]] = {
772
+ "text": render_text_summary,
773
+ "json": render_json_summary,
774
+ }