codd-dev 1.25.0__tar.gz → 1.26.0__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 (180) hide show
  1. {codd_dev-1.25.0 → codd_dev-1.26.0}/PKG-INFO +2 -1
  2. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/cli.py +234 -4
  3. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/builder.py +174 -13
  4. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/task_completion.py +3 -1
  5. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/user_journey_coherence.py +95 -1
  6. codd_dev-1.26.0/codd/dag/defaults/cpp_embedded.yaml +2 -0
  7. codd_dev-1.26.0/codd/dag/defaults/csharp.yaml +2 -0
  8. codd_dev-1.26.0/codd/dag/defaults/elixir.yaml +2 -0
  9. codd_dev-1.26.0/codd/dag/defaults/generic.yaml +5 -0
  10. codd_dev-1.26.0/codd/dag/defaults/java.yaml +2 -0
  11. codd_dev-1.26.0/codd/dag/defaults/kotlin.yaml +2 -0
  12. codd_dev-1.26.0/codd/dag/defaults/ruby.yaml +2 -0
  13. codd_dev-1.26.0/codd/dag/defaults/rust.yaml +2 -0
  14. codd_dev-1.26.0/codd/dag/defaults/scala.yaml +2 -0
  15. codd_dev-1.26.0/codd/dag/defaults/swift.yaml +2 -0
  16. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/defaults/web.yaml +2 -0
  17. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/extractor.py +12 -0
  18. codd_dev-1.26.0/codd/deployment/defaults/verification_means_catalog.yaml +6 -0
  19. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/extractor.py +129 -3
  20. codd_dev-1.26.0/codd/deployment/providers/ai_command.py +186 -0
  21. codd_dev-1.26.0/codd/deployment/providers/llm_consideration.py +338 -0
  22. codd_dev-1.26.0/codd/deployment/providers/verification/__init__.py +21 -0
  23. codd_dev-1.26.0/codd/deployment/providers/verification/assertion_handlers.py +244 -0
  24. codd_dev-1.26.0/codd/deployment/providers/verification/cdp_browser.py +350 -0
  25. codd_dev-1.26.0/codd/deployment/providers/verification/cdp_engines.py +40 -0
  26. codd_dev-1.26.0/codd/deployment/providers/verification/cdp_launchers.py +44 -0
  27. codd_dev-1.26.0/codd/deployment/providers/verification/cdp_wire.py +130 -0
  28. codd_dev-1.26.0/codd/deployment/providers/verification/form_strategies.py +44 -0
  29. codd_dev-1.26.0/codd/deployment/providers/verification/means_catalog.py +102 -0
  30. codd_dev-1.26.0/codd/llm/approval.py +324 -0
  31. codd_dev-1.26.0/codd/llm/means_catalog_loader.py +122 -0
  32. codd_dev-1.26.0/codd/llm/parser.py +119 -0
  33. codd_dev-1.26.0/codd/llm/prompt_builder.py +57 -0
  34. codd_dev-1.26.0/codd/llm/strategy_validator.py +37 -0
  35. codd_dev-1.26.0/codd/llm/templates/meta_instruction.md +37 -0
  36. codd_dev-1.26.0/codd/llm/templates/verification_means_catalog.yaml +6 -0
  37. codd_dev-1.26.0/codd/repair/__init__.py +5 -0
  38. codd_dev-1.26.0/codd/repair/engine.py +90 -0
  39. codd_dev-1.26.0/codd/repair/git_patcher.py +120 -0
  40. codd_dev-1.26.0/codd/repair/history.py +124 -0
  41. codd_dev-1.26.0/codd/repair/llm_repair_engine.py +305 -0
  42. codd_dev-1.26.0/codd/repair/schema.py +85 -0
  43. codd_dev-1.26.0/codd/repair/templates/analyze_meta.md +28 -0
  44. codd_dev-1.26.0/codd/repair/templates/propose_meta.md +34 -0
  45. codd_dev-1.26.0/codd/templates/extract_ai_prompt_baseline.md +418 -0
  46. codd_dev-1.26.0/codd/templates/lexicon_questions.md +151 -0
  47. codd_dev-1.26.0/docs/cookbook/cdp_browser/README.md +88 -0
  48. {codd_dev-1.25.0 → codd_dev-1.26.0}/pyproject.toml +3 -1
  49. {codd_dev-1.25.0 → codd_dev-1.26.0}/.gitignore +0 -0
  50. {codd_dev-1.25.0 → codd_dev-1.26.0}/LICENSE +0 -0
  51. {codd_dev-1.25.0 → codd_dev-1.26.0}/README.md +0 -0
  52. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/__init__.py +0 -0
  53. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/__main__.py +0 -0
  54. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/_git_helper.py +0 -0
  55. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/ask_user_question_adapter.py +0 -0
  56. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/assembler.py +0 -0
  57. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/bridge.py +0 -0
  58. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/clustering.py +0 -0
  59. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/coherence_adapters.py +0 -0
  60. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/coherence_engine.py +0 -0
  61. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/config.py +0 -0
  62. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/contracts.py +0 -0
  63. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/coverage_auditor.py +0 -0
  64. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/coverage_metrics.py +0 -0
  65. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/__init__.py +0 -0
  66. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/__init__.py +0 -0
  67. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/depends_on_consistency.py +0 -0
  68. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/deployment_completeness.py +0 -0
  69. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/edge_validity.py +0 -0
  70. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/node_completeness.py +0 -0
  71. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/checks/transitive_closure.py +0 -0
  72. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/defaults/cli.yaml +0 -0
  73. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/defaults/iot.yaml +0 -0
  74. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/defaults/mobile.yaml +0 -0
  75. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/defaults/test_frameworks.yaml +0 -0
  76. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/dag/runner.py +0 -0
  77. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/defaults.yaml +0 -0
  78. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deploy_targets/__init__.py +0 -0
  79. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deploy_targets/app_service.py +0 -0
  80. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deploy_targets/base.py +0 -0
  81. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deploy_targets/docker_compose.py +0 -0
  82. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployer.py +0 -0
  83. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/__init__.py +0 -0
  84. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/checks/__init__.py +0 -0
  85. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/defaults/deploy_targets.yaml +0 -0
  86. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/defaults/runtime_capability_inference.yaml +0 -0
  87. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/defaults/schema_providers.yaml +0 -0
  88. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/defaults/verification_templates.yaml +0 -0
  89. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/__init__.py +0 -0
  90. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/schema/__init__.py +0 -0
  91. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/schema/prisma.py +0 -0
  92. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/target/__init__.py +0 -0
  93. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/target/docker_compose.py +0 -0
  94. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/verification/curl.py +0 -0
  95. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/deployment/providers/verification/playwright.py +0 -0
  96. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/design_md.py +0 -0
  97. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/drift.py +0 -0
  98. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/e2e_extractor.py +0 -0
  99. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/e2e_generator.py +0 -0
  100. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/e2e_runner.py +0 -0
  101. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/env_refs.py +0 -0
  102. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/extract_ai.py +0 -0
  103. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/extractor.py +0 -0
  104. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/fixer.py +0 -0
  105. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/fixup_drift.py +0 -0
  106. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/fixup_drift_strategies/__init__.py +0 -0
  107. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/fixup_drift_strategies/design_token_drift.py +0 -0
  108. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/fixup_drift_strategies/lexicon_violation.py +0 -0
  109. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/fixup_drift_strategies/url_drift.py +0 -0
  110. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/generator.py +0 -0
  111. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/graph.py +0 -0
  112. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hitl_session.py +0 -0
  113. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hooks/__init__.py +0 -0
  114. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hooks/pre-commit +0 -0
  115. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hooks/recipes/claude_settings_example.json +0 -0
  116. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hooks/recipes/codex_hook.sh +0 -0
  117. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hooks/recipes/git_post_commit.sh +0 -0
  118. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/hooks/recipes/git_pre_commit.sh +0 -0
  119. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/implementer.py +0 -0
  120. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/inheritance.py +0 -0
  121. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/knowledge_fetcher.py +0 -0
  122. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/lexicon.py +0 -0
  123. {codd_dev-1.25.0/codd/deployment/providers/verification → codd_dev-1.26.0/codd/llm}/__init__.py +0 -0
  124. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/mcp_server.py +0 -0
  125. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/measure.py +0 -0
  126. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/parsing.py +0 -0
  127. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/planner.py +0 -0
  128. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/policy.py +0 -0
  129. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/preflight/__init__.py +0 -0
  130. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/preflight/defaults/cli.yaml +0 -0
  131. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/preflight/defaults/iot.yaml +0 -0
  132. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/preflight/defaults/mobile.yaml +0 -0
  133. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/preflight/defaults/web.yaml +0 -0
  134. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/propagate.py +0 -0
  135. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/propagator.py +0 -0
  136. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/registry.py +0 -0
  137. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/repair_slice.py +0 -0
  138. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/require.py +0 -0
  139. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/require_plugins.py +0 -0
  140. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/require_propagate.py +0 -0
  141. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/required_artifacts/defaults/cli.yaml +0 -0
  142. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/required_artifacts/defaults/iot.yaml +0 -0
  143. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/required_artifacts/defaults/mobile.yaml +0 -0
  144. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/required_artifacts/defaults/web.yaml +0 -0
  145. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/required_artifacts_deriver.py +0 -0
  146. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/requirement_completeness/defaults/cli.yaml +0 -0
  147. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/requirement_completeness/defaults/iot.yaml +0 -0
  148. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/requirement_completeness/defaults/mobile.yaml +0 -0
  149. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/requirement_completeness/defaults/web.yaml +0 -0
  150. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/requirement_completeness_auditor.py +0 -0
  151. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/restore.py +0 -0
  152. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/routes_extractor.py +0 -0
  153. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/scanner.py +0 -0
  154. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/schema_refs.py +0 -0
  155. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/screen_flow_validator.py +0 -0
  156. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/screen_transition_extractor.py +0 -0
  157. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/screen_transitions/defaults.yaml +0 -0
  158. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/synth.py +0 -0
  159. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/codd.yaml.tmpl +0 -0
  160. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/conventions.yaml.tmpl +0 -0
  161. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
  162. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/doc_links.yaml.tmpl +0 -0
  163. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
  164. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
  165. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
  166. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
  167. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/extracted/system-context.md.j2 +0 -0
  168. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/gitignore.tmpl +0 -0
  169. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/lexicon_schema.yaml +0 -0
  170. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/templates/overrides.yaml.tmpl +0 -0
  171. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/traceability.py +0 -0
  172. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/validator.py +0 -0
  173. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/watch/__init__.py +0 -0
  174. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/watch/events.py +0 -0
  175. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/watch/propagation_log.py +0 -0
  176. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/watch/propagation_pipeline.py +0 -0
  177. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/watch/test_runner.py +0 -0
  178. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/watch/watcher.py +0 -0
  179. {codd_dev-1.25.0 → codd_dev-1.26.0}/codd/wiring.py +0 -0
  180. {codd_dev-1.25.0 → codd_dev-1.26.0}/docs/requirements/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codd-dev
