diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e685fb56..813142062 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2500,6 +2500,26 @@ against justified byte thresholds in `ouroboros/context_budget.py` and emits a WARNING with a remediation pointer; explicitly sentinel-marked isolated devtool roots suppress it because their external reader owns the bounded run-local stores. +`ouroboros/domains.toml` is the production domain manifest (v7next CPL-1): the +1:1 module→domain assignment for every tracked runtime module (`ouroboros/`, +`supervisor/`, `server.py`, `launcher.py`) across the twenty D01–D20 domains, +with human sections (`[domains]`/`[modules]`/`[classification]`) and generated +baseline sections (`[graph]`/`[duplicates]`) that pin today's FACTUAL +cross-domain dependency data: the strict direction matrix (`allowed`), the +strict-quotient cycle ceiling (`cycle_groups`, target `[]`), the lazy/dynamic +hidden-coupling classification, and the cross-domain literal-copy baseline +(currently empty — copying a function body across a domain boundary instead of +importing its owner turns the gate red). Like the size ratchet, the baseline is +current reality, never an aspiration; tightening is a separate owner decision +made visible as a manifest diff. `scripts/domain_graph.py` is the shared +import-graph core (strict/lazy/guarded/TYPE_CHECKING/dynamic classification, +domain quotient, SCC, literal-copy scan); `scripts/check_domains.py` is the +gate and the `--write` regenerator (it also renders `docs/DOMAIN_MAP.md` from +the manifest alone); `tests/test_domain_manifest.py` is the CI verify half — +completeness (a tracked module without a row = red), baseline exactness, and +DOMAIN_MAP byte-identity; `scripts/v7next_domain_report.py` stays the +report-only witness-level companion (`docs/v7next/DOMAIN_QUOTIENT_REPORT.md`). + The shared hard prompt-size SSOT is `REVIEW_PROMPT_TOKEN_BUDGET = 920_000` in `ouroboros/tools/review_helpers.py`. `review_context_atlas.py` targets 850K estimated total prompt tokens for scope review and deep self-review, then leaves diff --git a/docs/DOMAIN_MAP.md b/docs/DOMAIN_MAP.md new file mode 100644 index 000000000..a9349bcb3 --- /dev/null +++ b/docs/DOMAIN_MAP.md @@ -0,0 +1,717 @@ +# Domain map — v7next + +Generated from `ouroboros/domains.toml` by `python scripts/check_domains.py --write`. Do not edit — edit the manifest and regenerate; `tests/test_domain_manifest.py` pins byte-identity. + +The manifest is the SSOT of the module→domain assignment (1:1, complete over the tracked runtime population) and pins today's factual cross-domain dependency data as baseline; the gate contract is described in `scripts/check_domains.py`. + +## Index + +| domain | name | modules | proposed | +|---|---|---:|---:| +| D01 | Agent core & main loop | 29 | 2 | +| D02 | LLM client, routing & providers | 33 | 13 | +| D03 | Context assembly, fit & compaction | 11 | 1 | +| D04 | Tool execution: registry, access & typed results | 19 | 0 | +| D05 | Tool surfaces: files, code, shell, media, external | 23 | 1 | +| D06 | Review stack | 62 | 17 | +| D07 | Delegation, subagents & Claudexor | 45 | 17 | +| D08 | Supervisor: queue, workers, events & runtime control | 40 | 4 | +| D09 | Cancellation, owner control & process custody | 11 | 0 | +| D10 | Git, update & release machinery | 28 | 1 | +| D11 | Gateway, server & Web UI | 42 | 2 | +| D12 | Settings & configuration | 14 | 1 | +| D13 | Safety, guards & runtime mode | 8 | 2 | +| D14 | Skills & extensions | 51 | 8 | +| D15 | Memory, knowledge, consciousness & self-evolution | 16 | 1 | +| D16 | Observability, usage accounting & cost | 8 | 0 | +| D17 | Projects, workspaces & task results | 19 | 1 | +| D18 | Launcher, packaging, platform & shared substrate | 10 | 0 | +| D19 | Frozen contracts (ABI) | 10 | 0 | +| D20 | Presence | 9 | 9 | +| **total** | | **488** | **80** | + +## Dependency direction matrix (strict, pinned) + +Rows may import columns (`[graph].allowed`). `·` = forbidden direction. + +| ↓ imports → | D01 | D02 | D03 | D04 | D05 | D06 | D07 | D08 | D09 | D10 | D11 | D12 | D13 | D14 | D15 | D16 | D17 | D18 | D19 | D20 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **D01** | · | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| **D02** | ✓ | · | ✓ | · | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | ✓ | · | · | +| **D03** | ✓ | ✓ | · | ✓ | · | · | · | · | · | · | · | ✓ | · | · | ✓ | · | · | ✓ | ✓ | · | +| **D04** | · | · | · | · | ✓ | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | ✓ | ✓ | · | +| **D05** | ✓ | ✓ | · | ✓ | · | · | · | · | · | ✓ | ✓ | ✓ | ✓ | · | ✓ | ✓ | ✓ | ✓ | ✓ | · | +| **D06** | ✓ | ✓ | ✓ | ✓ | · | · | · | · | · | ✓ | · | ✓ | ✓ | ✓ | · | ✓ | ✓ | ✓ | ✓ | · | +| **D07** | ✓ | ✓ | · | ✓ | ✓ | ✓ | · | ✓ | · | · | · | ✓ | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | · | +| **D08** | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | · | ✓ | · | · | ✓ | · | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · | +| **D09** | ✓ | · | · | · | · | ✓ | · | ✓ | · | · | · | · | · | · | · | · | · | ✓ | · | · | +| **D10** | · | · | · | ✓ | ✓ | ✓ | · | ✓ | · | · | · | ✓ | ✓ | · | · | · | · | ✓ | ✓ | · | +| **D11** | ✓ | ✓ | ✓ | ✓ | ✓ | · | ✓ | ✓ | ✓ | ✓ | · | ✓ | · | ✓ | · | ✓ | ✓ | ✓ | ✓ | ✓ | +| **D12** | ✓ | ✓ | ✓ | · | · | · | ✓ | · | · | · | ✓ | · | · | · | · | · | · | ✓ | · | · | +| **D13** | ✓ | ✓ | · | ✓ | · | · | · | ✓ | · | · | · | ✓ | · | · | · | · | ✓ | ✓ | · | · | +| **D14** | · | ✓ | · | ✓ | ✓ | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | · | · | · | ✓ | · | ✓ | ✓ | · | +| **D15** | ✓ | ✓ | ✓ | ✓ | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | ✓ | ✓ | · | +| **D16** | · | ✓ | · | · | · | ✓ | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | · | +| **D17** | ✓ | · | · | ✓ | · | · | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | · | +| **D18** | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | · | · | · | · | · | · | · | · | +| **D19** | · | · | · | · | · | · | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | · | +| **D20** | · | · | · | ✓ | ✓ | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | ✓ | · | + +## Cycle status + +1 pinned cycle group(s) — the SCC ceiling; the target is zero. Witness-level detail lives in `docs/v7next/DOMAIN_QUOTIENT_REPORT.md`. + +- group 1 (20 domains): D01 ⇄ D02 ⇄ D03 ⇄ D04 ⇄ D05 ⇄ D06 ⇄ D07 ⇄ D08 ⇄ D09 ⇄ D10 ⇄ D11 ⇄ D12 ⇄ D13 ⇄ D14 ⇄ D15 ⇄ D16 ⇄ D17 ⇄ D18 ⇄ D19 ⇄ D20 + +## Hidden coupling (classified out of the strict graph) + +- lazy-only cross-domain pairs: **92** + - D01->D08 + - D01->D10 + - D01->D11 + - D02->D09 + - D03->D05 + - D03->D06 + - D03->D07 + - D03->D08 + - D03->D10 + - D03->D11 + - D03->D14 + - D03->D16 + - D03->D17 + - D03->D20 + - D04->D06 + - D04->D10 + - D04->D12 + - D04->D14 + - D04->D20 + - D05->D06 + - D05->D07 + - D05->D09 + - D05->D14 + - D06->D05 + - D06->D07 + - D06->D08 + - D06->D09 + - D06->D15 + - D07->D09 + - D07->D10 + - D07->D11 + - D07->D14 + - D07->D15 + - D07->D20 + - D08->D02 + - D08->D10 + - D08->D11 + - D09->D02 + - D09->D05 + - D09->D07 + - D09->D11 + - D09->D12 + - D09->D14 + - D09->D15 + - D09->D16 + - D09->D17 + - D10->D01 + - D10->D14 + - D10->D15 + - D10->D17 + - D11->D06 + - D11->D15 + - D12->D05 + - D12->D06 + - D12->D10 + - D12->D17 + - D13->D05 + - D13->D06 + - D13->D15 + - D14->D08 + - D14->D13 + - D14->D20 + - D15->D06 + - D15->D07 + - D15->D08 + - D15->D09 + - D15->D10 + - D15->D17 + - D16->D03 + - D16->D05 + - D16->D12 + - D16->D17 + - D17->D02 + - D17->D03 + - D17->D05 + - D17->D07 + - D17->D09 + - D17->D12 + - D18->D01 + - D18->D07 + - D18->D11 + - D18->D14 + - D18->D16 + - D18->D17 + - D18->D19 + - D19->D04 + - D19->D08 + - D19->D20 + - D20->D01 + - D20->D07 + - D20->D11 + - D20->D12 +- dynamic-import cross-domain pairs: **0** + +## Literal-copy baseline + +No function body (≥ 10 normalized lines) is shared verbatim across domains. New occurrences turn the gate red. + +## Modules by domain + +`*` marks a `classification=proposed` row (owner review pending). + +### D01 — Agent core & main loop + +- `ouroboros/_outcome_receipts.py` +- `ouroboros/_outcome_tool_errors.py` +- `ouroboros/agent.py` +- `ouroboros/agent_dispatch.py` +- `ouroboros/agent_startup_checks.py` +- `ouroboros/agent_task_pipeline.py` +- `ouroboros/deadline_utils.py` +- `ouroboros/loop.py` +- `ouroboros/loop_acceptance.py` +- `ouroboros/loop_acceptance_review.py` +- `ouroboros/loop_budget.py` +- `ouroboros/loop_delivery.py` +- `ouroboros/loop_forced_finalization.py` +- `ouroboros/loop_llm_call.py` +- `ouroboros/loop_messages.py` +- `ouroboros/loop_model_call.py` +- `ouroboros/loop_nudges.py` +- `ouroboros/loop_round_limits.py` +- `ouroboros/loop_tool_execution.py` +- `ouroboros/loop_transport.py` * +- `ouroboros/mutation_attribution.py` +- `ouroboros/outcome_receipt_store.py` * +- `ouroboros/outcomes.py` +- `ouroboros/owner_mailbox.py` +- `ouroboros/post_task_checkpoint.py` +- `ouroboros/post_task_synthesis.py` +- `ouroboros/synthesis_cost_text.py` +- `ouroboros/task_finalization.py` +- `ouroboros/task_pacing.py` + +### D02 — LLM client, routing & providers + +- `ouroboros/anthropic_native_custody.py` * +- `ouroboros/fallback_cooldown.py` +- `ouroboros/llm.py` +- `ouroboros/llm_anthropic.py` +- `ouroboros/llm_attempt.py` +- `ouroboros/llm_capability_policy.py` +- `ouroboros/llm_fallback.py` +- `ouroboros/llm_gigachat.py` +- `ouroboros/llm_local.py` +- `ouroboros/llm_messages.py` +- `ouroboros/llm_observability.py` +- `ouroboros/llm_openai_compatible.py` +- `ouroboros/llm_pricing.py` +- `ouroboros/llm_probe.py` +- `ouroboros/llm_routing.py` +- `ouroboros/local_model.py` +- `ouroboros/local_model_autostart.py` +- `ouroboros/model_concurrency.py` +- `ouroboros/net_transport.py` * +- `ouroboros/openai_chat_custom.py` * +- `ouroboros/openai_chat_dispatch.py` * +- `ouroboros/openrouter_attribution.py` * +- `ouroboros/pricing.py` +- `ouroboros/provider_models.py` +- `ouroboros/request_wire_attempt.py` * +- `ouroboros/request_wire_contract.py` * +- `ouroboros/request_wire_custom_validation.py` * +- `ouroboros/request_wire_receipts.py` * +- `ouroboros/request_wire_recovery.py` * +- `ouroboros/request_wire_resolution.py` * +- `ouroboros/route_spec.py` * +- `ouroboros/transport_custody.py` * +- `ouroboros/vision_routing.py` + +### D03 — Context assembly, fit & compaction + +- `ouroboros/capability_evidence.py` +- `ouroboros/context.py` +- `ouroboros/context_budget.py` +- `ouroboros/context_compaction.py` +- `ouroboros/context_fit.py` +- `ouroboros/context_health.py` +- `ouroboros/context_layout.py` +- `ouroboros/context_mode_compat.py` +- `ouroboros/context_runtime_facts.py` +- `ouroboros/main_context_authority.py` * +- `ouroboros/tools/compact_context.py` + +### D04 — Tool execution: registry, access & typed results + +- `ouroboros/protected_artifacts.py` +- `ouroboros/tool_access.py` +- `ouroboros/tool_access_paths.py` +- `ouroboros/tool_access_roots.py` +- `ouroboros/tool_access_types.py` +- `ouroboros/tool_access_user_files.py` +- `ouroboros/tool_capabilities.py` +- `ouroboros/tool_policy.py` +- `ouroboros/tools/__init__.py` +- `ouroboros/tools/extension_dispatch.py` +- `ouroboros/tools/registry.py` +- `ouroboros/tools/registry_core.py` +- `ouroboros/tools/registry_guard_process.py` +- `ouroboros/tools/registry_guards.py` +- `ouroboros/tools/tool_catalog.py` +- `ouroboros/tools/tool_context.py` +- `ouroboros/tools/tool_discovery.py` +- `ouroboros/tools/tool_resolution.py` +- `ouroboros/tools/tool_result.py` + +### D05 — Tool surfaces: files, code, shell, media, external + +- `ouroboros/artifacts.py` +- `ouroboros/code_intelligence.py` +- `ouroboros/code_search_rg.py` +- `ouroboros/mcp_client.py` +- `ouroboros/python_interpreter.py` +- `ouroboros/tools/browser.py` +- `ouroboros/tools/core.py` +- `ouroboros/tools/core_artifacts.py` +- `ouroboros/tools/core_file_tools.py` +- `ouroboros/tools/edit_ops.py` +- `ouroboros/tools/health.py` +- `ouroboros/tools/media.py` +- `ouroboros/tools/query_code.py` +- `ouroboros/tools/recent_tasks.py` +- `ouroboros/tools/search.py` +- `ouroboros/tools/services.py` +- `ouroboros/tools/shell.py` +- `ouroboros/tools/shell_audit.py` * +- `ouroboros/tools/shell_effects.py` +- `ouroboros/tools/shell_outputs.py` +- `ouroboros/tools/shell_process.py` +- `ouroboros/tools/verify.py` +- `ouroboros/tools/vision.py` + +### D06 — Review stack + +- `ouroboros/commit_admission.py` * +- `ouroboros/deep_self_review.py` +- `ouroboros/preflight_node.py` * +- `ouroboros/preflight_runner.py` +- `ouroboros/review.py` +- `ouroboros/review_actor_aggregation.py` * +- `ouroboros/review_custody.py` * +- `ouroboros/review_cycles.py` +- `ouroboros/review_dispatch.py` * +- `ouroboros/review_evidence.py` +- `ouroboros/review_evidence_refs.py` +- `ouroboros/review_evidence_sections.py` +- `ouroboros/review_execution.py` +- `ouroboros/review_execution_projection.py` * +- `ouroboros/review_native_episode.py` * +- `ouroboros/review_owner_custody.py` * +- `ouroboros/review_projection.py` +- `ouroboros/review_records.py` +- `ouroboros/review_session_custody.py` * +- `ouroboros/review_session_usage.py` * +- `ouroboros/review_slot_cancel.py` +- `ouroboros/review_state.py` +- `ouroboros/review_state_custody.py` +- `ouroboros/review_state_model.py` +- `ouroboros/review_state_records.py` +- `ouroboros/review_substrate.py` +- `ouroboros/review_thread_continuity.py` * +- `ouroboros/review_verdict.py` +- `ouroboros/review_verdict_extraction.py` * +- `ouroboros/reviewer_slot_config.py` +- `ouroboros/reviewer_window.py` +- `ouroboros/task_continuation.py` +- `ouroboros/tools/claude_advisory_review.py` +- `ouroboros/tools/parallel_review.py` +- `ouroboros/tools/plan_evidence.py` +- `ouroboros/tools/plan_packet.py` +- `ouroboros/tools/plan_render.py` +- `ouroboros/tools/plan_review.py` +- `ouroboros/tools/plan_review_artifacts.py` * +- `ouroboros/tools/plan_review_references.py` * +- `ouroboros/tools/plan_review_runtime.py` +- `ouroboros/tools/plan_spec.py` +- `ouroboros/tools/preflight_review_prompt.py` +- `ouroboros/tools/preflight_review_run.py` +- `ouroboros/tools/review.py` +- `ouroboros/tools/review_admission.py` * +- `ouroboros/tools/review_binary_context.py` +- `ouroboros/tools/review_context_atlas.py` +- `ouroboros/tools/review_file_pack.py` +- `ouroboros/tools/review_helpers.py` +- `ouroboros/tools/review_multi_model.py` +- `ouroboros/tools/review_prompt_text.py` +- `ouroboros/tools/review_response.py` * +- `ouroboros/tools/review_subject.py` * +- `ouroboros/tools/review_synthesis.py` +- `ouroboros/tools/scope_review.py` +- `ouroboros/tools/scope_review_budget.py` +- `ouroboros/tools/scope_review_contract.py` +- `ouroboros/tools/scope_review_pack.py` +- `ouroboros/tools/scope_review_session.py` +- `ouroboros/tools/scope_window.py` +- `ouroboros/triad_review.py` + +### D07 — Delegation, subagents & Claudexor + +- `ouroboros/claudexor_daemon.py` +- `ouroboros/claudexor_runtime.py` +- `ouroboros/configured_subagents.py` * +- `ouroboros/delegate_containment.py` +- `ouroboros/delegate_custody.py` +- `ouroboros/delegate_custody_reconcile.py` +- `ouroboros/delegate_custody_usage.py` * +- `ouroboros/delegate_evidence.py` +- `ouroboros/delegate_hold.py` * +- `ouroboros/delegate_interactions.py` +- `ouroboros/delegate_output.py` +- `ouroboros/delegate_pending.py` * +- `ouroboros/delegate_progress.py` +- `ouroboros/delegate_recovery.py` * +- `ouroboros/delegate_shared.py` +- `ouroboros/delegate_source_coverage.py` * +- `ouroboros/delegate_start_claims.py` * +- `ouroboros/delegate_start_instructions.py` * +- `ouroboros/delegate_supervision.py` * +- `ouroboros/delegate_terminal.py` * +- `ouroboros/depth_evidence.py` * +- `ouroboros/gateways/__init__.py` +- `ouroboros/gateways/claudexor.py` +- `ouroboros/nanny_pacing.py` * +- `ouroboros/subagent_bootstrap.py` * +- `ouroboros/subagent_dispatch_notes.py` * +- `ouroboros/subagent_messages.py` * +- `ouroboros/subagent_route_health.py` +- `ouroboros/subagent_runtime.py` * +- `ouroboros/subagent_work_order.py` * +- `ouroboros/subagent_worktrees.py` +- `ouroboros/subagents.py` +- `ouroboros/task_tree_ledger.py` +- `ouroboros/tools/control_delegation.py` +- `ouroboros/tools/control_scheduling.py` +- `ouroboros/tools/control_subagent_spec.py` +- `ouroboros/tools/control_task_results.py` +- `ouroboros/tools/delegate.py` +- `ouroboros/tools/delegate_integration.py` +- `ouroboros/tools/delegate_payload_patch.py` +- `ouroboros/tools/delegate_terminal_evidence.py` +- `ouroboros/tools/join_ledger.py` +- `ouroboros/tools/subagent_integration.py` +- `ouroboros/tools/subagent_integration_delegated.py` +- `ouroboros/tools/task_tree.py` + +### D08 — Supervisor: queue, workers, events & runtime control + +- `ouroboros/promotion_source.py` +- `ouroboros/schedule_contract.py` +- `ouroboros/tools/control.py` +- `ouroboros/tools/control_events.py` +- `ouroboros/tools/control_routing.py` +- `ouroboros/tools/control_runtime.py` +- `ouroboros/tools/followup.py` +- `supervisor/__init__.py` +- `supervisor/active_activity.py` +- `supervisor/cognitive_operations.py` * +- `supervisor/events.py` +- `supervisor/events_budget.py` +- `supervisor/events_chat_delivery.py` +- `supervisor/events_coop_checkpoint.py` +- `supervisor/events_evolution_done.py` +- `supervisor/events_project_routing.py` +- `supervisor/events_runtime_controls.py` +- `supervisor/events_schedule_task.py` +- `supervisor/events_subagent_admission.py` +- `supervisor/events_task_done.py` +- `supervisor/events_worker_reports.py` +- `supervisor/log_addressing.py` * +- `supervisor/message_bus.py` +- `supervisor/queue.py` +- `supervisor/queue_schedules.py` +- `supervisor/queue_snapshot.py` +- `supervisor/queue_timeouts.py` +- `supervisor/queue_transitions.py` +- `supervisor/schedule_time.py` +- `supervisor/state.py` +- `supervisor/subagent_task_truth.py` * +- `supervisor/task_admission.py` +- `supervisor/task_dispatch.py` * +- `supervisor/worker_assignment.py` +- `supervisor/worker_chat_lane.py` +- `supervisor/worker_health.py` +- `supervisor/worker_pool_lifecycle.py` +- `supervisor/worker_process.py` +- `supervisor/worker_promotion.py` +- `supervisor/workers.py` + +### D09 — Cancellation, owner control & process custody + +- `ouroboros/cancel_intents.py` +- `ouroboros/owner_hurry.py` +- `ouroboros/process_containment.py` +- `ouroboros/process_custody.py` +- `ouroboros/server_control.py` +- `supervisor/cancel_publication.py` +- `supervisor/owner_stop.py` +- `supervisor/steering.py` +- `supervisor/task_lifecycle.py` +- `supervisor/task_reaper.py` +- `supervisor/terminal_delivery.py` + +### D10 — Git, update & release machinery + +- `ouroboros/repo_remotes.py` +- `ouroboros/size_ratchet_manifest.py` +- `ouroboros/tools/ci.py` +- `ouroboros/tools/commit_gate.py` +- `ouroboros/tools/git.py` +- `ouroboros/tools/git_evolution.py` +- `ouroboros/tools/git_plumbing.py` +- `ouroboros/tools/git_pr.py` +- `ouroboros/tools/git_repo_edit.py` +- `ouroboros/tools/git_review_cycle.py` +- `ouroboros/tools/git_rollback.py` +- `ouroboros/tools/git_vcs_ops.py` +- `ouroboros/tools/github.py` +- `ouroboros/tools/release_sync.py` +- `ouroboros/tools/review_revalidation.py` +- `ouroboros/version.py` +- `supervisor/git_ops.py` +- `supervisor/git_ops_remotes.py` +- `supervisor/git_ops_rescue.py` +- `supervisor/git_ops_reset.py` +- `supervisor/git_ops_updates.py` +- `supervisor/update_candidate.py` * +- `supervisor/update_carriers.py` +- `supervisor/update_merge.py` +- `supervisor/update_merge_plan.py` +- `supervisor/update_merge_policy.py` +- `supervisor/update_recovery.py` +- `supervisor/update_source.py` + +### D11 — Gateway, server & Web UI + +- `ouroboros/client_surface.py` +- `ouroboros/gateway/__init__.py` +- `ouroboros/gateway/_helpers.py` +- `ouroboros/gateway/claudexor_accounts.py` +- `ouroboros/gateway/contracts.py` +- `ouroboros/gateway/control.py` +- `ouroboros/gateway/extensions.py` +- `ouroboros/gateway/files.py` +- `ouroboros/gateway/history.py` +- `ouroboros/gateway/host_service.py` +- `ouroboros/gateway/logs.py` +- `ouroboros/gateway/marketplace.py` +- `ouroboros/gateway/mcp.py` +- `ouroboros/gateway/models.py` +- `ouroboros/gateway/onboarding.py` +- `ouroboros/gateway/onboarding_host.py` +- `ouroboros/gateway/owner_settings.py` +- `ouroboros/gateway/presence_settings.py` * +- `ouroboros/gateway/projects.py` +- `ouroboros/gateway/router.py` +- `ouroboros/gateway/schedules.py` +- `ouroboros/gateway/schema.py` +- `ouroboros/gateway/settings.py` +- `ouroboros/gateway/skill_publish.py` * +- `ouroboros/gateway/state.py` +- `ouroboros/gateway/task_events.py` +- `ouroboros/gateway/task_hurry.py` +- `ouroboros/gateway/task_list_scan.py` +- `ouroboros/gateway/tasks.py` +- `ouroboros/gateway/ui_preferences.py` +- `ouroboros/gateway/ws.py` +- `ouroboros/server_auth.py` +- `ouroboros/server_entrypoint.py` +- `ouroboros/server_liveness.py` +- `ouroboros/server_maintenance.py` +- `ouroboros/server_owner_routing.py` +- `ouroboros/server_process.py` +- `ouroboros/server_restart.py` +- `ouroboros/server_routing_context.py` +- `ouroboros/server_runtime.py` +- `ouroboros/server_web.py` +- `server.py` + +### D12 — Settings & configuration + +- `ouroboros/colab_bootstrap.py` +- `ouroboros/config.py` +- `ouroboros/launcher_onboarding.py` +- `ouroboros/model_slots.py` +- `ouroboros/onboarding_wizard.py` +- `ouroboros/review_model_routes.py` +- `ouroboros/runtime_limits.py` +- `ouroboros/secret_masking.py` +- `ouroboros/settings_defaults.py` +- `ouroboros/settings_integrity.py` * +- `ouroboros/settings_scales.py` +- `ouroboros/settings_setup_contract.py` +- `ouroboros/subscription_install_presets.py` +- `ouroboros/update_channels.py` + +### D13 — Safety, guards & runtime mode + +- `ouroboros/argv_budget.py` +- `ouroboros/git_shell_policy.py` +- `ouroboros/runtime_mode_policy.py` +- `ouroboros/safety.py` +- `ouroboros/shell_parse.py` +- `ouroboros/tools/deliverables_shell.py` * +- `ouroboros/tools/shell_guards.py` +- `ouroboros/tools/write_shape.py` * + +### D14 — Skills & extensions + +- `ouroboros/betterleaks_runtime.py` * +- `ouroboros/event_bus.py` +- `ouroboros/extension_child_catalog.py` +- `ouroboros/extension_companion.py` +- `ouroboros/extension_health.py` +- `ouroboros/extension_import_staging.py` +- `ouroboros/extension_isolated_deps.py` +- `ouroboros/extension_liveness.py` +- `ouroboros/extension_loader.py` +- `ouroboros/extension_plugin_api.py` +- `ouroboros/extension_process_runner.py` +- `ouroboros/extension_reconcile_queue.py` +- `ouroboros/extension_registry_state.py` +- `ouroboros/extension_surface_names.py` +- `ouroboros/extension_ui_validation.py` +- `ouroboros/marketplace/__init__.py` +- `ouroboros/marketplace/adapter.py` +- `ouroboros/marketplace/clawhub.py` +- `ouroboros/marketplace/fetcher.py` +- `ouroboros/marketplace/install.py` +- `ouroboros/marketplace/install_specs.py` +- `ouroboros/marketplace/isolated_deps.py` +- `ouroboros/marketplace/ouroboroshub.py` +- `ouroboros/marketplace/provenance.py` +- `ouroboros/skill_dependencies.py` +- `ouroboros/skill_lifecycle_queue.py` +- `ouroboros/skill_loader.py` +- `ouroboros/skill_owner_attestation.py` +- `ouroboros/skill_payload_binding.py` * +- `ouroboros/skill_publish_eligibility.py` +- `ouroboros/skill_publish_github.py` * +- `ouroboros/skill_publish_result.py` * +- `ouroboros/skill_publish_scanner.py` * +- `ouroboros/skill_publish_snapshot.py` * +- `ouroboros/skill_readiness.py` +- `ouroboros/skill_repair_admission.py` +- `ouroboros/skill_review.py` +- `ouroboros/skill_review_cycles.py` * +- `ouroboros/skill_review_history.py` +- `ouroboros/skill_review_output.py` +- `ouroboros/skill_review_packs.py` +- `ouroboros/skill_review_passes.py` +- `ouroboros/skill_review_prompt.py` +- `ouroboros/skill_review_rebuttals.py` +- `ouroboros/skill_review_runner.py` +- `ouroboros/skill_review_status.py` +- `ouroboros/skill_review_usage.py` * +- `ouroboros/skill_token.py` +- `ouroboros/tools/skill_exec.py` +- `ouroboros/tools/skill_preflight.py` +- `ouroboros/tools/skill_publish.py` + +### D15 — Memory, knowledge, consciousness & self-evolution + +- `ouroboros/consciousness.py` +- `ouroboros/consolidator.py` +- `ouroboros/dialogue_provenance.py` * +- `ouroboros/evolution_checkpoints.py` +- `ouroboros/evolution_fingerprint.py` +- `ouroboros/improvement_backlog.py` +- `ouroboros/memory.py` +- `ouroboros/post_task_evolution.py` +- `ouroboros/project_facts.py` +- `ouroboros/reflection.py` +- `ouroboros/semantic_dedup.py` +- `ouroboros/tools/evolution_stats.py` +- `ouroboros/tools/knowledge.py` +- `ouroboros/tools/memory_tools.py` +- `ouroboros/world_profiler.py` +- `supervisor/evolution_lifecycle.py` + +### D16 — Observability, usage accounting & cost + +- `ouroboros/_usage_response.py` +- `ouroboros/_usage_rows.py` +- `ouroboros/_usage_rows_memo.py` +- `ouroboros/cost_projection.py` +- `ouroboros/observability.py` +- `ouroboros/usage_accounting.py` +- `ouroboros/usage_ledger.py` +- `ouroboros/usage_legacy_import.py` + +### D17 — Projects, workspaces & task results + +- `ouroboros/coop_checkpoint.py` +- `ouroboros/deliverables_paths.py` * +- `ouroboros/headless.py` +- `ouroboros/headless_status.py` +- `ouroboros/project_dialogue.py` +- `ouroboros/project_lease.py` +- `ouroboros/project_naming.py` +- `ouroboros/project_sources.py` +- `ouroboros/projects_registry.py` +- `ouroboros/retention.py` +- `ouroboros/task_result_schema.py` +- `ouroboros/task_results.py` +- `ouroboros/task_status.py` +- `ouroboros/tools/project_journal.py` +- `ouroboros/workspace_admission.py` +- `ouroboros/workspace_executor.py` +- `ouroboros/workspace_patch_capture.py` +- `ouroboros/workspace_patch_rules.py` +- `ouroboros/workspace_preflight.py` + +### D18 — Launcher, packaging, platform & shared substrate + +- `launcher.py` +- `ouroboros/__init__.py` +- `ouroboros/cli.py` +- `ouroboros/launcher_bootstrap.py` +- `ouroboros/launcher_server_reaper.py` +- `ouroboros/launcher_windows_runtime.py` +- `ouroboros/packaged_cli.py` +- `ouroboros/packaged_cli_install.py` +- `ouroboros/platform_layer.py` +- `ouroboros/utils.py` + +### D19 — Frozen contracts (ABI) + +- `ouroboros/contracts/__init__.py` +- `ouroboros/contracts/chat_id_policy.py` +- `ouroboros/contracts/plugin_api.py` +- `ouroboros/contracts/schema_versions.py` +- `ouroboros/contracts/skill_manifest.py` +- `ouroboros/contracts/skill_payload_policy.py` +- `ouroboros/contracts/task_constraint.py` +- `ouroboros/contracts/task_contract.py` +- `ouroboros/contracts/tool_abi.py` +- `ouroboros/contracts/tool_context.py` + +### D20 — Presence + +- `ouroboros/presence_admission.py` * +- `ouroboros/presence_authority.py` * +- `ouroboros/presence_bindings.py` * +- `ouroboros/presence_capabilities.py` * +- `ouroboros/presence_context.py` * +- `ouroboros/presence_profile.py` * +- `ouroboros/presence_runner.py` * +- `ouroboros/presence_runtime.py` * +- `ouroboros/tools/presence.py` * diff --git a/docs/v7next/DOMAIN_QUOTIENT_REPORT.md b/docs/v7next/DOMAIN_QUOTIENT_REPORT.md index 513acad96..4633044c2 100644 --- a/docs/v7next/DOMAIN_QUOTIENT_REPORT.md +++ b/docs/v7next/DOMAIN_QUOTIENT_REPORT.md @@ -1,13 +1,14 @@ # Domain quotient report — v7next (report-only) -Generated by `scripts/v7next_domain_report.py` on 2026-08-31 20:19 UTC. Do not edit. +Generated by `scripts/v7next_domain_report.py` on 2026-09-01 10:17 UTC. Do not edit. -- generated from the WORKING TREE based on HEAD `1633b54f68a5f3cf5e85a36bed3023637ad09de0` (the report is typically written pre-commit, so this HEAD is the parent of the commit that carries it, not that commit itself) -- analyzed-inputs content fingerprint: sha256 `48c2e5c1c35a9735fb5d8e836fa3d61390e646a27668103a85eb07fb9a3aef5e` (sorted tracked runtime files, path+bytes; regenerate on any tree and compare to verify freshness) -- manifest: `scripts/v7next_domains.toml` sha256 `cba30e0cc82a9dfb8e7e2534cb4a53d7bd7af8d292b9a1af23563c1fdcf79b13` +- generated from the WORKING TREE based on HEAD `5187fcdc47df5b29e7f195e640382f00497dfed8` (the report is typically written pre-commit, so this HEAD is the parent of the commit that carries it, not that commit itself) +- analyzed-inputs content fingerprint: sha256 `0d05c19101ac7e258c1a5259e9d87db81da493f8b4765dd11995541f196f324b` (sorted tracked runtime files, path+bytes; regenerate on any tree and compare to verify freshness) +- manifest: `ouroboros/domains.toml` sha256 `cc21d9edea0296d2eba418e50a55f513475075e21db82c7427b5f846547e8079` - reference (domain vocabulary): ouroboros_v7_wip @ 9f691656e7e756c483f72ef549418c0e7a1b8054 (frozen) -- discipline: plan §7.1 / roast F17 — this is a REPORT; cycles are owner-batch - items (regroup vs split vs allowed-edge), the checker never dictates. +- discipline: this is a REPORT with witness-level detail; the GATE over the + same data is `scripts/check_domains.py` (+ `tests/test_domain_manifest.py`), + which pins the manifest's `[graph]`/`[duplicates]` baseline sections. ## Population @@ -39,20 +40,16 @@ Generated by `scripts/v7next_domain_report.py` on 2026-08-31 20:19 UTC. Do not e ## Strict import graph -- module-level import edges (unconditional): **1561** unique, 5082 statements -- cross-domain module edges: **921** -- domain-level edges (quotient): **163** +- module-level import edges (unconditional): **1565** unique, 5104 statements +- cross-domain module edges: **924** +- domain-level edges (quotient): **164** - quotient cycles (SCCs with >1 domain): **1** ## Quotient cycles ### Cycle group 1: D01 ⇄ D02 ⇄ D03 ⇄ D04 ⇄ D05 ⇄ D06 ⇄ D07 ⇄ D08 ⇄ D09 ⇄ D10 ⇄ D11 ⇄ D12 ⇄ D13 ⇄ D14 ⇄ D15 ⇄ D16 ⇄ D17 ⇄ D18 ⇄ D19 ⇄ D20 -20 domains form one strongly connected component. Every edge below -needs an owner disposition (regroup / split / allowed-edge). -0 of the 921 module-edge witnesses touch a `[split_pending]` -monolith (marked †): those edges are expected to move or dissolve when the -ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). +20 domains form one strongly connected component with 924 module-edge witnesses. Every edge below needs an owner disposition (regroup / split / allowed-edge). - **D01 → D02** (14 module edges) - `ouroboros/agent.py` → `ouroboros/llm.py` (line 29) @@ -397,8 +394,8 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/review_cycles.py` → `ouroboros/outcomes.py` (line 65) - `ouroboros/review_execution.py` → `ouroboros/deadline_utils.py` (line 37) - `ouroboros/review_native_episode.py` → `ouroboros/deadline_utils.py` (line 26) - - `ouroboros/review_substrate.py` → `ouroboros/_outcome_receipts.py` (line 65) - - `ouroboros/review_substrate.py` → `ouroboros/outcomes.py` (line 119) + - `ouroboros/review_substrate.py` → `ouroboros/_outcome_receipts.py` (line 64) + - `ouroboros/review_substrate.py` → `ouroboros/outcomes.py` (line 118) - `ouroboros/review_verdict.py` → `ouroboros/outcomes.py` (line 17) - `ouroboros/review_verdict_extraction.py` → `ouroboros/deadline_utils.py` (line 17) - `ouroboros/tools/claude_advisory_review.py` → `ouroboros/deadline_utils.py` (line 34) @@ -451,7 +448,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/review_native_episode.py` → `ouroboros/usage_accounting.py` (line 30) - `ouroboros/review_session_usage.py` → `ouroboros/_usage_rows.py` (line 7) - `ouroboros/review_substrate.py` → `ouroboros/observability.py` (line 22) - - `ouroboros/review_substrate.py` → `ouroboros/usage_accounting.py` (line 57) + - `ouroboros/review_substrate.py` → `ouroboros/usage_accounting.py` (line 56) - `ouroboros/tools/plan_review_artifacts.py` → `ouroboros/usage_accounting.py` (line 16) - `ouroboros/tools/plan_review_runtime.py` → `ouroboros/usage_accounting.py` (line 29) - **D06 → D17** (3 module edges) @@ -468,7 +465,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/review_execution_projection.py` → `ouroboros/utils.py` (line 14) - `ouroboros/review_state.py` → `ouroboros/platform_layer.py` (line 18) - `ouroboros/review_state.py` → `ouroboros/utils.py` (line 13) - - `ouroboros/review_substrate.py` → `ouroboros/utils.py` (line 64) + - `ouroboros/review_substrate.py` → `ouroboros/utils.py` (line 63) - `ouroboros/task_continuation.py` → `ouroboros/utils.py` (line 10) - `ouroboros/tools/claude_advisory_review.py` → `ouroboros/utils.py` (line 57) - `ouroboros/tools/parallel_review.py` → `ouroboros/utils.py` (line 10) @@ -488,9 +485,10 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/delegate_supervision.py` → `ouroboros/owner_mailbox.py` (line 12) - `ouroboros/subagent_dispatch_notes.py` → `ouroboros/agent_dispatch.py` (line 68) - `ouroboros/tools/control_task_results.py` → `ouroboros/outcomes.py` (line 17) -- **D07 → D02** (2 module edges) +- **D07 → D02** (3 module edges) - `ouroboros/configured_subagents.py` → `ouroboros/route_spec.py` (line 11) - `ouroboros/subagent_runtime.py` → `ouroboros/route_spec.py` (line 26) + - `ouroboros/tools/delegate.py` → `ouroboros/provider_models.py` (line 39) - **D07 → D04** (21 module edges) - `ouroboros/delegate_interactions.py` → `ouroboros/tool_capabilities.py` (line 24) - `ouroboros/delegate_interactions.py` → `ouroboros/tools/registry.py` (line 25) @@ -503,8 +501,8 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/tools/control_scheduling.py` → `ouroboros/tools/tool_result.py` (line 60) - `ouroboros/tools/control_task_results.py` → `ouroboros/tools/registry.py` (line 24) - `ouroboros/tools/control_task_results.py` → `ouroboros/tools/tool_result.py` (line 26) - - `ouroboros/tools/delegate.py` → `ouroboros/tool_capabilities.py` (line 39) - - `ouroboros/tools/delegate.py` → `ouroboros/tools/registry.py` (line 40) + - `ouroboros/tools/delegate.py` → `ouroboros/tool_capabilities.py` (line 40) + - `ouroboros/tools/delegate.py` → `ouroboros/tools/registry.py` (line 41) - `ouroboros/tools/delegate_integration.py` → `ouroboros/tools/registry.py` (line 24) - `ouroboros/tools/delegate_payload_patch.py` → `ouroboros/tools/registry.py` (line 20) - `ouroboros/tools/join_ledger.py` → `ouroboros/tools/registry.py` (line 33) @@ -533,9 +531,10 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - **D07 → D13** (2 module edges) - `ouroboros/tools/subagent_integration.py` → `ouroboros/runtime_mode_policy.py` (line 30) - `ouroboros/tools/subagent_integration_delegated.py` → `ouroboros/runtime_mode_policy.py` (line 26) -- **D07 → D16** (2 module edges) +- **D07 → D16** (3 module edges) - `ouroboros/delegate_custody.py` → `ouroboros/_usage_rows.py` (line 30) - `ouroboros/delegate_custody_reconcile.py` → `ouroboros/_usage_rows.py` (line 15) + - `ouroboros/subagents.py` → `ouroboros/cost_projection.py` (line 36) - **D07 → D17** (11 module edges) - `ouroboros/subagent_worktrees.py` → `ouroboros/retention.py` (line 34) - `ouroboros/task_tree_ledger.py` → `ouroboros/task_results.py` (line 23) @@ -886,7 +885,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/settings_setup_contract.py` → `ouroboros/task_pacing.py` (line 20) - **D12 → D02** (5 module edges) - `ouroboros/config.py` → `ouroboros/provider_models.py` (line 25) - - `ouroboros/review_model_routes.py` → `ouroboros/provider_models.py` (line 19) + - `ouroboros/review_model_routes.py` → `ouroboros/provider_models.py` (line 20) - `ouroboros/settings_setup_contract.py` → `ouroboros/provider_models.py` (line 9) - `ouroboros/subscription_install_presets.py` → `ouroboros/provider_models.py` (line 38) - `ouroboros/subscription_install_presets.py` → `ouroboros/route_spec.py` (line 41) @@ -921,11 +920,11 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/extension_companion.py` → `ouroboros/provider_models.py` (line 17) - `ouroboros/extension_loader.py` → `ouroboros/provider_models.py` (line 52) - `ouroboros/extension_plugin_api.py` → `ouroboros/provider_models.py` (line 77) - - `ouroboros/extension_process_runner.py` → `ouroboros/provider_models.py` (line 30) + - `ouroboros/extension_process_runner.py` → `ouroboros/provider_models.py` (line 32) - `ouroboros/tools/skill_exec.py` → `ouroboros/provider_models.py` (line 18) - `ouroboros/tools/skill_publish.py` → `ouroboros/llm.py` (line 24) - **D14 → D04** (12 module edges) - - `ouroboros/extension_process_runner.py` → `ouroboros/tools/registry.py` (line 33) + - `ouroboros/extension_process_runner.py` → `ouroboros/tools/registry.py` (line 35) - `ouroboros/skill_owner_attestation.py` → `ouroboros/tool_access.py` (line 31) - `ouroboros/skill_publish_github.py` → `ouroboros/tools/registry.py` (line 13) - `ouroboros/skill_publish_result.py` → `ouroboros/tool_capabilities.py` (line 16) @@ -938,7 +937,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/tools/skill_publish.py` → `ouroboros/tool_access.py` (line 54) - `ouroboros/tools/skill_publish.py` → `ouroboros/tools/registry.py` (line 61) - **D14 → D05** (3 module edges) - - `ouroboros/extension_process_runner.py` → `ouroboros/tools/shell.py` (line 34) + - `ouroboros/extension_process_runner.py` → `ouroboros/tools/shell.py` (line 36) - `ouroboros/tools/skill_exec.py` → `ouroboros/tools/shell.py` (line 39) - `ouroboros/tools/skill_preflight.py` → `ouroboros/tools/shell.py` (line 25) - **D14 → D06** (10 module edges) @@ -969,7 +968,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/tools/skill_publish.py` → `ouroboros/config.py` (line 14) - **D14 → D16** (6 module edges) - `ouroboros/extension_companion.py` → `ouroboros/usage_accounting.py` (line 18) - - `ouroboros/extension_process_runner.py` → `ouroboros/usage_accounting.py` (line 36) + - `ouroboros/extension_process_runner.py` → `ouroboros/usage_accounting.py` (line 38) - `ouroboros/skill_review_usage.py` → `ouroboros/_usage_rows.py` (line 8) - `ouroboros/skill_review_usage.py` → `ouroboros/_usage_rows_memo.py` (line 9) - `ouroboros/skill_review_usage.py` → `ouroboros/usage_ledger.py` (line 10) @@ -979,10 +978,10 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/extension_companion.py` → `ouroboros/platform_layer.py` (line 16) - `ouroboros/extension_companion.py` → `ouroboros/utils.py` (line 19) - `ouroboros/extension_health.py` → `ouroboros/utils.py` (line 19) - - `ouroboros/extension_loader.py` → `ouroboros/utils.py` (line 124) - - `ouroboros/extension_plugin_api.py` → `ouroboros/utils.py` (line 81) - - `ouroboros/extension_process_runner.py` → `ouroboros/platform_layer.py` (line 31) - - `ouroboros/extension_process_runner.py` → `ouroboros/utils.py` (line 37) + - `ouroboros/extension_loader.py` → `ouroboros/utils.py` (line 128) + - `ouroboros/extension_plugin_api.py` → `ouroboros/utils.py` (line 80) + - `ouroboros/extension_process_runner.py` → `ouroboros/platform_layer.py` (line 33) + - `ouroboros/extension_process_runner.py` → `ouroboros/utils.py` (line 39) - `ouroboros/extension_reconcile_queue.py` → `ouroboros/utils.py` (line 19) - `ouroboros/marketplace/adapter.py` → `ouroboros/utils.py` (line 25) - `ouroboros/marketplace/install.py` → `ouroboros/utils.py` (line 41) @@ -996,7 +995,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/skill_publish_scanner.py` → `ouroboros/platform_layer.py` (line 20) - `ouroboros/skill_repair_admission.py` → `ouroboros/utils.py` (line 28) - `ouroboros/skill_review.py` → `ouroboros/utils.py` (line 57) - - `ouroboros/skill_review_cycles.py` → `ouroboros/utils.py` (line 53) + - `ouroboros/skill_review_cycles.py` → `ouroboros/utils.py` (line 54) - `ouroboros/skill_review_history.py` → `ouroboros/platform_layer.py` (line 11) - `ouroboros/skill_review_history.py` → `ouroboros/utils.py` (line 13) - `ouroboros/skill_review_output.py` → `ouroboros/utils.py` (line 29) @@ -1008,7 +1007,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/tools/skill_preflight.py` → `ouroboros/platform_layer.py` (line 26) - `ouroboros/tools/skill_publish.py` → `ouroboros/utils.py` (line 62) - **D14 → D19** (15 module edges) - - `ouroboros/extension_child_catalog.py` → `ouroboros/contracts/plugin_api.py` (line 13) + - `ouroboros/extension_child_catalog.py` → `ouroboros/contracts/plugin_api.py` (line 16) - `ouroboros/extension_health.py` → `ouroboros/contracts/schema_versions.py` (line 17) - `ouroboros/extension_loader.py` → `ouroboros/contracts/plugin_api.py` (line 33) - `ouroboros/extension_plugin_api.py` → `ouroboros/contracts/plugin_api.py` (line 27) @@ -1027,7 +1026,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/consciousness.py` → `ouroboros/loop_tool_execution.py` (line 37) - `ouroboros/evolution_checkpoints.py` → `ouroboros/outcomes.py` (line 10) - `ouroboros/reflection.py` → `ouroboros/_outcome_tool_errors.py` (line 10) - - `supervisor/evolution_lifecycle.py` → `ouroboros/outcomes.py` (line 19) + - `supervisor/evolution_lifecycle.py` → `ouroboros/outcomes.py` (line 20) - **D15 → D02** (2 module edges) - `ouroboros/consciousness.py` → `ouroboros/llm.py` (line 36) - `ouroboros/consciousness.py` → `ouroboros/pricing.py` (line 40) @@ -1040,6 +1039,8 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/tools/memory_tools.py` → `ouroboros/tools/registry.py` (line 8) - **D15 → D12** (1 module edges) - `ouroboros/consciousness.py` → `ouroboros/config.py` (line 21) +- **D15 → D16** (1 module edges) + - `supervisor/evolution_lifecycle.py` → `ouroboros/cost_projection.py` (line 18) - **D15 → D18** (14 module edges) - `ouroboros/consciousness.py` → `ouroboros/platform_layer.py` (line 39) - `ouroboros/consciousness.py` → `ouroboros/utils.py` (line 41) @@ -1054,7 +1055,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/tools/knowledge.py` → `ouroboros/platform_layer.py` (line 14) - `ouroboros/tools/knowledge.py` → `ouroboros/utils.py` (line 13) - `ouroboros/world_profiler.py` → `ouroboros/platform_layer.py` (line 5) - - `supervisor/evolution_lifecycle.py` → `ouroboros/utils.py` (line 20) + - `supervisor/evolution_lifecycle.py` → `ouroboros/utils.py` (line 21) - **D15 → D19** (2 module edges) - `ouroboros/consolidator.py` → `ouroboros/contracts/chat_id_policy.py` (line 8) - `ouroboros/memory.py` → `ouroboros/contracts/chat_id_policy.py` (line 12) @@ -1091,7 +1092,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). - `ouroboros/project_sources.py` → `ouroboros/platform_layer.py` (line 31) - `ouroboros/projects_registry.py` → `ouroboros/utils.py` (line 28) - `ouroboros/task_result_schema.py` → `ouroboros/utils.py` (line 30) - - `ouroboros/task_results.py` → `ouroboros/utils.py` (line 13) + - `ouroboros/task_results.py` → `ouroboros/utils.py` (line 17) - `ouroboros/task_status.py` → `ouroboros/utils.py` (line 41) - `ouroboros/tools/project_journal.py` → `ouroboros/utils.py` (line 28) - `ouroboros/workspace_admission.py` → `ouroboros/platform_layer.py` (line 35) @@ -1201,14 +1202,14 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). | D06 | D18 | 23 | yes | | D06 | D19 | 1 | yes | | D07 | D01 | 3 | yes | -| D07 | D02 | 2 | yes | +| D07 | D02 | 3 | yes | | D07 | D04 | 21 | yes | | D07 | D05 | 1 | yes | | D07 | D06 | 2 | yes | | D07 | D08 | 1 | yes | | D07 | D12 | 9 | yes | | D07 | D13 | 2 | yes | -| D07 | D16 | 2 | yes | +| D07 | D16 | 3 | yes | | D07 | D17 | 11 | yes | | D07 | D18 | 19 | yes | | D07 | D19 | 5 | yes | @@ -1282,6 +1283,7 @@ ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3). | D15 | D03 | 3 | yes | | D15 | D04 | 2 | yes | | D15 | D12 | 1 | yes | +| D15 | D16 | 1 | yes | | D15 | D18 | 14 | yes | | D15 | D19 | 2 | yes | | D16 | D02 | 2 | yes | @@ -1319,7 +1321,7 @@ Rows import columns (module-edge counts, strict graph). | **D04** | · | · | · | · | 5 | · | · | · | · | · | · | · | 11 | · | · | · | 2 | 2 | 5 | · | | **D05** | 8 | 1 | · | 38 | · | · | · | · | · | 1 | 1 | 6 | 13 | · | 2 | 4 | 8 | 18 | 5 | · | | **D06** | 10 | 7 | 1 | 11 | · | · | · | · | · | 1 | · | 11 | 1 | 1 | · | 10 | 3 | 23 | 1 | · | -| **D07** | 3 | 2 | · | 21 | 1 | 2 | · | 1 | · | · | · | 9 | 2 | · | · | 2 | 11 | 19 | 5 | · | +| **D07** | 3 | 3 | · | 21 | 1 | 2 | · | 1 | · | · | · | 9 | 2 | · | · | 3 | 11 | 19 | 5 | · | | **D08** | 10 | · | · | 12 | 1 | 1 | 15 | · | 5 | · | · | 8 | · | 3 | 1 | 3 | 13 | 27 | 9 | · | | **D09** | 3 | · | · | · | · | 1 | · | 4 | · | · | · | · | · | · | · | · | · | 10 | · | · | | **D10** | · | · | · | 16 | 1 | 7 | · | 1 | · | · | · | 2 | 2 | · | · | · | · | 9 | 2 | · | @@ -1327,7 +1329,7 @@ Rows import columns (module-edge counts, strict graph). | **D12** | 1 | 5 | 1 | · | · | · | 1 | · | · | · | 1 | · | · | · | · | · | · | 3 | · | · | | **D13** | 1 | 2 | · | 2 | · | · | · | 1 | · | · | · | 1 | · | · | · | · | 1 | 2 | · | · | | **D14** | · | 6 | · | 12 | 3 | 10 | · | · | 1 | 2 | 2 | 6 | · | · | · | 6 | · | 32 | 15 | · | -| **D15** | 4 | 2 | 3 | 2 | · | · | · | · | · | · | · | 1 | · | · | · | · | · | 14 | 2 | · | +| **D15** | 4 | 2 | 3 | 2 | · | · | · | · | · | · | · | 1 | · | · | · | 1 | · | 14 | 2 | · | | **D16** | · | 2 | · | · | · | 1 | · | · | · | · | · | · | · | 1 | · | · | · | 4 | · | · | | **D17** | 4 | · | · | 2 | · | · | · | · | · | · | · | · | · | · | 2 | 2 | · | 15 | 5 | · | | **D18** | · | · | · | · | · | · | · | · | 1 | 1 | · | 2 | · | · | · | · | · | · | · | · | @@ -1336,7 +1338,7 @@ Rows import columns (module-edge counts, strict graph). ## Lazy imports (function-level) — excluded from the strict graph -1757 module edges, 229 cross-domain pairs. +1762 module edges, 229 cross-domain pairs. | from | to | module edges | pair also strict? | |---|---|---:|:---:| @@ -1359,7 +1361,7 @@ Rows import columns (module-edge counts, strict graph). | D01 | D19 | 2 | yes | | D02 | D03 | 3 | yes | | D02 | D09 | 1 | **lazy-only** | -| D02 | D12 | 13 | yes | +| D02 | D12 | 14 | yes | | D02 | D16 | 6 | yes | | D02 | D18 | 3 | yes | | D03 | D01 | 2 | yes | @@ -1488,7 +1490,7 @@ Rows import columns (module-edge counts, strict graph). | D11 | D12 | 18 | yes | | D11 | D14 | 31 | yes | | D11 | D15 | 8 | **lazy-only** | -| D11 | D16 | 8 | yes | +| D11 | D16 | 9 | yes | | D11 | D17 | 25 | yes | | D11 | D18 | 12 | yes | | D11 | D19 | 2 | yes | @@ -1515,7 +1517,7 @@ Rows import columns (module-edge counts, strict graph). | D14 | D13 | 1 | **lazy-only** | | D14 | D16 | 3 | yes | | D14 | D18 | 11 | yes | -| D14 | D19 | 5 | yes | +| D14 | D19 | 6 | yes | | D14 | D20 | 1 | **lazy-only** | | D15 | D01 | 1 | yes | | D15 | D02 | 11 | yes | @@ -1526,7 +1528,7 @@ Rows import columns (module-edge counts, strict graph). | D15 | D09 | 1 | **lazy-only** | | D15 | D10 | 2 | **lazy-only** | | D15 | D12 | 6 | yes | -| D15 | D16 | 2 | **lazy-only** | +| D15 | D16 | 2 | yes | | D15 | D17 | 1 | **lazy-only** | | D15 | D18 | 6 | yes | | D16 | D02 | 4 | yes | @@ -1851,9 +1853,6 @@ Cross-domain pairs reachable ONLY through lazy imports (hidden coupling): - **D15 → D10**: - `supervisor/evolution_lifecycle.py` → `supervisor/git_ops.py` - `supervisor/evolution_lifecycle.py` → `supervisor/update_merge.py` -- **D15 → D16**: - - `ouroboros/consciousness.py` → `ouroboros/usage_accounting.py` - - `ouroboros/reflection.py` → `ouroboros/observability.py` - **D15 → D17**: - `ouroboros/project_facts.py` → `ouroboros/projects_registry.py` - **D16 → D03**: @@ -1945,7 +1944,7 @@ Cross-domain pairs reachable ONLY through guarded imports (hidden coupling): ## TYPE_CHECKING imports — excluded from the strict graph -39 module edges, 7 cross-domain pairs. +41 module edges, 7 cross-domain pairs. | from | to | module edges | pair also strict? | |---|---|---:|:---:| diff --git a/scripts/v7next_domains.toml b/ouroboros/domains.toml similarity index 79% rename from scripts/v7next_domains.toml rename to ouroboros/domains.toml index 357e4b6ad..83d8035f0 100644 --- a/scripts/v7next_domains.toml +++ b/ouroboros/domains.toml @@ -1,23 +1,31 @@ -# v7next domains manifest — module -> domain, 1:1, for THIS tree (upstream monoliths). -# Ф0 evidence artifact (plan §7.1; roast F17 discipline: report first, gate later). +# Production domain manifest — module -> domain, 1:1, for every runtime module +# (plan §7.1, CPL-1). The Ф0 report-only stage lived at scripts/v7next_domains.toml; +# this file is its production successor and the SSOT the domain gates read. # -# Derivation: -# - domain vocabulary and 411-module assignment parsed from the frozen reference -# worktree ouroboros_v7_wip @ 9f691656 docs/DOMAIN_MAP.md (per-domain Owners prose; -# per-domain counts reproduce the map's index table exactly: 27/20/10/20/22/46/28/ -# 38/12/27/38/13/6/43/15/8/17/10/11); -# - monolith files of THIS tree keep the domain of their v7 facade; ledger-derived -# leaves absent here are recorded in [split_pending] (MIGRATION_v7.md is the oracle); -# - the 80 upstream modules absent from the v7 map are classified by reading each -# header/docstring/imports and are listed in [classification].proposed — every such -# row is a proposal for owner review, not a settled assignment. +# Structure: +# - [domains] / [modules] / [classification] are HUMAN sections: the domain +# vocabulary and the 1:1 assignment. Completeness against the live tree is +# enforced by scripts/check_domains.py + tests/test_domain_manifest.py +# (a tracked module without a row = red). +# - [graph] and [duplicates] are GENERATED sections (regenerate with +# `python scripts/check_domains.py --write`): today's factual dependency +# matrix, cycle ceiling, lazy/dynamic classification and the literal-copy +# baseline, pinned as data. Baseline = current reality; tightening any of +# it is a separate owner decision (plan §7.1 discipline). +# - docs/DOMAIN_MAP.md is generated from this manifest (same --write). +# +# Derivation of the assignment: +# - domain vocabulary and the original 411-module assignment parsed from the +# frozen reference worktree ouroboros_v7_wip @ 9f691656 docs/DOMAIN_MAP.md; +# - upstream modules absent from the v7 map were classified by reading each +# header/docstring/imports and are listed in [classification].proposed — +# every such row is a proposal for owner review, not a settled assignment. # # The seven package __init__.py files go with their package (DOMAIN_MAP Method note); # ouroboros/gateways/__init__.py is placed with D07 (its residents split D06/D07 — # claude_code.py is D06 transport, claudexor.py is D07). [meta] -target_tree = "ouroboros_v7next @ b9f7597f7baf3f0d613ed2fab2936cb3540f2487" reference_tree = "ouroboros_v7_wip @ 9f691656e7e756c483f72ef549418c0e7a1b8054 (frozen)" population_rule = "git-tracked *.py under ouroboros/ and supervisor/, plus server.py and launcher.py" @@ -618,22 +626,290 @@ proposed = [ "supervisor/update_candidate.py", ] -[split_pending] -# monolith -> domains of its ledger-derived leaves still absent from this tree -# (own domain included). A list longer than one element = the monolith spans domains. -# F2.3b resolved rows (owner decision 5.1=A lane, 2026-08-31): the advisory -# monolith split landed as preflight_review_prompt/preflight_review_run — -# re-derived from tip native-episode bytes under the organ's public rename -# (the reference leaf names review_advisory_prompt/review_advisory_run carried -# the retired SDK transport); scope_review's budget leaf landed as the -# reference-named scope_review_budget re-derived after PR #383. -# F2.2 resolved rows (owner decisions 2026-08-31): the D08 event/queue/worker -# families landed as reference-named leaves from tip bytes; the remaining -# reference leaves were retired by the owner — supervisor/cancel_custody.py -# (Q-a=A: the settle owner stays in task_lifecycle.py, upstream cut = floor) -# and supervisor/queue_evolution.py (Q-b=A: upstream already forked the -# evolution family into evolution_lifecycle.py; the two-symbol remainder -# stays on the queue facade). +# The Ф0 [split_pending]/[split_pending_leaves] sections are retired: every row +# was resolved during F2 (dispositions recorded in docs/v7next/LEDGER_CORRECTIONS.md, +# owner decisions 2026-08-31). -[split_pending_leaves] -# the exact absent leaf modules per monolith (ledger owner paths) +# --- BEGIN GENERATED (python scripts/check_domains.py --write) --- +# Factual dependency data of the live tree, pinned as baseline data. +# Regenerate with `python scripts/check_domains.py --write`; the diff of +# this block is the reviewed decision surface for every new direction, +# cycle change, hidden coupling, or cross-domain literal copy. + +[graph] +# Strict cross-domain dependency directions (from->to). Absent = forbidden. +allowed = [ + "D01->D02", + "D01->D03", + "D01->D04", + "D01->D05", + "D01->D06", + "D01->D07", + "D01->D09", + "D01->D12", + "D01->D13", + "D01->D14", + "D01->D15", + "D01->D16", + "D01->D17", + "D01->D18", + "D01->D19", + "D01->D20", + "D02->D01", + "D02->D03", + "D02->D12", + "D02->D16", + "D02->D18", + "D03->D01", + "D03->D02", + "D03->D04", + "D03->D12", + "D03->D15", + "D03->D18", + "D03->D19", + "D04->D05", + "D04->D13", + "D04->D17", + "D04->D18", + "D04->D19", + "D05->D01", + "D05->D02", + "D05->D04", + "D05->D10", + "D05->D11", + "D05->D12", + "D05->D13", + "D05->D15", + "D05->D16", + "D05->D17", + "D05->D18", + "D05->D19", + "D06->D01", + "D06->D02", + "D06->D03", + "D06->D04", + "D06->D10", + "D06->D12", + "D06->D13", + "D06->D14", + "D06->D16", + "D06->D17", + "D06->D18", + "D06->D19", + "D07->D01", + "D07->D02", + "D07->D04", + "D07->D05", + "D07->D06", + "D07->D08", + "D07->D12", + "D07->D13", + "D07->D16", + "D07->D17", + "D07->D18", + "D07->D19", + "D08->D01", + "D08->D04", + "D08->D05", + "D08->D06", + "D08->D07", + "D08->D09", + "D08->D12", + "D08->D14", + "D08->D15", + "D08->D16", + "D08->D17", + "D08->D18", + "D08->D19", + "D09->D01", + "D09->D06", + "D09->D08", + "D09->D18", + "D10->D04", + "D10->D05", + "D10->D06", + "D10->D08", + "D10->D12", + "D10->D13", + "D10->D18", + "D10->D19", + "D11->D01", + "D11->D02", + "D11->D03", + "D11->D04", + "D11->D05", + "D11->D07", + "D11->D08", + "D11->D09", + "D11->D10", + "D11->D12", + "D11->D14", + "D11->D16", + "D11->D17", + "D11->D18", + "D11->D19", + "D11->D20", + "D12->D01", + "D12->D02", + "D12->D03", + "D12->D07", + "D12->D11", + "D12->D18", + "D13->D01", + "D13->D02", + "D13->D04", + "D13->D08", + "D13->D12", + "D13->D17", + "D13->D18", + "D14->D02", + "D14->D04", + "D14->D05", + "D14->D06", + "D14->D09", + "D14->D10", + "D14->D11", + "D14->D12", + "D14->D16", + "D14->D18", + "D14->D19", + "D15->D01", + "D15->D02", + "D15->D03", + "D15->D04", + "D15->D12", + "D15->D16", + "D15->D18", + "D15->D19", + "D16->D02", + "D16->D06", + "D16->D14", + "D16->D18", + "D17->D01", + "D17->D04", + "D17->D15", + "D17->D16", + "D17->D18", + "D17->D19", + "D18->D09", + "D18->D10", + "D18->D12", + "D19->D14", + "D19->D18", + "D20->D04", + "D20->D05", + "D20->D14", + "D20->D15", + "D20->D17", + "D20->D18", + "D20->D19", +] +# Domains locked in strict-quotient cycles (SCC ceiling; the target is []). +cycle_groups = [ + ["D01", "D02", "D03", "D04", "D05", "D06", "D07", "D08", "D09", "D10", "D11", "D12", "D13", "D14", "D15", "D16", "D17", "D18", "D19", "D20"], +] +# Cross-domain pairs reachable ONLY through function-level (lazy) imports — +# hidden coupling, classified out of the strict graph. +lazy_only = [ + "D01->D08", + "D01->D10", + "D01->D11", + "D02->D09", + "D03->D05", + "D03->D06", + "D03->D07", + "D03->D08", + "D03->D10", + "D03->D11", + "D03->D14", + "D03->D16", + "D03->D17", + "D03->D20", + "D04->D06", + "D04->D10", + "D04->D12", + "D04->D14", + "D04->D20", + "D05->D06", + "D05->D07", + "D05->D09", + "D05->D14", + "D06->D05", + "D06->D07", + "D06->D08", + "D06->D09", + "D06->D15", + "D07->D09", + "D07->D10", + "D07->D11", + "D07->D14", + "D07->D15", + "D07->D20", + "D08->D02", + "D08->D10", + "D08->D11", + "D09->D02", + "D09->D05", + "D09->D07", + "D09->D11", + "D09->D12", + "D09->D14", + "D09->D15", + "D09->D16", + "D09->D17", + "D10->D01", + "D10->D14", + "D10->D15", + "D10->D17", + "D11->D06", + "D11->D15", + "D12->D05", + "D12->D06", + "D12->D10", + "D12->D17", + "D13->D05", + "D13->D06", + "D13->D15", + "D14->D08", + "D14->D13", + "D14->D20", + "D15->D06", + "D15->D07", + "D15->D08", + "D15->D09", + "D15->D10", + "D15->D17", + "D16->D03", + "D16->D05", + "D16->D12", + "D16->D17", + "D17->D02", + "D17->D03", + "D17->D05", + "D17->D07", + "D17->D09", + "D17->D12", + "D18->D01", + "D18->D07", + "D18->D11", + "D18->D14", + "D18->D16", + "D18->D17", + "D18->D19", + "D19->D04", + "D19->D08", + "D19->D20", + "D20->D01", + "D20->D07", + "D20->D11", + "D20->D12", +] +# Cross-domain pairs created by resolved dynamic imports (importlib/__import__). +dynamic_pairs = [] + +[duplicates] +# Literal-copy baseline: normalized function bodies (>= 10 normalized lines) +# present in modules of MORE THAN ONE domain. A new row is a banned +# literal-copy "fix" of a dependency edge; shrinking this list is progress. +allowed = [] +# --- END GENERATED --- diff --git a/pyproject.toml b/pyproject.toml index 77572651b..8dc7a669f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,9 @@ py-modules = ["server"] include = ["ouroboros*", "supervisor*", "web*"] [tool.setuptools.package-data] +ouroboros = [ + "domains.toml", +] web = [ "*.html", "*.js", diff --git a/scripts/check_domains.py b/scripts/check_domains.py new file mode 100644 index 000000000..3ef6ecdd7 --- /dev/null +++ b/scripts/check_domains.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Domain manifest gate (plan §7.1, CPL-1) over ``ouroboros/domains.toml``. + +Checks (exit 0 green, 1 findings, 2 structurally broken inputs): + +1. **Completeness** — the manifest's ``[modules]`` section equals the tracked + population (every runtime module has exactly one domain row; a tracked + module without a row = red, a stale row = red). +2. **Direction matrix** — the strict (unconditional module-level) cross-domain + dependency pairs of the live tree equal the pinned ``[graph].allowed`` + baseline. The baseline is today's FACTUAL matrix, not an aspiration + (plan §7.1: current reality = baseline; tightening = a separate owner + decision). A new direction = red until deliberately regenerated, so the + diff of this data file is where the decision becomes visible. +3. **Cycles** — the strict quotient's cycle groups (SCCs > 1 domain) equal + the pinned ``[graph].cycle_groups`` ceiling. A cycle group that grows, or + a new group, is red with its exact witnesses; the terminal target is + ``cycle_groups = []`` — zero cycles on domain nodes. +4. **Lazy/dynamic classification** — cross-domain pairs reachable ONLY + through function-level imports (``lazy_only``) and through resolved + dynamic imports (``dynamic_pairs``) equal their pinned inventories. +5. **Literal-copy ban** — normalized function bodies appearing in more than + one domain equal the pinned ``[duplicates].allowed`` baseline. "Fixing" a + cycle by copying the body across the boundary instead of importing it is + exactly what turns this red. +6. **DOMAIN_MAP.md** — the generated map equals what the manifest renders + (gen/verify pair: regeneration must not change the committed file). + +``--write`` regenerates the manifest's generated sections and +``docs/DOMAIN_MAP.md`` from the live tree; the resulting diff is the reviewed +decision surface. ``tests/test_domain_manifest.py`` runs the same checks as +the CI verify half of the pair. +""" +from __future__ import annotations + +import argparse +import pathlib +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.domain_graph import ( # noqa: E402 + DOMAIN_MAP_PATH, + MANIFEST_PATH, + Manifest, + build_import_graph, + computed_graph_sections, + duplicate_bodies, + load_manifest, + tracked_population, +) + +GENERATED_BEGIN = "# --- BEGIN GENERATED (python scripts/check_domains.py --write) ---" +GENERATED_END = "# --- END GENERATED ---" + + +def _toml_str_list(values: list[str], indent: str = " ") -> str: + if not values: + return "[]" + body = "\n".join(f'{indent}"{v}",' for v in values) + return f"[\n{body}\n]" + + +def _toml_group_list(groups: list[list[str]]) -> str: + if not groups: + return "[]" + rows = [] + for group in groups: + inner = ", ".join(f'"{d}"' for d in group) + rows.append(f" [{inner}],") + return "[\n" + "\n".join(rows) + "\n]" + + +def render_generated_block(sections: dict[str, list], duplicates: list[str]) -> str: + L: list[str] = [GENERATED_BEGIN] + L.append("# Factual dependency data of the live tree, pinned as baseline data.") + L.append("# Regenerate with `python scripts/check_domains.py --write`; the diff of") + L.append("# this block is the reviewed decision surface for every new direction,") + L.append("# cycle change, hidden coupling, or cross-domain literal copy.") + L.append("") + L.append("[graph]") + L.append("# Strict cross-domain dependency directions (from->to). Absent = forbidden.") + L.append(f"allowed = {_toml_str_list(sections['allowed'])}") + L.append("# Domains locked in strict-quotient cycles (SCC ceiling; the target is []).") + L.append(f"cycle_groups = {_toml_group_list(sections['cycle_groups'])}") + L.append("# Cross-domain pairs reachable ONLY through function-level (lazy) imports —") + L.append("# hidden coupling, classified out of the strict graph.") + L.append(f"lazy_only = {_toml_str_list(sections['lazy_only'])}") + L.append("# Cross-domain pairs created by resolved dynamic imports (importlib/__import__).") + L.append(f"dynamic_pairs = {_toml_str_list(sections['dynamic_pairs'])}") + L.append("") + L.append("[duplicates]") + L.append("# Literal-copy baseline: normalized function bodies (>= 10 normalized lines)") + L.append("# present in modules of MORE THAN ONE domain. A new row is a banned") + L.append('# literal-copy "fix" of a dependency edge; shrinking this list is progress.') + L.append(f"allowed = {_toml_str_list(duplicates)}") + L.append(GENERATED_END) + return "\n".join(L) + "\n" + + +def manifest_with_generated_block(raw_text: str, block: str) -> str: + begin = raw_text.find(GENERATED_BEGIN) + if begin == -1: + base = raw_text.rstrip("\n") + return f"{base}\n\n{block}" + end = raw_text.find(GENERATED_END, begin) + if end == -1: + raise ValueError("manifest has BEGIN GENERATED marker but no END marker") + end += len(GENERATED_END) + tail = raw_text[end:].lstrip("\n") + head = raw_text[:begin].rstrip("\n") + out = f"{head}\n\n{block}" + if tail: + out += "\n" + tail + return out + + +def render_domain_map(manifest: Manifest) -> str: + """docs/DOMAIN_MAP.md — rendered from the manifest ONLY (no live-tree + reads), so it drifts exactly when the manifest drifts.""" + mods_by_domain: dict[str, list[str]] = {d: [] for d in sorted(manifest.domains)} + for path in sorted(manifest.modules): + mods_by_domain[manifest.modules[path]].append(path) + + L: list[str] = [] + L.append("# Domain map — v7next") + L.append("") + L.append("Generated from `ouroboros/domains.toml` by `python scripts/check_domains.py" + " --write`. Do not edit — edit the manifest and regenerate;" + " `tests/test_domain_manifest.py` pins byte-identity.") + L.append("") + L.append("The manifest is the SSOT of the module→domain assignment (1:1, complete" + " over the tracked runtime population) and pins today's factual" + " cross-domain dependency data as baseline; the gate contract is" + " described in `scripts/check_domains.py`.") + L.append("") + L.append("## Index") + L.append("") + L.append("| domain | name | modules | proposed |") + L.append("|---|---|---:|---:|") + total = 0 + total_prop = 0 + for d in sorted(manifest.domains): + n = len(mods_by_domain[d]) + n_prop = sum(1 for p in mods_by_domain[d] if p in manifest.proposed) + total += n + total_prop += n_prop + L.append(f"| {d} | {manifest.domains[d]} | {n} | {n_prop} |") + L.append(f"| **total** | | **{total}** | **{total_prop}** |") + L.append("") + + used = sorted(manifest.domains) + allowed_pairs = {tuple(p.split("->")) for p in manifest.graph_allowed} + L.append("## Dependency direction matrix (strict, pinned)") + L.append("") + L.append("Rows may import columns (`[graph].allowed`). `·` = forbidden direction.") + L.append("") + L.append("| ↓ imports → | " + " | ".join(used) + " |") + L.append("|---|" + "---|" * len(used)) + for d1 in used: + row = [f"| **{d1}**"] + for d2 in used: + row.append("✓" if (d1, d2) in allowed_pairs else "·") + L.append(" | ".join(row) + " |") + L.append("") + + L.append("## Cycle status") + L.append("") + if not manifest.cycle_groups: + L.append("The strict domain quotient is **acyclic** (`cycle_groups = []`).") + else: + L.append(f"{len(manifest.cycle_groups)} pinned cycle group(s) — the SCC ceiling;" + " the target is zero. Witness-level detail lives in" + " `docs/v7next/DOMAIN_QUOTIENT_REPORT.md`.") + L.append("") + for i, group in enumerate(manifest.cycle_groups, 1): + L.append(f"- group {i} ({len(group)} domains): {' ⇄ '.join(group)}") + L.append("") + + L.append("## Hidden coupling (classified out of the strict graph)") + L.append("") + L.append(f"- lazy-only cross-domain pairs: **{len(manifest.lazy_only)}**") + for p in manifest.lazy_only: + L.append(f" - {p}") + L.append(f"- dynamic-import cross-domain pairs: **{len(manifest.dynamic_pairs)}**") + for p in manifest.dynamic_pairs: + L.append(f" - {p}") + L.append("") + + L.append("## Literal-copy baseline") + L.append("") + if not manifest.duplicates_allowed: + L.append("No function body (≥ 10 normalized lines) is shared verbatim across" + " domains. New occurrences turn the gate red.") + else: + L.append(f"{len(manifest.duplicates_allowed)} pinned cross-domain literal copies" + " (baseline; shrinking is progress, new rows are red):") + L.append("") + for row in manifest.duplicates_allowed: + digest, _, locs = row.partition(" ") + L.append(f"- `{digest}`: " + " · ".join(f"`{loc}`" for loc in locs.split())) + L.append("") + + L.append("## Modules by domain") + L.append("") + L.append("`*` marks a `classification=proposed` row (owner review pending).") + for d in sorted(manifest.domains): + L.append("") + L.append(f"### {d} — {manifest.domains[d]}") + L.append("") + for path in mods_by_domain[d]: + mark = " *" if path in manifest.proposed else "" + L.append(f"- `{path}`{mark}") + L.append("") + return "\n".join(L) + + +def run_checks(root: pathlib.Path = REPO_ROOT) -> tuple[list[str], list[str]]: + """Returns (findings, notes). Structural problems raise.""" + manifest = load_manifest() + findings: list[str] = [] + notes: list[str] = [] + + bad_domains = sorted({d for d in manifest.modules.values() if d not in manifest.domains}) + if bad_domains: + findings.append(f"manifest names unknown domains: {bad_domains}") + return findings, notes + + tracked = tracked_population(root) + for path in sorted(tracked - set(manifest.modules)): + findings.append(f"completeness: tracked module has no manifest row: {path}") + for path in sorted(set(manifest.modules) - tracked): + findings.append(f"completeness: manifest row is not a tracked module: {path}") + if findings: + # The graph below is only meaningful over a complete population. + return findings, notes + + graph = build_import_graph(manifest, root) + sections = computed_graph_sections(manifest, graph) + + actual_allowed = set(sections["allowed"]) + pinned_allowed = set(manifest.graph_allowed) + strict_pairs = graph.quotient() + for pair in sorted(actual_allowed - pinned_allowed): + d1, d2 = pair.split("->") + witnesses = strict_pairs.get((d1, d2), [])[:5] + wit = "; ".join(f"{s} -> {t}" for s, t in witnesses) + findings.append( + f"direction: NEW cross-domain dependency {pair} is not in [graph].allowed" + f" (witnesses: {wit}) — a new direction is an owner decision; regenerate" + " with --write to make it visible in the manifest diff") + for pair in sorted(pinned_allowed - actual_allowed): + findings.append( + f"direction: [graph].allowed pins {pair} but the tree no longer has it —" + " regenerate with --write to shrink the baseline") + + actual_cycles = sections["cycle_groups"] + if actual_cycles != manifest.cycle_groups: + findings.append( + f"cycles: computed cycle groups {actual_cycles} != pinned" + f" {manifest.cycle_groups} — growth is red (break the new edge instead);" + " shrinkage must be banked with --write") + if not actual_cycles: + notes.append("cycles: the strict domain quotient is acyclic (target reached)") + else: + n = sum(len(g) for g in actual_cycles) + notes.append(f"cycles: {len(actual_cycles)} pinned group(s), {n} domains still cyclic") + + if sections["lazy_only"] != manifest.lazy_only: + findings.append( + f"classification: lazy_only pairs drifted (computed {len(sections['lazy_only'])}" + f" != pinned {len(manifest.lazy_only)}) — regenerate with --write") + if sections["dynamic_pairs"] != manifest.dynamic_pairs: + findings.append( + f"classification: dynamic_pairs drifted (computed {len(sections['dynamic_pairs'])}" + f" != pinned {len(manifest.dynamic_pairs)}) — regenerate with --write") + + dups = duplicate_bodies(manifest, root) + pinned_dups = set(manifest.duplicates_allowed) + for row in dups: + if row not in pinned_dups: + findings.append( + f"literal-copy: cross-domain duplicate body not in baseline: {row} —" + " import the single owner instead of copying the body across domains") + for row in sorted(pinned_dups - set(dups)): + findings.append( + f"literal-copy: baseline row no longer observed: {row} — regenerate with" + " --write to bank the shrink") + + rendered = render_domain_map(manifest) + if not DOMAIN_MAP_PATH.is_file(): + findings.append(f"domain map: {DOMAIN_MAP_PATH.name} is missing — run --write") + elif DOMAIN_MAP_PATH.read_text(encoding="utf-8") != rendered: + findings.append( + "domain map: docs/DOMAIN_MAP.md is stale (regeneration changes it) — run --write") + return findings, notes + + +def write_generated(root: pathlib.Path = REPO_ROOT) -> None: + manifest = load_manifest() + tracked = tracked_population(root) + missing = sorted(tracked - set(manifest.modules)) + stale = sorted(set(manifest.modules) - tracked) + if missing or stale: + raise SystemExit( + "--write refuses on an incomplete [modules] section (the assignment is a" + f" human decision, never generated): missing rows {missing}, stale rows {stale}") + graph = build_import_graph(manifest, root) + sections = computed_graph_sections(manifest, graph) + dups = duplicate_bodies(manifest, root) + block = render_generated_block(sections, dups) + text = manifest.path.read_text(encoding="utf-8") + manifest.path.write_text(manifest_with_generated_block(text, block), encoding="utf-8") + # Re-load so the map renders the freshly pinned data. + DOMAIN_MAP_PATH.write_text(render_domain_map(load_manifest()), encoding="utf-8") + print(f"wrote {manifest.path.relative_to(root)} generated sections and " + f"{DOMAIN_MAP_PATH.relative_to(root)}") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--write", action="store_true", + help="regenerate the manifest's generated sections and docs/DOMAIN_MAP.md") + args = ap.parse_args(argv) + + if not MANIFEST_PATH.is_file(): + print(f"missing manifest: {MANIFEST_PATH}", file=sys.stderr) + return 2 + + if args.write: + write_generated() + return 0 + + try: + findings, notes = run_checks() + except (OSError, SyntaxError, ValueError, KeyError) as exc: + print(f"structural failure: {exc}", file=sys.stderr) + return 2 + for note in notes: + print(note) + if findings: + for f in findings: + print(f"RED: {f}", file=sys.stderr) + return 1 + print("OK: domain manifest complete; directions, cycles, classification," + " literal-copy baseline and DOMAIN_MAP.md all match the tree") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/domain_graph.py b/scripts/domain_graph.py new file mode 100644 index 000000000..de34b6044 --- /dev/null +++ b/scripts/domain_graph.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""Shared import-graph core for the domain manifest tools (plan §7.1, CPL-1). + +Single home of the machinery that both the report generator +(``scripts/v7next_domain_report.py``) and the gate checker +(``scripts/check_domains.py``) consume, so the two tools cannot drift apart — +the same discipline the checker itself enforces on runtime code (the +literal-copy ban). + +Provides: + +- manifest loading (``ouroboros/domains.toml``: human sections + generated + ``[graph]``/``[duplicates]`` sections); +- the module-level import collector with the strict / lazy / guarded / + TYPE_CHECKING / dynamic classification (an import is STRICT only when it + executes unconditionally at import time); +- the domain quotient (cross-domain edges keyed by domain pair, with exact + module-edge witnesses) and Tarjan SCC over domain nodes; +- the literal-copy scan: normalized function-body source segments appearing + in more than one domain (the span-normalization approach follows + ``scripts/v7next_transplant.py``: exact source segments, not name matching). + +This is analysis tooling, not runtime code: nothing under ``ouroboros/`` +imports it. +""" +from __future__ import annotations + +import ast +import hashlib +import pathlib +import subprocess +import textwrap +from collections import defaultdict +from dataclasses import dataclass, field + +try: # Python 3.11+ + import tomllib +except ImportError: # pragma: no cover - the 3.10 venv ships tomli + import tomli as tomllib + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +MANIFEST_PATH = REPO_ROOT / "ouroboros" / "domains.toml" +DOMAIN_MAP_PATH = REPO_ROOT / "docs" / "DOMAIN_MAP.md" + +STRICT, TYPE_ONLY, LAZY, DYNAMIC = "strict", "type_checking", "lazy", "dynamic" +# Executed at import time but failure-tolerant / entrypoint-only (F0 review F4): +# a `try: import x except ImportError/Exception` or an import under +# `if __name__ == "__main__"` must not stand as a strict cycle witness. +GUARDED = "guarded" + +# A function body shorter than this many normalized source lines is too small +# to stand as literal-copy evidence (getters, one-line delegates). +DUPLICATE_MIN_LINES = 10 + + +def module_name(path: str) -> str: + parts = path[:-3].split("/") # drop .py + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + + +def is_type_checking_test(test: ast.expr) -> bool: + if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING": + return True + if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING": + return True + return False + + +def is_main_guard_test(test: ast.expr) -> bool: + """`if __name__ == "__main__":` — the body never runs on import.""" + if not (isinstance(test, ast.Compare) and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) and len(test.comparators) == 1): + return False + sides = (test.left, test.comparators[0]) + has_name = any(isinstance(s, ast.Name) and s.id == "__name__" for s in sides) + has_main = any(isinstance(s, ast.Constant) and s.value == "__main__" for s in sides) + return has_name and has_main + + +_SWALLOWING = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"} + + +def try_swallows_import_failure(node: ast.Try) -> bool: + """True when at least one handler catches import failure (or everything) + AND does not re-raise. A handler whose body contains a top-level ``raise`` + may propagate the failure (``except ImportError: raise``), so it is not a + swallow — misclassifying it as guarded would hide a strict cycle witness + (F0 review round 2). A conditional re-raise nested in an ``if`` still + counts as re-raising here: erring toward STRICT is the safe direction.""" + for h in node.handlers: + reraises = any(isinstance(s, ast.Raise) for s in ast.walk(h)) + if reraises: + continue + if h.type is None: # bare except + return True + types = h.type.elts if isinstance(h.type, ast.Tuple) else [h.type] + for t in types: + if isinstance(t, ast.Name) and t.id in _SWALLOWING: + return True + if isinstance(t, ast.Attribute) and t.attr in _SWALLOWING: + return True + return False + + +class ImportCollector(ast.NodeVisitor): + """Collect (kind, raw dotted target or ImportFrom base+names, lineno).""" + + def __init__(self) -> None: + self.records: list[tuple[str, str, tuple[str, ...], int]] = [] + # each record: (kind, base_or_module, aliases (() for plain import), lineno) + self._depth = 0 # function nesting depth + self._tc = 0 # TYPE_CHECKING nesting depth + self._guard = 0 # __main__-guard / failure-swallowing-try nesting depth + + def _kind(self) -> str: + if self._tc: + return TYPE_ONLY + if self._depth: + return LAZY + if self._guard: + return GUARDED + return STRICT + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._depth += 1 + self.generic_visit(node) + self._depth -= 1 + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._depth += 1 + self.generic_visit(node) + self._depth -= 1 + + def visit_Lambda(self, node: ast.Lambda) -> None: + self._depth += 1 + self.generic_visit(node) + self._depth -= 1 + + def visit_If(self, node: ast.If) -> None: + tc = is_type_checking_test(node.test) + mg = is_main_guard_test(node.test) + if tc: + self._tc += 1 + if mg: + self._guard += 1 + for child in node.body: + self.visit(child) + if tc: + self._tc -= 1 + if mg: + self._guard -= 1 + for child in node.orelse: + self.visit(child) + + def visit_Try(self, node: ast.Try) -> None: + swallows = try_swallows_import_failure(node) + if swallows: + self._guard += 1 + for child in node.body: + self.visit(child) + if swallows: + self._guard -= 1 + for part in (node.handlers, node.orelse, node.finalbody): + for child in part: + self.visit(child) + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self.records.append((self._kind(), alias.name, (), node.lineno)) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + names = tuple(a.name for a in node.names) + base = ("." * node.level) + (node.module or "") + self.records.append((self._kind(), base, names, node.lineno)) + + def visit_Call(self, node: ast.Call) -> None: + target = None + f = node.func + if isinstance(f, ast.Attribute) and f.attr == "import_module": + target = "?" + elif isinstance(f, ast.Name) and f.id == "__import__": + target = "?" + if target is not None: + if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str): + target = node.args[0].value + else: + target = "" + self.records.append((DYNAMIC, target, (), node.lineno)) + self.generic_visit(node) + + +def resolve_relative(base: str, importer: str, is_pkg: bool) -> str: + level = len(base) - len(base.lstrip(".")) + tail = base[level:] + parts = importer.split(".") + if not is_pkg: + parts = parts[:-1] + if level > 1: + parts = parts[: len(parts) - (level - 1)] + prefix = ".".join(parts) + return f"{prefix}.{tail}" if tail else prefix + + +@dataclass +class Manifest: + """Parsed ``ouroboros/domains.toml``.""" + + path: pathlib.Path + raw_bytes: bytes + meta: dict[str, str] + domains: dict[str, str] + modules: dict[str, str] # path -> domain id + proposed: set[str] + graph_allowed: list[str] = field(default_factory=list) # "D01->D02" + cycle_groups: list[list[str]] = field(default_factory=list) + lazy_only: list[str] = field(default_factory=list) + dynamic_pairs: list[str] = field(default_factory=list) + duplicates_allowed: list[str] = field(default_factory=list) + + @property + def sha256(self) -> str: + return hashlib.sha256(self.raw_bytes).hexdigest() + + +def load_manifest(path: pathlib.Path = MANIFEST_PATH) -> Manifest: + raw = path.read_bytes() + data = tomllib.loads(raw.decode("utf-8")) + graph = data.get("graph", {}) + return Manifest( + path=path, + raw_bytes=raw, + meta=data.get("meta", {}), + domains=data["domains"], + modules=data["modules"], + proposed=set(data.get("classification", {}).get("proposed", [])), + graph_allowed=list(graph.get("allowed", [])), + cycle_groups=[list(g) for g in graph.get("cycle_groups", [])], + lazy_only=list(graph.get("lazy_only", [])), + dynamic_pairs=list(graph.get("dynamic_pairs", [])), + duplicates_allowed=list(data.get("duplicates", {}).get("allowed", [])), + ) + + +def tracked_population(root: pathlib.Path = REPO_ROOT) -> set[str]: + out = subprocess.run( + ["git", "ls-files", "ouroboros/**/*.py", "ouroboros/*.py", + "supervisor/*.py", "supervisor/**/*.py", "server.py", "launcher.py"], + cwd=root, capture_output=True, text=True, check=True, + ).stdout.split() + return set(out) + + +@dataclass +class ImportGraph: + """The classified module-level import graph over the manifest population.""" + + edges: dict[str, dict[tuple[str, str], list[int]]] + dynamic_unresolved: list[tuple[str, int]] + dom_of_path: dict[str, str] + + def quotient(self, kind: str = STRICT) -> dict[tuple[str, str], list[tuple[str, str]]]: + """Cross-domain edges of one kind, keyed by (from_domain, to_domain).""" + pairs: dict[tuple[str, str], list[tuple[str, str]]] = defaultdict(list) + for (src, dst) in sorted(self.edges[kind]): + d1, d2 = self.dom_of_path[src], self.dom_of_path[dst] + if d1 != d2: + pairs[(d1, d2)].append((src, dst)) + return pairs + + +def build_import_graph(manifest: Manifest, root: pathlib.Path = REPO_ROOT, + population: set[str] | None = None) -> ImportGraph: + modules = manifest.modules + mod_by_name = {module_name(p): p for p in modules} + + def resolve(dotted: str) -> str | None: + """Longest population module matching the dotted name.""" + parts = dotted.split(".") + for i in range(len(parts), 0, -1): + cand = ".".join(parts[:i]) + if cand in mod_by_name: + return mod_by_name[cand] + return None + + edges: dict[str, dict[tuple[str, str], list[int]]] = { + STRICT: defaultdict(list), TYPE_ONLY: defaultdict(list), + LAZY: defaultdict(list), DYNAMIC: defaultdict(list), + GUARDED: defaultdict(list), + } + dynamic_unresolved: list[tuple[str, int]] = [] + pop = set(modules) if population is None else (set(modules) & population) + + for path in sorted(pop): + src = root / path + tree = ast.parse(src.read_text(encoding="utf-8"), filename=path) + importer = module_name(path) + is_pkg = path.endswith("__init__.py") + col = ImportCollector() + col.visit(tree) + for kind, base, names, lineno in col.records: + if kind == DYNAMIC and base == "": + dynamic_unresolved.append((path, lineno)) + continue + base_abs = resolve_relative(base, importer, is_pkg) if base.startswith(".") else base + targets = [] + if names: + for name in names: + if name == "*": + targets.append(base_abs) + continue + sub = f"{base_abs}.{name}" if base_abs else name + targets.append(sub if resolve(sub) else base_abs) + else: + targets.append(base_abs) + for dotted in targets: + dst = resolve(dotted) + if dst is None or dst == path: + continue + edges[kind][(path, dst)].append(lineno) + + return ImportGraph(edges=edges, dynamic_unresolved=dynamic_unresolved, + dom_of_path=dict(modules)) + + +def strongly_connected(domains: list[str], + dom_edges: dict[tuple[str, str], list[tuple[str, str]]], + ) -> list[list[str]]: + """Tarjan SCC over domain nodes; returns every component (sorted members).""" + graph: dict[str, set[str]] = defaultdict(set) + for (d1, d2) in dom_edges: + graph[d1].add(d2) + index: dict[str, int] = {} + low: dict[str, int] = {} + on_stack: set[str] = set() + stack: list[str] = [] + sccs: list[list[str]] = [] + counter = [0] + + def strongconnect(v: str) -> None: + index[v] = low[v] = counter[0] + counter[0] += 1 + stack.append(v) + on_stack.add(v) + for w in sorted(graph.get(v, ())): + if w not in index: + strongconnect(w) + low[v] = min(low[v], low[w]) + elif w in on_stack: + low[v] = min(low[v], index[w]) + if low[v] == index[v]: + comp = [] + while True: + w = stack.pop() + on_stack.discard(w) + comp.append(w) + if w == v: + break + sccs.append(sorted(comp)) + + for v in sorted(domains): + if v not in index: + strongconnect(v) + return sccs + + +def cycle_groups_of(domains: list[str], + dom_edges: dict[tuple[str, str], list[tuple[str, str]]], + ) -> list[list[str]]: + """Only the SCCs with more than one domain (the actual cycles), sorted.""" + return sorted(c for c in strongly_connected(domains, dom_edges) if len(c) > 1) + + +def pair_key(d1: str, d2: str) -> str: + return f"{d1}->{d2}" + + +def _normalize_body(segment: str) -> str: + """Normalize a function's source segment for literal-copy comparison: + dedent, strip trailing whitespace, drop blank lines. Version-stable + (pure text), unlike ``ast.dump`` whose field set moves between Pythons.""" + lines = [ln.rstrip() for ln in textwrap.dedent(segment).splitlines()] + return "\n".join(ln for ln in lines if ln) + + +def duplicate_bodies(manifest: Manifest, root: pathlib.Path = REPO_ROOT, + min_lines: int = DUPLICATE_MIN_LINES) -> list[str]: + """Literal-copy scan: normalized function bodies appearing in modules of + MORE THAN ONE domain. Returns sorted rows + ``" :: :: ..."`` — the format + pinned in the manifest ``[duplicates].allowed`` baseline.""" + by_digest: dict[str, list[tuple[str, str]]] = defaultdict(list) + + for path in sorted(manifest.modules): + src_path = root / path + if not src_path.is_file(): + continue + source = src_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=path) + + def walk(node: ast.AST, prefix: str) -> None: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + qual = f"{prefix}{child.name}" + segment = ast.get_source_segment(source, child) + if segment is not None: + norm = _normalize_body(segment) + if norm.count("\n") + 1 >= min_lines: + digest = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] + by_digest[digest].append((path, qual)) + walk(child, f"{qual}.") + elif isinstance(child, ast.ClassDef): + walk(child, f"{prefix}{child.name}.") + + walk(tree, "") + + rows: list[str] = [] + for digest, locs in by_digest.items(): + domains = {manifest.modules[p] for p, _ in locs} + if len(domains) < 2: + continue + parts = " ".join(f"{p}::{q}" for p, q in sorted(set(locs))) + rows.append(f"{digest} {parts}") + return sorted(rows) + + +def computed_graph_sections(manifest: Manifest, graph: ImportGraph) -> dict[str, list]: + """The generated-section values derived from the live tree.""" + strict_pairs = graph.quotient(STRICT) + lazy_pairs = graph.quotient(LAZY) + dynamic_pairs = graph.quotient(DYNAMIC) + allowed = sorted(pair_key(d1, d2) for (d1, d2) in strict_pairs) + cycles = cycle_groups_of(sorted(manifest.domains), strict_pairs) + lazy_only = sorted(pair_key(d1, d2) for (d1, d2) in lazy_pairs + if (d1, d2) not in strict_pairs) + dyn = sorted(pair_key(d1, d2) for (d1, d2) in dynamic_pairs) + return { + "allowed": allowed, + "cycle_groups": cycles, + "lazy_only": lazy_only, + "dynamic_pairs": dyn, + } diff --git a/scripts/v7next_domain_report.py b/scripts/v7next_domain_report.py index c0eb12f09..f3b37776f 100644 --- a/scripts/v7next_domain_report.py +++ b/scripts/v7next_domain_report.py @@ -1,27 +1,30 @@ #!/usr/bin/env python3 -"""Report-only domain quotient graph for the v7next integration tree (Ф0). +"""Report-only domain quotient graph for the v7next integration tree. -Reads ``scripts/v7next_domains.toml`` (module -> domain, 1:1), computes the -import graph of THIS tree, collapses modules to domain nodes and REPORTS: +Reads ``ouroboros/domains.toml`` (module -> domain, 1:1; the production +manifest — the Ф0 stage lived at scripts/v7next_domains.toml), computes the +import graph of THIS tree through the shared ``scripts/domain_graph.py`` core, +collapses modules to domain nodes and REPORTS: - domain-level edges of the strict graph (unconditional module-level imports), - cycles on the domain quotient, each with its exact module-edge witnesses, - a dependency direction table, -- lazy / dynamic / TYPE_CHECKING imports, classified separately and excluded - from the strict graph. +- lazy / guarded / dynamic / TYPE_CHECKING imports, classified separately and + excluded from the strict graph. -Per plan §7.1 and roast disposition F17 this tool NEVER gates: cycles are -owner-batch material (regroup vs split vs allowed-edge), so the exit code is 0 -whenever the report was produced, regardless of findings. Exit 2 only when -the report itself cannot be trusted (unreadable/unparseable source, missing -manifest) — a silent skip would falsify the graph. +This tool NEVER gates (roast F17 discipline): the GATE over the same data is +``scripts/check_domains.py`` + ``tests/test_domain_manifest.py``, which pin +the manifest's generated baseline sections. The report stays the witness-level +companion: exit code is 0 whenever the report was produced, regardless of +findings. Exit 2 only when the report itself cannot be trusted +(unreadable/unparseable source, missing manifest) — a silent skip would +falsify the graph. Output: ``docs/v7next/DOMAIN_QUOTIENT_REPORT.md`` (generated; header names the generator and the input SHAs). """ from __future__ import annotations -import ast import hashlib import pathlib import subprocess @@ -29,183 +32,33 @@ import sys from collections import Counter, defaultdict from datetime import datetime, timezone -try: # Python 3.11+ - import tomllib -except ImportError: # pragma: no cover - the 3.10 venv ships tomli - import tomli as tomllib - REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent -MANIFEST = REPO_ROOT / "scripts" / "v7next_domains.toml" +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.domain_graph import ( # noqa: E402 + DYNAMIC, + GUARDED, + LAZY, + MANIFEST_PATH, + STRICT, + TYPE_ONLY, + build_import_graph, + cycle_groups_of, + load_manifest, + tracked_population, +) + REPORT = REPO_ROOT / "docs" / "v7next" / "DOMAIN_QUOTIENT_REPORT.md" -STRICT, TYPE_ONLY, LAZY, DYNAMIC = "strict", "type_checking", "lazy", "dynamic" -# Executed at import time but failure-tolerant / entrypoint-only (F0 review F4): -# a `try: import x except ImportError/Exception` or an import under -# `if __name__ == "__main__"` must not stand as a strict cycle witness. -GUARDED = "guarded" - - -def module_name(path: str) -> str: - parts = path[:-3].split("/") # drop .py - if parts[-1] == "__init__": - parts = parts[:-1] - return ".".join(parts) - - -def is_type_checking_test(test: ast.expr) -> bool: - if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING": - return True - if isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING": - return True - return False - - -def is_main_guard_test(test: ast.expr) -> bool: - """`if __name__ == "__main__":` — the body never runs on import.""" - if not (isinstance(test, ast.Compare) and len(test.ops) == 1 - and isinstance(test.ops[0], ast.Eq) and len(test.comparators) == 1): - return False - sides = (test.left, test.comparators[0]) - has_name = any(isinstance(s, ast.Name) and s.id == "__name__" for s in sides) - has_main = any(isinstance(s, ast.Constant) and s.value == "__main__" for s in sides) - return has_name and has_main - - -_SWALLOWING = {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"} - - -def try_swallows_import_failure(node: ast.Try) -> bool: - """True when at least one handler catches import failure (or everything) - AND does not re-raise. A handler whose body contains a top-level ``raise`` - may propagate the failure (``except ImportError: raise``), so it is not a - swallow — misclassifying it as guarded would hide a strict cycle witness - (F0 review round 2). A conditional re-raise nested in an ``if`` still - counts as re-raising here: erring toward STRICT is the safe direction.""" - for h in node.handlers: - reraises = any(isinstance(s, ast.Raise) for s in ast.walk(h)) - if reraises: - continue - if h.type is None: # bare except - return True - types = h.type.elts if isinstance(h.type, ast.Tuple) else [h.type] - for t in types: - if isinstance(t, ast.Name) and t.id in _SWALLOWING: - return True - if isinstance(t, ast.Attribute) and t.attr in _SWALLOWING: - return True - return False - - -class ImportCollector(ast.NodeVisitor): - """Collect (kind, raw dotted target or ImportFrom base+names, lineno).""" - - def __init__(self) -> None: - self.records: list[tuple[str, str, tuple[str, ...], int]] = [] - # each record: (kind, base_or_module, aliases (() for plain import), lineno) - self._depth = 0 # function nesting depth - self._tc = 0 # TYPE_CHECKING nesting depth - self._guard = 0 # __main__-guard / failure-swallowing-try nesting depth - - def _kind(self) -> str: - if self._tc: - return TYPE_ONLY - if self._depth: - return LAZY - if self._guard: - return GUARDED - return STRICT - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._depth += 1 - self.generic_visit(node) - self._depth -= 1 - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._depth += 1 - self.generic_visit(node) - self._depth -= 1 - - def visit_Lambda(self, node: ast.Lambda) -> None: - self._depth += 1 - self.generic_visit(node) - self._depth -= 1 - - def visit_If(self, node: ast.If) -> None: - tc = is_type_checking_test(node.test) - mg = is_main_guard_test(node.test) - if tc: - self._tc += 1 - if mg: - self._guard += 1 - for child in node.body: - self.visit(child) - if tc: - self._tc -= 1 - if mg: - self._guard -= 1 - for child in node.orelse: - self.visit(child) - - def visit_Try(self, node: ast.Try) -> None: - swallows = try_swallows_import_failure(node) - if swallows: - self._guard += 1 - for child in node.body: - self.visit(child) - if swallows: - self._guard -= 1 - for part in (node.handlers, node.orelse, node.finalbody): - for child in part: - self.visit(child) - - def visit_Import(self, node: ast.Import) -> None: - for alias in node.names: - self.records.append((self._kind(), alias.name, (), node.lineno)) - - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - names = tuple(a.name for a in node.names) - base = ("." * node.level) + (node.module or "") - self.records.append((self._kind(), base, names, node.lineno)) - - def visit_Call(self, node: ast.Call) -> None: - target = None - f = node.func - if isinstance(f, ast.Attribute) and f.attr == "import_module": - target = "?" - elif isinstance(f, ast.Name) and f.id == "__import__": - target = "?" - if target is not None: - if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str): - target = node.args[0].value - else: - target = "" - self.records.append((DYNAMIC, target, (), node.lineno)) - self.generic_visit(node) - - -def resolve_relative(base: str, importer: str, is_pkg: bool) -> str: - level = len(base) - len(base.lstrip(".")) - tail = base[level:] - parts = importer.split(".") - if not is_pkg: - parts = parts[:-1] - if level > 1: - parts = parts[: len(parts) - (level - 1)] - prefix = ".".join(parts) - return f"{prefix}.{tail}" if tail else prefix - def main() -> int: - if not MANIFEST.is_file(): - print(f"missing manifest: {MANIFEST}", file=sys.stderr) + if not MANIFEST_PATH.is_file(): + print(f"missing manifest: {MANIFEST_PATH}", file=sys.stderr) return 2 - manifest_bytes = MANIFEST.read_bytes() - data = tomllib.loads(manifest_bytes.decode("utf-8")) - modules: dict[str, str] = data["modules"] - domains: dict[str, str] = data["domains"] - proposed = set(data.get("classification", {}).get("proposed", [])) - split_pending = set(data.get("split_pending", {})) - meta = data.get("meta", {}) + manifest = load_manifest() + modules = manifest.modules + domains = manifest.domains + proposed = manifest.proposed bad_domains = sorted({d for d in modules.values() if d not in domains}) if bad_domains: @@ -215,12 +68,7 @@ def main() -> int: head = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, capture_output=True, text=True, check=True ).stdout.strip() - tracked = subprocess.run( - ["git", "ls-files", "ouroboros/**/*.py", "ouroboros/*.py", - "supervisor/*.py", "supervisor/**/*.py", "server.py", "launcher.py"], - cwd=REPO_ROOT, capture_output=True, text=True, check=True, - ).stdout.split() - tracked_set = set(tracked) + tracked_set = tracked_population(REPO_ROOT) # Content fingerprint of the actual analyzed inputs (working-tree bytes, # not the HEAD claim): the report is often generated pre-commit, so HEAD # alone cannot bind it to a SHA (F0 review round 2, F3). Verification @@ -237,105 +85,21 @@ def main() -> int: drift_missing = sorted(tracked_set - set(modules)) # in tree, not in manifest drift_stale = sorted(set(modules) - tracked_set) # in manifest, not in tree - mod_by_name = {module_name(p): p for p in modules} - dom_of_path = modules + try: + graph = build_import_graph(manifest, REPO_ROOT, population=tracked_set) + except (OSError, SyntaxError) as exc: + print(f"cannot parse: {exc}", file=sys.stderr) + return 2 + edges = graph.edges + dom_of_path = graph.dom_of_path + dynamic_unresolved = graph.dynamic_unresolved - def resolve(dotted: str) -> str | None: - """Longest population module matching the dotted name.""" - parts = dotted.split(".") - for i in range(len(parts), 0, -1): - cand = ".".join(parts[:i]) - if cand in mod_by_name: - return mod_by_name[cand] - return None - - # kind -> {(src_path, dst_path): [linenos]} - edges: dict[str, dict[tuple[str, str], list[int]]] = { - STRICT: defaultdict(list), TYPE_ONLY: defaultdict(list), - LAZY: defaultdict(list), DYNAMIC: defaultdict(list), - GUARDED: defaultdict(list), - } - dynamic_unresolved: list[tuple[str, int]] = [] - - for path in sorted(set(modules) & tracked_set): - src = REPO_ROOT / path - try: - tree = ast.parse(src.read_text(encoding="utf-8"), filename=path) - except (OSError, SyntaxError) as exc: - print(f"cannot parse {path}: {exc}", file=sys.stderr) - return 2 - importer = module_name(path) - is_pkg = path.endswith("__init__.py") - col = ImportCollector() - col.visit(tree) - for kind, base, names, lineno in col.records: - if kind == DYNAMIC and base == "": - dynamic_unresolved.append((path, lineno)) - continue - base_abs = resolve_relative(base, importer, is_pkg) if base.startswith(".") else base - targets = [] - if names: - for name in names: - if name == "*": - targets.append(base_abs) - continue - sub = f"{base_abs}.{name}" if base_abs else name - targets.append(sub if resolve(sub) else base_abs) - else: - targets.append(base_abs) - for dotted in targets: - dst = resolve(dotted) - if dst is None or dst == path: - continue - edges[kind][(path, dst)].append(lineno) - - # ---- quotient (strict only) --------------------------------------------- - dom_edges: dict[tuple[str, str], list[tuple[str, str]]] = defaultdict(list) - for (src, dst) in sorted(edges[STRICT]): - d1, d2 = dom_of_path[src], dom_of_path[dst] - if d1 != d2: - dom_edges[(d1, d2)].append((src, dst)) - - # Tarjan SCC over domain nodes - graph: dict[str, set[str]] = defaultdict(set) - for (d1, d2) in dom_edges: - graph[d1].add(d2) - index: dict[str, int] = {} - low: dict[str, int] = {} - on_stack: set[str] = set() - stack: list[str] = [] - sccs: list[list[str]] = [] - counter = [0] - - def strongconnect(v: str) -> None: - index[v] = low[v] = counter[0] - counter[0] += 1 - stack.append(v) - on_stack.add(v) - for w in sorted(graph.get(v, ())): - if w not in index: - strongconnect(w) - low[v] = min(low[v], low[w]) - elif w in on_stack: - low[v] = min(low[v], index[w]) - if low[v] == index[v]: - comp = [] - while True: - w = stack.pop() - on_stack.discard(w) - comp.append(w) - if w == v: - break - sccs.append(sorted(comp)) - - for v in sorted(domains): - if v not in index: - strongconnect(v) - cycles = [c for c in sccs if len(c) > 1] + dom_edges = graph.quotient(STRICT) + cycles = cycle_groups_of(sorted(domains), dom_edges) # ---- render --------------------------------------------------------------- used_domains = sorted({d for pair in dom_edges for d in pair} | set(Counter(dom_of_path.values()))) - manifest_sha = hashlib.sha256(manifest_bytes).hexdigest() + manifest_sha = manifest.sha256 now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") dom_counts = Counter(dom_of_path.values()) @@ -350,10 +114,11 @@ def main() -> int: L.append(f"- analyzed-inputs content fingerprint: sha256 `{content_fingerprint}` " "(sorted tracked runtime files, path+bytes; regenerate on any tree and " "compare to verify freshness)") - L.append(f"- manifest: `scripts/v7next_domains.toml` sha256 `{manifest_sha}`") - L.append(f"- reference (domain vocabulary): {meta.get('reference_tree', 'unknown')}") - L.append("- discipline: plan §7.1 / roast F17 — this is a REPORT; cycles are owner-batch") - L.append(" items (regroup vs split vs allowed-edge), the checker never dictates.") + L.append(f"- manifest: `ouroboros/domains.toml` sha256 `{manifest_sha}`") + L.append(f"- reference (domain vocabulary): {manifest.meta.get('reference_tree', 'unknown')}") + L.append("- discipline: this is a REPORT with witness-level detail; the GATE over the") + L.append(" same data is `scripts/check_domains.py` (+ `tests/test_domain_manifest.py`),") + L.append(" which pins the manifest's `[graph]`/`[duplicates]` baseline sections.") L.append("") L.append("## Population") L.append("") @@ -388,22 +153,15 @@ def main() -> int: L.append("None. The strict domain quotient is acyclic.") for i, comp in enumerate(cycles, 1): comp_set = set(comp) - n_split_wit = sum( - 1 for (d1, d2), wit in dom_edges.items() - if d1 in comp_set and d2 in comp_set - for (src, dst) in wit if src in split_pending or dst in split_pending - ) n_all_wit = sum( len(wit) for (d1, d2), wit in dom_edges.items() if d1 in comp_set and d2 in comp_set ) L.append(f"### Cycle group {i}: {' ⇄ '.join(comp)}") L.append("") - L.append(f"{len(comp)} domains form one strongly connected component. Every edge below") - L.append("needs an owner disposition (regroup / split / allowed-edge).") - L.append(f"{n_split_wit} of the {n_all_wit} module-edge witnesses touch a `[split_pending]`") - L.append("monolith (marked †): those edges are expected to move or dissolve when the") - L.append("ledger-derived leaves are transplanted (Ф1 recipe, plan §5.3).") + L.append(f"{len(comp)} domains form one strongly connected component with " + f"{n_all_wit} module-edge witnesses. Every edge below needs an owner " + "disposition (regroup / split / allowed-edge).") L.append("") for (d1, d2) in sorted(dom_edges): if d1 in comp_set and d2 in comp_set: @@ -411,18 +169,15 @@ def main() -> int: L.append(f"- **{d1} → {d2}** ({len(wit)} module edges)") for (src, dst) in wit: lines = ",".join(str(n) for n in sorted(set(edges[STRICT][(src, dst)]))[:4]) - mark = " †" if src in split_pending or dst in split_pending else "" - L.append(f" - `{src}` → `{dst}` (line {lines}){mark}") + L.append(f" - `{src}` → `{dst}` (line {lines})") L.append("") L.append("## Domain-level edges (strict)") L.append("") L.append("| from | to | module edges | in a cycle group |") L.append("|---|---|---:|:---:|") - cyc_nodes = {d for comp in cycles for d in comp} for (d1, d2) in sorted(dom_edges): - incyc = "yes" if d1 in cyc_nodes and d2 in cyc_nodes and any( - d1 in c and d2 in c for c in map(set, cycles)) else "" + incyc = "yes" if any(d1 in c and d2 in c for c in map(set, cycles)) else "" L.append(f"| {d1} | {d2} | {len(dom_edges[(d1, d2)])} | {incyc} |") L.append("") diff --git a/tests/test_domain_manifest.py b/tests/test_domain_manifest.py new file mode 100644 index 000000000..05bce7050 --- /dev/null +++ b/tests/test_domain_manifest.py @@ -0,0 +1,245 @@ +"""CPL-1 verify half (plan §7.1): the production domain manifest +``ouroboros/domains.toml`` is complete over the tracked runtime population and +its generated baseline sections match the live tree. + +The generator half is ``python scripts/check_domains.py --write``; these tests +are the CI pin that staleness = red: + +- a tracked runtime module without a manifest row (or a stale row) is red; +- a new cross-domain strict import direction outside ``[graph].allowed`` is red; +- cycle-group growth beyond the pinned SCC ceiling is red; +- lazy/dynamic classification drift is red; +- a new cross-domain literal-copy function body is red (the banned "fix" of a + dependency edge); +- ``docs/DOMAIN_MAP.md`` not byte-identical to its regeneration is red. + +Synthetic-tree tests prove the detection branches actually fire (a gate whose +red path is unreachable is coverage in form only). +""" +from __future__ import annotations + +import importlib.util +import pathlib +import textwrap + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + +_spec = importlib.util.spec_from_file_location( + "check_domains", REPO_ROOT / "scripts" / "check_domains.py") +check_domains = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_domains) + +from scripts.domain_graph import ( # noqa: E402 + LAZY, + STRICT, + Manifest, + build_import_graph, + computed_graph_sections, + cycle_groups_of, + duplicate_bodies, + load_manifest, + tracked_population, +) + + +@pytest.fixture(scope="module") +def manifest() -> Manifest: + return load_manifest() + + +@pytest.fixture(scope="module") +def graph(manifest): + return build_import_graph(manifest, REPO_ROOT) + + +@pytest.fixture(scope="module") +def sections(manifest, graph): + return computed_graph_sections(manifest, graph) + + +# --------------------------------------------------------------------------- +# Live-tree pins (the verify half of the gen/verify pair) +# --------------------------------------------------------------------------- + +def test_manifest_covers_the_tracked_population_exactly(manifest): + tracked = tracked_population(REPO_ROOT) + missing = sorted(tracked - set(manifest.modules)) + stale = sorted(set(manifest.modules) - tracked) + assert not missing, ( + "tracked runtime modules without a domain row (add them to " + f"ouroboros/domains.toml [modules]): {missing}") + assert not stale, ( + f"manifest rows that are no longer tracked modules (remove them): {stale}") + + +def test_every_module_maps_to_a_known_domain(manifest): + unknown = sorted({d for d in manifest.modules.values() if d not in manifest.domains}) + assert not unknown, f"manifest names unknown domains: {unknown}" + + +def test_strict_direction_matrix_matches_the_pinned_baseline(manifest, sections): + computed = set(sections["allowed"]) + pinned = set(manifest.graph_allowed) + new = sorted(computed - pinned) + gone = sorted(pinned - computed) + assert not new, ( + "NEW cross-domain strict dependency directions (an owner decision — make " + "it visible by regenerating the manifest: python scripts/check_domains.py " + f"--write): {new}") + assert not gone, ( + "the tree dropped pinned directions — bank the shrink with " + f"`python scripts/check_domains.py --write`: {gone}") + + +def test_cycle_groups_match_the_pinned_ceiling(manifest, sections): + assert sections["cycle_groups"] == manifest.cycle_groups, ( + "strict-quotient cycle groups drifted from the pinned SCC ceiling " + f"(computed {sections['cycle_groups']} != pinned {manifest.cycle_groups}). " + "Growth = break the new edge instead of widening the ceiling; shrinkage = " + "bank it with `python scripts/check_domains.py --write`. The target is [].") + + +def test_lazy_and_dynamic_classification_matches_the_pin(manifest, sections): + assert sections["lazy_only"] == manifest.lazy_only, ( + "lazy-only cross-domain pairs drifted — regenerate the manifest") + assert sections["dynamic_pairs"] == manifest.dynamic_pairs, ( + "dynamic-import cross-domain pairs drifted — regenerate the manifest") + + +def test_no_cross_domain_literal_copies_beyond_the_baseline(manifest): + rows = duplicate_bodies(manifest, REPO_ROOT) + pinned = set(manifest.duplicates_allowed) + new = [r for r in rows if r not in pinned] + gone = sorted(pinned - set(rows)) + assert not new, ( + "cross-domain literal-copy function bodies (the banned cycle 'fix' — " + f"import the single owner instead): {new}") + assert not gone, ( + f"literal-copy baseline rows no longer observed — bank the shrink: {gone}") + + +def test_domain_map_is_byte_identical_to_its_regeneration(manifest): + rendered = check_domains.render_domain_map(manifest) + on_disk = check_domains.DOMAIN_MAP_PATH.read_text(encoding="utf-8") + assert on_disk == rendered, ( + "docs/DOMAIN_MAP.md is stale — regenerate with " + "`python scripts/check_domains.py --write`") + + +def test_checker_cli_is_green_on_this_tree(capsys): + assert check_domains.main([]) == 0 + out = capsys.readouterr().out + assert "OK: domain manifest complete" in out + + +# --------------------------------------------------------------------------- +# Synthetic-tree tests: the red branches are reachable +# --------------------------------------------------------------------------- + +def _mini_manifest(tmp_path: pathlib.Path, modules: dict[str, str]) -> Manifest: + return Manifest( + path=tmp_path / "domains.toml", raw_bytes=b"", meta={}, + domains={"DA": "Alpha", "DB": "Beta"}, + modules=modules, proposed=set(), + ) + + +def test_strict_import_produces_a_cross_domain_pair(tmp_path): + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "pkg" / "a.py").write_text("from pkg import b\n", encoding="utf-8") + (tmp_path / "pkg" / "b.py").write_text("X = 1\n", encoding="utf-8") + m = _mini_manifest(tmp_path, { + "pkg/__init__.py": "DA", "pkg/a.py": "DA", "pkg/b.py": "DB"}) + g = build_import_graph(m, tmp_path) + sections = computed_graph_sections(m, g) + assert sections["allowed"] == ["DA->DB"] + assert sections["cycle_groups"] == [] + + +def test_lazy_import_is_classified_out_of_the_strict_graph(tmp_path): + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "pkg" / "a.py").write_text( + "def f():\n from pkg import b\n return b\n", encoding="utf-8") + (tmp_path / "pkg" / "b.py").write_text("X = 1\n", encoding="utf-8") + m = _mini_manifest(tmp_path, { + "pkg/__init__.py": "DA", "pkg/a.py": "DA", "pkg/b.py": "DB"}) + g = build_import_graph(m, tmp_path) + assert not g.quotient(STRICT) + assert list(g.quotient(LAZY)) == [("DA", "DB")] + assert computed_graph_sections(m, g)["lazy_only"] == ["DA->DB"] + + +def test_mutual_imports_form_a_cycle_group(tmp_path): + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "pkg" / "a.py").write_text("from pkg import b\n", encoding="utf-8") + (tmp_path / "pkg" / "b.py").write_text("from pkg import a\n", encoding="utf-8") + m = _mini_manifest(tmp_path, { + "pkg/__init__.py": "DA", "pkg/a.py": "DA", "pkg/b.py": "DB"}) + g = build_import_graph(m, tmp_path) + assert cycle_groups_of(["DA", "DB"], g.quotient(STRICT)) == [["DA", "DB"]] + + +_COPY_BODY = textwrap.dedent( + ''' + def shared_fix(value): + total = 0 + for item in value: + if item is None: + continue + if isinstance(item, str): + total += len(item) + else: + total += int(item) + if total > 100: + return total - 100 + return total + ''') + + +def test_literal_copy_across_domains_is_detected(tmp_path): + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "pkg" / "a.py").write_text(_COPY_BODY, encoding="utf-8") + (tmp_path / "pkg" / "b.py").write_text(_COPY_BODY, encoding="utf-8") + m = _mini_manifest(tmp_path, { + "pkg/__init__.py": "DA", "pkg/a.py": "DA", "pkg/b.py": "DB"}) + rows = duplicate_bodies(m, tmp_path) + assert len(rows) == 1 + assert "pkg/a.py::shared_fix" in rows[0] and "pkg/b.py::shared_fix" in rows[0] + + +def test_literal_copy_within_one_domain_is_not_flagged(tmp_path): + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "pkg" / "a.py").write_text(_COPY_BODY, encoding="utf-8") + (tmp_path / "pkg" / "b.py").write_text(_COPY_BODY, encoding="utf-8") + m = _mini_manifest(tmp_path, { + "pkg/__init__.py": "DA", "pkg/a.py": "DA", "pkg/b.py": "DA"}) + assert duplicate_bodies(m, tmp_path) == [] + + +def test_small_bodies_are_below_the_literal_copy_floor(tmp_path): + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "__init__.py").write_text("", encoding="utf-8") + small = "def tiny():\n return 1\n" + (tmp_path / "pkg" / "a.py").write_text(small, encoding="utf-8") + (tmp_path / "pkg" / "b.py").write_text(small, encoding="utf-8") + m = _mini_manifest(tmp_path, { + "pkg/__init__.py": "DA", "pkg/a.py": "DA", "pkg/b.py": "DB"}) + assert duplicate_bodies(m, tmp_path) == [] + + +def test_generated_block_replacement_is_idempotent(): + block = check_domains.render_generated_block( + {"allowed": ["DA->DB"], "cycle_groups": [], "lazy_only": [], + "dynamic_pairs": []}, []) + base = "# human header\n[modules]\n\"a.py\" = \"DA\"\n" + once = check_domains.manifest_with_generated_block(base, block) + twice = check_domains.manifest_with_generated_block(once, block) + assert once == twice + assert once.count(check_domains.GENERATED_BEGIN) == 1