Repository Stability¶
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. Four observations feed the model, each dropped when it
can’t be measured:
Observation |
Values |
Source |
|---|---|---|
|
weak / strong |
|
|
true / false |
commit-gap estimator — validated |
|
fresh \(<30\)d / recent \(<90\)d / aging \(<365\)d / stale \(<730\)d / ancient |
|
|
improving / stable / declining |
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.
Two independence gates¶
Naive Bayes assumes the observations are conditionally independent. The commit-derived ones aren’t, so two 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 30 days is active whatever its issue backlog does; in mature libraries flow decline is release-driven, not decay.
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.25\) — 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. Pull requests count as outflow only when merged — a PR closed unmerged is a backlog item dropped, not work shipped. Read \(r_b > 1\) as a draining backlog, \(\approx 1\) as keeping pace, \(\to 0\) as falling behind.
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 OSSIQ_GITHUB_TOKEN is set, 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_merged] 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 |
✅ |
|
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.
Note
The action is advisory. It appears in status, info, the JSON and CSV exports, the HTML
report and the agent/MCP verdict, but it does not change the ok / warn / block verdict that
gates a build.
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 --schema-version=1.5 JSON export 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 triage_action. The CSV carries the
same set minus the four that don’t fit a flat column — maintenance_coverage, median_gap_days,
span_days, archived — and minus engagement_buckets.
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.