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.