Skip to content

Commit ccb45dc

Browse files
frankieyanclaude
andauthored
feat!: --stage-record-file now requires files list; add config file support (#21)
* feat: Add config file support and improve monorepo compatibility Add support for `.react-compiler-tracker.config.json` to configure: - `recordsFile`: Path to the records file - `sourceGlob`: Glob pattern for source files Change `--stage-record-file` to accept files as arguments instead of reading from git, making it compatible with lint-staged in monorepos. Remove redundant `getStagedFromGit()` and `filterSupportedFiles()` functions since the glob pattern already filters by extension and callers now provide the file list directly. Closes #16 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add git path normalization for monorepo support Normalize file paths passed to --stage-record-file and --check-files by stripping the git prefix when running from a package subdirectory. This allows lint-staged to pass repo-root-relative paths that get correctly converted to cwd-relative paths. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Return copy of DEFAULT_CONFIG and add config validation - Return { ...DEFAULT_CONFIG } instead of DEFAULT_CONFIG to avoid shared mutable state across multiple loadConfig() calls - Add isValidConfig type guard to validate parsed JSON config, warning and falling back to defaults if validation fails Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: Exclude deleted files from pre-commit hook example Add --diff-filter=ACMR to git diff command to only include Added, Copied, Modified, and Renamed files, excluding deleted files that would cause the tracker to fail. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Consolidate path normalization into source-files.mts - Move getGitPrefix() and normalizeFilePaths() from git-utils.mts - Add absolute path handling to normalizeFilePaths() - Delete git-utils.mts and git-utils.test.mts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add filterByGlob to validate file paths against sourceGlob Explicit file lists from --stage-record-file and --check-files are now filtered against the sourceGlob config, ensuring only matching files are processed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add validateFilesExist to error on missing files Files that don't exist now throw an error instead of being silently ignored. This provides clear feedback when lint-staged passes a path to a file that was deleted. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Log warning when config file parsing fails Previously, JSON parsing errors in the config file were silently swallowed, falling back to defaults without any indication to the user. Now a warning is logged so users are aware of the issue. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Throw errors for invalid config files instead of silently using defaults Invalid config files now throw errors immediately rather than logging a warning and falling back to defaults. This makes configuration issues more visible. Also improved test cases with more realistic invalid config examples: - Trailing comma (common JSON editing mistake) - Object wrapper instead of string value Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Reject array configs in isValidConfig JSON.parse can return an array, which has typeof 'object' and would bypass the existing check. This could result in an empty config being returned since property access on arrays returns undefined. Added Array.isArray check and corresponding test case. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a41e003 commit ccb45dc

7 files changed

Lines changed: 585 additions & 159 deletions

File tree

README.md

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
The [React Compiler](https://react.dev/learn/react-compiler) automatically memoizes your components, eliminating the need for `useCallback` and `useMemo`. However, certain code patterns cause the compiler to bail out. When this happens, your components lose automatic optimization, potentially causing performance regressions.
88

9-
Inspired by [esplint](https://github.com/hjylewis/esplint) and [react-compiler-marker](https://github.com/blazejkustra/react-compiler-marker), this tool tracks compiler errors in a `.react-compiler-tracker.json` file and integrates with Git hooks and CI to prevent new violations from being introduced.
9+
Inspired by [esplint](https://github.com/hjylewis/esplint) and [react-compiler-marker](https://github.com/blazejkustra/react-compiler-marker), this tool tracks compiler errors in a `.react-compiler.rec.json` file and integrates with Git hooks and CI to prevent new violations from being introduced.
1010

1111
## Prerequisites
1212

@@ -22,24 +22,66 @@ npm install --save-dev babel-plugin-react-compiler
2222
npm install --save-dev @doist/react-compiler-tracker
2323
```
2424

25+
## Configuration
26+
27+
Create a `.react-compiler-tracker.config.json` file in your project root to customize the tool's behavior:
28+
29+
```json
30+
{
31+
"recordsFile": ".react-compiler.rec.json",
32+
"sourceGlob": "src/**/*.{js,jsx,ts,tsx}"
33+
}
34+
```
35+
36+
All fields are optional. Default values are shown above.
37+
38+
| Option | Description | Default |
39+
|--------|-------------|---------|
40+
| `recordsFile` | Path to the records file | `.react-compiler.rec.json` |
41+
| `sourceGlob` | Glob pattern for source files | `src/**/*.{js,jsx,ts,tsx}` |
42+
43+
### Monorepo Usage
44+
45+
In a monorepo, run the tool from your package directory. The config file and all paths are relative to where you run the command. The defaults typically work out of the box:
46+
47+
```
48+
my-monorepo/
49+
├── packages/
50+
│ └── frontend/
51+
│ ├── .react-compiler-tracker.config.json # Optional config
52+
│ ├── .react-compiler.rec.json # Records file
53+
│ └── src/
54+
│ └── ...
55+
```
56+
57+
If your source files are in a different location (e.g., `app/` instead of `src/`), customize the config:
58+
59+
```json
60+
{
61+
"sourceGlob": "app/**/*.{ts,tsx}"
62+
}
63+
```
64+
2565
## Usage
2666

2767
### `--overwrite`
2868

29-
Regenerates the entire `.react-compiler-tracker.json` by scanning all supported source files (`src/**/*.{js,jsx,ts,tsx}`). Useful for initialization or picking up changes from skipped Git hooks.
69+
Regenerates the entire records file by scanning all source files matching `sourceGlob`. Useful for initialization or picking up changes from skipped Git hooks.
3070

3171
```bash
3272
npx @doist/react-compiler-tracker --overwrite
3373
```
3474

35-
### `--stage-record-file`
75+
### `--stage-record-file <file1> <file2> ...`
3676

37-
Checks Git staged files and updates the records. Exits with code 1 if errors increase (preventing the commit), otherwise updates `.react-compiler-tracker.json` for staged files.
77+
Checks the provided files and updates the records. Exits with code 1 if errors increase (preventing the commit), otherwise updates the records file for the checked files.
3878

3979
```bash
40-
npx @doist/react-compiler-tracker --stage-record-file
80+
npx @doist/react-compiler-tracker --stage-record-file src/components/Button.tsx src/hooks/useData.ts
4181
```
4282

83+
If no files are provided, exits cleanly with a success message.
84+
4385
### `--check-files <file1> <file2> ...`
4486

4587
Checks specific files without updating records. Exits with code 1 if checked files show increased error counts (or new errors). Primarily for CI to ensure PRs don't introduce new compiler errors.
@@ -50,7 +92,7 @@ npx @doist/react-compiler-tracker --check-files src/components/Button.tsx src/ho
5092

5193
### No flags
5294

53-
Checks all supported source files and reports the total error count. The records file is **not** updated in this mode.
95+
Checks all source files matching `sourceGlob` and reports the total error count. The records file is **not** updated in this mode.
5496

5597
```bash
5698
npx @doist/react-compiler-tracker
@@ -65,11 +107,13 @@ In `package.json`:
65107
```json
66108
{
67109
"lint-staged": {
68-
"src/**/*.{js,jsx,ts,tsx}": "npx @doist/react-compiler-tracker --stage-record-file"
110+
"*.{js,jsx,ts,tsx}": "npx @doist/react-compiler-tracker --stage-record-file"
69111
}
70112
}
71113
```
72114

115+
With lint-staged, the matched files are automatically passed as arguments to the command.
116+
73117
### GitHub Actions CI
74118

75119
```yaml
@@ -82,6 +126,17 @@ In `package.json`:
82126
fi
83127
```
84128
129+
### Pre-commit hook (manual)
130+
131+
```bash
132+
#!/bin/sh
133+
# Get staged files and pass them to the tracker
134+
FILES=$(git diff --diff-filter=ACMR --cached --name-only -- '*.tsx' '*.ts' '*.jsx' '*.js' | tr '\n' ' ')
135+
if [ -n "$FILES" ]; then
136+
npx @doist/react-compiler-tracker --stage-record-file $FILES
137+
fi
138+
```
139+
85140
## License
86141

87142
Released under the [MIT License](https://opensource.org/licenses/MIT).

src/config.mts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
4+
type Config = {
5+
recordsFile: string
6+
sourceGlob: string
7+
}
8+
9+
const DEFAULT_CONFIG: Config = {
10+
recordsFile: '.react-compiler.rec.json',
11+
sourceGlob: 'src/**/*.{js,jsx,ts,tsx}',
12+
}
13+
14+
const CONFIG_FILE_NAME = '.react-compiler-tracker.config.json'
15+
16+
function isValidConfig(config: unknown): config is Partial<Config> {
17+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
18+
return false
19+
}
20+
const obj = config as Record<string, unknown>
21+
if (obj.recordsFile !== undefined && typeof obj.recordsFile !== 'string') {
22+
return false
23+
}
24+
if (obj.sourceGlob !== undefined && typeof obj.sourceGlob !== 'string') {
25+
return false
26+
}
27+
return true
28+
}
29+
30+
function loadConfig(): Config {
31+
const configPath = join(process.cwd(), CONFIG_FILE_NAME)
32+
33+
if (!existsSync(configPath)) {
34+
return { ...DEFAULT_CONFIG }
35+
}
36+
37+
try {
38+
const configContent = readFileSync(configPath, 'utf8')
39+
const parsed: unknown = JSON.parse(configContent)
40+
if (!isValidConfig(parsed)) {
41+
throw new Error(`Invalid config file at ${configPath}`)
42+
}
43+
44+
return {
45+
...DEFAULT_CONFIG,
46+
...parsed,
47+
}
48+
} catch (error) {
49+
throw new Error(`Failed to parse config at ${configPath}: ${error}`)
50+
}
51+
}
52+
53+
export { type Config, DEFAULT_CONFIG, loadConfig }

src/config.test.mts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
import { DEFAULT_CONFIG, loadConfig } from './config.mjs'
4+
5+
vi.mock('node:fs', () => ({
6+
existsSync: vi.fn(),
7+
readFileSync: vi.fn(),
8+
}))
9+
10+
afterEach(() => {
11+
vi.clearAllMocks()
12+
})
13+
14+
describe('loadConfig', () => {
15+
it('returns defaults when no config file exists', () => {
16+
vi.mocked(existsSync).mockReturnValue(false)
17+
18+
const result = loadConfig()
19+
20+
expect(result).toEqual(DEFAULT_CONFIG)
21+
})
22+
23+
it('loads and merges user config correctly', () => {
24+
vi.mocked(existsSync).mockReturnValue(true)
25+
vi.mocked(readFileSync).mockReturnValue(
26+
JSON.stringify({
27+
recordsFile: 'custom-records.json',
28+
sourceGlob: 'app/**/*.{ts,tsx}',
29+
}),
30+
)
31+
32+
const result = loadConfig()
33+
34+
expect(result).toEqual({
35+
recordsFile: 'custom-records.json',
36+
sourceGlob: 'app/**/*.{ts,tsx}',
37+
})
38+
})
39+
40+
it('handles partial config (only some fields specified)', () => {
41+
vi.mocked(existsSync).mockReturnValue(true)
42+
vi.mocked(readFileSync).mockReturnValue(
43+
JSON.stringify({
44+
sourceGlob: 'packages/frontend/**/*.tsx',
45+
}),
46+
)
47+
48+
const result = loadConfig()
49+
50+
expect(result).toEqual({
51+
recordsFile: DEFAULT_CONFIG.recordsFile,
52+
sourceGlob: 'packages/frontend/**/*.tsx',
53+
})
54+
})
55+
56+
it('throws when config file is invalid JSON', () => {
57+
vi.mocked(existsSync).mockReturnValue(true)
58+
// Trailing comma - a common mistake when editing JSON by hand
59+
vi.mocked(readFileSync).mockReturnValue(`{
60+
"recordsFile": ".react-compiler.rec.json",
61+
}`)
62+
63+
expect(() => loadConfig()).toThrow('Failed to parse config')
64+
})
65+
66+
it('throws when config has invalid field types', () => {
67+
vi.mocked(existsSync).mockReturnValue(true)
68+
vi.mocked(readFileSync).mockReturnValue(
69+
JSON.stringify({
70+
recordsFile: { path: '.react-compiler.rec.json' },
71+
}),
72+
)
73+
74+
expect(() => loadConfig()).toThrow('Invalid config file')
75+
})
76+
77+
it('throws when config file contains an array instead of object', () => {
78+
vi.mocked(existsSync).mockReturnValue(true)
79+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ recordsFile: 'records.json' }]))
80+
81+
expect(() => loadConfig()).toThrow('Invalid config file')
82+
})
83+
})

