From 37c3ae3ee95599f6f0da0a1dd32ccfeb266c9014 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Fri, 16 Jan 2026 21:24:06 -0800 Subject: [PATCH 01/10] 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 --- README.md | 69 ++++++++++++++++++--- src/config.mts | 36 +++++++++++ src/config.test.mts | 64 +++++++++++++++++++ src/index.integration.test.mts | 95 +++++++++++++++++++++++++++- src/index.mts | 110 +++++++++++++++++++-------------- src/source-files.mts | 49 +-------------- src/source-files.test.mts | 92 +++------------------------ 7 files changed, 332 insertions(+), 183 deletions(-) create mode 100644 src/config.mts create mode 100644 src/config.test.mts diff --git a/README.md b/README.md index 1b4eb72..432f735 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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. -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. +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. ## Prerequisites @@ -22,24 +22,66 @@ npm install --save-dev babel-plugin-react-compiler npm install --save-dev @doist/react-compiler-tracker ``` +## Configuration + +Create a `.react-compiler-tracker.config.json` file in your project root to customize the tool's behavior: + +```json +{ + "recordsFile": ".react-compiler.rec.json", + "sourceGlob": "src/**/*.{js,jsx,ts,tsx}" +} +``` + +All fields are optional. Default values are shown above. + +| Option | Description | Default | +|--------|-------------|---------| +| `recordsFile` | Path to the records file | `.react-compiler.rec.json` | +| `sourceGlob` | Glob pattern for source files | `src/**/*.{js,jsx,ts,tsx}` | + +### Monorepo Usage + +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: + +``` +my-monorepo/ +├── packages/ +│ └── frontend/ +│ ├── .react-compiler-tracker.config.json # Optional config +│ ├── .react-compiler.rec.json # Records file +│ └── src/ +│ └── ... +``` + +If your source files are in a different location (e.g., `app/` instead of `src/`), customize the config: + +```json +{ + "sourceGlob": "app/**/*.{ts,tsx}" +} +``` + ## Usage ### `--overwrite` -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. +Regenerates the entire records file by scanning all source files matching `sourceGlob`. Useful for initialization or picking up changes from skipped Git hooks. ```bash npx @doist/react-compiler-tracker --overwrite ``` -### `--stage-record-file` +### `--stage-record-file ...` -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. +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. ```bash -npx @doist/react-compiler-tracker --stage-record-file +npx @doist/react-compiler-tracker --stage-record-file src/components/Button.tsx src/hooks/useData.ts ``` +If no files are provided, exits cleanly with a success message. + ### `--check-files ...` 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 ### No flags -Checks all supported source files and reports the total error count. The records file is **not** updated in this mode. +Checks all source files matching `sourceGlob` and reports the total error count. The records file is **not** updated in this mode. ```bash npx @doist/react-compiler-tracker @@ -65,11 +107,13 @@ In `package.json`: ```json { "lint-staged": { - "src/**/*.{js,jsx,ts,tsx}": "npx @doist/react-compiler-tracker --stage-record-file" + "*.{js,jsx,ts,tsx}": "npx @doist/react-compiler-tracker --stage-record-file" } } ``` +With lint-staged, the matched files are automatically passed as arguments to the command. + ### GitHub Actions CI ```yaml @@ -82,6 +126,17 @@ In `package.json`: fi ``` +### Pre-commit hook (manual) + +```bash +#!/bin/sh +# Get staged files and pass them to the tracker +FILES=$(git diff --cached --name-only -- '*.tsx' '*.ts' '*.jsx' '*.js' | tr '\n' ' ') +if [ -n "$FILES" ]; then + npx @doist/react-compiler-tracker --stage-record-file $FILES +fi +``` + ## License Released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/src/config.mts b/src/config.mts new file mode 100644 index 0000000..ffc3b75 --- /dev/null +++ b/src/config.mts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +type Config = { + recordsFile: string + sourceGlob: string +} + +const DEFAULT_CONFIG: Config = { + recordsFile: '.react-compiler.rec.json', + sourceGlob: 'src/**/*.{js,jsx,ts,tsx}', +} + +const CONFIG_FILE_NAME = '.react-compiler-tracker.config.json' + +function loadConfig(): Config { + const configPath = join(process.cwd(), CONFIG_FILE_NAME) + + if (!existsSync(configPath)) { + return DEFAULT_CONFIG + } + + try { + const configContent = readFileSync(configPath, 'utf8') + const userConfig = JSON.parse(configContent) as Partial + + return { + ...DEFAULT_CONFIG, + ...userConfig, + } + } catch { + return DEFAULT_CONFIG + } +} + +export { type Config, DEFAULT_CONFIG, loadConfig } diff --git a/src/config.test.mts b/src/config.test.mts new file mode 100644 index 0000000..442dc93 --- /dev/null +++ b/src/config.test.mts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_CONFIG, loadConfig } from './config.mjs' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('loadConfig', () => { + it('returns defaults when no config file exists', () => { + vi.mocked(existsSync).mockReturnValue(false) + + const result = loadConfig() + + expect(result).toEqual(DEFAULT_CONFIG) + }) + + it('loads and merges user config correctly', () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + recordsFile: 'custom-records.json', + sourceGlob: 'app/**/*.{ts,tsx}', + }), + ) + + const result = loadConfig() + + expect(result).toEqual({ + recordsFile: 'custom-records.json', + sourceGlob: 'app/**/*.{ts,tsx}', + }) + }) + + it('handles partial config (only some fields specified)', () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + sourceGlob: 'packages/frontend/**/*.tsx', + }), + ) + + const result = loadConfig() + + expect(result).toEqual({ + recordsFile: DEFAULT_CONFIG.recordsFile, + sourceGlob: 'packages/frontend/**/*.tsx', + }) + }) + + it('returns defaults when config file is invalid JSON', () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue('not valid json') + + const result = loadConfig() + + expect(result).toEqual(DEFAULT_CONFIG) + }) +}) diff --git a/src/index.integration.test.mts b/src/index.integration.test.mts index 1d9856a..d39965f 100644 --- a/src/index.integration.test.mts +++ b/src/index.integration.test.mts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process' -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' const __dirname = dirname(fileURLToPath(import.meta.url)) const fixtureDir = join(__dirname, '__fixtures__/sample-project') const recordsPath = join(fixtureDir, '.react-compiler.rec.json') +const configPath = join(fixtureDir, '.react-compiler-tracker.config.json') function runCLI(args: string[] = [], cwd = fixtureDir): string { const cliPath = join(__dirname, 'index.mts') @@ -27,6 +28,9 @@ describe('CLI', () => { if (existsSync(recordsPath)) { rmSync(recordsPath) } + if (existsSync(configPath)) { + rmSync(configPath) + } }) it('runs check on all files when no flag provided', () => { @@ -72,4 +76,93 @@ describe('CLI', () => { }, }) }) + + describe('--stage-record-file flag', () => { + it('exits cleanly when no files provided', () => { + const output = runCLI(['--stage-record-file']) + + expect(output).toContain('✅ No files to check') + }) + + it('checks provided files and reports increased errors', () => { + const output = runCLI([ + '--stage-record-file', + 'src/bad-component.tsx', + 'src/bad-hook.ts', + ]) + + expect(output).toContain( + '🔍 Checking 2 files for React Compiler errors and updating records…', + ) + expect(output).toContain('React Compiler errors have increased in:') + expect(output).toContain('• src/bad-component.tsx: +1') + expect(output).toContain('• src/bad-hook.ts: +3') + }) + + it('checks provided files with existing records', () => { + // First create records + runCLI(['--overwrite']) + + // Then check staged files - should pass since errors match records + const output = runCLI([ + '--stage-record-file', + 'src/bad-component.tsx', + 'src/good-component.tsx', + ]) + + expect(output).toContain( + '🔍 Checking 2 files for React Compiler errors and updating records…', + ) + // The staging step may fail in test environment due to gitignore, + // but the important thing is that error checking passed + expect(output).not.toContain('React Compiler errors have increased') + }) + }) + + describe('config file', () => { + it('uses custom sourceGlob from config', () => { + writeFileSync( + configPath, + JSON.stringify({ + sourceGlob: 'src/**/*.tsx', + }), + ) + + const output = runCLI() + + // Should only find .tsx files (2 instead of 4) + expect(output).toContain('🔍 Checking all 2 source files for React Compiler errors…') + }) + + it('uses custom recordsFile from config', () => { + const customRecordsPath = join(fixtureDir, 'custom-records.json') + writeFileSync( + configPath, + JSON.stringify({ + recordsFile: 'custom-records.json', + }), + ) + + try { + runCLI(['--overwrite']) + + expect(existsSync(customRecordsPath)).toBe(true) + expect(existsSync(recordsPath)).toBe(false) + + const records = JSON.parse(readFileSync(customRecordsPath, 'utf8')) + expect(records.files).toEqual({ + 'src/bad-component.tsx': { + CompileError: 1, + }, + 'src/bad-hook.ts': { + CompileError: 3, + }, + }) + } finally { + if (existsSync(customRecordsPath)) { + rmSync(customRecordsPath) + } + } + }) + }) }) diff --git a/src/index.mts b/src/index.mts index 49ce846..36ba5d6 100644 --- a/src/index.mts +++ b/src/index.mts @@ -3,20 +3,16 @@ import { join, relative } from 'node:path' import type { Logger as ReactCompilerLogger } from 'babel-plugin-react-compiler' import * as babel from './babel.mjs' +import { loadConfig } from './config.mjs' import type { FileErrors } from './records-file.mjs' import * as recordsFile from './records-file.mjs' import * as sourceFiles from './source-files.mjs' -const RECORDS_PATH = '.react-compiler.rec.json' -const SUPPORTED_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] -const SOURCE_FILES = 'src/**/*.{js,jsx,ts,tsx}' - const OVERWRITE_FLAG = '--overwrite' const STAGE_RECORD_FILE_FLAG = '--stage-record-file' const CHECK_FILES_FLAG = '--check-files' const compilerErrors: Map = new Map() -const recordsFilePath = join(process.cwd(), RECORDS_PATH) const customReactCompilerLogger: ReactCompilerLogger = { logEvent: (filename, event) => { @@ -39,24 +35,34 @@ const customReactCompilerLogger: ReactCompilerLogger = { main().catch(console.error) async function main() { + const config = loadConfig() + try { const flag = process.argv[2] switch (flag) { - case STAGE_RECORD_FILE_FLAG: - await runStageRecords() - break - case OVERWRITE_FLAG: - await runOverwriteRecords() - break - case CHECK_FILES_FLAG: - { - const filePaths = process.argv.slice(3) - await runCheckFiles(filePaths) - } - break - default: - await runCheckAllFiles() + case STAGE_RECORD_FILE_FLAG: { + const filePaths = process.argv.slice(3) + + return await runStageRecords({ + filePaths, + recordsFilePath: config.recordsFile, + }) + } + case OVERWRITE_FLAG: { + return await runOverwriteRecords({ + sourceGlob: config.sourceGlob, + recordsFilePath: config.recordsFile, + }) + } + case CHECK_FILES_FLAG: { + const filePaths = process.argv.slice(3) + + return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }) + } + default: { + return await runCheckAllFiles({ sourceGlob: config.sourceGlob }) + } } } catch (error: unknown) { if (error instanceof Error) { @@ -70,10 +76,15 @@ async function main() { /** * Handles the `--overwrite` flag by re-recording errors across all files. */ -async function runOverwriteRecords() { +async function runOverwriteRecords({ + sourceGlob, + recordsFilePath, +}: { + sourceGlob: string + recordsFilePath: string +}) { const filePaths = sourceFiles.getAll({ - globPattern: SOURCE_FILES, - supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, + globPattern: sourceGlob, }) if (!filePaths.length) { @@ -120,23 +131,24 @@ async function runOverwriteRecords() { } /** - * Handles the `--stage-record-file` flag by checking git staged files and updating the records file. + * Handles the `--stage-record-file` flag by checking provided files and updating the records file. * * If errors have increased, the process will exit with code 1 and the records file will not be updated. */ -async function runStageRecords() { - const filePaths = sourceFiles.getStagedFromGit({ - globPattern: SOURCE_FILES, - supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, - }) - +async function runStageRecords({ + filePaths, + recordsFilePath, +}: { + filePaths: string[] + recordsFilePath: string +}) { if (!filePaths.length) { - console.log('✅ No staged files to check') + console.log('✅ No files to check') return } console.log( - `🔍 Checking ${filePaths.length} staged files for React Compiler errors and updating records…`, + `🔍 Checking ${filePaths.length} files for React Compiler errors and updating records…`, ) // @@ -148,7 +160,7 @@ async function runStageRecords() { customReactCompilerLogger: customReactCompilerLogger, }) - const records = exitIfErrorsIncreased({ filePaths }) + const records = exitIfErrorsIncreased({ filePaths, recordsFilePath }) // // Update and stage records file @@ -161,10 +173,12 @@ async function runStageRecords() { records: records?.files ?? null, }) + const recordsFileRelativePath = join(process.cwd(), recordsFilePath) + try { - recordsFile.stage(RECORDS_PATH) + recordsFile.stage(recordsFileRelativePath) } catch { - exitWithWarning(`Failed to stage records file at ${RECORDS_PATH}`) + exitWithWarning(`Failed to stage records file at ${recordsFileRelativePath}`) } console.log('✅ No new React Compiler errors in staged files') @@ -175,12 +189,13 @@ async function runStageRecords() { * * If errors have increased, the process will exit with code 1. */ -async function runCheckFiles(filePathArgs: string[]) { - const filePaths = sourceFiles.filterSupportedFiles({ - filePaths: filePathArgs, - supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, - }) - +async function runCheckFiles({ + filePaths, + recordsFilePath, +}: { + filePaths: string[] + recordsFilePath: string +}) { if (!filePaths.length) { console.log('✅ No files to check') return @@ -197,7 +212,7 @@ async function runCheckFiles(filePathArgs: string[]) { customReactCompilerLogger: customReactCompilerLogger, }) - exitIfErrorsIncreased({ filePaths }) + exitIfErrorsIncreased({ filePaths, recordsFilePath }) console.log('✅ No new React Compiler errors in checked files') } @@ -207,10 +222,9 @@ async function runCheckFiles(filePathArgs: string[]) { * * The records file is not updated. */ -async function runCheckAllFiles() { +async function runCheckAllFiles({ sourceGlob }: { sourceGlob: string }) { const filePaths = sourceFiles.getAll({ - globPattern: SOURCE_FILES, - supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS, + globPattern: sourceGlob, }) if (!filePaths.length) { @@ -254,7 +268,13 @@ function getErrorCount() { * Compare error changes between the existing records and errors captured during this session in `compilerErrors`. * If errors have increased, exit with an error message. */ -function exitIfErrorsIncreased({ filePaths }: { filePaths: string[] }) { +function exitIfErrorsIncreased({ + filePaths, + recordsFilePath, +}: { + filePaths: string[] + recordsFilePath: string +}) { const records = recordsFile.load(recordsFilePath) const errorIncreases = recordsFile.getErrorIncreases({ filePaths, diff --git a/src/source-files.mts b/src/source-files.mts index 8a77799..585ed51 100644 --- a/src/source-files.mts +++ b/src/source-files.mts @@ -1,53 +1,10 @@ -import { execSync } from 'node:child_process' - import { glob } from 'glob' -function getStagedFromGit({ - globPattern, - supportedFileExtensions, -}: { - globPattern: string - supportedFileExtensions: string[] -}) { - const allFilePaths = getAll({ globPattern, supportedFileExtensions }) - - try { - const output = execSync('git diff --cached --name-only', { encoding: 'utf8' }) - const stagedFiles = output.trim().split('\n').filter(Boolean) - const allFilePathsSet = new Set(allFilePaths) - const filePaths = stagedFiles.filter((path) => allFilePathsSet.has(path)) - - return filterSupportedFiles({ filePaths, supportedFileExtensions }) - } catch { - return [] - } -} - -function getAll({ - globPattern, - supportedFileExtensions, -}: { - globPattern: string - supportedFileExtensions: string[] -}) { - const srcFiles = glob.sync(globPattern, { +function getAll({ globPattern }: { globPattern: string }) { + return glob.sync(globPattern, { cwd: process.cwd(), absolute: false, }) - return filterSupportedFiles({ filePaths: srcFiles, supportedFileExtensions }) -} - -function filterSupportedFiles({ - filePaths, - supportedFileExtensions, -}: { - filePaths: string[] - supportedFileExtensions: string[] -}) { - return filePaths.filter((filePath) => { - const ext = filePath.split('.').pop()?.toLowerCase() - return supportedFileExtensions.includes(ext || '') - }) } -export { filterSupportedFiles, getAll, getStagedFromGit } +export { getAll } diff --git a/src/source-files.test.mts b/src/source-files.test.mts index 2769590..2b48d69 100644 --- a/src/source-files.test.mts +++ b/src/source-files.test.mts @@ -1,7 +1,6 @@ -import { execSync } from 'node:child_process' import { glob } from 'glob' import { afterEach, describe, expect, it, vi } from 'vitest' -import { filterSupportedFiles, getAll, getStagedFromGit } from './source-files.mjs' +import { getAll } from './source-files.mjs' vi.mock('glob', () => ({ glob: { @@ -9,102 +8,27 @@ vi.mock('glob', () => ({ }, })) -vi.mock('node:child_process', () => ({ - execSync: vi.fn(), -})) - afterEach(() => { vi.clearAllMocks() }) -describe('filterSupportedFiles', () => { - const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx'] - - it('filters files by supported extensions', () => { - const filePaths = [ - 'src/index.ts', - 'src/app.tsx', - 'src/utils.js', - 'src/styles.module.css', - 'README.md', - 'src/fixture.json', - 'Makefile', - ] - - const result = filterSupportedFiles({ filePaths, supportedFileExtensions }) - - expect(result).toEqual(['src/index.ts', 'src/app.tsx', 'src/utils.js']) - }) - - it('handles empty file list', () => { - const result = filterSupportedFiles({ filePaths: [], supportedFileExtensions }) - - expect(result).toEqual([]) - }) -}) - describe('getAll', () => { - const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx'] + it('returns files matching the glob pattern', () => { + vi.mocked(glob.sync).mockReturnValue(['src/index.ts', 'src/app.tsx', 'src/utils.js']) - it('returns results that match glob pattern and supported extensions', () => { - vi.mocked(glob.sync).mockReturnValue([ - 'src/index.ts', - 'src/app.tsx', - 'src/utils.js', - 'src/styles.module.css', - 'README.md', - 'src/fixture.json', - 'Makefile', - ]) - - const result = getAll({ globPattern: 'src/**/*', supportedFileExtensions }) + const result = getAll({ globPattern: 'src/**/*.{js,jsx,ts,tsx}' }) expect(result).toEqual(['src/index.ts', 'src/app.tsx', 'src/utils.js']) - expect(glob.sync).toHaveBeenCalledWith('src/**/*', { + expect(glob.sync).toHaveBeenCalledWith('src/**/*.{js,jsx,ts,tsx}', { cwd: process.cwd(), absolute: false, }) }) -}) - -describe('getStagedFromGit', () => { - const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx'] - - it('returns staged files that match glob pattern and supported extensions', () => { - vi.mocked(glob.sync).mockReturnValue([ - 'src/index.ts', - 'src/app.tsx', - 'src/utils.js', - 'src/styles.module.css', - 'README.md', - 'src/fixture.json', - 'Makefile', - ]) - vi.mocked(execSync).mockReturnValue( - 'eslint.config.ts\nsrc/index.ts\nsrc/app.tsx\nsrc/styles.module.css\nsrc/fixture.json\nMakefile', - ) - - const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }) - - expect(result).toEqual(['src/index.ts', 'src/app.tsx']) - }) - it('returns empty array when no files are staged', () => { - vi.mocked(glob.sync).mockReturnValue(['src/index.ts']) - vi.mocked(execSync).mockReturnValue('') - - const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }) - - expect(result).toEqual([]) - }) - - it('returns empty array when git command fails', () => { - vi.mocked(glob.sync).mockReturnValue(['src/index.ts']) - vi.mocked(execSync).mockImplementation(() => { - throw new Error('git error') - }) + it('returns empty array when no files match', () => { + vi.mocked(glob.sync).mockReturnValue([]) - const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }) + const result = getAll({ globPattern: 'nonexistent/**/*.ts' }) expect(result).toEqual([]) }) From 11cb3e3ce23de16144f481d3215d056eb89dd33f Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 02:40:18 -0800 Subject: [PATCH 02/10] 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 --- src/git-utils.mts | 49 +++++++++++++++++++++++++++++ src/git-utils.test.mts | 70 ++++++++++++++++++++++++++++++++++++++++++ src/index.mts | 9 ++++-- 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 src/git-utils.mts create mode 100644 src/git-utils.test.mts diff --git a/src/git-utils.mts b/src/git-utils.mts new file mode 100644 index 0000000..5fdba6b --- /dev/null +++ b/src/git-utils.mts @@ -0,0 +1,49 @@ +import { execSync } from 'node:child_process' + +/** + * Gets the current working directory relative to the git repository root. + * + * @returns The cwd relative to git root (e.g., "apps/frontend/"), empty string if at git root, or null if not in a git repo + */ +function getGitPrefix() { + try { + const result = execSync('git rev-parse --show-prefix', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + return result.trim() + } catch { + return null + } +} + +/** + * Normalizes file paths by stripping the git prefix when present. + * + * When running from a package subdirectory in a monorepo, file paths from git + * (e.g., lint-staged) are relative to the repo root. This function converts + * those paths to cwd-relative paths. + * + * @example + * // When cwd is apps/frontend/ (prefix is "apps/frontend/") + * normalizeFilePaths(["apps/frontend/src/file.tsx"]) // => ["src/file.tsx"] + * + * // Paths that don't start with prefix are unchanged + * normalizeFilePaths(["src/file.tsx"]) // => ["src/file.tsx"] + */ +function normalizeFilePaths(filePaths: string[]) { + const prefix = getGitPrefix() + + if (!prefix) { + return filePaths + } + + return filePaths.map((filePath) => { + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + return filePath + }) +} + +export { normalizeFilePaths } diff --git a/src/git-utils.test.mts b/src/git-utils.test.mts new file mode 100644 index 0000000..5ea4cbe --- /dev/null +++ b/src/git-utils.test.mts @@ -0,0 +1,70 @@ +import { execSync } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeFilePaths } from './git-utils.mjs' + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('normalizeFilePaths', () => { + it('strips prefix from matching paths', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths([ + 'apps/frontend/src/App.tsx', + 'apps/frontend/src/utils/helper.ts', + ]) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('leaves non-matching paths unchanged', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('handles mixed paths (some matching, some not)', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths([ + 'apps/frontend/src/App.tsx', + 'src/local.ts', + 'apps/frontend/index.ts', + ]) + + expect(result).toEqual(['src/App.tsx', 'src/local.ts', 'index.ts']) + }) + + it('returns paths unchanged when at git root', () => { + vi.mocked(execSync).mockReturnValue('\n') + + const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('returns paths unchanged when not in a git repo', () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('fatal: not a git repository') + }) + + const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('handles empty file list', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths([]) + + expect(result).toEqual([]) + }) +}) diff --git a/src/index.mts b/src/index.mts index 36ba5d6..043673c 100644 --- a/src/index.mts +++ b/src/index.mts @@ -4,6 +4,7 @@ import { join, relative } from 'node:path' import type { Logger as ReactCompilerLogger } from 'babel-plugin-react-compiler' import * as babel from './babel.mjs' import { loadConfig } from './config.mjs' +import { normalizeFilePaths } from './git-utils.mjs' import type { FileErrors } from './records-file.mjs' import * as recordsFile from './records-file.mjs' import * as sourceFiles from './source-files.mjs' @@ -42,7 +43,8 @@ async function main() { switch (flag) { case STAGE_RECORD_FILE_FLAG: { - const filePaths = process.argv.slice(3) + const filePathParams = process.argv.slice(3) + const filePaths = normalizeFilePaths(filePathParams) return await runStageRecords({ filePaths, @@ -56,7 +58,8 @@ async function main() { }) } case CHECK_FILES_FLAG: { - const filePaths = process.argv.slice(3) + const filePathParams = process.argv.slice(3) + const filePaths = normalizeFilePaths(filePathParams) return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }) } @@ -181,7 +184,7 @@ async function runStageRecords({ exitWithWarning(`Failed to stage records file at ${recordsFileRelativePath}`) } - console.log('✅ No new React Compiler errors in staged files') + console.log('✅ No new React Compiler errors') } /** From 64d13f9484cd88e68f216020e9fefce755b2d78e Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 12:16:58 -0800 Subject: [PATCH 03/10] 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 --- src/config.mts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/config.mts b/src/config.mts index ffc3b75..34da1ae 100644 --- a/src/config.mts +++ b/src/config.mts @@ -13,23 +13,41 @@ const DEFAULT_CONFIG: Config = { const CONFIG_FILE_NAME = '.react-compiler-tracker.config.json' +function isValidConfig(config: unknown): config is Partial { + if (typeof config !== 'object' || config === null) { + return false + } + const obj = config as Record + if (obj.recordsFile !== undefined && typeof obj.recordsFile !== 'string') { + return false + } + if (obj.sourceGlob !== undefined && typeof obj.sourceGlob !== 'string') { + return false + } + return true +} + function loadConfig(): Config { const configPath = join(process.cwd(), CONFIG_FILE_NAME) if (!existsSync(configPath)) { - return DEFAULT_CONFIG + return { ...DEFAULT_CONFIG } } try { const configContent = readFileSync(configPath, 'utf8') - const userConfig = JSON.parse(configContent) as Partial + const parsed: unknown = JSON.parse(configContent) + if (!isValidConfig(parsed)) { + console.warn(`Invalid config file at ${configPath}, using defaults`) + return { ...DEFAULT_CONFIG } + } return { ...DEFAULT_CONFIG, - ...userConfig, + ...parsed, } } catch { - return DEFAULT_CONFIG + return { ...DEFAULT_CONFIG } } } From 4be2e9a6153084497196a3fec85be0dda5bf7729 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 12:17:05 -0800 Subject: [PATCH 04/10] 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 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 432f735..b6db88b 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ With lint-staged, the matched files are automatically passed as arguments to the ```bash #!/bin/sh # Get staged files and pass them to the tracker -FILES=$(git diff --cached --name-only -- '*.tsx' '*.ts' '*.jsx' '*.js' | tr '\n' ' ') +FILES=$(git diff --diff-filter=ACMR --cached --name-only -- '*.tsx' '*.ts' '*.jsx' '*.js' | tr '\n' ' ') if [ -n "$FILES" ]; then npx @doist/react-compiler-tracker --stage-record-file $FILES fi From 034defdb0435b66a53520d6da8474e3e0c7ff026 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 14:58:38 -0800 Subject: [PATCH 05/10] 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 --- src/git-utils.mts | 49 --------------------- src/git-utils.test.mts | 70 ----------------------------- src/index.mts | 5 +-- src/source-files.mts | 58 +++++++++++++++++++++++- src/source-files.test.mts | 92 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 150 insertions(+), 124 deletions(-) delete mode 100644 src/git-utils.mts delete mode 100644 src/git-utils.test.mts diff --git a/src/git-utils.mts b/src/git-utils.mts deleted file mode 100644 index 5fdba6b..0000000 --- a/src/git-utils.mts +++ /dev/null @@ -1,49 +0,0 @@ -import { execSync } from 'node:child_process' - -/** - * Gets the current working directory relative to the git repository root. - * - * @returns The cwd relative to git root (e.g., "apps/frontend/"), empty string if at git root, or null if not in a git repo - */ -function getGitPrefix() { - try { - const result = execSync('git rev-parse --show-prefix', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return result.trim() - } catch { - return null - } -} - -/** - * Normalizes file paths by stripping the git prefix when present. - * - * When running from a package subdirectory in a monorepo, file paths from git - * (e.g., lint-staged) are relative to the repo root. This function converts - * those paths to cwd-relative paths. - * - * @example - * // When cwd is apps/frontend/ (prefix is "apps/frontend/") - * normalizeFilePaths(["apps/frontend/src/file.tsx"]) // => ["src/file.tsx"] - * - * // Paths that don't start with prefix are unchanged - * normalizeFilePaths(["src/file.tsx"]) // => ["src/file.tsx"] - */ -function normalizeFilePaths(filePaths: string[]) { - const prefix = getGitPrefix() - - if (!prefix) { - return filePaths - } - - return filePaths.map((filePath) => { - if (filePath.startsWith(prefix)) { - return filePath.slice(prefix.length) - } - return filePath - }) -} - -export { normalizeFilePaths } diff --git a/src/git-utils.test.mts b/src/git-utils.test.mts deleted file mode 100644 index 5ea4cbe..0000000 --- a/src/git-utils.test.mts +++ /dev/null @@ -1,70 +0,0 @@ -import { execSync } from 'node:child_process' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { normalizeFilePaths } from './git-utils.mjs' - -vi.mock('node:child_process', () => ({ - execSync: vi.fn(), -})) - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('normalizeFilePaths', () => { - it('strips prefix from matching paths', () => { - vi.mocked(execSync).mockReturnValue('apps/frontend/\n') - - const result = normalizeFilePaths([ - 'apps/frontend/src/App.tsx', - 'apps/frontend/src/utils/helper.ts', - ]) - - expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) - }) - - it('leaves non-matching paths unchanged', () => { - vi.mocked(execSync).mockReturnValue('apps/frontend/\n') - - const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) - - expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) - }) - - it('handles mixed paths (some matching, some not)', () => { - vi.mocked(execSync).mockReturnValue('apps/frontend/\n') - - const result = normalizeFilePaths([ - 'apps/frontend/src/App.tsx', - 'src/local.ts', - 'apps/frontend/index.ts', - ]) - - expect(result).toEqual(['src/App.tsx', 'src/local.ts', 'index.ts']) - }) - - it('returns paths unchanged when at git root', () => { - vi.mocked(execSync).mockReturnValue('\n') - - const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) - - expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) - }) - - it('returns paths unchanged when not in a git repo', () => { - vi.mocked(execSync).mockImplementation(() => { - throw new Error('fatal: not a git repository') - }) - - const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) - - expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) - }) - - it('handles empty file list', () => { - vi.mocked(execSync).mockReturnValue('apps/frontend/\n') - - const result = normalizeFilePaths([]) - - expect(result).toEqual([]) - }) -}) diff --git a/src/index.mts b/src/index.mts index 043673c..11f2cfa 100644 --- a/src/index.mts +++ b/src/index.mts @@ -4,7 +4,6 @@ import { join, relative } from 'node:path' import type { Logger as ReactCompilerLogger } from 'babel-plugin-react-compiler' import * as babel from './babel.mjs' import { loadConfig } from './config.mjs' -import { normalizeFilePaths } from './git-utils.mjs' import type { FileErrors } from './records-file.mjs' import * as recordsFile from './records-file.mjs' import * as sourceFiles from './source-files.mjs' @@ -44,7 +43,7 @@ async function main() { switch (flag) { case STAGE_RECORD_FILE_FLAG: { const filePathParams = process.argv.slice(3) - const filePaths = normalizeFilePaths(filePathParams) + const filePaths = sourceFiles.normalizeFilePaths(filePathParams) return await runStageRecords({ filePaths, @@ -59,7 +58,7 @@ async function main() { } case CHECK_FILES_FLAG: { const filePathParams = process.argv.slice(3) - const filePaths = normalizeFilePaths(filePathParams) + const filePaths = sourceFiles.normalizeFilePaths(filePathParams) return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }) } diff --git a/src/source-files.mts b/src/source-files.mts index 585ed51..d35c763 100644 --- a/src/source-files.mts +++ b/src/source-files.mts @@ -1,3 +1,5 @@ +import { execSync } from 'node:child_process' +import { relative } from 'node:path' import { glob } from 'glob' function getAll({ globPattern }: { globPattern: string }) { @@ -7,4 +9,58 @@ function getAll({ globPattern }: { globPattern: string }) { }) } -export { getAll } +/** + * Gets the current working directory relative to the git repository root. + * + * @returns The cwd relative to git root (e.g., "apps/frontend/"), empty string if at git root, or null if not in a git repo + */ +function getGitPrefix() { + try { + const result = execSync('git rev-parse --show-prefix', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + return result.trim() + } catch { + return null + } +} + +/** + * Normalizes file paths by converting absolute paths to cwd-relative + * and stripping the git prefix when present. + * + * When running from a package subdirectory in a monorepo, file paths from git + * (e.g., lint-staged) are relative to the repo root. This function converts + * those paths to cwd-relative paths. + * + * @example + * // When cwd is apps/frontend/ (prefix is "apps/frontend/") + * normalizeFilePaths(["apps/frontend/src/file.tsx"]) // => ["src/file.tsx"] + * + * // Absolute paths are converted to cwd-relative + * normalizeFilePaths(["/Users/frankie/project/src/file.tsx"]) // => ["src/file.tsx"] + * + * // Paths that don't start with prefix are unchanged + * normalizeFilePaths(["src/file.tsx"]) // => ["src/file.tsx"] + */ +function normalizeFilePaths(filePaths: string[]) { + const prefix = getGitPrefix() + const cwd = process.cwd() + + return filePaths.map((filePath) => { + // Handle absolute paths by converting to cwd-relative + if (filePath.startsWith('/')) { + return relative(cwd, filePath) + } + + // Handle monorepo prefix stripping + if (prefix && filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + + return filePath + }) +} + +export { getAll, normalizeFilePaths } diff --git a/src/source-files.test.mts b/src/source-files.test.mts index 2b48d69..5927a07 100644 --- a/src/source-files.test.mts +++ b/src/source-files.test.mts @@ -1,6 +1,11 @@ +import { execSync } from 'node:child_process' import { glob } from 'glob' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getAll } from './source-files.mjs' +import { getAll, normalizeFilePaths } from './source-files.mjs' + +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})) vi.mock('glob', () => ({ glob: { @@ -33,3 +38,88 @@ describe('getAll', () => { expect(result).toEqual([]) }) }) + +describe('normalizeFilePaths', () => { + it('strips prefix from matching paths', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths([ + 'apps/frontend/src/App.tsx', + 'apps/frontend/src/utils/helper.ts', + ]) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('leaves non-matching paths unchanged', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('handles mixed paths (some matching, some not)', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths([ + 'apps/frontend/src/App.tsx', + 'src/local.ts', + 'apps/frontend/index.ts', + ]) + + expect(result).toEqual(['src/App.tsx', 'src/local.ts', 'index.ts']) + }) + + it('returns paths unchanged when at git root', () => { + vi.mocked(execSync).mockReturnValue('\n') + + const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('returns paths unchanged when not in a git repo', () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('fatal: not a git repository') + }) + + const result = normalizeFilePaths(['src/App.tsx', 'src/utils/helper.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('handles empty file list', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') + + const result = normalizeFilePaths([]) + + expect(result).toEqual([]) + }) + + it('converts absolute paths to cwd-relative paths', () => { + vi.mocked(execSync).mockReturnValue('\n') + const cwd = process.cwd() + + const result = normalizeFilePaths([`${cwd}/src/App.tsx`, `${cwd}/src/utils/helper.ts`]) + + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) + + it('handles absolute paths outside cwd with ../', () => { + vi.mocked(execSync).mockReturnValue('\n') + + const result = normalizeFilePaths(['/other/project/file.tsx']) + + expect(result[0]).toMatch(/^\.\.\//) + }) + + it('handles mixed absolute and relative paths', () => { + vi.mocked(execSync).mockReturnValue('\n') + const cwd = process.cwd() + + const result = normalizeFilePaths([`${cwd}/src/App.tsx`, 'src/local.ts']) + + expect(result).toEqual(['src/App.tsx', 'src/local.ts']) + }) +}) From dfdfe3c58b8ffe9e475d4b26d9334268b110a417 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 15:01:15 -0800 Subject: [PATCH 06/10] 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 --- src/index.mts | 10 ++++++++-- src/source-files.mts | 10 +++++++++- src/source-files.test.mts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/index.mts b/src/index.mts index 11f2cfa..a6e55d7 100644 --- a/src/index.mts +++ b/src/index.mts @@ -43,7 +43,10 @@ async function main() { switch (flag) { case STAGE_RECORD_FILE_FLAG: { const filePathParams = process.argv.slice(3) - const filePaths = sourceFiles.normalizeFilePaths(filePathParams) + const filePaths = sourceFiles.filterByGlob({ + filePaths: sourceFiles.normalizeFilePaths(filePathParams), + globPattern: config.sourceGlob, + }) return await runStageRecords({ filePaths, @@ -58,7 +61,10 @@ async function main() { } case CHECK_FILES_FLAG: { const filePathParams = process.argv.slice(3) - const filePaths = sourceFiles.normalizeFilePaths(filePathParams) + const filePaths = sourceFiles.filterByGlob({ + filePaths: sourceFiles.normalizeFilePaths(filePathParams), + globPattern: config.sourceGlob, + }) return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }) } diff --git a/src/source-files.mts b/src/source-files.mts index d35c763..f774c09 100644 --- a/src/source-files.mts +++ b/src/source-files.mts @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process' import { relative } from 'node:path' import { glob } from 'glob' +import { minimatch } from 'minimatch' function getAll({ globPattern }: { globPattern: string }) { return glob.sync(globPattern, { @@ -63,4 +64,11 @@ function normalizeFilePaths(filePaths: string[]) { }) } -export { getAll, normalizeFilePaths } +/** + * Filters file paths to only include those matching the glob pattern. + */ +function filterByGlob({ filePaths, globPattern }: { filePaths: string[]; globPattern: string }) { + return filePaths.filter((filePath) => minimatch(filePath, globPattern)) +} + +export { getAll, normalizeFilePaths, filterByGlob } diff --git a/src/source-files.test.mts b/src/source-files.test.mts index 5927a07..2ca7a22 100644 --- a/src/source-files.test.mts +++ b/src/source-files.test.mts @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process' import { glob } from 'glob' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getAll, normalizeFilePaths } from './source-files.mjs' +import { filterByGlob, getAll, normalizeFilePaths } from './source-files.mjs' vi.mock('node:child_process', () => ({ execSync: vi.fn(), @@ -123,3 +123,32 @@ describe('normalizeFilePaths', () => { expect(result).toEqual(['src/App.tsx', 'src/local.ts']) }) }) + +describe('filterByGlob', () => { + it('filters paths matching the glob pattern', () => { + const result = filterByGlob({ + filePaths: ['src/App.tsx', 'src/utils.ts', 'package.json', 'README.md'], + globPattern: 'src/**/*.{ts,tsx}', + }) + + expect(result).toEqual(['src/App.tsx', 'src/utils.ts']) + }) + + it('returns empty array when no paths match', () => { + const result = filterByGlob({ + filePaths: ['package.json', 'README.md'], + globPattern: 'src/**/*.{ts,tsx}', + }) + + expect(result).toEqual([]) + }) + + it('returns all paths when all match', () => { + const result = filterByGlob({ + filePaths: ['src/a.ts', 'src/b.tsx'], + globPattern: 'src/**/*.{ts,tsx}', + }) + + expect(result).toEqual(['src/a.ts', 'src/b.tsx']) + }) +}) From f1908a0e445eb0e01362e6a2c6b8bad2d63a9d45 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 15:02:48 -0800 Subject: [PATCH 07/10] 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 --- src/index.integration.test.mts | 6 ++++++ src/index.mts | 2 ++ src/source-files.mts | 15 ++++++++++++++- src/source-files.test.mts | 31 ++++++++++++++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/index.integration.test.mts b/src/index.integration.test.mts index d39965f..227ee9f 100644 --- a/src/index.integration.test.mts +++ b/src/index.integration.test.mts @@ -54,6 +54,12 @@ describe('CLI', () => { expect(() => JSON.parse(readFileSync(recordsPath, 'utf8'))).toThrow() }) + it('errors when file does not exist', () => { + const output = runCLI(['--check-files', 'src/nonexistent-file.tsx']) + + expect(output).toContain('File not found: src/nonexistent-file.tsx') + }) + it('accepts --overwrite flag', () => { const output = runCLI(['--overwrite']) diff --git a/src/index.mts b/src/index.mts index a6e55d7..c90db3a 100644 --- a/src/index.mts +++ b/src/index.mts @@ -47,6 +47,7 @@ async function main() { filePaths: sourceFiles.normalizeFilePaths(filePathParams), globPattern: config.sourceGlob, }) + sourceFiles.validateFilesExist(filePaths) return await runStageRecords({ filePaths, @@ -65,6 +66,7 @@ async function main() { filePaths: sourceFiles.normalizeFilePaths(filePathParams), globPattern: config.sourceGlob, }) + sourceFiles.validateFilesExist(filePaths) return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }) } diff --git a/src/source-files.mts b/src/source-files.mts index f774c09..e856a3e 100644 --- a/src/source-files.mts +++ b/src/source-files.mts @@ -1,4 +1,5 @@ import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { relative } from 'node:path' import { glob } from 'glob' import { minimatch } from 'minimatch' @@ -71,4 +72,16 @@ function filterByGlob({ filePaths, globPattern }: { filePaths: string[]; globPat return filePaths.filter((filePath) => minimatch(filePath, globPattern)) } -export { getAll, normalizeFilePaths, filterByGlob } +/** + * Validates that all file paths exist on the filesystem. + * Throws an error if any file is not found. + */ +function validateFilesExist(filePaths: string[]) { + for (const filePath of filePaths) { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`) + } + } +} + +export { getAll, normalizeFilePaths, filterByGlob, validateFilesExist } diff --git a/src/source-files.test.mts b/src/source-files.test.mts index 2ca7a22..5755f46 100644 --- a/src/source-files.test.mts +++ b/src/source-files.test.mts @@ -1,12 +1,17 @@ import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { glob } from 'glob' import { afterEach, describe, expect, it, vi } from 'vitest' -import { filterByGlob, getAll, normalizeFilePaths } from './source-files.mjs' +import { filterByGlob, getAll, normalizeFilePaths, validateFilesExist } from './source-files.mjs' vi.mock('node:child_process', () => ({ execSync: vi.fn(), })) +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) + vi.mock('glob', () => ({ glob: { sync: vi.fn(), @@ -152,3 +157,27 @@ describe('filterByGlob', () => { expect(result).toEqual(['src/a.ts', 'src/b.tsx']) }) }) + +describe('validateFilesExist', () => { + it('throws error when file does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false) + + expect(() => validateFilesExist(['nonexistent.tsx'])).toThrow( + 'File not found: nonexistent.tsx', + ) + }) + + it('does not throw when all files exist', () => { + vi.mocked(existsSync).mockReturnValue(true) + + expect(() => validateFilesExist(['exists.tsx'])).not.toThrow() + }) + + it('throws on first missing file', () => { + vi.mocked(existsSync).mockImplementation((path) => path === 'exists.tsx') + + expect(() => validateFilesExist(['exists.tsx', 'missing.tsx'])).toThrow( + 'File not found: missing.tsx', + ) + }) +}) From d0a4f4ccfdac580f07eab67556fc563c4c6e2166 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 16:11:39 -0800 Subject: [PATCH 08/10] 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 --- src/config.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.mts b/src/config.mts index 34da1ae..37b3670 100644 --- a/src/config.mts +++ b/src/config.mts @@ -46,7 +46,8 @@ function loadConfig(): Config { ...DEFAULT_CONFIG, ...parsed, } - } catch { + } catch (error) { + console.warn(`Failed to parse config at ${configPath}, using defaults. Error: ${error}`) return { ...DEFAULT_CONFIG } } } From c397cb3663b32f584e23d2cd7024ec129e1c6193 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 16:43:10 -0800 Subject: [PATCH 09/10] 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 --- src/config.mts | 6 ++---- src/config.test.mts | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/config.mts b/src/config.mts index 37b3670..f249978 100644 --- a/src/config.mts +++ b/src/config.mts @@ -38,8 +38,7 @@ function loadConfig(): Config { const configContent = readFileSync(configPath, 'utf8') const parsed: unknown = JSON.parse(configContent) if (!isValidConfig(parsed)) { - console.warn(`Invalid config file at ${configPath}, using defaults`) - return { ...DEFAULT_CONFIG } + throw new Error(`Invalid config file at ${configPath}`) } return { @@ -47,8 +46,7 @@ function loadConfig(): Config { ...parsed, } } catch (error) { - console.warn(`Failed to parse config at ${configPath}, using defaults. Error: ${error}`) - return { ...DEFAULT_CONFIG } + throw new Error(`Failed to parse config at ${configPath}: ${error}`) } } diff --git a/src/config.test.mts b/src/config.test.mts index 442dc93..a19dc63 100644 --- a/src/config.test.mts +++ b/src/config.test.mts @@ -53,12 +53,24 @@ describe('loadConfig', () => { }) }) - it('returns defaults when config file is invalid JSON', () => { + it('throws when config file is invalid JSON', () => { vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(readFileSync).mockReturnValue('not valid json') + // Trailing comma - a common mistake when editing JSON by hand + vi.mocked(readFileSync).mockReturnValue(`{ + "recordsFile": ".react-compiler.rec.json", + }`) - const result = loadConfig() + expect(() => loadConfig()).toThrow('Failed to parse config') + }) - expect(result).toEqual(DEFAULT_CONFIG) + it('throws when config has invalid field types', () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + recordsFile: { path: '.react-compiler.rec.json' }, + }), + ) + + expect(() => loadConfig()).toThrow('Invalid config file') }) }) From 34a10413c4d110fa1feb6d70bd0b5cdb53134661 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 17 Jan 2026 16:55:57 -0800 Subject: [PATCH 10/10] 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 --- src/config.mts | 2 +- src/config.test.mts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/config.mts b/src/config.mts index f249978..e0addd8 100644 --- a/src/config.mts +++ b/src/config.mts @@ -14,7 +14,7 @@ const DEFAULT_CONFIG: Config = { const CONFIG_FILE_NAME = '.react-compiler-tracker.config.json' function isValidConfig(config: unknown): config is Partial { - if (typeof config !== 'object' || config === null) { + if (typeof config !== 'object' || config === null || Array.isArray(config)) { return false } const obj = config as Record diff --git a/src/config.test.mts b/src/config.test.mts index a19dc63..abb091d 100644 --- a/src/config.test.mts +++ b/src/config.test.mts @@ -73,4 +73,11 @@ describe('loadConfig', () => { expect(() => loadConfig()).toThrow('Invalid config file') }) + + it('throws when config file contains an array instead of object', () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ recordsFile: 'records.json' }])) + + expect(() => loadConfig()).toThrow('Invalid config file') + }) })