Pipeline
The deterministic engine has five stages, each pure, each consuming the output of an earlier stage. The arrows below match the call order in analyze().
┌───────────────────────────────────────────┐
│ CommitRecord[] (you produce this) │
└──────────────────┬────────────────────────┘
│
mineCommits(commits)
│
▼
┌───────────────────────────────────────────┐
│ MinedCommits │
│ .records[i] — record + bug-fix signal │
│ .stats — totals + bug-fix rate │
└─────┬───────┬──────────┬──────────────────┘
│ │ │
computeChurn ─┘ │ └─ computeHotspots
│
computeCochange
│
▼
┌───────────────────────────────┐
│ computeRisk(...) │
│ ↑ ↑ ↑ ↑ │
│ all four engine outputs │
└───────────────────────────────┘
│
▼
RiskReport.byFile[path]
(every claim grounded by SHA) 1. mineCommits — bug-fix classification
Input: raw CommitRecord[]. Output: MinedCommits — same records with a signal bit attached, plus aggregate stats (totalCommits, bugFixCommits, bugFixRate).
The signal comes from BUGFIX_REGEX applied to the commit message — a conservative pattern matching fix, bug, regression, hotfix, etc. with their common suffixes. False positives ("fix typo") are accepted; false negatives ("address payment edge case") are filtered out at downstream stages by the bundled isBugFixCommit rules.
You can override the heuristic by filtering input yourself — the engine treats the signal field as data, not as the source of truth.
2. computeChurn — touch frequency
Input: MinedCommits. Output: ChurnReport.byFile[path] with:
commitCount— how many commits touched the file.bugFixCount— how many of those were classified as bug-fix.defectDensity—bugFixCount / commitCount(ornullifcommitCount === 0).firstTouched,lastTouched— ISO-8601 strings from the commit author dates.
Cheap (~0.8 ms over 10 k commits) and the foundation for hotspots and risk.
3. computeCochange — co-modification graph
Input: MinedCommits. Output: CochangeReport with edges[]. Each edge {a, b, count, jaccard} describes how often two files appear in the same commit, both as a raw count and as a Jaccard-style normalised weight (|A ∩ B| / |A ∪ B|).
This is the heaviest engine — O(C × F²) where F is files-per-commit. The default maxFilesPerCommit: 50 cap prevents a single mass-rename commit from blowing the budget. Even at 10 k commits / 500 files, median runtime is ~5 ms.
4. computeHotspots — Bayesian-smoothed bug-fix density
Input: MinedCommits. Output: HotspotsReport.byFile[path] with score ∈ [0, 1] and the supporting counts.
The score is a Bayesian-smoothed bug-fix rate per file. Smoothing matters because raw bugFixCount / commitCount is noisy on small samples — a brand-new file with one bug-fix commit shouldn't outrank a 38-commit file with 10 bug-fix commits. The prior pulls scores toward the repository-wide bug-fix rate when the per-file sample is small.
5. computeRisk — the combiner
Input: all four prior outputs. Output: RiskReport.byFile[path] with:
score: number | null— combined risk in[0, 1].nullwhen there's no signal, never a fabricated0.tier: 'low' | 'medium' | 'high'— discretised tier.groundedIn: string[]— the real commit SHAs that contributed to the score. Every numeric claim points back to data.caveats: string[]— known limitations (e.g."short history (38 commits)").
The combiner weights hotspots, churn, and cochange centrality — the exact formula is in src/risk/compute-risk.ts and intentionally simple. Sophistication lives in the closed-source hosted layer; the OSS engine is the deterministic floor everyone can verify.
End-to-end with analyze()
import { analyze, type AnalyzeContext } from '@nkwib/pr-engine';
const ctx: AnalyzeContext = await yourAdapter.collect();
const output = analyze(ctx);
// Same as:
// const mined = mineCommits({ commits: ctx.commits });
// const churn = computeChurn({ mined });
// const cochange = computeCochange({ mined });
// const hotspots = computeHotspots({ mined });
// const risk = computeRisk({ mined, hotspots, churn, cochange });
//
// plus header fields (version, head, pr, diff) attached. Skipping stages
You can run any subset of stages — every compute* is independent of the others except for computeRisk which combines them. Common patterns:
- Just churn, e.g. for a "files most touched in the last 90 days" dashboard:
const churn = computeChurn({ mined: mineCommits({ commits }) }); - Hotspots + churn, no cochange (skip the heaviest engine):
const mined = mineCommits({ commits }); const churn = computeChurn({ mined }); const hotspots = computeHotspots({ mined }); // No risk score — that's fine, hotspots are useful on their own. - Cochange-only, e.g. for "files I should glance at when touching X":
const cochange = computeCochange({ mined: mineCommits({ commits }) });
The engine does not enforce a particular order or completeness. analyze() is a convenience; the lower-level functions are the contract.