codd-dev 1.22.0__tar.gz → 1.23.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 (132) hide show
  1. {codd_dev-1.22.0 → codd_dev-1.23.0}/PKG-INFO +32 -5
  2. {codd_dev-1.22.0 → codd_dev-1.23.0}/README.md +30 -4
  3. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/__init__.py +1 -1
  4. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/cli.py +118 -18
  5. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coherence_engine.py +0 -1
  6. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coverage_metrics.py +2 -18
  7. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/builder.py +214 -1
  8. codd_dev-1.23.0/codd/dag/defaults/test_frameworks.yaml +7 -0
  9. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/web.yaml +10 -0
  10. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deployer.py +1 -120
  11. codd_dev-1.23.0/codd/hooks/recipes/claude_settings_example.json +15 -0
  12. codd_dev-1.23.0/codd/hooks/recipes/codex_hook.sh +31 -0
  13. codd_dev-1.23.0/codd/hooks/recipes/git_post_commit.sh +15 -0
  14. codd_dev-1.23.0/codd/hooks/recipes/git_pre_commit.sh +15 -0
  15. codd_dev-1.23.0/codd/watch/__init__.py +1 -0
  16. codd_dev-1.23.0/codd/watch/events.py +43 -0
  17. codd_dev-1.23.0/codd/watch/propagation_log.py +40 -0
  18. codd_dev-1.23.0/codd/watch/propagation_pipeline.py +233 -0
  19. codd_dev-1.23.0/codd/watch/test_runner.py +187 -0
  20. codd_dev-1.23.0/codd/watch/watcher.py +112 -0
  21. {codd_dev-1.22.0 → codd_dev-1.23.0}/pyproject.toml +4 -1
  22. codd_dev-1.22.0/codd/drift_linkers/__init__.py +0 -46
  23. codd_dev-1.22.0/codd/drift_linkers/api.py +0 -484
  24. codd_dev-1.22.0/codd/drift_linkers/defaults/cli.yaml +0 -1
  25. codd_dev-1.22.0/codd/drift_linkers/defaults/iot.yaml +0 -1
  26. codd_dev-1.22.0/codd/drift_linkers/defaults/mobile.yaml +0 -2
  27. codd_dev-1.22.0/codd/drift_linkers/defaults/web.yaml +0 -8
  28. codd_dev-1.22.0/codd/drift_linkers/schema.py +0 -262
  29. codd_dev-1.22.0/codd/drift_linkers/screen_flow.py +0 -171
  30. {codd_dev-1.22.0 → codd_dev-1.23.0}/.gitignore +0 -0
  31. {codd_dev-1.22.0 → codd_dev-1.23.0}/LICENSE +0 -0
  32. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/__main__.py +0 -0
  33. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/_git_helper.py +0 -0
  34. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/ask_user_question_adapter.py +0 -0
  35. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/assembler.py +0 -0
  36. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/bridge.py +0 -0
  37. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/clustering.py +0 -0
  38. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coherence_adapters.py +0 -0
  39. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/config.py +0 -0
  40. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/contracts.py +0 -0
  41. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/coverage_auditor.py +0 -0
  42. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/__init__.py +0 -0
  43. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/__init__.py +0 -0
  44. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/depends_on_consistency.py +0 -0
  45. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/edge_validity.py +0 -0
  46. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/node_completeness.py +0 -0
  47. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/task_completion.py +0 -0
  48. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/checks/transitive_closure.py +0 -0
  49. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/cli.yaml +0 -0
  50. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/iot.yaml +0 -0
  51. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/defaults/mobile.yaml +0 -0
  52. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/extractor.py +0 -0
  53. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/dag/runner.py +0 -0
  54. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/defaults.yaml +0 -0
  55. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/__init__.py +0 -0
  56. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/app_service.py +0 -0
  57. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/base.py +0 -0
  58. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/deploy_targets/docker_compose.py +0 -0
  59. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/design_md.py +0 -0
  60. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/drift.py +0 -0
  61. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/e2e_extractor.py +0 -0
  62. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/e2e_generator.py +0 -0
  63. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/e2e_runner.py +0 -0
  64. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/env_refs.py +0 -0
  65. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/extract_ai.py +0 -0
  66. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/extractor.py +0 -0
  67. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixer.py +0 -0
  68. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift.py +0 -0
  69. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/__init__.py +0 -0
  70. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/design_token_drift.py +0 -0
  71. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/lexicon_violation.py +0 -0
  72. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/fixup_drift_strategies/url_drift.py +0 -0
  73. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/generator.py +0 -0
  74. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/graph.py +0 -0
  75. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/hitl_session.py +0 -0
  76. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/hooks/__init__.py +0 -0
  77. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/hooks/pre-commit +0 -0
  78. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/implementer.py +0 -0
  79. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/inheritance.py +0 -0
  80. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/knowledge_fetcher.py +0 -0
  81. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/lexicon.py +0 -0
  82. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/mcp_server.py +0 -0
  83. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/measure.py +0 -0
  84. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/parsing.py +0 -0
  85. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/planner.py +0 -0
  86. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/policy.py +0 -0
  87. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/__init__.py +0 -0
  88. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/cli.yaml +0 -0
  89. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/iot.yaml +0 -0
  90. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/mobile.yaml +0 -0
  91. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/preflight/defaults/web.yaml +0 -0
  92. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/propagate.py +0 -0
  93. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/propagator.py +0 -0
  94. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/registry.py +0 -0
  95. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/repair_slice.py +0 -0
  96. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/require.py +0 -0
  97. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/require_plugins.py +0 -0
  98. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/require_propagate.py +0 -0
  99. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/cli.yaml +0 -0
  100. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/iot.yaml +0 -0
  101. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/mobile.yaml +0 -0
  102. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts/defaults/web.yaml +0 -0
  103. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/required_artifacts_deriver.py +0 -0
  104. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/cli.yaml +0 -0
  105. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/iot.yaml +0 -0
  106. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/mobile.yaml +0 -0
  107. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness/defaults/web.yaml +0 -0
  108. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/requirement_completeness_auditor.py +0 -0
  109. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/restore.py +0 -0
  110. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/routes_extractor.py +0 -0
  111. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/scanner.py +0 -0
  112. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/schema_refs.py +0 -0
  113. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/screen_flow_validator.py +0 -0
  114. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/screen_transition_extractor.py +0 -0
  115. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/screen_transitions/defaults.yaml +0 -0
  116. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/synth.py +0 -0
  117. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/codd.yaml.tmpl +0 -0
  118. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/conventions.yaml.tmpl +0 -0
  119. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
  120. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/doc_links.yaml.tmpl +0 -0
  121. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
  122. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
  123. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
  124. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
  125. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/extracted/system-context.md.j2 +0 -0
  126. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/gitignore.tmpl +0 -0
  127. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/lexicon_schema.yaml +0 -0
  128. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/templates/overrides.yaml.tmpl +0 -0
  129. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/traceability.py +0 -0
  130. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/validator.py +0 -0
  131. {codd_dev-1.22.0 → codd_dev-1.23.0}/codd/wiring.py +0 -0
  132. {codd_dev-1.22.0 → codd_dev-1.23.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.22.0
3
+ Version: 1.23.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
@@ -19,6 +19,7 @@ Requires-Dist: click>=8.0
19
19
  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
+ Requires-Dist: watchdog>=4.0.0
22
23
  Provides-Extra: ai
23
24
  Provides-Extra: api-parsers
24
25
  Requires-Dist: graphql-core>=3.2.0; extra == 'api-parsers'
@@ -950,23 +951,49 @@ You: yes
950
951
 
951
952
  ### Hook Integration — Set It Once, Never Think Again
952
953
 
953
- Add this hook and **you never run `codd scan` manually again.** Every file edit triggers it automatically — the dependency graph is always current, always accurate, zero mental overhead:
954
+ CoDD ships copyable hook recipes in `codd/hooks/recipes/` so Claude, Codex, and Git can all trigger the same change-driven propagation path:
955
+ `codd propagate-from --files <changed-file>`.
956
+
957
+ #### Claude PostToolUse
958
+
959
+ Use `codd/hooks/recipes/claude_settings_example.json` as the starting point for `.claude/settings.json`. It listens for Edit / Write / MultiEdit and extracts changed files from `TOOL_INPUT` before calling `propagate-from`.
954
960
 
955
961
  ```json
956
962
  {
957
963
  "hooks": {
958
964
  "PostToolUse": [{
959
- "matcher": "Edit|Write",
965
+ "matcher": "Edit|Write|MultiEdit",
960
966
  "hooks": [{
961
967
  "type": "command",
962
- "command": "codd scan --path ."
968
+ "command": "python -m codd propagate-from --files <changed-file> --source editor_hook --editor claude"
963
969
  }]
964
970
  }]
965
971
  }
966
972
  }
967
973
  ```
968
974
 
969
- With hooks active, your entire workflow becomes: **edit files normally, then run `/codd-impact` when you want to know what's affected.** That's it. The graph maintenance is invisible.
975
+ #### Codex Post-Edit
976
+
977
+ Use `codd/hooks/recipes/codex_hook.sh` when your Codex wrapper can pass edited files through `CODEX_EDITED_FILES`:
978
+
979
+ ```bash
980
+ export CODEX_EDITED_FILES="src/app.ts,docs/design/api.md"
981
+ bash codd/hooks/recipes/codex_hook.sh
982
+ ```
983
+
984
+ #### Git Hooks
985
+
986
+ Use the git recipes as a final catch for manual edits and non-editor workflows:
987
+
988
+ ```bash
989
+ cp codd/hooks/recipes/git_pre_commit.sh .git/hooks/pre-commit
990
+ cp codd/hooks/recipes/git_post_commit.sh .git/hooks/post-commit
991
+ chmod +x .git/hooks/pre-commit .git/hooks/post-commit
992
+ ```
993
+
994
+ The pre-commit hook runs `propagate-from` in `--dry-run` mode against staged files. The post-commit hook runs propagation against the committed file set.
995
+
996
+ With hooks active, your entire workflow becomes: **edit files normally, and CoDD propagates the change from the files that actually changed.** The graph maintenance is invisible.
970
997
 
971
998
  ### Available Skills
972
999
 
@@ -912,23 +912,49 @@ You: yes
912
912
 
913
913
  ### Hook Integration — Set It Once, Never Think Again
914
914
 
915
- Add this hook and **you never run `codd scan` manually again.** Every file edit triggers it automatically — the dependency graph is always current, always accurate, zero mental overhead:
915
+ CoDD ships copyable hook recipes in `codd/hooks/recipes/` so Claude, Codex, and Git can all trigger the same change-driven propagation path:
916
+ `codd propagate-from --files <changed-file>`.
917
+
918
+ #### Claude PostToolUse
919
+
920
+ Use `codd/hooks/recipes/claude_settings_example.json` as the starting point for `.claude/settings.json`. It listens for Edit / Write / MultiEdit and extracts changed files from `TOOL_INPUT` before calling `propagate-from`.
916
921
 
917
922
  ```json
918
923
  {
919
924
  "hooks": {
920
925
  "PostToolUse": [{
921
- "matcher": "Edit|Write",
926
+ "matcher": "Edit|Write|MultiEdit",
922
927
  "hooks": [{
923
928
  "type": "command",
924
- "command": "codd scan --path ."
929
+ "command": "python -m codd propagate-from --files <changed-file> --source editor_hook --editor claude"
925
930
  }]
926
931
  }]
927
932
  }
928
933
  }
929
934
  ```
930
935
 
931
- With hooks active, your entire workflow becomes: **edit files normally, then run `/codd-impact` when you want to know what's affected.** That's it. The graph maintenance is invisible.
936
+ #### Codex Post-Edit
937
+
938
+ Use `codd/hooks/recipes/codex_hook.sh` when your Codex wrapper can pass edited files through `CODEX_EDITED_FILES`:
939
+
940
+ ```bash
941
+ export CODEX_EDITED_FILES="src/app.ts,docs/design/api.md"
942
+ bash codd/hooks/recipes/codex_hook.sh
943
+ ```
944
+
945
+ #### Git Hooks
946
+
947
+ Use the git recipes as a final catch for manual edits and non-editor workflows:
948
+
949
+ ```bash
950
+ cp codd/hooks/recipes/git_pre_commit.sh .git/hooks/pre-commit
951
+ cp codd/hooks/recipes/git_post_commit.sh .git/hooks/post-commit
952
+ chmod +x .git/hooks/pre-commit .git/hooks/post-commit
953
+ ```
954
+
955
+ The pre-commit hook runs `propagate-from` in `--dry-run` mode against staged files. The post-commit hook runs propagation against the committed file set.
956
+
957
+ With hooks active, your entire workflow becomes: **edit files normally, and CoDD propagates the change from the files that actually changed.** The graph maintenance is invisible.
932
958
 
933
959
  ### Available Skills
934
960
 
@@ -1,3 +1,3 @@
1
1
  """CoDD — Coherence-Driven Development."""
2
2
 
3
- __version__ = "1.22.0"
3
+ __version__ = "1.23.0"
@@ -344,23 +344,65 @@ def scan(path: str):
344
344
  run_scan(project_root, codd_dir)
345
345
 
346
346
 
347
- @main.command()
348
- @click.option("--diff", default="HEAD", help="Git diff target (default: HEAD, shows uncommitted changes)")
349
- @click.option("--path", default=".", help="Project root directory")
350
- @click.option("--output", default=None, help="Output file (default: stdout)")
351
- def impact(diff: str, path: str, output: str):
347
+ @main.command()
348
+ @click.option("--diff", default="HEAD", help="Git diff target (default: HEAD, shows uncommitted changes)")
349
+ @click.option("--path", default=".", help="Project root directory")
350
+ @click.option("--output", default=None, help="Output file (default: stdout)")
351
+ def impact(diff: str, path: str, output: str):
352
352
  """Analyze change impact from git diff."""
353
353
  from codd.propagate import run_impact
354
354
  project_root = Path(path).resolve()
355
355
  codd_dir = _require_codd_dir(project_root)
356
-
357
- run_impact(project_root, codd_dir, diff, output)
358
-
359
-
360
- @main.command()
361
- @click.option("--wave", required=True, type=click.IntRange(min=1), help="Wave number to generate")
362
- @click.option("--path", default=".", help="Project root directory")
363
- @click.option("--force", is_flag=True, help="Overwrite existing files")
356
+
357
+ run_impact(project_root, codd_dir, diff, output)
358
+
359
+
360
+ @main.command("watch")
361
+ @click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
362
+ @click.option("--debounce", default=500, show_default=True, type=int, help="Debounce interval in milliseconds")
363
+ @click.option("--background", is_flag=True, default=False, help="Run watcher in background mode")
364
+ @click.option("--status", is_flag=True, default=False, help="Show watcher status")
365
+ def watch_cmd(project_path: str, debounce: int, background: bool, status: bool) -> None:
366
+ """Watch for file changes and emit CDAP file-change events."""
367
+ project_root = Path(project_path).resolve()
368
+ pid_file = project_root / ".codd" / "watch.pid"
369
+
370
+ if status:
371
+ if pid_file.exists():
372
+ click.echo(f"Watcher running (PID: {pid_file.read_text(encoding='utf-8').strip()})")
373
+ else:
374
+ click.echo("Watcher not running")
375
+ return
376
+
377
+ from codd.watch.events import FileChangeEvent
378
+ from codd.watch.watcher import start_watch
379
+
380
+ if not project_root.exists():
381
+ click.echo(f"Error: Project path does not exist: {project_root}")
382
+ raise SystemExit(1)
383
+ if not project_root.is_dir():
384
+ click.echo(f"Error: Project path is not a directory: {project_root}")
385
+ raise SystemExit(1)
386
+
387
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
388
+ pid_file.write_text(f"{os.getpid()}\n", encoding="utf-8")
389
+
390
+ def on_change(event: FileChangeEvent) -> None:
391
+ preview = ", ".join(event.files[:3])
392
+ suffix = "" if len(event.files) <= 3 else f", ... {len(event.files) - 3} more"
393
+ click.echo(f"[watch] {len(event.files)} file(s) changed: {preview}{suffix}")
394
+
395
+ click.echo(f"Watching {project_root} (debounce={debounce}ms)")
396
+ observer = start_watch(project_root, on_change, debounce_ms=debounce, background=background)
397
+ if background:
398
+ click.echo(f"Watcher running in background mode (PID: {os.getpid()})")
399
+ observer.join(timeout=0)
400
+
401
+
402
+ @main.command()
403
+ @click.option("--wave", required=True, type=click.IntRange(min=1), help="Wave number to generate")
404
+ @click.option("--path", default=".", help="Project root directory")
405
+ @click.option("--force", is_flag=True, help="Overwrite existing files")
364
406
  @click.option(
365
407
  "--ai-cmd",
366
408
  default=None,
@@ -852,11 +894,47 @@ def propagate(diff: str, path: str, update: bool, verify: bool, do_commit: bool,
852
894
  click.echo(f" [{status}] {doc.path} ({doc.node_id})")
853
895
  click.echo(f" modules: {', '.join(doc.matched_modules)}")
854
896
 
855
- if not update and result.affected_docs:
856
- click.echo(f"\nRun with --update to update these docs via AI.")
857
-
858
-
859
- @main.command()
897
+ if not update and result.affected_docs:
898
+ click.echo(f"\nRun with --update to update these docs via AI.")
899
+
900
+
901
+ @main.command("propagate-from")
902
+ @click.option("--project-path", default=".", show_default=True, help="Project root directory")
903
+ @click.option("--files", multiple=True, required=True, help="Changed file path. Can be repeated.")
904
+ @click.option(
905
+ "--source",
906
+ default="manual",
907
+ show_default=True,
908
+ type=click.Choice(["watch", "git_hook", "editor_hook", "manual"]),
909
+ help="Change source that triggered propagation.",
910
+ )
911
+ @click.option(
912
+ "--editor",
913
+ default=None,
914
+ type=click.Choice(["claude", "codex", "manual"]),
915
+ help="Editor that produced the change, when known.",
916
+ )
917
+ @click.option("--dry-run", is_flag=True, default=False, help="Compute impact without propagate/fix/log writes.")
918
+ def propagate_from(project_path: str, files: tuple[str, ...], source: str, editor: str | None, dry_run: bool):
919
+ """Run the CDAP propagation pipeline from changed files."""
920
+ from codd.watch.events import FileChangeEvent
921
+ from codd.watch.propagation_pipeline import run_propagation_pipeline
922
+
923
+ project_root = Path(project_path).resolve()
924
+ event = FileChangeEvent(files=list(files), source=source, editor=editor)
925
+ result = run_propagation_pipeline(project_root, list(files), dry_run=dry_run, event=event)
926
+
927
+ click.echo(f"Impacted nodes: {len(result.impacted_nodes)}")
928
+ click.echo(f"Propagated: {result.propagated_count}")
929
+ click.echo(f"Fixed: {result.fixed_count}")
930
+ if result.errors:
931
+ click.echo(f"Errors: {result.errors}", err=True)
932
+
933
+ if not result.success:
934
+ raise SystemExit(1)
935
+
936
+
937
+ @main.command()
860
938
  @click.option("--path", default=".", help="Project root directory")
861
939
  @click.option("--task", default=None, help="Generate only one task by task ID or title match")
862
940
  @click.option("--clean", is_flag=True, default=False, help="Remove existing generated output before re-generating")
@@ -2183,6 +2261,28 @@ def mcp_server(project: str):
2183
2261
  run_stdio(project_root)
2184
2262
 
2185
2263
 
2264
+ @main.command("test")
2265
+ @click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
2266
+ @click.option("--related", multiple=True, help="Run only tests related to these files")
2267
+ @click.option("--dry-run", is_flag=True, default=False, help="Print the related test command without running it")
2268
+ def test_cmd(project_path: str, related: tuple[str, ...], dry_run: bool):
2269
+ """Run tests. Use --related <file> to run only related tests."""
2270
+ from codd.watch.test_runner import run_related_tests
2271
+
2272
+ project_root = Path(project_path).resolve()
2273
+ if not related:
2274
+ click.echo("Use --related <file> to specify files. Full test run not supported via this command.")
2275
+ return
2276
+
2277
+ result = run_related_tests(project_root, list(related), dry_run=dry_run)
2278
+ click.echo(f"Related tests: {result['related']}")
2279
+ if result.get("cmd"):
2280
+ click.echo(f"Command: {result['cmd']}")
2281
+ click.echo(f"Status: {result['status']}")
2282
+ if result.get("exit_code") not in (None, 0):
2283
+ raise SystemExit(1)
2284
+
2285
+
2186
2286
  @main.group()
2187
2287
  def dag():
2188
2288
  """DAG Completeness Gate commands."""
@@ -80,7 +80,6 @@ def set_coherence_bus(bus: EventBus | None) -> None:
80
80
  """Set the opt-in coherence bus on detectors that publish DriftEvents."""
81
81
  for module_name in (
82
82
  "codd.drift",
83
- "codd.drift_linkers.api",
84
83
  "codd.deployer",
85
84
  "codd.hitl_session",
86
85
  "codd.validator",
@@ -134,7 +134,7 @@ def compute_screen_flow_coverage(
134
134
  config: dict[str, Any],
135
135
  threshold: float = 100.0,
136
136
  ) -> CoverageResult:
137
- """Measure screen-flow drift as a coverage gate metric."""
137
+ """Measure screen-flow route drift as a coverage gate metric."""
138
138
 
139
139
  try:
140
140
  from codd.cli import CoddCLIError
@@ -153,25 +153,9 @@ def compute_screen_flow_coverage(
153
153
  details=[f"error: {exc}"],
154
154
  )
155
155
 
156
- design_drift_details: list[str] = []
157
- design_drift_count = 0
158
- try:
159
- from codd.drift_linkers.screen_flow import ScreenFlowGate
160
-
161
- gate_result = ScreenFlowGate(
162
- project_root=project_root,
163
- settings={**config, "apply": True},
164
- ).run()
165
- if not gate_result.skipped:
166
- design_drift_count = gate_result.drift_count
167
- design_drift_details = gate_result.details
168
- except Exception as exc: # pragma: no cover - defensive gate behavior
169
- return _exception_result("screen_flow_coverage", threshold, exc)
170
-
171
- drift_count = len(drifts) + design_drift_count
156
+ drift_count = len(drifts)
172
157
  pct = 100.0 if drift_count == 0 else max(0.0, 100.0 - drift_count * 10.0)
173
158
  details = [f"drift_count: {drift_count}"]
174
- details.extend(design_drift_details)
175
159
  return CoverageResult(
176
160
  metric="screen_flow_coverage",
177
161
  total=1,
@@ -19,8 +19,10 @@ from codd.dag.extractor import extract_design_doc_metadata, extract_imports
19
19
  DEFAULTS_DIR = Path(__file__).parent / "defaults"
20
20
  DEFAULT_PROJECT_TYPE = "web"
21
21
  IMPLEMENTATION_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java")
22
+ TEST_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".bats")
22
23
  PLAN_HEADER_RE = re.compile(r"^#{2,6}\s+([A-Za-z0-9]+(?:[-_.][A-Za-z0-9]+)*)(?:\s+(.+))?$", re.MULTILINE)
23
24
  OUTPUTS_RE = re.compile(r"(?im)^outputs?[ \t]*:[ \t]*(.*)$")
25
+ PY_IMPORT_RE = re.compile(r"(?m)^\s*(?:from\s+([A-Za-z_][\w.]*)(?:\s+import\s+)|import\s+([A-Za-z_][\w.]*))")
24
26
 
25
27
 
26
28
  def build_dag(project_root: Path, settings: dict[str, Any] | None = None) -> DAG:
@@ -35,9 +37,11 @@ def build_dag(project_root: Path, settings: dict[str, Any] | None = None) -> DAG
35
37
  dag = DAG()
36
38
  design_docs = _add_design_docs(dag, root, dag_settings)
37
39
  impl_nodes = _add_impl_files(dag, root, dag_settings)
40
+ test_nodes = _add_test_files(dag, root, dag_settings)
38
41
 
39
42
  _add_design_edges(dag, root, design_docs, impl_nodes)
40
43
  _add_import_edges(dag, root, impl_nodes, dag_settings)
44
+ _add_tested_by_edges(dag, root, impl_nodes, test_nodes, dag_settings)
41
45
  _add_plan_tasks(dag, root, dag_settings)
42
46
  _add_expected_nodes(dag, root, dag_settings, impl_nodes)
43
47
 
@@ -59,9 +63,12 @@ def load_dag_settings(project_root: Path, settings: dict[str, Any] | None = None
59
63
  merged = _read_default_settings(project_type)
60
64
  merged = _deep_merge(merged, _dag_overrides(project_config))
61
65
  merged = _deep_merge(merged, _dag_overrides(requested_settings))
66
+ _apply_scan_patterns(merged, project_config)
67
+ _apply_scan_patterns(merged, requested_settings)
62
68
  merged["project_type"] = project_type
63
69
  merged.setdefault("design_doc_patterns", [])
64
70
  merged.setdefault("impl_file_patterns", [])
71
+ merged.setdefault("test_file_patterns", [])
65
72
  merged.setdefault("plan_task_file", "docs/design/implementation_plan.md")
66
73
  merged.setdefault("lexicon_file", "project_lexicon.yaml")
67
74
  return merged
@@ -160,7 +167,11 @@ def _add_design_docs(dag: DAG, project_root: Path, settings: dict[str, Any]) ->
160
167
  def _add_impl_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> dict[str, Path]:
161
168
  impl_nodes: dict[str, Path] = {}
162
169
  for file_path in _glob_project_paths(project_root, settings.get("impl_file_patterns", [])):
163
- if not file_path.is_file() or file_path.suffix not in IMPLEMENTATION_SUFFIXES:
170
+ if (
171
+ not file_path.is_file()
172
+ or file_path.suffix not in IMPLEMENTATION_SUFFIXES
173
+ or _is_test_file(file_path, project_root)
174
+ ):
164
175
  continue
165
176
  node_id = _relative_id(file_path, project_root)
166
177
  impl_nodes[node_id] = file_path.resolve()
@@ -179,6 +190,28 @@ def _add_impl_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> d
179
190
  return impl_nodes
180
191
 
181
192
 
193
+ def _add_test_files(dag: DAG, project_root: Path, settings: dict[str, Any]) -> dict[str, Path]:
194
+ test_nodes: dict[str, Path] = {}
195
+ for file_path in _glob_project_paths(project_root, settings.get("test_file_patterns", [])):
196
+ if not file_path.is_file() or file_path.suffix not in TEST_SUFFIXES or not _is_test_file(file_path, project_root):
197
+ continue
198
+ node_id = _relative_id(file_path, project_root)
199
+ test_nodes[node_id] = file_path.resolve()
200
+ _add_node_once(
201
+ dag,
202
+ Node(
203
+ id=node_id,
204
+ kind="test_file",
205
+ path=node_id,
206
+ attributes={
207
+ "language": _language_for_path(file_path),
208
+ "imports": _extract_test_imports(file_path),
209
+ },
210
+ ),
211
+ )
212
+ return test_nodes
213
+
214
+
182
215
  def _add_design_edges(
183
216
  dag: DAG,
184
217
  project_root: Path,
@@ -217,6 +250,25 @@ def _add_import_edges(
217
250
  dag.add_edge(Edge(from_id=node_id, to_id=target_id, kind="imports"))
218
251
 
219
252
 
253
+ def _add_tested_by_edges(
254
+ dag: DAG,
255
+ project_root: Path,
256
+ impl_nodes: dict[str, Path],
257
+ test_nodes: dict[str, Path],
258
+ settings: dict[str, Any],
259
+ ) -> None:
260
+ path_to_node = {path: node_id for node_id, path in impl_nodes.items()}
261
+ aliases = _load_import_aliases(project_root, settings)
262
+ existing_edges = {(edge.from_id, edge.to_id, edge.kind) for edge in dag.edges}
263
+
264
+ for test_id, test_path in test_nodes.items():
265
+ for target_id in _infer_test_targets(test_path, project_root, path_to_node, aliases):
266
+ edge_key = (target_id, test_id, "tested_by")
267
+ if edge_key not in existing_edges:
268
+ dag.add_edge(Edge(from_id=target_id, to_id=test_id, kind="tested_by"))
269
+ existing_edges.add(edge_key)
270
+
271
+
220
272
  def _add_plan_tasks(dag: DAG, project_root: Path, settings: dict[str, Any]) -> None:
221
273
  plan_path = _project_path(project_root, str(settings.get("plan_task_file", "")))
222
274
  if not plan_path.is_file():
@@ -374,6 +426,52 @@ def _resolve_import_target(
374
426
  return None
375
427
 
376
428
 
429
+ def _infer_test_targets(
430
+ test_path: Path,
431
+ project_root: Path,
432
+ path_to_node: dict[Path, str],
433
+ aliases: dict[str, list[str]],
434
+ ) -> set[str]:
435
+ targets: set[str] = set()
436
+
437
+ for import_ref in _extract_test_imports(test_path):
438
+ target_id = _resolve_import_target(import_ref, test_path, project_root, path_to_node, aliases)
439
+ if not target_id and "." in import_ref and not import_ref.startswith("."):
440
+ target_id = _resolve_python_import_target(import_ref, project_root, path_to_node)
441
+ if target_id:
442
+ targets.add(target_id)
443
+
444
+ convention_key = _test_convention_key(test_path, project_root)
445
+ if convention_key:
446
+ targets.update(_match_impl_by_convention(convention_key, path_to_node))
447
+ for candidate in _convention_path_candidates(test_path, project_root, convention_key):
448
+ target_id = _resolve_file_candidate(candidate, path_to_node)
449
+ if target_id:
450
+ targets.add(target_id)
451
+
452
+ return targets
453
+
454
+
455
+ def _extract_test_imports(file_path: Path) -> list[str]:
456
+ imports = extract_imports(file_path)
457
+ if file_path.suffix != ".py":
458
+ return imports
459
+
460
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
461
+ python_imports = [match.group(1) or match.group(2) for match in PY_IMPORT_RE.finditer(content)]
462
+ return [*imports, *python_imports]
463
+
464
+
465
+ def _resolve_python_import_target(import_ref: str, project_root: Path, path_to_node: dict[Path, str]) -> str | None:
466
+ module_path = (project_root / import_ref.replace(".", "/")).resolve()
467
+ resolved = _resolve_file_candidate(module_path, path_to_node)
468
+ if resolved:
469
+ return resolved
470
+
471
+ init_path = module_path / "__init__.py"
472
+ return path_to_node.get(init_path)
473
+
474
+
377
475
  def _resolve_file_candidate(candidate: Path, path_to_node: dict[Path, str]) -> str | None:
378
476
  if candidate in path_to_node:
379
477
  return path_to_node[candidate]
@@ -388,9 +486,75 @@ def _resolve_file_candidate(candidate: Path, path_to_node: dict[Path, str]) -> s
388
486
  indexed = candidate / f"index{suffix}"
389
487
  if indexed in path_to_node:
390
488
  return path_to_node[indexed]
489
+ init_file = candidate / "__init__.py"
490
+ if init_file in path_to_node:
491
+ return path_to_node[init_file]
391
492
  return None
392
493
 
393
494
 
495
+ def _test_convention_key(test_path: Path, project_root: Path) -> str | None:
496
+ relative_parts = _relative_id(test_path, project_root).split("/")
497
+ name = test_path.name
498
+
499
+ for marker in (".test.", ".spec."):
500
+ if marker in name:
501
+ return name.split(marker, 1)[0]
502
+
503
+ if test_path.suffix == ".py" and any(part in {"tests", "test"} for part in relative_parts[:-1]):
504
+ stem = test_path.stem
505
+ if stem.startswith("test_"):
506
+ return stem.removeprefix("test_")
507
+ if stem.endswith("_test"):
508
+ return stem.removesuffix("_test")
509
+
510
+ return None
511
+
512
+
513
+ def _match_impl_by_convention(convention_key: str, path_to_node: dict[Path, str]) -> set[str]:
514
+ matches: set[str] = set()
515
+ for _impl_path, node_id in path_to_node.items():
516
+ if convention_key in _impl_convention_tokens(node_id):
517
+ matches.add(node_id)
518
+ return matches
519
+
520
+
521
+ def _impl_convention_tokens(node_id: str) -> set[str]:
522
+ path = Path(node_id)
523
+ parts = [*path.with_suffix("").parts]
524
+ tokens = {path.stem}
525
+ if len(parts) >= 2:
526
+ tokens.add("_".join(parts[-2:]))
527
+ if len(parts) >= 3:
528
+ tokens.add("_".join(parts[-3:]))
529
+ return tokens
530
+
531
+
532
+ def _convention_path_candidates(test_path: Path, project_root: Path, convention_key: str) -> list[Path]:
533
+ candidates: list[Path] = []
534
+ suffixes = [".py"] if test_path.suffix == ".py" else [".ts", ".tsx", ".js", ".jsx"]
535
+
536
+ if any(marker in test_path.name for marker in (".test.", ".spec.")):
537
+ candidates.append((test_path.parent / convention_key).resolve())
538
+
539
+ for root_name in ("codd", "src"):
540
+ for suffix in suffixes:
541
+ candidates.append((project_root / root_name / f"{convention_key}{suffix}").resolve())
542
+
543
+ return candidates
544
+
545
+
546
+ def _is_test_file(file_path: Path, project_root: Path) -> bool:
547
+ relative_parts = _relative_id(file_path, project_root).split("/")
548
+ name = file_path.name
549
+ if any(marker in name for marker in (".test.", ".spec.")):
550
+ return True
551
+ if file_path.suffix == ".bats":
552
+ return True
553
+ if file_path.suffix == ".py" and any(part in {"tests", "test"} for part in relative_parts[:-1]):
554
+ return name.startswith("test_") or name.endswith("_test.py")
555
+ return False
556
+
557
+
394
558
  def _load_import_aliases(project_root: Path, settings: dict[str, Any]) -> dict[str, list[str]]:
395
559
  aliases: dict[str, list[str]] = {}
396
560
  configured = settings.get("import_aliases", {})
@@ -467,6 +631,7 @@ def _dag_overrides(config: dict[str, Any]) -> dict[str, Any]:
467
631
  direct_keys = {
468
632
  "design_doc_patterns",
469
633
  "impl_file_patterns",
634
+ "test_file_patterns",
470
635
  "plan_task_file",
471
636
  "lexicon_file",
472
637
  "import_aliases",
@@ -477,6 +642,31 @@ def _dag_overrides(config: dict[str, Any]) -> dict[str, Any]:
477
642
  return overrides
478
643
 
479
644
 
645
+ def _apply_scan_patterns(settings: dict[str, Any], config: dict[str, Any]) -> None:
646
+ scan = config.get("scan", {})
647
+ if not isinstance(scan, dict):
648
+ return
649
+
650
+ source_dirs = _as_list(scan.get("source_dirs"))
651
+ test_dirs = _as_list(scan.get("test_dirs"))
652
+ doc_dirs = _as_list(scan.get("doc_dirs"))
653
+
654
+ if source_dirs:
655
+ _extend_unique(
656
+ settings,
657
+ "impl_file_patterns",
658
+ _file_patterns_for_dirs(source_dirs, IMPLEMENTATION_SUFFIXES),
659
+ )
660
+ if test_dirs:
661
+ _extend_unique(
662
+ settings,
663
+ "test_file_patterns",
664
+ _file_patterns_for_dirs(test_dirs, TEST_SUFFIXES),
665
+ )
666
+ if doc_dirs:
667
+ _extend_unique(settings, "design_doc_patterns", _file_patterns_for_dirs(doc_dirs, (".md",)))
668
+
669
+
480
670
  def _normalize_dag_section(section: dict[str, Any]) -> dict[str, Any]:
481
671
  normalized = deepcopy(section)
482
672
  node_extraction = normalized.pop("node_extraction", None)
@@ -485,6 +675,8 @@ def _normalize_dag_section(section: dict[str, Any]) -> dict[str, Any]:
485
675
  normalized["design_doc_patterns"] = node_extraction["design_glob"]
486
676
  if "impl_glob" in node_extraction:
487
677
  normalized["impl_file_patterns"] = node_extraction["impl_glob"]
678
+ if "test_glob" in node_extraction:
679
+ normalized["test_file_patterns"] = node_extraction["test_glob"]
488
680
  if "plan_path" in node_extraction:
489
681
  normalized["plan_task_file"] = node_extraction["plan_path"]
490
682
  return normalized
@@ -522,6 +714,27 @@ def _as_list(value: Any) -> list[Any]:
522
714
  return [value]
523
715
 
524
716
 
717
+ def _file_patterns_for_dirs(dirs: list[Any], suffixes: tuple[str, ...]) -> list[str]:
718
+ patterns: list[str] = []
719
+ for directory in dirs:
720
+ if not isinstance(directory, str) or not directory.strip():
721
+ continue
722
+ base = directory.strip().strip("/")
723
+ if not base or base == ".":
724
+ base = "**"
725
+ for suffix in suffixes:
726
+ patterns.append(f"{base}/**/*{suffix}")
727
+ return patterns
728
+
729
+
730
+ def _extend_unique(settings: dict[str, Any], key: str, values: list[str]) -> None:
731
+ current = [str(item) for item in _as_list(settings.get(key)) if item]
732
+ for value in values:
733
+ if value not in current:
734
+ current.append(value)
735
+ settings[key] = current
736
+
737
+
525
738
  def _add_node_once(dag: DAG, node: Node) -> None:
526
739
  if node.id not in dag.nodes:
527
740
  dag.add_node(node)
@@ -0,0 +1,7 @@
1
+ test_framework: pytest
2
+ test_runners:
3
+ pytest: "python -m pytest {files} -q"
4
+ jest: "npx jest {files}"
5
+ vitest: "npx vitest run {files}"
6
+ bats: "bats {files}"
7
+ go_test: "go test {files}"
@@ -11,4 +11,14 @@ impl_file_patterns:
11
11
  - "src/**/*.tsx"
12
12
  - "app/**/*.ts"
13
13
  - "app/**/*.tsx"
14
+ test_file_patterns:
15
+ - "tests/**/*.py"
16
+ - "tests/**/*.ts"
17
+ - "tests/**/*.tsx"
18
+ - "tests/**/*.js"
19
+ - "tests/**/*.jsx"
20
+ - "**/*.test.ts"
21
+ - "**/*.test.tsx"
22
+ - "**/*.spec.ts"
23
+ - "**/*.spec.tsx"
14
24
  plan_task_file: "docs/design/implementation_plan.md"