Skip to content

Commit d88ef70

Browse files
committed
feat(enrichment): churn-hotspot analyzer
Add a REES analyzer (#1513) that flags the changed files which are statistical fragility hotspots — a high recent commit frequency AND a high fraction of fix/revert commits — so the reviewer scrutinizes defect-prone areas harder. For each changed (non-added, non-generated) file it reads one page of the file's commit history from the GitHub commits API over a 90-day window, counts the commits, and classifies each subject line as a fix/revert. A file with >=8 commits and >=30% fixes is reported. Counts only — never file contents. Distinct from the history analyzer (#1478, author track record); this scores the change AREA's defect density. Pure helpers (isFixCommit/summarizeChurn/isHotspot) + an injected fetch; registered as a descriptor and rendered as a public-safe block. Tests in their own file; analyzer-metadata + the descriptor-derived lists regenerated.
1 parent bbfc46b commit d88ef70

9 files changed

Lines changed: 346 additions & 3 deletions

File tree

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,17 @@ GITTENSORY_REVIEW_ENRICHMENT=false
6565
# Current analyzer names:
6666
# dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos
6767
# provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild
68-
# history,docCommentDrift
68+
# history,docCommentDrift,churnHotspot
6969
#
7070
# Profile defaults:
7171
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7272
# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
7373
# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
7474
# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
75-
# iacMisconfig,nativeBuild,history,docCommentDrift
75+
# iacMisconfig,nativeBuild,history,docCommentDrift,churnHotspot
7676
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7777
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
78-
# nativeBuild,history,docCommentDrift
78+
# nativeBuild,history,docCommentDrift,churnHotspot
7979
# END GENERATED REES ANALYZERS
8080

8181
# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep

apps/gittensory-ui/src/lib/rees-analyzers.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,32 @@ export const REES_ANALYZERS = [
505505
"Conservative: only named function declarations with confidently-enumerable params; non-parameter signature edits are not reported.",
506506
},
507507
},
508+
{
509+
name: "churnHotspot",
510+
title: "Churn hotspots",
511+
category: "history",
512+
cost: "github-heavy",
513+
defaultEnabled: true,
514+
profiles: ["balanced", "deep"],
515+
requires: ["files", "github-token"],
516+
limits: {
517+
maxFilesProbed: 8,
518+
windowDays: 90,
519+
perPage: 100,
520+
},
521+
docs: {
522+
summary:
523+
"Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
524+
looksAt:
525+
"Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
526+
reports:
527+
"File, commit count, fix/revert count, and the window — counts only, never file contents.",
528+
network:
529+
"Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
530+
notes:
531+
"Distinct from the history analyzer's author track record; this scores the change AREA's defect density.",
532+
},
533+
},
508534
] as const satisfies readonly ReesAnalyzerDoc[];
509535

510536
export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);

