Data Model — Target Topology for doctor.py Decomposition (#2059)

Not a persisted data model — this records the target module topology (orchestration surface + cohesive sibling modules) and the invariants that gate the decomposition. Source-research artifact only.


Current state (pre-#2059)

src/specify_cli/cli/commands/
├── doctor.py                 3434 LOC  ← god-module (16 subcommands + 11 clusters of helpers)
├── _doctrine_health.py        188 LOC  ← ALREADY extracted (#1623): health MODEL
└── _profile_health_render.py  339 LOC  ← ALREADY extracted (#1623): doctrine RENDER + shared `console`

_auth_doctor.py (889) is unrelated (auth doctor, not the doctor group) — out of scope.


Target topology (post-#2059)

Recommended: thin command shells stay in doctor.py; per-cluster logic moves to siblings; shared infra centralized. doctor.py keeps app = typer.Typer(...), every @app.command, and delegates each body to its sibling. Test-facing private symbols are re-exported from doctor (WP08 precedent).

src/specify_cli/cli/commands/
├── doctor.py                  ← ORCHESTRATION SURFACE (target ≤ ~400 LOC)
│     • app = typer.Typer(name="doctor", ...)   (must stay here)
│     • 16 @app.command thin shells → delegate to siblings
│     • re-exports test-facing private symbols (from ._x import _y as _y)
│
├── _doctor_shared.py          ← NEW: shared infra single-home (R1)
│     • console (or re-exported from _profile_health_render), _json_output_guard,
│       _json_error, _CI_ENV_VARS, _STARTED_AT_COLUMN, _NOT_IN_PROJECT_MESSAGE,
│       _is_interactive_environment
│
├── _doctrine_health.py        ← EXISTING (#1623) — health MODEL (unchanged)
├── _profile_health_render.py  ← EXISTING (#1623) — doctrine RENDER (unchanged)
├── _doctrine_collect.py       ← NEW: doctrine-health COLLECTORS (Cluster J)
│     • _resolve_pack_version, _count_pack_artifacts, _summarize_org_charter,
│       _collect_profile_health, _attach_pack_health, _build_pack_entries,
│       _collect_doctrine_collisions, _collect_org_layer_data, _resolve_artifact_source,
│       _read_project_selections, _read_org_required, _build_selection_block,
│       _ORG_ARTIFACT_DIRS
│
├── _identity_audit.py         ← NEW (mirrors _doctrine_health naming): Cluster D
│     • identity + topology: _scope_to_mission, _scope_prefixes, _print_dup_and_ambig,
│       _print_identity_human, _read_stored_topology, _collect_topology_rows,
│       _print_topology_human   (split topology out if it grows)
│
├── _command_surface_doctor.py ← NEW: Cluster A (tool-surface + command-skill + slash)
│     • SlashCommandGap + all slash/command-skill/tool-surface helpers
│       (the `skills` command fuses command-skills + slash-commands → one sibling, OQ1)
│
├── _mission_state_doctor.py   ← NEW: Cluster H (audit / repair / teamspace-dry-run)
│     • _MissionStateMode, _validate_modes, _resolve_fail_on, _resolve_audit_root,
│       _emit_mission_state, _run_mission_repair, _run_teamspace_dry_run_mode,
│       _emit_json_error, _audit_fail_gate, _run_audit_mode, _print_rich_audit_report,
│       _audit_fixture_root
│
├── _coordination_doctor.py    ← NEW: Cluster K (git-version + worktree/sparse health)
│     • DoctorFinding, _MIN_GIT_VERSION, _detect_git_version, _check_git_version,
│       _check_tracked_worktrees_content, _check_coordination_worktree_health,
│       _check_lane_sparse_checkout_drift
│       (keep `merge.path_is_under_worktrees` import FUNCTION-LOCAL — H2)
│
├── _sparse_checkout_doctor.py ← NEW: Cluster E (remediation render + flow)
│     • _render_sparse_finding, _render_remediation_plan
│
├── _workspace_husk_doctor.py  ← NEW: Cluster C
│     • _workspace_husk_status_label, _emit_workspace_husk_fix, _emit_workspace_husk_report
│
└── _daemon_doctor.py          ← NEW: Cluster I (orphan-daemons + restart-daemon bodies)

Small clusters (B state-roots, F shim-registry, G ops/invocation) are already thin delegators to external packages (state.doctor, compat, doctor.ops); the plan may leave their thin shells in doctor.py or fold only their private render helpers (_print_overdue_details, _run_ops_sweep) into a sibling. Decide in plan (OQ2). Avoid a _misc catch-all (re-creates a mini-god-module).


Invariants (decomposition acceptance gates)

IDInvariantEnforced by
I-1CLI surface byte-identical. All 16 subcommand names, every flag, help text, and exit-code contract unchanged pre/post.NEW golden characterization test (enumerate app.registered_commands + per-subcommand --help snapshot) — must land FIRST.
I-2One-way imports. Direction: doctor.py (orchestrator) → sibling clusters → _doctor_shared → external packages. No sibling↔sibling imports; no sibling→orchestrator import.tests/architectural/ import-graph check; review.
I-3Single console/guard home. Exactly one Console() instance + one _json_output_guard/_json_error, imported by all siblings + orchestrator. No per-module Console().Grep gate + selections snapshot test.
I-4maxCC ≤ 15 for every function post-extraction (Ruff C901 / Sonar S3776). Mega-functions (skills CC20, identity 19, sparse_checkout 19, _check_lane_sparse_checkout_drift 19, state_roots 17, _repair_command_skill_state 16) decomposed into tested sub-helpers, not relocated oversized. Drop mission_state # noqa: C901 if helpers move with it.ruff check --select C901; per-helper focused tests.
I-5Test-facing symbols stay importable from doctor. The 11 private symbols + app + SlashCommandGap resolve via from specify_cli.cli.commands.doctor import ... (re-export).Existing 58 test files; CI.
I-6merge import stays function-local in _coordination_doctor.py (no doctor↔merge module-load cycle).Import-graph check.
I-7Safety predicates + argv fast-paths intact. compat/safety_modes.py ("doctor",*) registrations and __init__.py _is_doctor_skills_invocation/_is_doctor_restart_daemon_invocation continue to fire (they key on subcommand name strings).tests/cli_gate/test_doctor_modes.py, test_safe_commands.py; golden test covers those names.
I-8No new responsibilities added to doctor.py during this mission (god-module pointer rule, doctor.py:3-7).Review.

Orchestration-surface contract (what stays in doctor.py)

1. app = typer.Typer(name="doctor", help="Project health diagnostics") (anchor for add_typer). 2. The 16 @app.command(...) decorated callbacks — kept as thin shells: resolve repo_root/flags, then call one sibling entrypoint; preserve every raise typer.Exit(code). 3. Re-export block for test-facing private symbols (mirror _profile_health_render re-export style). 4. Import of console/guards/constants from _doctor_shared (or _profile_health_render for console).

Everything else moves to a sibling.