Skip to content

Commit ace21da

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 f70b409 commit ace21da

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,duplication
68+
# history,docCommentDrift,duplication,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,duplication
75+
# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot
7676
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7777
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
78-
# nativeBuild,history,docCommentDrift,duplication
78+
# nativeBuild,history,docCommentDrift,duplication,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
@@ -533,6 +533,32 @@ export const REES_ANALYZERS = [
533533
"Conservative: trivial/boilerplate lines are dropped and a long contiguous run is required, so incidental overlap is not flagged. Never returns code content.",
534534
},
535535
},
536+
{
537+
name: "churnHotspot",
538+
title: "Churn hotspots",
539+
category: "history",
540+
cost: "github-heavy",
541+
defaultEnabled: true,
542+
profiles: ["balanced", "deep"],
543+
requires: ["files", "github-token"],
544+
limits: {
545+
maxFilesProbed: 8,
546+
windowDays: 90,
547+
perPage: 100,
548+
},
549+
docs: {
550+
summary:
551+
"Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
552+
looksAt:
553+
"Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
554+
reports:
555+
"File, commit count, fix/revert count, and the window — counts only, never file contents.",
556+
network:
557+
"Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
558+
notes:
559+
"Distinct from the history analyzer's author track record; this scores the change AREA's defect density.",
560+
},
561+
},
536562
] as const satisfies readonly ReesAnalyzerDoc[];
537563

538564
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
@@ -609,6 +609,33 @@
609609
"network": "Calls the GitHub API for the git tree and candidate blobs. Requires headSha and token forwarding for private repos.",
610610
"notes": "Conservative: trivial/boilerplate lines are dropped and a long contiguous run is required, so incidental overlap is not flagged. Never returns code content."
611611
}
612+
},
613+
{
614+
"name": "churnHotspot",
615+
"title": "Churn hotspots",
616+
"category": "history",
617+
"cost": "github-heavy",
618+
"defaultEnabled": true,
619+
"profiles": [
620+
"balanced",
621+
"deep"
622+
],
623+
"requires": [
624+
"files",
625+
"github-token"
626+
],
627+
"limits": {
628+
"maxFilesProbed": 8,
629+
"windowDays": 90,
630+
"perPage": 100
631+
},
632+
"docs": {
633+
"summary": "Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
634+
"looksAt": "Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
635+
"reports": "File, commit count, fix/revert count, and the window — counts only, never file contents.",
636+
"network": "Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
637+
"notes": "Distinct from the history analyzer's author track record; this scores the change AREA's defect density."
638+
}
612639
}
613640
]
614641
}
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";
@@ -418,6 +419,27 @@ export const ANALYZER_DESCRIPTORS = [
418419
run: (req, { signal, analysis, diagnostics }) =>
419420
scanDuplication(req, fetch, { signal, analysis, diagnostics }),
420421
}),
422+
descriptor({
423+
name: "churnHotspot",
424+
title: "Churn hotspots",
425+
category: "history",
426+
cost: "github-heavy",
427+
defaultEnabled: true,
428+
requires: ["files", "github-token"],
429+
limits: { maxFilesProbed: 8, windowDays: 90, perPage: 100 },
430+
docs: {
431+
summary:
432+
"Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
433+
looksAt:
434+
"Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
435+
reports: "File, commit count, fix/revert count, and the window — counts only, never file contents.",
436+
network: "Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
437+
notes:
438+
"Distinct from the history analyzer's author track record; this scores the change AREA's defect density.",
439+
},
440+
run: (req, { signal, analysis, diagnostics }) =>
441+
scanChurnHotspot(req, fetch, { signal, analysis, diagnostics }),
442+
}),
421443
] as const satisfies readonly AnyAnalyzerDescriptor[];
422444

423445
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
@@ -365,6 +365,20 @@ export function renderBrief(
365365
}
366366
}
367367

368+
const churnHotspots = findings.churnHotspot ?? [];
369+
if (churnHotspots.length) {
370+
lines.push(
371+
"### Churn hotspots (high commit + fix/revert density — historically fragile, scrutinize)",
372+
);
373+
for (const item of churnHotspots) {
374+
const pct = item.commitCount ? Math.round((item.fixCount / item.commitCount) * 100) : 0;
375+
const count = `${item.commitCount}${item.capped ? "+" : ""}`;
376+
lines.push(
377+
`- ${safeCodeSpan(item.file)}${count} commits in ${item.windowDays}d, ${item.fixCount} fix/revert (${pct}%)`,
378+
);
379+
}
380+
}
381+
368382
if (!lines.length) return { promptSection: "", systemSuffix: "" };
369383

370384
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[];
@@ -292,6 +307,7 @@ export interface BriefFindings {
292307
history?: HistoryFinding[];
293308
docCommentDrift?: DocCommentDriftFinding[];
294309
duplication?: DuplicationFinding[];
310+
churnHotspot?: ChurnHotspotFinding[];
295311
}
296312

297313
/** 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
@@ -30,6 +30,7 @@ const EXPECTED_ANALYZERS = [
3030
"history",
3131
"docCommentDrift",
3232
"duplication",
33+
"churnHotspot",
3334
];
3435

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

0 commit comments

Comments
 (0)