review-enrichment/analyzer-metadata.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,33 @@
579579
"network": "Calls the GitHub API for changed file contents. Requires headSha and token forwarding for private repos.",
580580
"notes": "Conservative: only named function declarations with confidently-enumerable params; non-parameter signature edits are not reported."
581581
}
582+
},
583+
{
584+
"name": "churnHotspot",
585+
"title": "Churn hotspots",
586+
"category": "history",
587+
"cost": "github-heavy",
588+
"defaultEnabled": true,
589+
"profiles": [
590+
"balanced",
591+
"deep"
592+
],
593+
"requires": [
594+
"files",
595+
"github-token"
596+
],
597+
"limits": {
598+
"maxFilesProbed": 8,
599+
"windowDays": 90,
600+
"perPage": 100
601+
},
602+
"docs": {
603+
"summary": "Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
604+
"looksAt": "Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
605+
"reports": "File, commit count, fix/revert count, and the window — counts only, never file contents.",
606+
"network": "Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
607+
"notes": "Distinct from the history analyzer's author track record; this scores the change AREA's defect density."
608+
}
582609
}
583610
]
584611
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Churn-hotspot analyzer (#1513). For the files a PR changes, reads each file's recent commit history from the
2+
// GitHub API and flags the ones that are statistical fragility hotspots — a high commit frequency AND a high
3+
// fraction of fix/revert commits in the window. These are areas where defects historically cluster, so the
4+
// reviewer should scrutinize the change harder. This is heavy/external/historical analysis the no-checkout
5+
// `claude --print` reviewer cannot do. Surfaces only counts derived from the public commit log — never file
6+
// contents. Distinct from the history analyzer (#1478), which scores the AUTHOR's track record.
7+
import type {
8+
AnalyzerDiagnostics,
9+
EnrichRequest,
10+
ChurnHotspotFinding,
11+
} from "../types.js";
12+
import type { AnalysisContext } from "../analysis-context.js";
13+
import { boundedFetchJson } from "../external-fetch.js";
14+
15+
const GITHUB_API = "https://api.github.com";
16+
const SLUG_RE = /^[A-Za-z0-9._-]+$/;
17+
const WINDOW_DAYS = 90;
18+
const PER_PAGE = 100; // one page; a file with a full page of commits in the window is already a clear hotspot
19+
const MAX_FILES_PROBED = 8; // bound the GitHub round-trips, matching the other history-class analyzers
20+
const MIN_COMMITS = 8; // a hotspot must change frequently within the window
21+
const MIN_FIX_FRACTION = 0.3; // and a meaningful share of those changes must be fixes/reverts
22+
// Files whose commit churn is not a useful code-fragility signal — lockfiles, generated output, and binaries.
23+
const SKIP_RE =
24+
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|go\.sum)$|\.(?:lock|min\.js|map|snap|png|jpe?g|gif|svg|ico|pdf|zip|gz|woff2?)$|(?:^|\/)(?:dist|build|vendor)\//i;
25+
// Defect-correcting commit subjects: fix/bugfix/hotfix/revert/regression (conventional-commit `fix:` included).
26+
const FIX_RE = /\b(?:fix(?:e[ds]|ing)?|bug ?fix|hotfix|revert(?:ed|s)?|regression)\b/i;
27+
28+
interface ScanOptions {
29+
signal?: AbortSignal;
30+
analysis?: Pick<AnalysisContext, "fetchJson">;
31+
diagnostics?: AnalyzerDiagnostics;
32+
}
33+
34+
/** The slice of a GitHub commit-list item this analyzer reads. */
35+
interface CommitItem {
36+
commit?: { message?: string };
37+
}
38+
39+
/** True when a commit's SUBJECT line describes a defect correction. Pure. */
40+
export function isFixCommit(message: string): boolean {
41+
const subject = message.split("\n", 1)[0] ?? "";
42+
return FIX_RE.test(subject);
43+
}
44+
45+
/** Reduce a commit list to total + fix counts, capped flag, and the fix fraction. Pure. */
46+
export function summarizeChurn(commits: CommitItem[]): {
47+
commitCount: number;
48+
fixCount: number;
49+
fixFraction: number;
50+
} {
51+
let fixCount = 0;
52+
for (const item of commits) if (isFixCommit(item.commit?.message ?? "")) fixCount += 1;
53+
const commitCount = commits.length;
54+
return { commitCount, fixCount, fixFraction: commitCount ? fixCount / commitCount : 0 };
55+
}
56+
57+
/** True when a file's churn summary meets the hotspot thresholds (enough commits AND enough of them fixes). Pure. */
58+
export function isHotspot(summary: { commitCount: number; fixFraction: number }): boolean {
59+
return summary.commitCount >= MIN_COMMITS && summary.fixFraction >= MIN_FIX_FRACTION;
60+
}
61+
62+
function githubHeaders(token: string): Record<string, string> {
63+
return {
64+
Authorization: `Bearer ${token}`,
65+
Accept: "application/vnd.github+json",
66+
"X-GitHub-Api-Version": "2022-11-28",
67+
};
68+
}
69+
70+
/** Fetch one page of commits touching `path` since `since`. Returns the list, or null on any error / non-200. */
71+
async function fetchFileCommits(
72+
url: string,
73+
headers: Record<string, string>,
74+
fetchFn: typeof fetch,
75+
signal: AbortSignal | undefined,
76+
options: Pick<ScanOptions, "analysis" | "diagnostics">,
77+
): Promise<CommitItem[] | null> {
78+
const fetchOptions = {
79+
endpointCategory: "github-commits",
80+
headers,
81+
signal,
82+
fetchImpl: fetchFn,
83+
diagnostics: options.diagnostics,
84+
phase: "churn-hotspot",
85+
subcall: "github-commits",
86+
maxBytes: 512 * 1024,
87+
maxCallsPerCategory: MAX_FILES_PROBED,
88+
};
89+
const response = options.analysis
90+
? await options.analysis.fetchJson<CommitItem[]>(url, fetchOptions)
91+
: await boundedFetchJson<CommitItem[]>(url, fetchOptions);
92+
return response.ok && Array.isArray(response.data) ? response.data : null;
93+
}
94+
95+
/** Analyzer entrypoint: changed files → per-file recent commit history → fragility hotspots. Fail-safe. */
96+
export async function scanChurnHotspot(
97+
req: EnrichRequest,
98+
fetchFn: typeof fetch = fetch,
99+
options: ScanOptions = {},
100+
): Promise<ChurnHotspotFinding[]> {
101+
const { repoFullName, githubToken, files = [] } = req;
102+
if (!githubToken) return [];
103+
const [owner, repo] = repoFullName.split("/");
104+
if (!owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return [];
105+
106+
const headers = githubHeaders(githubToken);
107+
const since = new Date(Date.now() - WINDOW_DAYS * 86_400_000).toISOString();
108+
// A newly-added file has no prior history; skip it (and non-code/generated files) before spending a round-trip.
109+
const paths = files
110+
.filter((file) => file.status !== "added" && !SKIP_RE.test(file.path))
111+
.map((file) => file.path)
112+
.slice(0, MAX_FILES_PROBED);
113+
114+
const findings: ChurnHotspotFinding[] = [];
115+
for (const path of paths) {
116+
if (options.signal?.aborted) break;
117+
const url =
118+
`${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits` +
119+
`?path=${encodeURIComponent(path)}&since=${encodeURIComponent(since)}&per_page=${PER_PAGE}`;
120+
const commits = await fetchFileCommits(url, headers, fetchFn, options.signal, options);
121+
if (!commits) continue;
122+
const summary = summarizeChurn(commits);
123+
if (!isHotspot(summary)) continue;
124+
findings.push({
125+
file: path,
126+
commitCount: summary.commitCount,
127+
fixCount: summary.fixCount,
128+
windowDays: WINDOW_DAYS,
129+
capped: summary.commitCount >= PER_PAGE,
130+
});
131+
}
132+
return findings;
133+
}

review-enrichment/src/analyzers/registry.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { scanActionPins } from "./actions-pin.js";
22
import { scanAssetWeight } from "./asset-weight.js";
3+
import { scanChurnHotspot } from "./churn-hotspot.js";
34
import { scanCodeowners } from "./codeowners.js";
45
import { scanCommitSignature } from "./commit-signature.js";
56
import { dependencyAnalyzer } from "./dependency/descriptor.js";
@@ -388,6 +389,27 @@ export const ANALYZER_DESCRIPTORS = [
388389
},
389390
run: (req, { signal }) => scanDocCommentDrift(req, fetch, { signal }),
390391
}),
392+
descriptor({
393+
name: "churnHotspot",
394+
title: "Churn hotspots",
395+
category: "history",
396+
cost: "github-heavy",
397+
defaultEnabled: true,
398+
requires: ["files", "github-token"],
399+
limits: { maxFilesProbed: 8, windowDays: 90, perPage: 100 },
400+
docs: {
401+
summary:
402+
"Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
403+
looksAt:
404+
"Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
405+
reports: "File, commit count, fix/revert count, and the window — counts only, never file contents.",
406+
network: "Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
407+
notes:
408+
"Distinct from the history analyzer's author track record; this scores the change AREA's defect density.",
409+
},
410+
run: (req, { signal, analysis, diagnostics }) =>
411+
scanChurnHotspot(req, fetch, { signal, analysis, diagnostics }),
412+
}),
391413
] as const satisfies readonly AnyAnalyzerDescriptor[];
392414

