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-priority 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 — 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.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. 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.