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.
  • defectDensitybugFixCount / commitCount (or null if commitCount === 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]. null when there's no signal, never a fabricated 0.
  • 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.

@nkwib/pr-engine Deterministic engine — mining, churn, cochange, hotspots, risk