Repository Stability¶
Part of Explanation.
A dependency can fail two ways, so OSS IQ runs two pipelines and never averages them into one score.
Pipeline |
Metric |
Question |
Action |
|---|---|---|---|
Tactical |
EPSS |
Is a known CVE being exploited right now? |
Patch or bump |
Strategic |
Maintenance state |
Will this project still be maintained? |
Refactor or replace |
They meet in one place only: the triage matrix, which turns the pair into a
single recommended action — retain, patch, refactor or evict.
Why they stay separate¶
EPSS is scoped to published CVE identifiers. A package with no filed CVEs has an undefined EPSS, not a zero. Treating an unvetted or abandoned package as safe because nobody filed an advisory is the most common way a dependency scanner misleads you.
The units don’t mix either: EPSS is an exploit probability over 30 days; maintenance state is a statement about upstream capacity. Multiplying them would hide both.
The maintenance-state model¶
risk/maintenance.py
computes one naive-Bayes posterior over four states, healthiest first:
maintained → winding_down → abandoned → deprecated
PRIORS and the LIKELIHOOD tables are hand-tunable constants, fit from the 63-repo, 4-state
corpus in qa/calibrate_stability.py. Five observations feed the model, each dropped when it
can’t be measured:
Observation |
Values |
Source |
|---|---|---|
|
weak / strong |
|
|
true / false |
commit-gap estimator — validated |
|
fresh \(<45\)d / recent \(<90\)d / aging \(<365\)d / stale \(<730\)d / ancient |
|
|
improving / stable / declining |
|
|
current \(<90\)d / recent \(<365\)d / stale \(<730\)d / ancient |
|
The first four describe the repository; release_age describes what reached users. A package
can be quiet on one clock and alive on the other, and telling a library that is finished from one
that is dying needs both — in the corpus no maintained repo has gone a year without a release, and
no abandoned one has shipped inside a year.
Finding no deprecation marker is not an observation: the strength only reaches the model once at
least one signal fires, so a repository nobody has flagged stays scored on its commit history
instead of being pushed toward maintained by the absence of a notice.
Three independence gates¶
Naive Bayes assumes the observations are conditionally independent. The activity-derived ones aren’t, so three gates drop the correlated signal instead of double-counting it:
deprecation_strength = strong(archived, registry-deprecated, or PyPIDevelopment Status :: 7 - Inactive) drops every other observation. An archived repo with fresh CI commits is still deprecated.push_age = freshdropsflow_trend. A repo pushed in the last 45 days is active whatever its issue backlog does; in mature libraries flow decline is release-driven, not decay.push_age = freshalso dropsrelease_age, for the opposite reason: the push already proved the project is being worked on, so a recent release would say the same thing twice.
One consequence limits what the corpus can check. Every maintained entry in it pushes inside the
fresh window and every deprecated entry carries a strong marker, so neither ever reaches
release_age — two of that table’s four columns are set by reasoning rather than fitted. Closing
the gap needs corpus entries that are alive but slow-pushing.
What it reports¶
The “not maintained” risk is the mass on the bottom two states:
At or above MAINTENANCE_THRESHOLD \(= 0.5\), triage marks the package unstable.
Coverage counts the observations that actually reached the model, so a strong deprecation
marker scores \(0.2\) — the gate dropped the rest by design, not for lack of data. Both fields are
null when nothing could be observed.
The commit-gap estimator¶
Drives has_stopped, the model’s dominant abandonment signal, and the only channel that is
independently validated.
The channel samples the last 100 commits — a fixed event count over a random elapsed time — and measures the gaps between them. For a memoryless process the inter-arrival times are Exponential, whose coefficient of variation is \(1\) at any rate, so
is volume-free by construction: a busy repository and a quiet one with the same rhythm score alike. Read \(\texttt{gap_cv} > 1\) as bursty, \(\approx 1\) as memoryless, \(< 1\) as more regular than chance. It is reported only from 20 gaps up, the same floor the dormancy test uses.
Warning
gap_cv is a displayed diagnostic, not a model input. Release cadence confounds it — a
low-churn library committing in bursts around releases looks identical to a decaying one.
The dormancy test¶
Let \(d\) be the days since the most recent sampled commit, over \(n\) historical gaps \(g_1 \dots g_n\):
The empirical tail is preferred because real gap distributions are overdispersed; the fitted exponential is a fallback when there are too few gaps to resolve a percentile.
Both halves of the conjunction are load-bearing. The p-value clears calm-but-alive repos — a package whose typical gap is 60 days sitting silent for 90 is unremarkable for it. The 90-day floor clears both a hyperactive repo’s long weekend and a low-churn library’s ordinary one-to-two-month lull.
benjaminp/six shows why the comparison must be self-referential. It has been silent ~184 days,
but six’s own history has gone quiet that long often enough (\(\texttt{silence_p} \approx 0.06\))
that the silence isn’t unusual for six. A fixed calendar window would flag it; measuring each
repository against its own gap history keeps a finished-but-fine library apart from a dead one.
Sampling¶
GET /repos/{owner}/{repo}/commits?per_page=100 — one page, one request, newest-first. Bot
commits (dependabot[bot], renovate, pre-commit-ci, github-actions, any *[bot]) are
filtered before gaps are computed; a weekly dependabot commit otherwise gives a dead repo a
perfectly regular distribution. Timestamps use commit.committer.date, because author dates
survive rebase and are occasionally in the future.
Runs for direct dependencies only — there is no independent lever to act on a transitive
package you didn’t choose. Cached 7 days (--stability-cache-ttl, default 168h).
The model itself is not direct-only. push_age and the registry-side deprecation markers come
from metadata every package in the tree already has, so a transitive still gets a state — just at
lower coverage, without the commit, engagement or README evidence.
The engagement-flow channel¶
Supplies flow_trend. Issues and PRs aren’t in git, so this is a batched GraphQL job: one
POST /graphql per repo per stream (issues and PRs are separate requests), at low concurrency to
stay under GitHub’s CPU-time secondary rate limit.
It tracks issue and PR flow across a 180-day window in six ~30-day buckets. Both streams share one ratio per bucket \(b\):
The numerator counts items finished in that bucket regardless of when they opened, so slow items that outlive the window still count. A pull request counts as outflow when it closes, merged or not: rejecting a PR is the maintainer responding, and counting only merges made every project with a high bar for contributions read as unresponsive. Read \(r_b > 1\) as a draining backlog, \(\approx 1\) as keeping pace, \(\to 0\) as falling behind.
Two corrections keep the ratio from inverting, neither decidable from a single bucket:
The denominator is capped at twice \(\tilde{o}\), the median
openedacross measured buckets. Dividing by inbound volume means a burst of incoming work drives \(r_b\) toward zero and reads exactly like the maintainer going silent. A drive-by contributor filing a dozen PRs in a fortnight is the common shape, and bot filtering cannot catch it because the author is human. Capping lets a spike dilute the ratio without manufacturing a collapse out of what is really a sign of interest in the project.A bucket with fewer than three items of movement is reported unmeasured rather than as a confident \(0.0\). One issue opened and nothing closed carries no more information than an empty bucket, but it used to enter the fit with the same weight as a bucket of thirty items.
flow_trend is the direction of the OLS slope \(\hat{\beta}\) of \(r_b\) against bucket index, with
one override for a sharp recent drop:
The override exists because “outflow just stopped” is exactly the case a shallow slope hides.
Empty buckets are dropped from the fit but keep their index, so a run of quiet months spreads the
regression rather than collapsing it. flow_trend is null below three measured buckets.
Like has_stopped, this measures a repository against its own recent history: a project that
always closed 30% of its issues and still does is stable; one that closed 90% and now closes 10%
is not.
GraphQL needs a token, so the channel defaults on when a GitHub token is available (a login or
OSSIQ_GITHUB_TOKEN), off otherwise; force it with --stability-responsiveness / --no-stability-responsiveness. Direct
dependencies only, cached 7 days. The raw buckets are persisted in the JSON export as
engagement_buckets — one [issues_opened, issues_closed, prs_opened, prs_closed] row per
bucket, oldest first — so the trend can be re-fitted offline.
Deprecation evidence¶
deprecation_evidence reduces eight markers to a strength tier. Most ride the metadata the scan
already fetches; two do not, and both are direct-dependency only: the README banner costs one
extra GET .../readme per repository (cached at the stability TTL), and the pinned-issue notice
arrives inside the GraphQL activity payload, so it is unavailable without a token.
Signal |
Source |
Strong on its own |
|---|---|---|
|
GitHub |
✅ |
|
npm deprecated the |
✅ |
|
PyPI trove |
✅ |
|
GitHub topic |
|
|
deprecation phrase in the repo description or registry summary |
|
|
deprecation banner in the first screenful of the README (extra request) |
|
|
a pinned issue titled like a migration / end-of-life notice (needs a token) |
|
|
a replacement package named in metadata, a deprecation message, or the README |
strong = any one hard marker, or any two signals. weak = exactly one soft signal.
A strong marker is near-deterministic and, per the gate above, becomes the only observation the
model sees.
Unknown is not zero¶
Situation |
|
Meaning |
|---|---|---|
No GitHub repository record (non-GitHub host, none declared) and no deprecation marker |
|
Nothing to assess |
At least one observation available |
a state |
Assessed on what was measured |
A package that could not be assessed is never marked for refactoring. It keeps its
exploit-driven action, so it can reach patch but never evict or refactor. Prefetch only
resolves github.com URLs, so GitLab, Codeberg and packages with no declared repository stay
unknown.
The triage matrix¶
Exploit signal |
Repository |
Action |
Why |
|---|---|---|---|
EPSS \(\ge 0.10\) |
not maintained |
|
Exploited, and no upstream fix is coming. Replace it. |
EPSS \(\ge 0.10\) |
maintained |
|
Exploited, but a fix can ship. Bump it. |
EPSS \(< 0.10\) |
not maintained |
|
No exploit pressure, but real maintenance debt. Schedule it. |
otherwise |
|
Baseline healthy state. |
“Not maintained” means \(P(\text{abandoned}) + P(\text{deprecated}) \ge 0.5\) from the model, not a
raw calendar rule. CVEs scoring below \(0.005\) — a 0.5% chance of exploitation — don’t count
toward the exploit signal; they are reported as suppressed_cves rather than dropped, so the
noise reduction stays visible. A retain with suppressed CVEs says so in its reason (“N CVE(s)
scored below EPSS 0.005”) rather than claiming no signal at all.
Note
The action is advisory. It changes no exit code. It appears in status, info, the
JSON export (dependency_health_action), the HTML report and the agent/MCP output
(dependency_health). To gate a build on it, write the check yourself over the
ossiq export JSON. Mistaking advice for a gate in
What the recommendation catalogue risks
says why.
Reading the output¶
status reports counts, never one score — maintenance doesn’t compose across a dependency tree:
Unmaintained deps: 3 (1 deprecated) of 18 assessed | Unassessed: 4
That count is the most probable state — packages whose posterior peaks on abandoned or
deprecated. Triage asks the softer question, \(P(\text{not maintained}) \ge 0.5\), so a package
split evenly between winding_down and abandoned can be triaged refactor without appearing in
the unmaintained count.
info <package> shows the full decomposition: Gap CV and the number of commits behind it,
silence and its p-value, the engagement flow trend, Last pushed, any Deprecation signals,
the Maintenance state with its risk and the observations behind it, and the resulting triage
action.
The JSON export (schema 1.5 and later) carries maintenance_state, maintenance_risk,
maintenance_coverage, gap_cv, median_gap_days, silence_days, silence_p,
commits_sampled, span_days, flow_trend, engagement_buckets, deprecation_signals,
deprecation_successor, days_since_push, archived and dependency_health_action (the triage action).
Any field is null when it couldn’t be measured. That means “unknown”, never “no risk.”
Switch the whole channel off with --no-stability (OSSIQ_STABILITY=false); switch just the
GraphQL flow channel off with --no-stability-responsiveness — already off by default on
unauthenticated scans, where GitHub allows 60 requests an hour.
Calibration¶
qa/calibrate_stability.py (just calibrate-stability, needs OSSIQ_GITHUB_TOKEN) runs the real
scan path against the 63-repo hand-labelled 4-state corpus. Three gap checks gate the exit code
and are passing:
gap_cvdecoupled from commit volume — Pearson \(|r| < 0.5\) across themaintainedset.Every
abandonedrepo flaggedhas_stopped;benjaminp/sixnot flagged.No repo with a commit inside 30 days flagged.
The model itself is reported as a confusion matrix, multiclass Brier score and reliability curve
over the four states — informational until the LIKELIHOOD tables are tuned against the
corpus, then promoted to a hard check (diagonal accuracy \(\ge 70\%\), Brier \(\le 0.20\)). The
tuning procedure is in qa/manual/stability-calibrate.md.