Skip to content

Commit f2ba66f

Browse files
Merge pull request #34 from mvanhorn/fix/dedupe-stale-file-errors
fix(staleness): collapse day + commit thresholds into a single issue
2 parents 15cee0e + 53ab856 commit f2ba66f

2 files changed

Lines changed: 141 additions & 37 deletions

File tree

src/drift/checkers/staleness.ts

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,92 @@
11
import { daysSinceLastChange, commitsSinceLastChange } from "../../git.js";
2-
import type { DriftIssue } from "../../types.js";
2+
import type { DriftIssue, Severity } from "../../types.js";
33

44
const WARN_DAYS = 30;
55
const ERROR_DAYS = 90;
66
const WARN_COMMITS = 50;
77
const ERROR_COMMITS = 200;
88

9-
/** Check how stale a scaffold file is based on git history */
10-
export async function checkStaleness(
11-
filePath: string,
12-
source: string,
13-
cwd: string
14-
): Promise<DriftIssue[]> {
15-
const issues: DriftIssue[] = [];
9+
type StaleSignal = { severity: Severity; message: string };
1610

17-
const days = await daysSinceLastChange(filePath, cwd);
18-
const commits = await commitsSinceLastChange(filePath, cwd);
19-
20-
if (days !== null && days >= ERROR_DAYS) {
21-
issues.push({
22-
code: "STALE_FILE",
11+
function daysSignal(days: number): StaleSignal | null {
12+
if (days >= ERROR_DAYS) {
13+
return {
2314
severity: "error",
24-
file: source,
25-
line: null,
2615
message: `File hasn't been updated in ${days} days (threshold: ${ERROR_DAYS}d)`,
27-
});
28-
} else if (days !== null && days >= WARN_DAYS) {
29-
issues.push({
30-
code: "STALE_FILE",
16+
};
17+
}
18+
if (days >= WARN_DAYS) {
19+
return {
3120
severity: "warning",
32-
file: source,
33-
line: null,
3421
message: `File hasn't been updated in ${days} days (threshold: ${WARN_DAYS}d)`,
35-
});
22+
};
3623
}
24+
return null;
25+
}
3726

38-
if (commits !== null && commits >= ERROR_COMMITS) {
39-
issues.push({
40-
code: "STALE_FILE",
27+
function commitsSignal(commits: number): StaleSignal | null {
28+
if (commits >= ERROR_COMMITS) {
29+
return {
4130
severity: "error",
42-
file: source,
43-
line: null,
4431
message: `${commits} commits since file was last updated (threshold: ${ERROR_COMMITS})`,
45-
});
46-
} else if (commits !== null && commits >= WARN_COMMITS) {
47-
issues.push({
48-
code: "STALE_FILE",
32+
};
33+
}
34+
if (commits >= WARN_COMMITS) {
35+
return {
4936
severity: "warning",
50-
file: source,
51-
line: null,
5237
message: `${commits} commits since file was last updated (threshold: ${WARN_COMMITS})`,
53-
});
38+
};
39+
}
40+
return null;
41+
}
42+
43+
const SEVERITY_RANK: Record<Severity, number> = {
44+
info: 0,
45+
warning: 1,
46+
error: 2,
47+
};
48+
49+
/**
50+
* Check how stale a scaffold file is based on git history.
51+
*
52+
* When both the day threshold and the commit threshold are exceeded, this
53+
* returns a single combined issue at the higher of the two severities —
54+
* two STALE_FILE issues on the same file are the same underlying condition
55+
* and should cost the score once, not twice.
56+
*/
57+
export async function checkStaleness(
58+
filePath: string,
59+
source: string,
60+
cwd: string
61+
): Promise<DriftIssue[]> {
62+
const days = await daysSinceLastChange(filePath, cwd);
63+
const commits = await commitsSinceLastChange(filePath, cwd);
64+
65+
const signals: StaleSignal[] = [];
66+
if (days !== null) {
67+
const s = daysSignal(days);
68+
if (s) signals.push(s);
69+
}
70+
if (commits !== null) {
71+
const s = commitsSignal(commits);
72+
if (s) signals.push(s);
5473
}
5574

56-
return issues;
75+
if (signals.length === 0) return [];
76+
77+
const severity = signals.reduce<Severity>(
78+
(acc, s) => (SEVERITY_RANK[s.severity] > SEVERITY_RANK[acc] ? s.severity : acc),
79+
signals[0].severity
80+
);
81+
const message = signals.map((s) => s.message).join("; ");
82+
83+
return [
84+
{
85+
code: "STALE_FILE",
86+
severity,
87+
file: source,
88+
line: null,
89+
message,
90+
},
91+
];
5792
}

test/checkers.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import {
33
mkdtempSync,
44
writeFileSync,
@@ -15,6 +15,13 @@ import { checkCrossFile } from "../src/drift/checkers/cross-file.js";
1515
import { checkIndexSync } from "../src/drift/checkers/index-sync.js";
1616
import type { Claim, ScaffoldFrontmatter } from "../src/types.js";
1717

18+
vi.mock("../src/git.js", () => ({
19+
daysSinceLastChange: vi.fn(),
20+
commitsSinceLastChange: vi.fn(),
21+
}));
22+
const gitMock = await import("../src/git.js");
23+
const { checkStaleness } = await import("../src/drift/checkers/staleness.js");
24+
1825
let tmpDir: string;
1926

2027
beforeEach(() => {
@@ -299,3 +306,65 @@ describe("checkIndexSync", () => {
299306
expect(issues).toHaveLength(0);
300307
});
301308
});
309+
310+
// ── Staleness Checker ──
311+
312+
describe("checkStaleness", () => {
313+
const daysFn = gitMock.daysSinceLastChange as unknown as ReturnType<typeof vi.fn>;
314+
const commitsFn = gitMock.commitsSinceLastChange as unknown as ReturnType<typeof vi.fn>;
315+
316+
beforeEach(() => {
317+
daysFn.mockReset();
318+
commitsFn.mockReset();
319+
});
320+
321+
it("returns no issues when both thresholds are clean", async () => {
322+
daysFn.mockResolvedValue(10);
323+
commitsFn.mockResolvedValue(5);
324+
const issues = await checkStaleness("file.md", "source.md", ".");
325+
expect(issues).toHaveLength(0);
326+
});
327+
328+
it("returns a single issue when only the day threshold is exceeded", async () => {
329+
daysFn.mockResolvedValue(100);
330+
commitsFn.mockResolvedValue(5);
331+
const issues = await checkStaleness("file.md", "source.md", ".");
332+
expect(issues).toHaveLength(1);
333+
expect(issues[0].severity).toBe("error");
334+
expect(issues[0].message).toContain("100 days");
335+
});
336+
337+
it("collapses day + commit thresholds into a single compound issue", async () => {
338+
daysFn.mockResolvedValue(100);
339+
commitsFn.mockResolvedValue(250);
340+
const issues = await checkStaleness("file.md", "source.md", ".");
341+
expect(issues).toHaveLength(1);
342+
expect(issues[0].code).toBe("STALE_FILE");
343+
expect(issues[0].severity).toBe("error");
344+
expect(issues[0].message).toContain("100 days");
345+
expect(issues[0].message).toContain("250 commits");
346+
});
347+
348+
it("uses the higher severity when one threshold is warning and the other error", async () => {
349+
daysFn.mockResolvedValue(40);
350+
commitsFn.mockResolvedValue(250);
351+
const issues = await checkStaleness("file.md", "source.md", ".");
352+
expect(issues).toHaveLength(1);
353+
expect(issues[0].severity).toBe("error");
354+
});
355+
356+
it("keeps warning severity when neither threshold reaches error", async () => {
357+
daysFn.mockResolvedValue(40);
358+
commitsFn.mockResolvedValue(60);
359+
const issues = await checkStaleness("file.md", "source.md", ".");
360+
expect(issues).toHaveLength(1);
361+
expect(issues[0].severity).toBe("warning");
362+
});
363+
364+
it("returns empty when git history is unavailable", async () => {
365+
daysFn.mockResolvedValue(null);
366+
commitsFn.mockResolvedValue(null);
367+
const issues = await checkStaleness("file.md", "source.md", ".");
368+
expect(issues).toHaveLength(0);
369+
});
370+
});

0 commit comments

Comments
 (0)