codd-dev 1.20.0__tar.gz → 1.22.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 (122) hide show
  1. {codd_dev-1.20.0 → codd_dev-1.22.0}/PKG-INFO +1 -1
  2. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/__init__.py +1 -1
  3. codd_dev-1.22.0/codd/__main__.py +7 -0
  4. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/ask_user_question_adapter.py +19 -0
  5. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/cli.py +303 -25
  6. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/coherence_engine.py +12 -7
  7. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/contracts.py +1 -1
  8. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/coverage_metrics.py +114 -4
  9. codd_dev-1.22.0/codd/dag/__init__.py +112 -0
  10. codd_dev-1.22.0/codd/dag/builder.py +569 -0
  11. codd_dev-1.22.0/codd/dag/checks/__init__.py +39 -0
  12. codd_dev-1.22.0/codd/dag/checks/depends_on_consistency.py +320 -0
  13. codd_dev-1.22.0/codd/dag/checks/edge_validity.py +71 -0
  14. codd_dev-1.22.0/codd/dag/checks/node_completeness.py +63 -0
  15. codd_dev-1.22.0/codd/dag/checks/task_completion.py +162 -0
  16. codd_dev-1.22.0/codd/dag/checks/transitive_closure.py +64 -0
  17. codd_dev-1.22.0/codd/dag/defaults/cli.yaml +6 -0
  18. codd_dev-1.22.0/codd/dag/defaults/iot.yaml +6 -0
  19. codd_dev-1.22.0/codd/dag/defaults/mobile.yaml +6 -0
  20. codd_dev-1.22.0/codd/dag/defaults/web.yaml +14 -0
  21. codd_dev-1.22.0/codd/dag/extractor.py +76 -0
  22. codd_dev-1.22.0/codd/dag/runner.py +74 -0
  23. codd_dev-1.22.0/codd/deployer.py +711 -0
  24. codd_dev-1.22.0/codd/drift_linkers/__init__.py +46 -0
  25. codd_dev-1.22.0/codd/drift_linkers/api.py +484 -0
  26. codd_dev-1.22.0/codd/drift_linkers/defaults/cli.yaml +1 -0
  27. codd_dev-1.22.0/codd/drift_linkers/defaults/iot.yaml +1 -0
  28. codd_dev-1.22.0/codd/drift_linkers/defaults/mobile.yaml +2 -0
  29. codd_dev-1.22.0/codd/drift_linkers/defaults/web.yaml +8 -0
  30. codd_dev-1.22.0/codd/drift_linkers/schema.py +262 -0
  31. codd_dev-1.22.0/codd/drift_linkers/screen_flow.py +171 -0
  32. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/env_refs.py +1 -1
  33. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/extract_ai.py +1 -1
  34. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/extractor.py +10 -10
  35. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/fixer.py +6 -6
  36. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/generator.py +4 -4
  37. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/graph.py +4 -4
  38. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/hooks/__init__.py +1 -1
  39. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/measure.py +1 -1
  40. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/policy.py +1 -1
  41. codd_dev-1.22.0/codd/preflight/__init__.py +353 -0
  42. codd_dev-1.22.0/codd/preflight/defaults/cli.yaml +9 -0
  43. codd_dev-1.22.0/codd/preflight/defaults/iot.yaml +7 -0
  44. codd_dev-1.22.0/codd/preflight/defaults/mobile.yaml +8 -0
  45. codd_dev-1.22.0/codd/preflight/defaults/web.yaml +14 -0
  46. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/propagate.py +3 -3
  47. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/propagator.py +4 -4
  48. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/repair_slice.py +1 -1
  49. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/required_artifacts_deriver.py +1 -1
  50. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/requirement_completeness_auditor.py +1 -1
  51. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/scanner.py +6 -6
  52. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/schema_refs.py +1 -1
  53. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/traceability.py +1 -1
  54. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/validator.py +3 -3
  55. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/wiring.py +1 -1
  56. {codd_dev-1.20.0 → codd_dev-1.22.0}/pyproject.toml +1 -1
  57. codd_dev-1.20.0/codd/deployer.py +0 -224
  58. {codd_dev-1.20.0 → codd_dev-1.22.0}/.gitignore +0 -0
  59. {codd_dev-1.20.0 → codd_dev-1.22.0}/LICENSE +0 -0
  60. {codd_dev-1.20.0 → codd_dev-1.22.0}/README.md +0 -0
  61. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/_git_helper.py +0 -0
  62. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/assembler.py +0 -0
  63. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/bridge.py +0 -0
  64. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/clustering.py +0 -0
  65. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/coherence_adapters.py +0 -0
  66. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/config.py +0 -0
  67. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/coverage_auditor.py +0 -0
  68. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/defaults.yaml +0 -0
  69. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/deploy_targets/__init__.py +0 -0
  70. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/deploy_targets/app_service.py +0 -0
  71. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/deploy_targets/base.py +0 -0
  72. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/deploy_targets/docker_compose.py +0 -0
  73. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/design_md.py +0 -0
  74. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/drift.py +0 -0
  75. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/e2e_extractor.py +0 -0
  76. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/e2e_generator.py +0 -0
  77. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/e2e_runner.py +0 -0
  78. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/fixup_drift.py +0 -0
  79. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/fixup_drift_strategies/__init__.py +0 -0
  80. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/fixup_drift_strategies/design_token_drift.py +0 -0
  81. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/fixup_drift_strategies/lexicon_violation.py +0 -0
  82. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/fixup_drift_strategies/url_drift.py +0 -0
  83. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/hitl_session.py +0 -0
  84. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/hooks/pre-commit +0 -0
  85. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/implementer.py +0 -0
  86. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/inheritance.py +0 -0
  87. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/knowledge_fetcher.py +0 -0
  88. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/lexicon.py +0 -0
  89. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/mcp_server.py +0 -0
  90. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/parsing.py +0 -0
  91. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/planner.py +0 -0
  92. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/registry.py +0 -0
  93. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/require.py +0 -0
  94. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/require_plugins.py +0 -0
  95. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/require_propagate.py +0 -0
  96. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/required_artifacts/defaults/cli.yaml +0 -0
  97. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/required_artifacts/defaults/iot.yaml +0 -0
  98. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/required_artifacts/defaults/mobile.yaml +0 -0
  99. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/required_artifacts/defaults/web.yaml +0 -0
  100. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/requirement_completeness/defaults/cli.yaml +0 -0
  101. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/requirement_completeness/defaults/iot.yaml +0 -0
  102. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/requirement_completeness/defaults/mobile.yaml +0 -0
  103. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/requirement_completeness/defaults/web.yaml +0 -0
  104. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/restore.py +0 -0
  105. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/routes_extractor.py +0 -0
  106. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/screen_flow_validator.py +0 -0
  107. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/screen_transition_extractor.py +0 -0
  108. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/screen_transitions/defaults.yaml +0 -0
  109. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/synth.py +0 -0
  110. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/codd.yaml.tmpl +0 -0
  111. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/conventions.yaml.tmpl +0 -0
  112. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
  113. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/doc_links.yaml.tmpl +0 -0
  114. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
  115. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
  116. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
  117. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
  118. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/extracted/system-context.md.j2 +0 -0
  119. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/gitignore.tmpl +0 -0
  120. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/lexicon_schema.yaml +0 -0
  121. {codd_dev-1.20.0 → codd_dev-1.22.0}/codd/templates/overrides.yaml.tmpl +0 -0
  122. {codd_dev-1.20.0 → codd_dev-1.22.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.20.0
3
+ Version: 1.22.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
@@ -1,3 +1,3 @@
1
1
  """CoDD — Coherence-Driven Development."""
2
2
 
3
- __version__ = "1.20.0"
3
+ __version__ = "1.22.0"
@@ -0,0 +1,7 @@
1
+ """Module entrypoint for ``python -m codd``."""
2
+
3
+ from codd.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -13,6 +13,7 @@ from codd.lexicon import AskItem
13
13
 
14
14
 
15
15
  DEFAULT_CHANNELS = ["askuserquestion", "ntfy", "lexicon"]
16
+ SEVERITY_ORDER = ["critical", "high", "medium", "low"]
16
17
 
17
18
 
18
19
  def send_ask_items(
@@ -20,6 +21,7 @@ def send_ask_items(
20
21
  channels: list[str] = DEFAULT_CHANNELS,
21
22
  ntfy_topic: str = "",
22
23
  lexicon_path: str | Path | None = None,
24
+ ntfy_severity_threshold: str = "critical",
23
25
  ) -> None:
24
26
  """Send ASK items through Claude, ntfy, and lexicon channels when available."""
25
27
  normalized_channels = {channel.lower() for channel in channels}
@@ -30,6 +32,8 @@ def send_ask_items(
30
32
 
31
33
  if "ntfy" in normalized_channels and ntfy_topic:
32
34
  for item in ask_items:
35
+ if not _severity_at_or_above(_ask_item_severity(item), ntfy_severity_threshold):
36
+ continue
33
37
  _post_ntfy(ntfy_topic, format_ask_for_ntfy(item))
34
38
 
35
39
  if "lexicon" in normalized_channels and lexicon_path is not None:
@@ -65,6 +69,21 @@ def parse_user_answer(raw: str, item: AskItem) -> str:
65
69
  return answer
66
70
 
67
71
 
72
+ def _severity_at_or_above(item_severity: str, threshold: str) -> bool:
73
+ """Return True when item_severity is at least as severe as threshold."""
74
+ try:
75
+ return SEVERITY_ORDER.index(item_severity) <= SEVERITY_ORDER.index(threshold)
76
+ except ValueError:
77
+ return True
78
+
79
+
80
+ def _ask_item_severity(item: AskItem) -> str:
81
+ severity = getattr(item, "severity", "")
82
+ if isinstance(severity, str) and severity:
83
+ return severity.lower()
84
+ return "critical" if item.blocking else "high"
85
+
86
+
68
87
  def _send_ask_user_question(item: AskItem) -> bool:
69
88
  """Best-effort hook for Claude Code AskUserQuestion integrations."""
70
89
  try:
@@ -1,5 +1,6 @@
1
- """CoDD CLI — codd init / scan / impact / require / plan."""
2
-
1
+ """CoDD CLI — codd init / scan / impact / require / plan."""
2
+
3
+ from dataclasses import asdict, is_dataclass
3
4
  import json
4
5
  import os
5
6
  import shutil
@@ -189,14 +190,86 @@ def _ensure_bootstrap_codd_yaml(
189
190
 
190
191
  @click.group()
191
192
  @click.version_option(package_name="codd-dev")
192
- def main():
193
- """CoDD: Coherence-Driven Development."""
194
- pass
195
-
196
-
197
- @main.command()
198
- @click.option("--project-name", prompt="Project name", help="Name of the project")
199
- @click.option("--language", prompt="Primary language", help="Primary language (python/typescript/javascript/go — full support; java — symbols only)")
193
+ def main():
194
+ """CoDD: Coherence-Driven Development."""
195
+ pass
196
+
197
+
198
+ @main.command("preflight")
199
+ @click.argument("task_yaml", type=click.Path(exists=True, dir_okay=False, path_type=Path))
200
+ @click.option("--path", "project_path", default=".", help="Project root directory")
201
+ @click.option("--strict", is_flag=True, help="Treat high severity as halt-worthy")
202
+ @click.option("--ntfy-topic", default="", help="ntfy topic for critical alerts")
203
+ @click.option(
204
+ "--ntfy-severity-threshold",
205
+ default=None,
206
+ help="Minimum severity sent to ntfy (default: codd.yaml preflight.ntfy_severity_threshold or critical)",
207
+ )
208
+ def preflight(task_yaml: Path, project_path: str, strict: bool, ntfy_topic: str, ntfy_severity_threshold: str | None):
209
+ """Run preflight checks on a task YAML before autonomous execution."""
210
+ _run_preflight_command(task_yaml, project_path, strict, ntfy_topic, ntfy_severity_threshold)
211
+
212
+
213
+ @main.command("gungi")
214
+ @click.argument("task_yaml", type=click.Path(exists=True, dir_okay=False, path_type=Path))
215
+ @click.option("--path", "project_path", default=".", help="Project root directory")
216
+ @click.option("--strict", is_flag=True, help="Treat high severity as halt-worthy")
217
+ @click.option("--ntfy-topic", default="", help="ntfy topic for critical alerts")
218
+ @click.option(
219
+ "--ntfy-severity-threshold",
220
+ default=None,
221
+ help="Minimum severity sent to ntfy (default: codd.yaml preflight.ntfy_severity_threshold or critical)",
222
+ )
223
+ def gungi(task_yaml: Path, project_path: str, strict: bool, ntfy_topic: str, ntfy_severity_threshold: str | None):
224
+ """Alias for preflight."""
225
+ _run_preflight_command(task_yaml, project_path, strict, ntfy_topic, ntfy_severity_threshold)
226
+
227
+
228
+ def _run_preflight_command(
229
+ task_yaml: Path,
230
+ project_path: str,
231
+ strict: bool,
232
+ ntfy_topic: str,
233
+ ntfy_severity_threshold: str | None,
234
+ ) -> None:
235
+ from codd.ask_user_question_adapter import _post_ntfy, _severity_at_or_above
236
+ from codd.preflight import PreflightAuditor
237
+
238
+ project_root = Path(project_path).resolve()
239
+ auditor = PreflightAuditor(project_root=project_root)
240
+ result = auditor.run(task_yaml)
241
+ strict_halt = strict and result.severity == "high"
242
+ if strict_halt:
243
+ result.halt_recommended = True
244
+
245
+ threshold = ntfy_severity_threshold or str(
246
+ auditor.preflight_config.get("ntfy_severity_threshold") or "critical"
247
+ )
248
+ if ntfy_topic and _severity_at_or_above(result.severity, threshold):
249
+ result.ntfy_sent = _post_ntfy(ntfy_topic, _format_preflight_ntfy(result))
250
+
251
+ for check in result.checks:
252
+ click.echo(f"[{check.status}] {check.name}: {check.message}")
253
+ for detail in check.details:
254
+ click.echo(f" - {detail}")
255
+ click.echo(f"Overall severity: {result.severity}")
256
+ click.echo(f"ntfy_sent: {str(result.ntfy_sent).lower()}")
257
+
258
+ if result.halt_recommended:
259
+ reason = "strict high severity" if strict_halt else "critical issue found"
260
+ click.echo(f"HALT recommended: {reason}")
261
+ raise SystemExit(1)
262
+
263
+
264
+ def _format_preflight_ntfy(result: Any) -> str:
265
+ failed = [check.name for check in result.checks if check.status in {"FAIL", "WARN"}]
266
+ suffix = f" ({', '.join(failed)})" if failed else ""
267
+ return f"CoDD preflight: {result.task_id} severity={result.severity}{suffix}"
268
+
269
+
270
+ @main.command()
271
+ @click.option("--project-name", prompt="Project name", help="Name of the project")
272
+ @click.option("--language", prompt="Primary language", help="Primary language (python/typescript/javascript/go — full support; java — symbols only)")
200
273
  @click.option("--dest", default=".", help="Destination directory (default: current dir)")
201
274
  @click.option(
202
275
  "--requirements",
@@ -239,7 +312,7 @@ def init(project_name: str, language: str, dest: str, requirements: str | None,
239
312
  _render_template("gitignore.tmpl", codd_dir / ".gitignore", {})
240
313
 
241
314
  # Version file
242
- (dest_path / ".codd_version").write_text("0.2.0\n")
315
+ (dest_path / ".codd_version").write_text("0.2.0\n", encoding="utf-8")
243
316
 
244
317
  # Import requirements if provided
245
318
  if requirements:
@@ -1197,7 +1270,7 @@ def repair_slice_cmd(path, files, issue, issue_file, language, source_dirs, top_
1197
1270
 
1198
1271
  issue_text = issue or ""
1199
1272
  if issue_file and not issue_text:
1200
- issue_text = Path(issue_file).read_text(errors="ignore")
1273
+ issue_text = Path(issue_file).read_text(encoding="utf-8", errors="ignore")
1201
1274
 
1202
1275
  dirs = [d.strip() for d in source_dirs.split(",") if d.strip()] if source_dirs else None
1203
1276
 
@@ -1353,8 +1426,21 @@ def validate(lexicon: bool, design_tokens: bool, screen_flow: bool, edges: bool,
1353
1426
  type=float,
1354
1427
  help="Lexicon compliance threshold percentage.",
1355
1428
  )
1429
+ @click.option(
1430
+ "--screen-flow-threshold",
1431
+ default=100.0,
1432
+ show_default=True,
1433
+ type=float,
1434
+ help="Screen-flow coverage threshold percentage.",
1435
+ )
1356
1436
  @click.option("--json", "as_json", is_flag=True, help="Output machine-readable JSON.")
1357
- def coverage(path: str, e2e_threshold: float, lexicon_threshold: float, as_json: bool):
1437
+ def coverage(
1438
+ path: str,
1439
+ e2e_threshold: float,
1440
+ lexicon_threshold: float,
1441
+ screen_flow_threshold: float,
1442
+ as_json: bool,
1443
+ ):
1358
1444
  """Coverage metrics merge gate: E2E, design tokens, and lexicon."""
1359
1445
  from codd.coverage_metrics import run_coverage
1360
1446
 
@@ -1364,6 +1450,7 @@ def coverage(path: str, e2e_threshold: float, lexicon_threshold: float, as_json:
1364
1450
  e2e_threshold=e2e_threshold,
1365
1451
  design_token_threshold=0.0,
1366
1452
  lexicon_threshold=lexicon_threshold,
1453
+ screen_flow_threshold=screen_flow_threshold,
1367
1454
  )
1368
1455
 
1369
1456
  if as_json:
@@ -2079,7 +2166,7 @@ def measure(path: str, as_json: bool):
2079
2166
 
2080
2167
  @main.command("mcp-server")
2081
2168
  @click.option("--project", default=".", help="Project root directory")
2082
- def mcp_server(project: str):
2169
+ def mcp_server(project: str):
2083
2170
  """Start MCP server for AI tool integration (stdio).
2084
2171
 
2085
2172
  Exposes CoDD tools (validate, impact, policy, audit, scan) via the
@@ -2092,14 +2179,205 @@ def mcp_server(project: str):
2092
2179
  from codd.mcp_server import run_stdio
2093
2180
 
2094
2181
  project_root = Path(project).resolve()
2095
- _require_codd_dir(project_root)
2096
- run_stdio(project_root)
2097
-
2098
-
2099
- @main.group()
2100
- def hooks():
2101
- """Manage Git hook integration."""
2102
- pass
2182
+ _require_codd_dir(project_root)
2183
+ run_stdio(project_root)
2184
+
2185
+
2186
+ @main.group()
2187
+ def dag():
2188
+ """DAG Completeness Gate commands."""
2189
+ pass
2190
+
2191
+
2192
+ @dag.command("build")
2193
+ @click.option("--path", "project_path", default=".", help="Project root directory")
2194
+ @click.option(
2195
+ "--format",
2196
+ "output_format",
2197
+ default="json",
2198
+ type=click.Choice(["json", "mermaid"]),
2199
+ help="Output format",
2200
+ )
2201
+ @click.option("--cache", is_flag=True, help="Use cached DAG if output exists")
2202
+ @click.option("--output", default=None, help="Output file (default: .codd/dag.json or .codd/dag.mmd)")
2203
+ def dag_build(project_path: str, output_format: str, cache: bool, output: str | None):
2204
+ """Build the project DAG and output it under .codd/."""
2205
+ from codd.dag.builder import (
2206
+ build_dag,
2207
+ default_dag_json_path,
2208
+ default_dag_mermaid_path,
2209
+ write_dag_json,
2210
+ write_dag_mermaid,
2211
+ )
2212
+
2213
+ project_root = Path(project_path).resolve()
2214
+ default_output = default_dag_json_path(project_root) if output_format == "json" else default_dag_mermaid_path(project_root)
2215
+ output_path = Path(output).expanduser() if output else default_output
2216
+ if not output_path.is_absolute():
2217
+ output_path = project_root / output_path
2218
+
2219
+ if cache and output_path.exists():
2220
+ click.echo(f"Using cached DAG: {_display_path(output_path, project_root)}")
2221
+ return
2222
+
2223
+ try:
2224
+ built_dag = build_dag(project_root)
2225
+ except (FileNotFoundError, ValueError) as exc:
2226
+ click.echo(f"Error: {exc}")
2227
+ raise SystemExit(1)
2228
+
2229
+ if output_format == "json":
2230
+ if output_path != default_dag_json_path(project_root):
2231
+ write_dag_json(built_dag, project_root, output_path)
2232
+ else:
2233
+ write_dag_mermaid(built_dag, output_path)
2234
+
2235
+ click.echo(
2236
+ "Built DAG: "
2237
+ f"{len(built_dag.nodes)} nodes, "
2238
+ f"{len(built_dag.edges)} edges, "
2239
+ f"{len(built_dag.detect_cycles())} cycles -> "
2240
+ f"{_display_path(output_path, project_root)}"
2241
+ )
2242
+
2243
+
2244
+ @dag.command("verify")
2245
+ @click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
2246
+ @click.option("--check", "check_names", multiple=True, help="Run specific check(s) only")
2247
+ @click.option(
2248
+ "--format",
2249
+ "output_format",
2250
+ default="text",
2251
+ type=click.Choice(["text", "json"]),
2252
+ help="Output format",
2253
+ )
2254
+ def dag_verify(project_path: str, check_names: tuple[str, ...], output_format: str):
2255
+ """Run DAG completeness checks."""
2256
+ from codd.dag.runner import run_all_checks
2257
+
2258
+ project_root = Path(project_path).resolve()
2259
+ try:
2260
+ results = run_all_checks(project_root, check_names=list(check_names) or None)
2261
+ except (FileNotFoundError, ValueError) as exc:
2262
+ click.echo(f"Error: {exc}")
2263
+ raise SystemExit(1)
2264
+
2265
+ failed_red = [
2266
+ result
2267
+ for result in results
2268
+ if not _dag_result_passed(result) and _dag_result_severity(result) == "red"
2269
+ ]
2270
+ amber_findings = [
2271
+ result
2272
+ for result in results
2273
+ if _dag_result_severity(result) == "amber" and _dag_result_has_findings(result)
2274
+ ]
2275
+
2276
+ if output_format == "json":
2277
+ click.echo(json.dumps([_dag_result_to_dict(result) for result in results], indent=2, default=str))
2278
+ else:
2279
+ for result in results:
2280
+ severity = _dag_result_severity(result)
2281
+ if _dag_result_passed(result):
2282
+ status = "PASS"
2283
+ else:
2284
+ status = "WARN" if severity == "amber" else "FAIL"
2285
+ click.echo(f" {status} {_dag_result_name(result)} [{severity}]")
2286
+ for detail in _dag_result_details(result):
2287
+ click.echo(f" {detail}")
2288
+
2289
+ if failed_red:
2290
+ click.echo(f"\n{len(failed_red)} check(s) FAILED (severity=red)")
2291
+ elif amber_findings:
2292
+ click.echo(f"\n{len(amber_findings)} check(s) WARN (severity=amber, deploy allowed)")
2293
+
2294
+ raise SystemExit(1 if failed_red else 0)
2295
+
2296
+
2297
+ @dag.command("visualize")
2298
+ @click.option("--project-path", "--path", default=".", show_default=True, help="Project root directory")
2299
+ def dag_visualize(project_path: str):
2300
+ """Build and print the project DAG as Mermaid."""
2301
+ from codd.dag.builder import build_dag, render_mermaid
2302
+
2303
+ project_root = Path(project_path).resolve()
2304
+ try:
2305
+ built_dag = build_dag(project_root)
2306
+ except (FileNotFoundError, ValueError) as exc:
2307
+ click.echo(f"Error: {exc}")
2308
+ raise SystemExit(1)
2309
+ click.echo(render_mermaid(built_dag), nl=False)
2310
+
2311
+
2312
+ def _dag_result_to_dict(result: Any) -> dict[str, Any]:
2313
+ if is_dataclass(result):
2314
+ return asdict(result)
2315
+ if isinstance(result, dict):
2316
+ return result
2317
+ return dict(vars(result))
2318
+
2319
+
2320
+ def _dag_result_name(result: Any) -> str:
2321
+ return str(_dag_result_value(result, "check_name") or result.__class__.__name__)
2322
+
2323
+
2324
+ def _dag_result_severity(result: Any) -> str:
2325
+ return str(_dag_result_value(result, "severity") or "red")
2326
+
2327
+
2328
+ def _dag_result_passed(result: Any) -> bool:
2329
+ return _dag_result_value(result, "passed") is not False
2330
+
2331
+
2332
+ def _dag_result_has_findings(result: Any) -> bool:
2333
+ for key in (
2334
+ "violations",
2335
+ "missing_impl_files",
2336
+ "orphan_edges",
2337
+ "dangling_refs",
2338
+ "incomplete_tasks",
2339
+ "unreachable_nodes",
2340
+ ):
2341
+ value = _dag_result_value(result, key)
2342
+ if value:
2343
+ return True
2344
+ return False
2345
+
2346
+
2347
+ def _dag_result_details(result: Any) -> list[str]:
2348
+ details: list[str] = []
2349
+ for key in (
2350
+ "missing_impl_files",
2351
+ "orphan_edges",
2352
+ "dangling_refs",
2353
+ "violations",
2354
+ "incomplete_tasks",
2355
+ "unreachable_nodes",
2356
+ "warnings",
2357
+ ):
2358
+ value = _dag_result_value(result, key)
2359
+ if not value:
2360
+ continue
2361
+ if isinstance(value, list):
2362
+ rendered = ", ".join(str(item) for item in value[:5])
2363
+ if len(value) > 5:
2364
+ rendered += f", ... {len(value) - 5} more"
2365
+ details.append(f"{key}: {rendered}")
2366
+ else:
2367
+ details.append(f"{key}: {value}")
2368
+ return details
2369
+
2370
+
2371
+ def _dag_result_value(result: Any, key: str) -> Any:
2372
+ if isinstance(result, dict):
2373
+ return result.get(key)
2374
+ return getattr(result, key, None)
2375
+
2376
+
2377
+ @main.group()
2378
+ def hooks():
2379
+ """Manage Git hook integration."""
2380
+ pass
2103
2381
 
2104
2382
 
2105
2383
  @hooks.command("install")
@@ -2137,13 +2415,13 @@ def _render_template(template_name: str, dest: Path, variables: dict):
2137
2415
  tmpl_path = TEMPLATES_DIR / template_name
2138
2416
  if not tmpl_path.exists():
2139
2417
  # Create empty file if template doesn't exist yet
2140
- dest.write_text(f"# TODO: template {template_name} not yet created\n")
2418
+ dest.write_text(f"# TODO: template {template_name} not yet created\n", encoding="utf-8")
2141
2419
  return
2142
2420
 
2143
- content = tmpl_path.read_text()
2421
+ content = tmpl_path.read_text(encoding="utf-8")
2144
2422
  for key, value in variables.items():
2145
2423
  content = content.replace(f"{{{{{key}}}}}", value)
2146
- dest.write_text(content)
2424
+ dest.write_text(content, encoding="utf-8")
2147
2425
 
2148
2426
 
2149
2427
  def _import_requirements(project_root: Path, source: Path, project_name: str) -> Path:
@@ -80,6 +80,8 @@ 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
+ "codd.deployer",
83
85
  "codd.hitl_session",
84
86
  "codd.validator",
85
87
  "codd.screen_flow_validator",
@@ -129,7 +131,7 @@ class Orchestrator:
129
131
  self._hitl_path = hitl_path
130
132
  self._ntfy_rate_limit = ntfy_rate_limit_seconds
131
133
  self._last_ntfy_time: float = 0.0
132
- self._pending_amber: list[DriftEvent] = []
134
+ self._pending_ntfy_events: list[DriftEvent] = []
133
135
  bus.subscribe("*", self._handle)
134
136
 
135
137
  def resolve_fix_strategy(self, event: DriftEvent) -> FixStrategy:
@@ -200,8 +202,9 @@ class Orchestrator:
200
202
  handle.write("# Pending HITL Reviews\n")
201
203
  handle.write(self._format_hitl_entry(event))
202
204
 
203
- self._pending_amber.append(event)
204
- self._maybe_send_ntfy()
205
+ if event.severity == "red":
206
+ self._pending_ntfy_events.append(event)
207
+ self._maybe_send_ntfy(event)
205
208
 
206
209
  def _format_hitl_entry(self, event: DriftEvent) -> str:
207
210
  original_severity = event.payload.get("downgraded_from", event.severity)
@@ -223,17 +226,19 @@ class Orchestrator:
223
226
  f"- **Reviewer**: (pending)\n"
224
227
  )
225
228
 
226
- def _maybe_send_ntfy(self) -> None:
227
- """Send at most one ntfy notification per rate-limit window."""
229
+ def _maybe_send_ntfy(self, event: DriftEvent) -> None:
230
+ """Send at most one red-severity ntfy notification per rate-limit window."""
231
+ if event.severity != "red":
232
+ return
228
233
  now = time.time()
229
234
  if now - self._last_ntfy_time < self._ntfy_rate_limit:
230
235
  return
231
- count = len(self._pending_amber)
236
+ count = len(self._pending_ntfy_events)
232
237
  if count == 0:
233
238
  return
234
239
  self._send_ntfy(f"CoDD Coherence: {count} HITL event(s) pending review")
235
240
  self._last_ntfy_time = now
236
- self._pending_amber.clear()
241
+ self._pending_ntfy_events.clear()
237
242
 
238
243
  def _send_ntfy(self, message: str) -> None:
239
244
  """Send an ntfy notification when NTFY_URL is configured."""
@@ -94,7 +94,7 @@ def build_interface_contracts(facts: ProjectFacts, project_root: Path) -> None:
94
94
  if init_files:
95
95
  init_path = project_root / init_files[0]
96
96
  try:
97
- init_content = init_path.read_text(errors="ignore")
97
+ init_content = init_path.read_text(encoding="utf-8", errors="ignore")
98
98
  except Exception:
99
99
  init_content = ""
100
100
  public = detect_init_exports(init_content)
@@ -132,7 +132,7 @@ def compute_lexicon_compliance(project_root: Path | str, threshold: float = 100.
132
132
  def compute_screen_flow_coverage(
133
133
  project_root: Path | str,
134
134
  config: dict[str, Any],
135
- threshold: float = 0.0,
135
+ threshold: float = 100.0,
136
136
  ) -> CoverageResult:
137
137
  """Measure screen-flow drift as a coverage gate metric."""
138
138
 
@@ -153,8 +153,25 @@ def compute_screen_flow_coverage(
153
153
  details=[f"error: {exc}"],
154
154
  )
155
155
 
156
- drift_count = len(drifts)
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
157
172
  pct = 100.0 if drift_count == 0 else max(0.0, 100.0 - drift_count * 10.0)
173
+ details = [f"drift_count: {drift_count}"]
174
+ details.extend(design_drift_details)
158
175
  return CoverageResult(
159
176
  metric="screen_flow_coverage",
160
177
  total=1,
@@ -163,7 +180,49 @@ def compute_screen_flow_coverage(
163
180
  pct=pct,
164
181
  threshold=threshold,
165
182
  passed=pct >= threshold,
166
- details=[f"drift_count: {drift_count}"],
183
+ details=details,
184
+ )
185
+
186
+
187
+ def compute_dag_completeness(
188
+ project_root: Path | str,
189
+ config: dict[str, Any] | None = None,
190
+ threshold: float = 100.0,
191
+ ) -> CoverageResult:
192
+ """Measure red-severity DAG completeness checks as a coverage metric."""
193
+
194
+ try:
195
+ from codd.dag.runner import run_all_checks
196
+
197
+ results = run_all_checks(Path(project_root), settings=config or {})
198
+ except Exception as exc: # pragma: no cover - defensive gate behavior
199
+ return _exception_result("dag_completeness", threshold, exc)
200
+
201
+ red_results = [result for result in results if _dag_result_severity(result) == "red"]
202
+ failed_red = [result for result in red_results if _dag_result_passed(result) is False]
203
+ amber_findings = [
204
+ result
205
+ for result in results
206
+ if _dag_result_severity(result) == "amber" and _dag_result_has_findings(result)
207
+ ]
208
+
209
+ total = len(red_results)
210
+ uncovered = len(failed_red)
211
+ covered = max(0, total - uncovered)
212
+ pct = _coverage_pct(covered, total)
213
+ details = [f"checks: {len(results)}", f"red_failures: {uncovered}"]
214
+ details.extend(_format_dag_result(result) for result in failed_red[:5])
215
+ details.extend(f"warning: {_format_dag_result(result)}" for result in amber_findings[:5])
216
+
217
+ return CoverageResult(
218
+ metric="dag_completeness",
219
+ total=total,
220
+ covered=covered,
221
+ uncovered=uncovered,
222
+ pct=pct,
223
+ threshold=threshold,
224
+ passed=pct >= threshold,
225
+ details=details,
167
226
  )
168
227
 
169
228
 
@@ -199,7 +258,7 @@ def run_coverage(
199
258
  e2e_threshold: float = 100.0,
200
259
  design_token_threshold: float = 0.0,
201
260
  lexicon_threshold: float = 100.0,
202
- screen_flow_threshold: float = 0.0,
261
+ screen_flow_threshold: float = 100.0,
203
262
  config: dict[str, Any] | None = None,
204
263
  ) -> CoverageReport:
205
264
  """Run all coverage metrics and return an aggregated report."""
@@ -213,6 +272,7 @@ def run_coverage(
213
272
  report.add(compute_design_token_coverage(project_root, threshold=design_token_threshold))
214
273
  report.add(compute_lexicon_compliance(project_root, threshold=lexicon_threshold))
215
274
  report.add(compute_screen_flow_coverage(project_root, config, threshold=screen_flow_threshold))
275
+ report.add(compute_dag_completeness(project_root, config=config))
216
276
  return report
217
277
 
218
278
 
@@ -307,3 +367,53 @@ def _exception_result(metric: str, threshold: float, exc: Exception) -> Coverage
307
367
  passed=False,
308
368
  details=[f"error: {type(exc).__name__}: {exc}"],
309
369
  )
370
+
371
+
372
+ def _dag_result_severity(result: Any) -> str:
373
+ return str(_dag_result_value(result, "severity") or "red")
374
+
375
+
376
+ def _dag_result_passed(result: Any) -> bool:
377
+ return _dag_result_value(result, "passed") is not False
378
+
379
+
380
+ def _dag_result_name(result: Any) -> str:
381
+ return str(_dag_result_value(result, "check_name") or result.__class__.__name__)
382
+
383
+
384
+ def _dag_result_has_findings(result: Any) -> bool:
385
+ for key in (
386
+ "violations",
387
+ "missing_impl_files",
388
+ "orphan_edges",
389
+ "dangling_refs",
390
+ "incomplete_tasks",
391
+ "unreachable_nodes",
392
+ ):
393
+ if _dag_result_value(result, key):
394
+ return True
395
+ return False
396
+
397
+
398
+ def _format_dag_result(result: Any) -> str:
399
+ details = []
400
+ for key in (
401
+ "missing_impl_files",
402
+ "orphan_edges",
403
+ "dangling_refs",
404
+ "violations",
405
+ "incomplete_tasks",
406
+ "unreachable_nodes",
407
+ "warnings",
408
+ ):
409
+ value = _dag_result_value(result, key)
410
+ if not value:
411
+ continue
412
+ details.append(f"{key}: {value}")
413
+ return f"{_dag_result_name(result)} ({'; '.join(details)})" if details else _dag_result_name(result)
414
+
415
+
416
+ def _dag_result_value(result: Any, key: str) -> Any:
417
+ if isinstance(result, dict):
418
+ return result.get(key)
419
+ return getattr(result, key, None)