diff --git a/README.md b/README.md index 1b4eb72..b6db88b 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 --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 +``` + ## 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..e0addd8 --- /dev/null +++ b/src/config.mts @@ -0,0 +1,53 @@ +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 isValidConfig(config: unknown): config is Partial { + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + 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 } + } + + try { + const configContent = readFileSync(configPath, 'utf8') + const parsed: unknown = JSON.parse(configContent) + if (!isValidConfig(parsed)) { + throw new Error(`Invalid config file at ${configPath}`) + } + + return { + ...DEFAULT_CONFIG, + ...parsed, + } + } catch (error) { + throw new Error(`Failed to parse config at ${configPath}: ${error}`) + } +} + +export { type Config, DEFAULT_CONFIG, loadConfig } diff --git a/src/config.test.mts b/src/config.test.mts new file mode 100644 index 0000000..abb091d --- /dev/null +++ b/src/config.test.mts @@ -0,0 +1,83 @@ +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('throws when config file is invalid JSON', () => { + vi.mocked(existsSync).mockReturnValue(true) + // Trailing comma - a common mistake when editing JSON by hand + vi.mocked(readFileSync).mockReturnValue(`{ + "recordsFile": ".react-compiler.rec.json", + }`) + + expect(() => loadConfig()).toThrow('Failed to parse 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') + }) + + 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') + }) +}) diff --git a/src/index.integration.test.mts b/src/index.integration.test.mts index 1d9856a..227ee9f 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', () => { @@ -50,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']) @@ -72,4 +82,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..c90db3a 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,44 @@ 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 filePathParams = process.argv.slice(3) + const filePaths = sourceFiles.filterByGlob({ + filePaths: sourceFiles.normalizeFilePaths(filePathParams), + globPattern: config.sourceGlob, + }) + sourceFiles.validateFilesExist(filePaths) + + return await runStageRecords({ + filePaths, + recordsFilePath: config.recordsFile, + }) + } + case OVERWRITE_FLAG: { + return await runOverwriteRecords({ + sourceGlob: config.sourceGlob, + recordsFilePath: config.recordsFile, + }) + } + case CHECK_FILES_FLAG: { + const filePathParams = process.argv.slice(3) + const filePaths = sourceFiles.filterByGlob({ + filePaths: sourceFiles.normalizeFilePaths(filePathParams), + globPattern: config.sourceGlob, + }) + sourceFiles.validateFilesExist(filePaths) + + return await runCheckFiles({ filePaths, recordsFilePath: config.recordsFile }) + } + default: { + return await runCheckAllFiles({ sourceGlob: config.sourceGlob }) + } } } catch (error: unknown) { if (error instanceof Error) { @@ -70,10 +86,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 +141,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 +170,7 @@ async function runStageRecords() { customReactCompilerLogger: customReactCompilerLogger, }) - const records = exitIfErrorsIncreased({ filePaths }) + const records = exitIfErrorsIncreased({ filePaths, recordsFilePath }) // // Update and stage records file @@ -161,13 +183,15 @@ 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') + console.log('✅ No new React Compiler errors') } /** @@ -175,12 +199,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 +222,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 +232,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 +278,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..e856a3e 100644 --- a/src/source-files.mts +++ b/src/source-files.mts @@ -1,53 +1,87 @@ import { execSync } from 'node:child_process' - +import { existsSync } from 'node:fs' +import { relative } from 'node:path' import { glob } from 'glob' +import { minimatch } from 'minimatch' -function getStagedFromGit({ - globPattern, - supportedFileExtensions, -}: { - globPattern: string - supportedFileExtensions: string[] -}) { - const allFilePaths = getAll({ globPattern, supportedFileExtensions }) +function getAll({ globPattern }: { globPattern: string }) { + return glob.sync(globPattern, { + cwd: process.cwd(), + absolute: false, + }) +} +/** + * 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 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 }) + const result = execSync('git rev-parse --show-prefix', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + return result.trim() } catch { - return [] + return null } } -function getAll({ - globPattern, - supportedFileExtensions, -}: { - globPattern: string - supportedFileExtensions: string[] -}) { - const srcFiles = glob.sync(globPattern, { - cwd: process.cwd(), - absolute: false, +/** + * 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 }) - 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 || '') - }) +/** + * 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)) +} + +/** + * 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 { filterSupportedFiles, getAll, getStagedFromGit } +export { getAll, normalizeFilePaths, filterByGlob, validateFilesExist } diff --git a/src/source-files.test.mts b/src/source-files.test.mts index 2769590..5755f46 100644 --- a/src/source-files.test.mts +++ b/src/source-files.test.mts @@ -1,7 +1,16 @@ import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { glob } from 'glob' import { afterEach, describe, expect, it, vi } from 'vitest' -import { filterSupportedFiles, getAll, getStagedFromGit } 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: { @@ -9,103 +18,166 @@ 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', - ] +describe('getAll', () => { + it('returns files matching the glob pattern', () => { + vi.mocked(glob.sync).mockReturnValue(['src/index.ts', 'src/app.tsx', 'src/utils.js']) - const result = filterSupportedFiles({ filePaths, 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/**/*.{js,jsx,ts,tsx}', { + cwd: process.cwd(), + absolute: false, + }) }) - it('handles empty file list', () => { - const result = filterSupportedFiles({ filePaths: [], supportedFileExtensions }) + it('returns empty array when no files match', () => { + vi.mocked(glob.sync).mockReturnValue([]) + + const result = getAll({ globPattern: 'nonexistent/**/*.ts' }) expect(result).toEqual([]) }) }) -describe('getAll', () => { - const supportedFileExtensions = ['js', 'jsx', 'ts', 'tsx'] - - 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', +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', ]) - const result = getAll({ globPattern: 'src/**/*', supportedFileExtensions }) + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) + }) - expect(result).toEqual(['src/index.ts', 'src/app.tsx', 'src/utils.js']) - expect(glob.sync).toHaveBeenCalledWith('src/**/*', { - cwd: process.cwd(), - absolute: false, - }) + 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']) }) -}) -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', + 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', ]) - 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/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/index.ts', 'src/app.tsx']) + expect(result).toEqual(['src/App.tsx', 'src/utils/helper.ts']) }) - it('returns empty array when no files are staged', () => { - vi.mocked(glob.sync).mockReturnValue(['src/index.ts']) - vi.mocked(execSync).mockReturnValue('') + it('handles empty file list', () => { + vi.mocked(execSync).mockReturnValue('apps/frontend/\n') - const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }) + const result = normalizeFilePaths([]) 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('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']) + }) +}) + +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}', }) - const result = getStagedFromGit({ globPattern: 'src/**/*', supportedFileExtensions }) + 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']) + }) +}) + +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', + ) + }) })