Skip to content

Commit fa5f561

Browse files
committed
feat(ci): add file size guard for god-file regression prevention
Adds scripts/check-file-sizes.ts wired into CI and "npm run check:file-sizes". Tracks the nine god-files called out in PLAN.md with explicit line budgets (slightly above current line counts) so the next regression fails CI loudly. When a file is split, lower its limit in FILE_LIMITS to lock in the win. When a file is fully retired, remove the entry. Includes unit tests covering ok / over-limit / shrinkable / missing states and validating the live config.
1 parent 3b9f7db commit fa5f561

7 files changed

Lines changed: 324 additions & 1 deletion

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Audit UI: full audit page with issue list/filters, tree view with severity overlays, per-person audit issues panel
1010
- Auto-run schema migrations on server startup
1111
- `hint` severity tier for low-priority audit issues (gray styling, HelpCircle icon)
12+
- File size guard: `npm run check:file-sizes` (wired into CI) fails the build if tracked god-files grow beyond their recorded budget. Locks in shrinkage wins from the Phase 15 god-file remediation
1213

1314
## Changed
1415

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
- name: Install dependencies
3333
run: npm ci
3434

35+
- name: Check file size budgets
36+
run: npm run check:file-sizes
37+
3538
- name: Build all packages
3639
run: npm run build
3740

DONE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Completed items archived from PLAN.md. For per-version release notes see `.changelog/`. For full phase histories see [docs/roadmap.md](./docs/roadmap.md).
44

5+
## 2026-05-01
6+
7+
- **File size guard**`scripts/check-file-sizes.ts` + `npm run check:file-sizes` wired into CI's build job. Tracks the nine god-files called out in PLAN.md and fails when any exceeds its budget. Prevents the regression we measured in the Phase 15.14 follow-up. Lock-in mechanism: when a file is split, lower its limit in `FILE_LIMITS`. Includes unit tests in `tests/unit/scripts/checkFileSizes.spec.ts`.
8+
59
## 2026-04-28
610

711
- **Phase 18 foundation (AI Tree Auditor)**`audit_run` / `audit_issue` / `audit_change` schema (migration 007), BFS walker with cursor serialization (pause/resume/cancel), SSE progress endpoint. Structural checks live: `impossible_date`, `parent_age_conflict`, `placeholder_name`, `missing_gender`, `orphaned_edge`, `unlinked_provider` (chain-aware), `date_mismatch`. Auto-run migrations on server startup.