src/index.integration.test.mts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { execSync } from 'node:child_process'
2-
import { existsSync, readFileSync, rmSync } from 'node:fs'
2+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33
import { dirname, join } from 'node:path'
44
import { fileURLToPath } from 'node:url'
55
import { afterEach, describe, expect, it } from 'vitest'
66

77
const __dirname = dirname(fileURLToPath(import.meta.url))
88
const fixtureDir = join(__dirname, '__fixtures__/sample-project')
99
const recordsPath = join(fixtureDir, '.react-compiler.rec.json')
10+
const configPath = join(fixtureDir, '.react-compiler-tracker.config.json')
1011

1112
function runCLI(args: string[] = [], cwd = fixtureDir): string {
1213
const cliPath = join(__dirname, 'index.mts')
@@ -27,6 +28,9 @@ describe('CLI', () => {
2728
if (existsSync(recordsPath)) {
2829
rmSync(recordsPath)
2930
}
31+
if (existsSync(configPath)) {
32+
rmSync(configPath)
33+
}
3034
})
3135

3236
it('runs check on all files when no flag provided', () => {
@@ -50,6 +54,12 @@ describe('CLI', () => {
5054
expect(() => JSON.parse(readFileSync(recordsPath, 'utf8'))).toThrow()
5155
})
5256

57+
it('errors when file does not exist', () => {
58+
const output = runCLI(['--check-files', 'src/nonexistent-file.tsx'])
59+
60+
expect(output).toContain('File not found: src/nonexistent-file.tsx')
61+
})
62+
5363
it('accepts --overwrite flag', () => {
5464
const output = runCLI(['--overwrite'])
5565

@@ -72,4 +82,93 @@ describe('CLI', () => {
7282
},
7383
})
7484
})
85+
86+
describe('--stage-record-file flag', () => {
87+
it('exits cleanly when no files provided', () => {
88+
const output = runCLI(['--stage-record-file'])
89+
90+
expect(output).toContain('✅ No files to check')
91+
})
92+
93+
it('checks provided files and reports increased errors', () => {
94+
const output = runCLI([
95+
'--stage-record-file',
96+
'src/bad-component.tsx',
97+
'src/bad-hook.ts',
98+
])
99+
100+
expect(output).toContain(
101+
'🔍 Checking 2 files for React Compiler errors and updating records…',
102+
)
103+
expect(output).toContain('React Compiler errors have increased in:')
104+
expect(output).toContain('• src/bad-component.tsx: +1')
105+
expect(output).toContain('• src/bad-hook.ts: +3')
106+
})
107+
108+
it('checks provided files with existing records', () => {
109+
// First create records
110+
runCLI(['--overwrite'])
111+
112+
// Then check staged files - should pass since errors match records
113+
const output = runCLI([
114+
'--stage-record-file',
115+
'src/bad-component.tsx',
116+
'src/good-component.tsx',
117+
])
118+
119+
expect(output).toContain(
120+
'🔍 Checking 2 files for React Compiler errors and updating records…',
121+
)
122+
// The staging step may fail in test environment due to gitignore,
123+
// but the important thing is that error checking passed
124+
expect(output).not.toContain('React Compiler errors have increased')
125+
})
126+
})
127+
128+
describe('config file', () => {
129+
it('uses custom sourceGlob from config', () => {
130+
writeFileSync(
131+
configPath,
132+
JSON.stringify({
133+
sourceGlob: 'src/**/*.tsx',
134+
}),
135+
)
136+
137+
const output = runCLI()
138+
139+
// Should only find .tsx files (2 instead of 4)
140+
expect(output).toContain('🔍 Checking all 2 source files for React Compiler errors…')
141+
})
142+
143+
it('uses custom recordsFile from config', () => {
144+
const customRecordsPath = join(fixtureDir, 'custom-records.json')
145+
writeFileSync(
146+
configPath,
147+
JSON.stringify({
148+
recordsFile: 'custom-records.json',
149+
}),
150+
)
151+
152+
try {
153+
runCLI(['--overwrite'])
154+
155+
expect(existsSync(customRecordsPath)).toBe(true)
156+
expect(existsSync(recordsPath)).toBe(false)
157+
158+
const records = JSON.parse(readFileSync(customRecordsPath, 'utf8'))
159+
expect(records.files).toEqual({
160+
'src/bad-component.tsx': {
161+
CompileError: 1,
162+
},
163+
'src/bad-hook.ts': {
164+
CompileError: 3,
165+
},
166+
})
167+
} finally {
168+
if (existsSync(customRecordsPath)) {
169+
rmSync(customRecordsPath)
170+
}
171+
}
172+
})
173+
})
75174
})

0 commit comments

Comments
 (0)