3
- Version: 1.25.0
3
+ Version: 1.26.0
4
4
  Summary: CoDD: Coherence-Driven Development — cross-artifact change impact analysis
5
5
  Project-URL: Homepage, https://github.com/yohey-w/codd-dev
6
6
  Project-URL: Repository, https://github.com/yohey-w/codd-dev
@@ -20,6 +20,7 @@ Requires-Dist: jinja2>=3.1.0
20
20
  Requires-Dist: pyyaml>=6.0
21
21
  Requires-Dist: tomli>=2.0.1; python_version < '3.11'
22
22
  Requires-Dist: watchdog>=4.0.0
23
+ Requires-Dist: websocket-client>=1.8.0
23
24
  Provides-Extra: ai
24
25
  Provides-Extra: api-parsers
25
26
  Requires-Dist: graphql-core>=3.2.0; extra == 'api-parsers'
@@ -2283,6 +2283,119 @@ def test_cmd(project_path: str, related: tuple[str, ...], dry_run: bool):
2283
2283
  raise SystemExit(1)
2284
2284
 
2285
2285
 
2286
+ @main.group()
2287
+ def llm():
2288
+ """Manage LLM-derived considerations."""
2289
+ pass
2290
+
2291
+
2292
+ @llm.command("derive")
2293
+ @click.argument("design_doc", type=click.Path(exists=True, dir_okay=False, path_type=Path))
2294
+ @click.option("--path", "project_path", default=".", help="Project root directory")
2295
+ @click.option("--ai-cmd", default=None, help="Override AI CLI command")
2296
+ @click.option("--model", default=None, help="Override AI model")
2297
+ @click.option("--force", is_flag=True, help="Bypass cached derived considerations")
2298
+ def llm_derive(design_doc: Path, project_path: str, ai_cmd: str | None, model: str | None, force: bool):
2299
+ """Derive considerations for a design document."""
2300
+ from codd.deployment.providers.ai_command import SubprocessAiCommand
2301
+ from codd.deployment.providers.llm_consideration import LlmConsiderationProvider
2302
+ from codd.llm.approval import notify_pending_considerations
2303
+
2304
+ project_root = Path(project_path).resolve()
2305
+ config = _load_optional_project_config(project_root)
2306
+ provider = LlmConsiderationProvider(
2307
+ SubprocessAiCommand(command=ai_cmd, project_root=project_root, config=config),
2308
+ project_root=project_root,
2309
+ cache_dir=project_root / ".codd" / "consideration_cache",
2310
+ model=model,
2311
+ use_cache=not force,
2312
+ )
2313
+ result = provider.provide(design_doc.read_text(encoding="utf-8"), {"model": model} if model else {})
2314
+ notify_pending_considerations(result.considerations, config)
2315
+ click.echo(f"Derived considerations: {len(result.considerations)}")
2316
+
2317
+
2318
+ @llm.command("approve")
2319
+ @click.argument("consideration_id", required=False)
2320
+ @click.option("--all", "approve_all", is_flag=True, help="Approve all pending considerations")
2321
+ @click.option("--path", "project_path", default=".", help="Project root directory")
2322
+ def llm_approve(consideration_id: str | None, approve_all: bool, project_path: str):
2323
+ """Approve one or all pending considerations."""
2324
+ from codd.llm.approval import ApprovalCache, consideration_status, load_cached_considerations
2325
+
2326
+ project_root = Path(project_path).resolve()
2327
+ considerations = load_cached_considerations(project_root)
2328
+ by_id = {consideration.id: consideration for consideration in considerations}
2329
+
2330
+ if approve_all:
2331
+ targets = [
2332
+ consideration
2333
+ for consideration in considerations
2334
+ if consideration_status(consideration, project_root) == "pending"
2335
+ ]
2336
+ else:
2337
+ if not consideration_id:
2338
+ click.echo("Error: consideration_id or --all is required")
2339
+ raise SystemExit(2)
2340
+ if consideration_id not in by_id:
2341
+ click.echo(f"Error: consideration not found: {consideration_id}")
2342
+ raise SystemExit(1)
2343
+ targets = [by_id[consideration_id]]
2344
+
2345
+ for consideration in targets:
2346
+ ApprovalCache.save(consideration.id, "approved", project_root)
2347
+ click.echo(f"Approved considerations: {len(targets)}")
2348
+
2349
+
2350
+ @llm.command("skip")
2351
+ @click.argument("consideration_id")
2352
+ @click.option("--path", "project_path", default=".", help="Project root directory")
2353
+ def llm_skip(consideration_id: str, project_path: str):
2354
+ """Skip one consideration."""
2355
+ from codd.llm.approval import ApprovalCache, load_cached_considerations
2356
+
2357
+ project_root = Path(project_path).resolve()
2358
+ known = {consideration.id for consideration in load_cached_considerations(project_root)}
2359
+ if consideration_id not in known:
2360
+ click.echo(f"Error: consideration not found: {consideration_id}")
2361
+ raise SystemExit(1)
2362
+ ApprovalCache.save(consideration_id, "skipped", project_root)
2363
+ click.echo(f"Skipped consideration: {consideration_id}")
2364
+
2365
+
2366
+ @llm.command("list")
2367
+ @click.option("--path", "project_path", default=".", help="Project root directory")
2368
+ @click.option("--format", "output_format", default="text", type=click.Choice(["text", "json"]))
2369
+ def llm_list(project_path: str, output_format: str):
2370
+ """List generated considerations with approval status."""
2371
+ from codd.llm.approval import consideration_status, consideration_to_dict, load_cached_considerations
2372
+
2373
+ project_root = Path(project_path).resolve()
2374
+ rows = []
2375
+ for consideration in sorted(load_cached_considerations(project_root), key=lambda item: item.id):
2376
+ row = consideration_to_dict(consideration)
2377
+ row["status"] = consideration_status(consideration, project_root)
2378
+ rows.append(row)
2379
+
2380
+ if output_format == "json":
2381
+ click.echo(json.dumps(rows, ensure_ascii=False, indent=2))
2382
+ return
2383
+
2384
+ if not rows:
2385
+ click.echo("No considerations found")
2386
+ return
2387
+ for row in rows:
2388
+ description = str(row.get("description") or "")
2389
+ click.echo(f"{row['id']}\t{row['status']}\t{description}")
2390
+
2391
+
2392
+ def _load_optional_project_config(project_root: Path) -> dict[str, Any]:
2393
+ try:
2394
+ return load_project_config(project_root)
2395
+ except (FileNotFoundError, ValueError):
2396
+ return {}
2397
+
2398
+
2286
2399
  @main.group()
