Code map

Package by package, with the one function or type worth opening first in each file. Paths are relative to ci_doctor/.

ci_doctor/The wiring, and nothing else.
FileStart hereWhat it decides
cli.py_process()The only place the pieces are joined: Typer commands, config loading, logging, and the `exit 0` guarantee. Segment → assign phases → attribute → build bundle → report.
config/What is tunable, and how the layers combine.
FileStart hereWhat it decides
schema.pyConfigThe pydantic model. Source of truth for the published JSON Schema and for every scalar default. `extra="forbid"` — an unknown key in a config file is an error, not a shrug.
loader.pyload_config()Applies the five layers in order and owns the merge-by-id rule that lets your matcher packs add to the shipped ones instead of replacing them.
defaults.ymlThe data-heavy baseline: phase map, the 35 matcher packs, noise patterns. Data, not code — which is why the docs can generate tables from it.
core/The domain. Pure functions, no network, no clock, no provider ever named.
FileStart hereWhat it decides
models.pyJob, Section, LogLine, PhaseThe vocabulary everything downstream speaks. A `Section` is a named region of a log with its own lines, children and a `closed` flag; that flag is what makes "died mid-step" detectable.
ports.pyLogSegmenter, RunFetcherThe protocols a provider must satisfy. The entire reason `core/` can stay blind to which CI system produced a log.
phases.pyassign_phases()Walks the section tree and stamps a `Phase` on each node from the config map. An unrecognised section inherits the nearest known phase, falling back to `script` — an unknown block is far more often your command than infrastructure.
attribution.pyattribute()The classifier: a first-match-wins ladder of rules over job metadata and log structure. Returns the phase, the reason, a confidence, the single most telling line, and the `rule_id` that fired.
denoise.pydenoise()Strip ANSI, collapse `\r` progress rewrites to their final state, optionally drop the timestamp prefix, drop configured noise, then fold runs of identical lines into one with a count.
extract.pyextract()Runs the matchers, turns hits into windows, merges what overlaps, sheds the lowest-priority windows under budget pressure, and renders the gaps as explicit elision markers.
budget.pyfit()The last-resort cap: trims *inside* the surviving evidence, keeping the tail, and announces how much it dropped. Token counting is a 4-chars-per-token estimate — no tokenizer is loaded.
analyze.pybuild_bundle()The orchestrator for the evidence: picks the last section carrying the blamed phase, then denoise → extract → fit into one `EvidenceBundle`.
redact.pyredact()The secret scrubber. Run on the prompt and again on the finished report.
providers/One directory per CI system. Adding a provider means adding a directory, never editing core.
FileStart hereWhat it decides
gitlab/provider.pyGitLabProviderREST client: find the failed pipeline, list its jobs, pull the trace for the failed ones.
gitlab/segmenter.pyGitLabSegmenterReads GitLab's `section_start:…:step_script` markers into the Section tree.
gitlab/reasons.pyMaps GitLab's `failure_reason` strings onto the domain `FailureReason` enum.
github/segmenter.pyGitHubSegmenterThe harder half of the same job: GitHub group names embed action versions ("Run actions/checkout@v4"), so they are canonicalised to stable tokens here — which is what lets the phase map in `core/` stay provider-free.
git_origin.pyInfers the project from the checkout's git remote, so the CLI usually needs no `--project`.
llm/The one optional model call. It explains why; it never decides where.
FileStart hereWhat it decides
report.pyproduce_report()Builds the report. With no model configured this is the whole output — a deterministic report, not a stub — and the failure-category signatures are computed here either way.
backends.py`openai`, `litellm`, `claude_code`. All prompt-and-parse; the caller validates the JSON against the schema.
schema.pyLLMAnswerThe shape a model answer must have. A reply that does not validate is discarded, not trusted.
render/Presentation only — no decisions live here.
FileStart hereWhat it decides
terminal.pyThe rich terminal view.
markdown.py`report.md`, and the body of the MR/PR note.
json_out.py`report.json`, for anything downstream that wants to parse rather than read.

Configuration layering

Every knob in every one of those files is set by the same five-layer merge:

schema defaults  <  defaults.yml  <  repo .ci-doctor.yml  <  CI_DOCTOR_* env  <  CLI flags

Mappings deep-merge, lists and scalars replace. The one exception is a list whose entries all carry an id — today only extraction.matchers — which merges per id, so your packs add to the shipped ones instead of wiping them. SeeConfiguration  for the full reference.

Adding a CI provider is a directory, not a patch. Implement the protocols in core/ports.py, add a segmenter and a reason map, and extend the shipped phase map with your section names. Nothing in core/ changes — and CI greps to make sure of it.