393415
export const ANALYZER_NAMES = ANALYZER_DESCRIPTORS.map(

review-enrichment/src/render.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,20 @@ export function renderBrief(
353353
}
354354
}
355355

356+
const churnHotspots = findings.churnHotspot ?? [];
357+
if (churnHotspots.length) {
358+
lines.push(
359+
"### Churn hotspots (high commit + fix/revert density — historically fragile, scrutinize)",
360+
);
361+
for (const item of churnHotspots) {
362+
const pct = item.commitCount ? Math.round((item.fixCount / item.commitCount) * 100) : 0;
363+
const count = `${item.commitCount}${item.capped ? "+" : ""}`;
364+
lines.push(
365+
`- ${safeCodeSpan(item.file)}${count} commits in ${item.windowDays}d, ${item.fixCount} fix/revert (${pct}%)`,
366+
);
367+
}
368+
}
369+
356370
if (!lines.length) return { promptSection: "", systemSuffix: "" };
357371

358372
const header =

review-enrichment/src/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,21 @@ export interface HistoryFinding {
270270
partial: boolean;
271271
}
272272

273+
/** A changed file that is a statistical churn hotspot: many recent commits AND a high fraction of fix/revert
274+
* commits, so defects historically cluster there. Counts come from the repository's public commit history within
275+
* a fixed window — never file contents. (#1513) */
276+
export interface ChurnHotspotFinding {
277+
file: string;
278+
/** Commits touching this file in the window (capped at one page; `capped` marks the cap was hit). */
279+
commitCount: number;
280+
/** Of those, how many were fix/revert/hotfix/regression commits. */
281+
fixCount: number;
282+
/** The lookback window in days. */
283+
windowDays: number;
284+
/** True when `commitCount` reached the per-page cap, so the real count is at least that. */
285+
capped: boolean;
286+
}
287+
273288
/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
274289
export interface BriefFindings {
275290
dependency?: DependencyFinding[];
@@ -291,6 +306,7 @@ export interface BriefFindings {
291306
nativeBuild?: NativeBuildFinding[];
292307
history?: HistoryFinding[];
293308
docCommentDrift?: DocCommentDriftFinding[];
309+
churnHotspot?: ChurnHotspotFinding[];
294310
}
295311

296312
/** A JSDoc/TSDoc block whose `@param` tags name parameters the adjacent function no longer declares — a

review-enrichment/test/analyzer-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const EXPECTED_ANALYZERS = [
2929
"nativeBuild",
3030
"history",
3131
"docCommentDrift",
32+
"churnHotspot",
3233
];
3334

3435
test("analyzer descriptors cover the runtime registry in stable order", () => {

0 commit comments

Comments
 (0)