2287
2400
  def dag():
2288
2401
  """DAG Completeness Gate commands."""
@@ -2429,7 +2542,7 @@ def dag_journeys(project_path: str, output_format: str):
2429
2542
  click.echo(f"Error: {exc}")
2430
2543
  raise SystemExit(1)
2431
2544
 
2432
- journeys = _collect_dag_journeys(built_dag)
2545
+ journeys = _collect_dag_journeys(built_dag, project_root, _load_optional_project_config(project_root))
2433
2546
  if output_format == "json":
2434
2547
  click.echo(json.dumps(journeys, ensure_ascii=False, indent=2))
2435
2548
  return
@@ -2447,12 +2560,120 @@ def dag_journeys(project_path: str, output_format: str):
2447
2560
  click.echo(f" {journey['name']} [{journey['criticality']}] requires: {requires}")
2448
2561
 
2449
2562
 
2450
- def _collect_dag_journeys(dag: Any) -> list[dict[str, Any]]:
2563
+ @dag.command("run-journey")
2564
+ @click.argument("journey_name")
2565
+ @click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
2566
+ @click.option(
2567
+ "--config-section",
2568
+ default="cdp_browser",
2569
+ show_default=True,
2570
+ help="verification.templates section used for browser config",
2571
+ )
2572
+ def dag_run_journey(journey_name: str, project_path: str, config_section: str):
2573
+ """Run one declared user_journey with the CDP browser template."""
2574
+ from codd.dag.builder import build_dag
2575
+ from codd.deployment.providers.verification.cdp_browser import CdpBrowser
2576
+
2577
+ project_root = Path(project_path).resolve()
2578
+ try:
2579
+ config = load_project_config(project_root)
2580
+ template_config = _journey_template_config(config, config_section)
2581
+ built_dag = build_dag(project_root)
2582
+ except (FileNotFoundError, ValueError) as exc:
2583
+ click.echo(f"Error: {exc}")
2584
+ raise SystemExit(2)
2585
+
2586
+ journey_record = _find_dag_journey(built_dag, journey_name, project_root, config)
2587
+ if journey_record is None:
2588
+ click.echo(f"Error: user_journey not found: {journey_name}")
2589
+ raise SystemExit(2)
2590
+
2591
+ command = json.dumps(
2592
+ _journey_execution_plan(project_root, journey_record, template_config),
2593
+ sort_keys=True,
2594
+ )
2595
+ result = CdpBrowser(config=template_config).execute(command)
2596
+ if result.output:
2597
+ click.echo(result.output)
2598
+ raise SystemExit(0 if result.passed else 1)
2599
+
2600
+
2601
+ def _journey_template_config(config: dict[str, Any], config_section: str) -> dict[str, Any]:
2602
+ verification = config.get("verification")
2603
+ templates = verification.get("templates") if isinstance(verification, dict) else None
2604
+ if not isinstance(templates, dict) or config_section not in templates:
2605
+ raise ValueError(f"verification.templates.{config_section} config not found")
2606
+
2607
+ section = templates[config_section]
2608
+ if not isinstance(section, dict):
2609
+ raise ValueError(f"verification.templates.{config_section} must be a mapping")
2610
+ return dict(section)
2611
+
2612
+
2613
+ def _find_dag_journey(
2614
+ dag: Any,
2615
+ journey_name: str,
2616
+ project_root: Path | None = None,
2617
+ settings: dict[str, Any] | None = None,
2618
+ ) -> dict[str, Any] | None:
2619
+ for node in sorted(dag.nodes.values(), key=lambda item: item.id):
2620
+ if node.kind != "design_doc":
2621
+ continue
2622
+ for journey in _node_user_journeys(node, project_root, settings):
2623
+ if str(journey.get("name") or "") == journey_name:
2624
+ return {
2625
+ "design_doc": node.path or node.id,
2626
+ "journey": dict(journey),
2627
+ }
2628
+ return None
2629
+
2630
+
2631
+ def _journey_execution_plan(
2632
+ project_root: Path,
2633
+ journey_record: dict[str, Any],
2634
+ template_config: dict[str, Any],
2635
+ ) -> dict[str, Any]:
2636
+ journey = dict(journey_record["journey"])
2637
+ journey_name = str(journey.get("name") or "")
2638
+ steps = journey.get("steps")
2639
+ return {
2640
+ "template": "cdp_browser",
2641
+ "test_kind": "e2e",
2642
+ "target": _journey_target(journey),
2643
+ "identifier": f"journey:{journey_name}",
2644
+ "journey": journey_name,
2645
+ "steps": steps if isinstance(steps, list) else [],
2646
+ "project_root": str(project_root),
2647
+ "design_doc": journey_record["design_doc"],
2648
+ "config": template_config,
2649
+ }
2650
+
2651
+
2652
+ def _journey_target(journey: dict[str, Any]) -> str:
2653
+ steps = journey.get("steps")
2654
+ if isinstance(steps, list):
2655
+ for step in steps:
2656
+ if not isinstance(step, dict):
2657
+ continue
2658
+ if step.get("action") == "navigate":
2659
+ target = step.get("target") or step.get("url")
2660
+ if target:
2661
+ return str(target)
2662
+
2663
+ target = journey.get("target") or journey.get("url")
2664
+ return str(target or "")
2665
+
2666
+
2667
+ def _collect_dag_journeys(
2668
+ dag: Any,
2669
+ project_root: Path | None = None,
2670
+ settings: dict[str, Any] | None = None,
2671
+ ) -> list[dict[str, Any]]:
2451
2672
  journeys: list[dict[str, Any]] = []