PLAN.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ For phase-by-phase implementation history, see [docs/roadmap.md](./docs/roadmap.
7575

7676
## Future / Ideas
7777

78-
- CI guard that fails when target files exceed line limits (PersonDetail.tsx, ProviderDataTable.tsx, database.service.ts) — prevents the regression we just measured.
7978
- React 19 upgrade — currently on 18.3 (and react-leaflet 4→5). Audit hooks behavior, types, react-leaflet breaking changes; gate behind a branch.
8079
- Stats trends over time — recent `TreeStatsPage` is a snapshot; chart per-database growth across audit runs.
8180
- Mobile-first review flow — recent mobile fixes (40px touch targets, card overflow) suggest demand for a phone-friendly verification queue.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"test:feature-coverage": "npx tsx scripts/generate-feature-coverage-report.ts",
3333
"test:all-reports": "npm run test:coverage && npm run test:feature-coverage",
3434
"validate:mocks": "npx tsx scripts/validate-mock-selectors.ts",
35+
"check:file-sizes": "npx tsx scripts/check-file-sizes.ts",
3536
"migrate": "npx tsx scripts/migrate.ts",
3637
"migrate:status": "npx tsx scripts/migrate.ts --status",
3738
"migrate:dry-run": "npx tsx scripts/migrate.ts --dry-run"

scripts/check-file-sizes.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#!/usr/bin/env npx tsx
2+
3+
/**
4+
* File Size Guard
5+
*
6+
* Fails CI when any tracked god-file (or its successor) exceeds its line
7+
* limit. This is the regression alarm for the "Reverse god-file regression"
8+
* item in PLAN.md — once a file is split or shrunk, we lock in the win by
9+
* lowering its budget here.
10+
*
11+
* Add new entries to FILE_LIMITS as god-files are extracted into smaller
12+
* modules. The "limit" is intentionally a hair above the current line count
13+
* so an accidental ten-line drift trips the guard and forces a conversation
14+
* before it grows back into the four-figure range.
15+
*
16+
* Usage:
17+
* npx tsx scripts/check-file-sizes.ts # fail on any over-limit file
18+
* npx tsx scripts/check-file-sizes.ts --json # machine-readable report
19+
*/
20+
import { readFileSync, existsSync } from 'node:fs';
21+
import { join, dirname } from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
24+
export interface FileLimit {
25+
path: string;
26+
limit: number;
27+
note?: string;
28+
}
29+
30+
export interface FileLimitResult {
31+
path: string;
32+
limit: number;
33+
lines: number;
34+
status: 'ok' | 'over' | 'missing' | 'shrinkable';
35+
note?: string;
36+
/** How much room (or overage) there is. Negative = over limit. */
37+
slack: number;
38+
}
39+
40+
/**
41+
* Files we have actively been shrinking, and the cap above which CI fails.
42+
*
43+
* When a file is split, lower its limit to (current lines + small buffer)
44+
* so the next regression is loud. When a file is fully retired, remove it.
45+
*/
46+
export const FILE_LIMITS: readonly FileLimit[] = [
47+
{
48+
path: 'client/src/components/person/PersonDetail.tsx',
49+
limit: 1400,
50+
note: 'extract usePersonData / usePersonOverrides hooks + sub-components',
51+
},
52+
{
53+
path: 'client/src/components/person/ProviderDataTable.tsx',
54+
limit: 1280,
55+
note: 'extract PhotoThumbnail / ComparisonCell / ProviderRow',
56+
},
57+
{
58+
path: 'server/src/services/database.service.ts',
59+
limit: 1480,
60+
note: 'split along entity lines (person, edges, events, overrides)',
61+
},
62+
{
63+
path: 'server/src/services/auditor-agent.service.ts',
64+
limit: 1280,
65+
note: 'split into walker + per-check modules',
66+
},
67+
{
68+
path: 'server/src/services/multi-platform-comparison.service.ts',
69+
limit: 1140,
70+
},
71+
{
72+
path: 'client/src/services/api.ts',
73+
limit: 1290,
74+
note: 'collapse per-platform link/photo helpers into generics',
75+
},
76+
{
77+
path: 'client/src/components/ancestry-tree/views/VerticalFamilyView.tsx',
78+
limit: 1020,
79+
},
80+
{
81+
path: 'server/src/services/favorites.service.ts',
82+
limit: 920,
83+
},
84+
{
85+
path: 'server/src/routes/person.routes.ts',
86+
limit: 1010,
87+
note: 'standardize on asyncHandler, drop ad-hoc .catch(next)',
88+
},
89+
];
90+
91+
const __dirname = dirname(fileURLToPath(import.meta.url));
92+
const REPO_ROOT = join(__dirname, '..');
93+
94+
const SHRINK_BUFFER = 50;
95+
96+
function countLines(absolutePath: string): number {
97+
const content = readFileSync(absolutePath, 'utf8');
98+
if (content.length === 0) return 0;
99+
// Count newlines + 1 unless the file ends with a newline (then just newlines).
100+
const lines = content.split('\n').length;
101+
return content.endsWith('\n') ? lines - 1 : lines;
102+
}
103+
104+
export function evaluateFile(entry: FileLimit, repoRoot: string = REPO_ROOT): FileLimitResult {
105+
const abs = join(repoRoot, entry.path);
106+
if (!existsSync(abs)) {
107+
return {
108+
path: entry.path,
109+
limit: entry.limit,
110+
lines: 0,
111+
status: 'missing',
112+
note: entry.note,
113+
slack: entry.limit,
114+
};
115+
}
116+
const lines = countLines(abs);
117+
const slack = entry.limit - lines;
118+
let status: FileLimitResult['status'] = 'ok';
119+
if (lines > entry.limit) status = 'over';
120+
else if (slack > SHRINK_BUFFER) status = 'shrinkable';
121+
return { path: entry.path, limit: entry.limit, lines, status, note: entry.note, slack };
122+
}
123+
124+
export function evaluateAll(
125+
limits: readonly FileLimit[] = FILE_LIMITS,
126+
repoRoot: string = REPO_ROOT,
127+
): FileLimitResult[] {
128+
return limits.map(entry => evaluateFile(entry, repoRoot));
129+
}
130+
131+
function formatRow(r: FileLimitResult): string {
132+
const icon =
133+
r.status === 'over' ? '❌' :
134+
r.status === 'missing' ? '⚠️ ' :
135+
r.status === 'shrinkable' ? '🔽' : '✅';
136+
const slack = r.status === 'over' ? `+${r.lines - r.limit}` : `${r.slack}`;
137+
return `${icon} ${r.path.padEnd(64)} ${String(r.lines).padStart(5)}/${String(r.limit).padEnd(5)} slack=${slack}`;
138+
}
139+
140+
function main(): void {
141+
const args = process.argv.slice(2);
142+
const asJson = args.includes('--json');
143+
144+
const results = evaluateAll();
145+
146+
if (asJson) {
147+
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
148+
} else {
149+
console.log('📏 File size guard');
150+
for (const r of results) console.log(formatRow(r));
151+
}
152+
153+
const over = results.filter(r => r.status === 'over');
154+
const missing = results.filter(r => r.status === 'missing');
155+
const shrinkable = results.filter(r => r.status === 'shrinkable');
156+
157+
if (over.length > 0) {
158+
console.error(
159+
`\n❌ ${over.length} file(s) exceed their line budget. Either split the file or, if growth is justified, raise the limit in scripts/check-file-sizes.ts (with rationale in PR description).`,
160+
);
161+
process.exit(1);
162+
}
163+
if (missing.length > 0) {
164+
console.error(
165+
`\n⚠️ ${missing.length} tracked file(s) are missing — they may have been moved or deleted. Update FILE_LIMITS to match the new path or remove the entry.`,
166+
);
167+
process.exit(1);
168+
}
169+
if (!asJson && shrinkable.length > 0) {
170+
console.log(
171+
`\n🔽 ${shrinkable.length} file(s) are well under their limit — consider lowering the limit to lock in the win.`,
172+
);
173+
}
174+
if (!asJson) console.log(`\n✅ All ${results.length} files within budget.`);
175+
}
176+
177+
const isDirectInvocation = import.meta.url === `file://${process.argv[1]}`;
178+
if (isDirectInvocation) main();
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Unit tests for scripts/check-file-sizes.ts
3+
*
4+
* The script is the CI guard that fails when god-files grow beyond their
5+
* recorded budget. These tests pin its evaluation logic against a fixture
6+
* tree built in a temp directory so they don't break when real source files
7+
* change line counts.
8+
*/
9+
10+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
12+
import { tmpdir } from 'node:os';
13+
import { join } from 'node:path';
14+
import {
15+
evaluateFile,
16+
evaluateAll,
17+
FILE_LIMITS,
18+
} from '../../../scripts/check-file-sizes.js';
19+
20+
let tmpRoot: string;
21+
22+
beforeAll(() => {
23+
tmpRoot = mkdtempSync(join(tmpdir(), 'sparsetree-filesize-'));
24+
mkdirSync(join(tmpRoot, 'src'), { recursive: true });
25+
26+
// 100 lines, no trailing newline
27+
writeFileSync(
28+
join(tmpRoot, 'src', 'tight.ts'),
29+
Array.from({ length: 100 }, (_, i) => `// line ${i + 1}`).join('\n'),
30+
);
31+
32+
// 100 lines, with trailing newline (line count should still be 100)
33+
writeFileSync(
34+
join(tmpRoot, 'src', 'trailing-newline.ts'),
35+
Array.from({ length: 100 }, (_, i) => `// line ${i + 1}`).join('\n') + '\n',
36+
);
37+
38+
// 200 lines — well under any reasonable limit
39+
writeFileSync(
40+
join(tmpRoot, 'src', 'shrinkable.ts'),
41+
Array.from({ length: 200 }, (_, i) => `// line ${i + 1}`).join('\n'),
42+
);
43+
44+
// 0 lines — empty file
45+
writeFileSync(join(tmpRoot, 'src', 'empty.ts'), '');
46+
});
47+
48+
afterAll(() => {
49+
rmSync(tmpRoot, { recursive: true, force: true });
50+
});
51+
52+
describe('evaluateFile', () => {
53+
it('returns ok when within limit', () => {
54+
const result = evaluateFile({ path: 'src/tight.ts', limit: 100 }, tmpRoot);
55+
expect(result.status).toBe('ok');
56+
expect(result.lines).toBe(100);
57+
expect(result.slack).toBe(0);
58+
});
59+
60+
it('does not double-count a trailing newline', () => {
61+
const result = evaluateFile({ path: 'src/trailing-newline.ts', limit: 100 }, tmpRoot);
62+
expect(result.lines).toBe(100);
63+
expect(result.status).toBe('ok');
64+
});
65+
66+
it('flags files over the limit', () => {
67+
const result = evaluateFile({ path: 'src/tight.ts', limit: 50 }, tmpRoot);
68+
expect(result.status).toBe('over');
69+
expect(result.slack).toBeLessThan(0);
70+
expect(result.lines).toBe(100);
71+
expect(result.limit).toBe(50);
72+
});
73+
74+
it('flags files significantly under the limit as shrinkable', () => {
75+
const result = evaluateFile({ path: 'src/shrinkable.ts', limit: 1000 }, tmpRoot);
76+
expect(result.status).toBe('shrinkable');
77+
expect(result.slack).toBeGreaterThan(50);
78+
});
79+
80+
it('reports missing files with status "missing"', () => {
81+
const result = evaluateFile({ path: 'src/does-not-exist.ts', limit: 100 }, tmpRoot);
82+
expect(result.status).toBe('missing');
83+
expect(result.lines).toBe(0);
84+
});
85+
86+
it('treats empty files as 0 lines', () => {
87+
const result = evaluateFile({ path: 'src/empty.ts', limit: 200 }, tmpRoot);
88+
expect(result.lines).toBe(0);
89+
expect(result.status).toBe('shrinkable');
90+
});
91+
92+
it('preserves the note from the limit entry', () => {
93+
const result = evaluateFile(
94+
{ path: 'src/tight.ts', limit: 200, note: 'extract submodule X' },
95+
tmpRoot,
96+
);
97+
expect(result.note).toBe('extract submodule X');
98+
});
99+
});
100+
101+
describe('evaluateAll', () => {
102+
it('evaluates each entry exactly once and preserves order', () => {
103+
const limits = [
104+
{ path: 'src/tight.ts', limit: 100 },
105+
{ path: 'src/shrinkable.ts', limit: 1000 },
106+
];
107+
const results = evaluateAll(limits, tmpRoot);
108+
expect(results).toHaveLength(2);
109+
expect(results[0].path).toBe('src/tight.ts');
110+
expect(results[1].path).toBe('src/shrinkable.ts');
111+
});
112+
});
113+
114+
describe('FILE_LIMITS configuration', () => {
115+
it('declares unique paths', () => {
116+
const paths = FILE_LIMITS.map(e => e.path);
117+
expect(new Set(paths).size).toBe(paths.length);
118+
});
119+
120+
it('has positive integer limits', () => {
121+
for (const entry of FILE_LIMITS) {
122+
expect(entry.limit).toBeGreaterThan(0);
123+
expect(Number.isInteger(entry.limit)).toBe(true);
124+
}
125+
});
126+
127+
it('has all current files under their declared limit', () => {
128+
// This is the contract: every tracked file must be at or under its
129+
// budget at HEAD. If this fails, either split the file or raise the
130+
// limit (with rationale in the PR).
131+
const results = evaluateAll();
132+
const over = results.filter(r => r.status === 'over');
133+
const missing = results.filter(r => r.status === 'missing');
134+
expect(over).toEqual([]);
135+
expect(missing).toEqual([]);
136+
});
137+
});

0 commit comments

Comments
 (0)