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.
File Start here What 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.
File Start here What it decides
schema.py Config The 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.py load_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.yml — The 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.
File Start here What it decides
models.py Job, Section, LogLine, Phase The 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.py LogSegmenter, RunFetcher The protocols a provider must satisfy. The entire reason `core/` can stay blind to which CI system produced a log.
phases.py assign_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.py attribute() 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.py denoise() 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.py extract() Runs the matchers, turns hits into windows, merges what overlaps, sheds the lowest-ranked windows under budget pressure, and renders the gaps as explicit elision markers.
budget.py fit() 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.py build_bundle() The orchestrator for the evidence: picks the last section carrying the blamed phase, then denoise → extract → fit into one `EvidenceBundle`.
redact.py redact() 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.
File Start here What it decides
gitlab/provider.py GitLabProvider REST client: find the failed pipeline, list its jobs, pull the trace for the failed ones.
gitlab/segmenter.py GitLabSegmenter Reads GitLab's `section_start:…:step_script` markers into the Section tree.
gitlab/reasons.py — Maps GitLab's `failure_reason` strings onto the domain `FailureReason` enum.
github/segmenter.py GitHubSegmenter The 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.py — Infers 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.
File Start here What it decides
report.py produce_report() Builds the report. With no model configured this is the whole output — a deterministic report, not a stub. It sets the category only when the provider reason settles it; a script failure's category is the model's job.
backends.py — `openai`, `litellm`, `claude_code`. All prompt-and-parse; the caller validates the JSON against the schema.
schema.py LLMAnswer The shape a model answer must have. A reply that does not validate is discarded, not trusted.
render/ Presentation only — no decisions live here.
File Start here What it decides
terminal.py — The 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. See Configuration 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.