2452
2673
  for node in sorted(dag.nodes.values(), key=lambda item: item.id):
2453
2674
  if node.kind != "design_doc":
2454
2675
  continue
2455
- for journey in _node_user_journeys(node):
2676
+ for journey in _node_user_journeys(node, project_root, settings):
2456
2677
  journeys.append(
2457
2678
  {
2458
2679
  "design_doc": node.path or node.id,
@@ -2464,7 +2685,16 @@ def _collect_dag_journeys(dag: Any) -> list[dict[str, Any]]:
2464
2685
  return journeys
2465
2686
 
2466
2687
 
2467
- def _node_user_journeys(node: Any) -> list[dict[str, Any]]:
2688
+ def _node_user_journeys(
2689
+ node: Any,
2690
+ project_root: Path | None = None,
2691
+ settings: dict[str, Any] | None = None,
2692
+ ) -> list[dict[str, Any]]:
2693
+ if project_root is not None:
2694
+ from codd.dag.checks.user_journey_coherence import UserJourneyCoherenceCheck
2695
+
2696
+ return UserJourneyCoherenceCheck(project_root=project_root, settings=settings or {})._journey_entries(node)
2697
+
2468
2698
  attributes = getattr(node, "attributes", {}) or {}
2469
2699
  value = attributes.get("user_journeys")
2470
2700
  if not isinstance(value, list):
@@ -29,9 +29,9 @@ from codd.deployment.extractor import (
29
29
 
30
30
  LOGGER = logging.getLogger(__name__)
31
31
  DEFAULTS_DIR = Path(__file__).parent / "defaults"
32
- DEFAULT_PROJECT_TYPE = "web"
33
- IMPLEMENTATION_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java")
34
- TEST_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".bats")
32
+ DEFAULT_PROJECT_TYPE = "generic"
33
+ LEGACY_IMPLEMENTATION_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java")
34
+ LEGACY_TEST_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".bats")
35
35
  PLAN_HEADER_RE = re.compile(r"^#{2,6}\s+([A-Za-z0-9]+(?:[-_.][A-Za-z0-9]+)*)(?:\s+(.+))?$", re.MULTILINE)
36
36
  OUTPUTS_RE = re.compile(r"(?im)^outputs?[ \t]*:[ \t]*(.*)$")
37
37
  PY_IMPORT_RE = re.compile(r"(?m)^\s*(?:from\s+([A-Za-z_][\w.]*)(?:\s+import\s+)|import\s+([A-Za-z_][\w.]*))")
@@ -88,17 +88,21 @@ def build_dag(project_root: Path, settings: dict[str, Any] | None = None) -> DAG
88
88
  def load_dag_settings(project_root: Path, settings: dict[str, Any] | None = None) -> dict[str, Any]:
89
89
  """Load project-type defaults and apply ``codd.yaml dag:`` overrides."""
90
90
 
91
- project_config = _load_project_config_or_empty(project_root)
91
+ root = Path(project_root).resolve()
92
+ project_config = _load_project_config_or_empty(root)
92
93
 
93
94
  requested_settings = settings or {}
94
- project_type = _project_type(requested_settings) or _project_type(project_config) or DEFAULT_PROJECT_TYPE
95
+ project_type = _project_type(requested_settings) or _project_type(project_config) or _detect_project_type(root)
95
96
  merged = _read_default_settings(project_type)
96
97
  merged = _deep_merge(merged, _dag_overrides(project_config))
97
98
  merged = _deep_merge(merged, _dag_overrides(requested_settings))
99
+ merged["project_type"] = project_type
100
+ implementation_suffixes, test_suffixes = _load_suffix_config(root, merged)
101
+ merged["implementation_suffixes"] = implementation_suffixes
102
+ merged["test_suffixes"] = test_suffixes
98
103
  _apply_scan_patterns(merged, project_config)
99
104
  _apply_scan_patterns(merged, requested_settings)
100
105
  merged["coherence"] = _coherence_settings(project_config, requested_settings)
101
- merged["project_type"] = project_type
102
106
  merged.setdefault("design_doc_patterns", [])
103
107
  merged.setdefault("impl_file_patterns", [])
104
108
  merged.setdefault("test_file_patterns", [])
@@ -214,10 +218,11 @@ def _add_design_docs(dag: DAG, project_root: Path, settings: dict[str, Any]) ->
214
218
  def _add_impl_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> dict[str, Path]:
215
219
  impl_nodes: dict[str, Path] = {}
216
220
  capability_patterns = _capability_patterns(settings)
221
+ implementation_suffixes = _suffix_tuple(settings.get("implementation_suffixes")) or LEGACY_IMPLEMENTATION_SUFFIXES
217
222
  for file_path in _glob_project_paths(project_root, settings.get("impl_file_patterns", [])):
218
223
  if (
219
224
  not file_path.is_file()
220
- or file_path.suffix not in IMPLEMENTATION_SUFFIXES
225
+ or file_path.suffix not in implementation_suffixes
221
226
  or _is_test_file(file_path, project_root)
222
227
  ):
223
228
  continue
@@ -241,8 +246,9 @@ def _add_impl_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> d
241
246
 
242
247
  def _add_test_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> dict[str, Path]:
243
248
  test_nodes: dict[str, Path] = {}
249
+ test_suffixes = _suffix_tuple(settings.get("test_suffixes")) or LEGACY_TEST_SUFFIXES
244
250
  for file_path in _glob_project_paths(project_root, settings.get("test_file_patterns", [])):
245
- if not file_path.is_file() or file_path.suffix not in TEST_SUFFIXES or not _is_test_file(file_path, project_root):
251
+ if not file_path.is_file() or file_path.suffix not in test_suffixes or not _is_test_file(file_path, project_root):
246
252
  continue
247
253
  node_id = _relative_id(file_path, project_root)
248
254
  test_nodes[node_id] = file_path.resolve()
@@ -445,8 +451,9 @@ def _add_deployment_graph(
445
451
  project_root,
446
452
  deployment_docs,
447
453
  [{"id": node_id, **metadata} for node_id, metadata in design_docs.items()],
454
+ project_config,
448
455
  )
449
- verification_tests = extract_verification_tests(project_root)
456
+ verification_tests = extract_verification_tests(project_root, project_config, design_docs)
450
457
 
451
458
  for deployment_doc in deployment_docs:
452
459
  _add_node_once(
@@ -768,7 +775,18 @@ def _impl_convention_tokens(node_id: str) -> set[str]:
768
775
 
769
776
  def _convention_path_candidates(test_path: Path, project_root: Path, convention_key: str) -> list[Path]:
770
777
  candidates: list[Path] = []
771
- suffixes = [".py"] if test_path.suffix == ".py" else [".ts", ".tsx", ".js", ".jsx"]
778
+ suffix_groups = {
779
+ ".py": [".py"],
780
+ ".rs": [".rs"],
781
+ ".rb": [".rb"],
782
+ ".cs": [".cs"],
783
+ ".kt": [".kt", ".kts"],
784
+ ".swift": [".swift"],
785
+ ".exs": [".ex", ".exs"],
786
+ ".scala": [".scala"],
787
+ ".cpp": [".cpp", ".c", ".h", ".hpp"],
788
+ }
789
+ suffixes = suffix_groups.get(test_path.suffix, [".ts", ".tsx", ".js", ".jsx"])
772
790
 
773
791
  if any(marker in test_path.name for marker in (".test.", ".spec.")):
774
792
  candidates.append((test_path.parent / convention_key).resolve())
@@ -859,6 +877,128 @@ def _read_default_settings(project_type: str) -> dict[str, Any]:
859
877
  return payload if isinstance(payload, dict) else {}
860
878
 
861
879
 
880
+ def _detect_project_type(project_root: Path) -> str:
881
+ root = Path(project_root)
882
+ if (root / "Cargo.toml").is_file():
883
+ return "rust"
884
+ if (root / "Gemfile").is_file():
885
+ return "ruby"
886
+ if (root / "package.json").is_file():
887
+ return "web"
888
+ if (root / "go.mod").is_file():
889
+ return "go"
890
+ if any(root.glob("*.csproj")) or any(root.glob("*.sln")):
891
+ return "csharp"
892
+ if (root / "CMakeLists.txt").is_file() or (
893
+ (root / "Makefile").is_file() and _has_any_file(root, ("*.c", "*.cpp"))
894
+ ):
895
+ return "cpp_embedded"
896
+ if any(root.glob("*.gradle.kts")):
897
+ return "kotlin"
898
+ if (root / "pom.xml").is_file():
899
+ return "java"
900
+ if (root / "build.gradle").is_file() or any(root.glob("*.gradle")):
901
+ if _has_any_file(root, ("*.kt", "*.kts")):
902
+ return "kotlin"
903
+ return "java"
904
+ if (root / "mix.exs").is_file():
905
+ return "elixir"
906
+ if (root / "build.sbt").is_file():
907
+ return "scala"
908
+ if _has_any_file(root, ("*.swift",)):
909
+ return "swift"
910
+ return "generic"
911
+
912
+
913
+ def _has_any_file(project_root: Path, patterns: tuple[str, ...]) -> bool:
914
+ for pattern in patterns:
915
+ if any(path.is_file() for path in project_root.rglob(pattern)):
916
+ return True
917
+ return False
918
+
919
+
920
+ def _load_suffix_config(project_root: Path, codd_yaml: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
921
+ implementation_suffixes = _suffixes_from_config(codd_yaml, "implementation_suffixes")
922
+ test_suffixes = _suffixes_from_config(codd_yaml, "test_suffixes")
923
+ project_type = _project_type(codd_yaml) or _detect_project_type(project_root)
924
+ defaults = _read_suffix_default_mapping(project_type)
925
+
926
+ if implementation_suffixes is None:
927
+ implementation_suffixes = _suffixes_from_config(defaults, "implementation_suffixes")
928
+ if implementation_suffixes is None:
929
+ LOGGER.warning(
930
+ "DAG suffix defaults for project_type=%s missing implementation_suffixes; using legacy fallback",
931
+ project_type,
932
+ )
933
+ implementation_suffixes = LEGACY_IMPLEMENTATION_SUFFIXES
934
+ if test_suffixes is None:
935
+ test_suffixes = _suffixes_from_config(defaults, "test_suffixes")
936
+ if test_suffixes is None:
937
+ LOGGER.warning(
938
+ "DAG suffix defaults for project_type=%s missing test_suffixes; using legacy fallback",
939
+ project_type,
940
+ )
941
+ test_suffixes = LEGACY_TEST_SUFFIXES
942
+
943
+ implementation_suffixes = _extend_suffixes(
944
+ implementation_suffixes,
945
+ _suffixes_from_config(codd_yaml, "implementation_suffixes_extend"),
946
+ )
947
+ test_suffixes = _extend_suffixes(
948
+ test_suffixes,
949
+ _suffixes_from_config(codd_yaml, "test_suffixes_extend"),
950
+ )
951
+ return implementation_suffixes, test_suffixes
952
+
953
+
954
+ def _read_suffix_default_mapping(project_type: str) -> dict[str, Any]:
955
+ for suffix_type in (project_type, DEFAULT_PROJECT_TYPE):
956
+ path = DEFAULTS_DIR / f"{suffix_type}.yaml"
957
+ if not path.is_file():
958
+ continue
959
+ try:
960
+ payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
961
+ except yaml.YAMLError as exc:
962
+ LOGGER.warning("Could not load DAG suffix defaults from %s: %s", path, exc)
963
+ return {}
964
+ if isinstance(payload, dict):
965
+ return payload
966
+ LOGGER.warning("DAG suffix defaults %s must contain a YAML mapping; using legacy fallback", path)
967
+ return {}
968
+ LOGGER.warning("DAG suffix defaults for project_type=%s not found; using legacy fallback", project_type)
969
+ return {}
970
+
971
+
972
+ def _suffixes_from_config(config: dict[str, Any], key: str) -> tuple[str, ...] | None:
973
+ value = config.get(key)
974
+ if value is None and isinstance(config.get("dag"), dict):
975
+ value = config["dag"].get(key)
976
+ return _suffix_tuple(value)
977
+
978
+
979
+ def _suffix_tuple(value: Any) -> tuple[str, ...] | None:
980
+ if value is None:
981
+ return None
982
+ suffixes: list[str] = []
983
+ for item in _as_list(value):
984
+ if not isinstance(item, str) or not item.strip():
985
+ continue
986
+ suffix = item.strip()
987
+ if not suffix.startswith("."):
988
+ suffix = f".{suffix}"
989
+ if suffix not in suffixes:
990
+ suffixes.append(suffix)
991
+ return tuple(suffixes) if suffixes else None
992
+
993
+
994
+ def _extend_suffixes(base: tuple[str, ...], extensions: tuple[str, ...] | None) -> tuple[str, ...]:
995
+ suffixes = list(base)
996
+ for suffix in extensions or ():
997
+ if suffix not in suffixes:
998
+ suffixes.append(suffix)
999
+ return tuple(suffixes)
1000
+
1001
+
862
1002
  def _dag_overrides(config: dict[str, Any]) -> dict[str, Any]:
863
1003
  overrides: dict[str, Any] = {}
864
1004
  dag_section = config.get("dag", {})
@@ -869,6 +1009,10 @@ def _dag_overrides(config: dict[str, Any]) -> dict[str, Any]:
869
1009
  "design_doc_patterns",
870
1010
  "impl_file_patterns",
871
1011
  "test_file_patterns",
1012
+ "implementation_suffixes",
1013
+ "implementation_suffixes_extend",
1014
+ "test_suffixes",
1015
+ "test_suffixes_extend",
872
1016
  "plan_task_file",
873
1017
  "lexicon_file",
874
1018
  "import_aliases",
@@ -922,13 +1066,16 @@ def _apply_scan_patterns(settings: dict[str, Any], config: dict[str, Any]) -> No
922
1066
  _extend_unique(
923
1067
  settings,
924
1068
  "impl_file_patterns",
925
- _file_patterns_for_dirs(source_dirs, IMPLEMENTATION_SUFFIXES),
1069
+ _file_patterns_for_dirs(
1070
+ source_dirs,
1071
+ _suffix_tuple(settings.get("implementation_suffixes")) or LEGACY_IMPLEMENTATION_SUFFIXES,
1072
+ ),
926
1073
  )
927
1074
  if test_dirs:
928
1075
  _extend_unique(
929
1076
  settings,
930
1077
  "test_file_patterns",
931
- _file_patterns_for_dirs(test_dirs, TEST_SUFFIXES),
1078
+ _file_patterns_for_dirs(test_dirs, _suffix_tuple(settings.get("test_suffixes")) or LEGACY_TEST_SUFFIXES),
932
1079
  )
933
1080
  if doc_dirs:
934
1081
  _extend_unique(settings, "design_doc_patterns", _file_patterns_for_dirs(doc_dirs, (".md",)))
@@ -1034,11 +1181,25 @@ def _language_for_path(path: Path) -> str:
1034
1181
  ".jsx": "javascript",
1035
1182
  ".go": "go",
1036
1183
  ".java": "java",
1184
+ ".rs": "rust",
1185
+ ".rb": "ruby",
1186
+ ".cs": "csharp",
1187
+ ".c": "c",
1188
+ ".cpp": "cpp",
1189
+ ".h": "cpp",
1190
+ ".hpp": "cpp",
1191
+ ".kt": "kotlin",
1192
+ ".kts": "kotlin",
1193
+ ".swift": "swift",
1194
+ ".ex": "elixir",
1195
+ ".exs": "elixir",
1196
+ ".scala": "scala",
1037
1197
  }.get(path.suffix, "unknown")
1038
1198
 
1039
1199
 
1040
1200
  def _looks_like_project_path(value: str) -> bool:
1041
- return "/" in value and Path(value).suffix in IMPLEMENTATION_SUFFIXES
1201
+ generic_suffixes, _ = _load_suffix_config(Path.cwd(), {"project_type": DEFAULT_PROJECT_TYPE})
1202
+ return "/" in value and Path(value).suffix in generic_suffixes
1042
1203
 
1043
1204
 
1044
1205
  def _mermaid_id(node_id: str) -> str:
@@ -122,7 +122,9 @@ class TaskCompletionCheck:
122
122
  def _impl_file_exists(self, impl_node: Any, fallback_path: str, project_root: Path | None) -> bool:
123
123
  if project_root is None:
124
124
  return True
125
- node_path = getattr(impl_node, "path", None) or fallback_path
125
+ attributes = getattr(impl_node, "attributes", {}) or {}
126
+ attribute_path = attributes.get("path") if isinstance(attributes, dict) else None
127
+ node_path = getattr(impl_node, "path", None) or attribute_path or fallback_path
126
128
  candidate = Path(node_path)
127
129
  if not candidate.is_absolute():
128
130
  candidate = project_root / candidate