Skip to content

Commit 34b00f8

Browse files
frankieyanclaude
andauthored
fix: remove deleted files from records file with --stage-record-file (#34)
* fix: remove deleted files from records file (#31) When using --stage-record-file, deleted files are now automatically removed from the records instead of throwing an error. This enables pre-commit hooks to include deleted files (diff-filter=ACMRD) so the tracker can clean up records for removed files. - Add partitionByExistence() to separate existing/deleted files - Update --stage-record-file to compile only existing files while passing all files to records for cleanup - Add log messages for deleted files being removed - Fix grammar in log messages ("1 file" vs "1 files") - Update README with new behavior and diff-filter guidance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract pluralize helper into utils module Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: only check existing files for error increases in stage mode Pass existingFilePaths instead of allFilePaths to exitIfErrorsIncreased since only existing files are compiled. Deleted files are handled separately and don't need error checking. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent eba54c3 commit 34b00f8

6 files changed

Lines changed: 163 additions & 21 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ npx @doist/react-compiler-tracker --overwrite
7474

7575
### `--stage-record-file <file1> <file2> ...`
7676

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.
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. Deleted files are automatically removed from the records.
7878

7979
```bash
8080
npx @doist/react-compiler-tracker --stage-record-file src/components/Button.tsx src/hooks/useData.ts
@@ -84,7 +84,7 @@ If no files are provided, exits cleanly with a success message.
8484

8585
### `--check-files <file1> <file2> ...`
8686

87-
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.
87+
Checks specific files without updating records. Exits with code 1 if checked files show increased error counts (or new errors), or if any provided file does not exist. Primarily for CI to ensure PRs don't introduce new compiler errors.
8888

8989
```bash
9090
npx @doist/react-compiler-tracker --check-files src/components/Button.tsx src/hooks/useData.ts
@@ -130,8 +130,8 @@ With lint-staged, the matched files are automatically passed as arguments to the
130130
131131
```bash
132132
#!/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' ' ')
133+
# Get staged files (including deleted) and pass them to the tracker
134+
FILES=$(git diff --diff-filter=ACMRD --cached --name-only -- '*.tsx' '*.ts' '*.jsx' '*.js' | tr '\n' ' ')
135135
if [ -n "$FILES" ]; then
136136
npx @doist/react-compiler-tracker --stage-record-file $FILES
137137
fi

src/index.integration.test.mts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('CLI', () => {
6464
// Simulate what CI tools do when passing filenames with $ through shell variables
6565
const output = runCLI(['--check-files', 'src/route.\\$param.tsx'])
6666

67-
expect(output).toContain('🔍 Checking 1 files for React Compiler errors…')
67+
expect(output).toContain('🔍 Checking 1 file for React Compiler errors…')
6868
expect(output).not.toContain('File not found')
6969
})
7070

@@ -131,6 +131,56 @@ describe('CLI', () => {
131131
// but the important thing is that error checking passed
132132
expect(output).not.toContain('React Compiler errors have increased')
133133
})
134+
135+
it('removes deleted files from records', () => {
136+
// First create records
137+
runCLI(['--overwrite'])
138+
139+
// Manually add a fake entry to records simulating a previously tracked file
140+
let records = JSON.parse(readFileSync(recordsPath, 'utf8'))
141+
records.files['src/deleted-file.tsx'] = { CompileError: 2 }
142+
writeFileSync(recordsPath, JSON.stringify(records, null, 2))
143+
144+
// Verify the fake entry is in records
145+
records = JSON.parse(readFileSync(recordsPath, 'utf8'))
146+
expect(records.files['src/deleted-file.tsx']).toEqual({ CompileError: 2 })
147+
148+
// Now run --stage-record-file with the deleted file path
149+
// The file src/deleted-file.tsx doesn't exist, simulating deletion
150+
const output = runCLI([
151+
'--stage-record-file',
152+
'src/good-component.tsx',
153+
'src/deleted-file.tsx',
154+
])
155+
156+
// Should log the deleted file being removed
157+
expect(output).toContain('🗑️ Removing 1 deleted file from records:')
158+
expect(output).toContain('• src/deleted-file.tsx')
159+
// Should check only existing files (1 file, not 2)
160+
expect(output).toContain('🔍 Checking 1 file for React Compiler errors')
161+
// Should not error on the deleted file
162+
expect(output).not.toContain('File not found')
163+
164+
// Verify the deleted file was removed from records
165+
records = JSON.parse(readFileSync(recordsPath, 'utf8'))
166+
expect(records.files['src/deleted-file.tsx']).toBeUndefined()
167+
})
168+
169+
it('exits cleanly when only deleted files provided', () => {
170+
// First create records
171+
runCLI(['--overwrite'])
172+
173+
// Run with only deleted files
174+
const output = runCLI(['--stage-record-file', 'src/deleted1.tsx', 'src/deleted2.tsx'])
175+
176+
// Should log the deleted files being removed
177+
expect(output).toContain('🗑️ Removing 2 deleted files from records:')
178+
expect(output).toContain('• src/deleted1.tsx')
179+
expect(output).toContain('• src/deleted2.tsx')
180+
// Should show clear message when no existing files
181+
expect(output).toContain('📁 No existing files to check.')
182+
expect(output).not.toContain('File not found')
183+
})
134184
})
135185

136186
describe('config file', () => {

src/index.mts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { loadConfig } from './config.mjs'
77
import type { FileErrors } from './records-file.mjs'
88
import * as recordsFile from './records-file.mjs'
99
import * as sourceFiles from './source-files.mjs'
10+
import { pluralize } from './utils.mjs'
1011

1112
const OVERWRITE_FLAG = '--overwrite'
1213
const STAGE_RECORD_FILE_FLAG = '--stage-record-file'
@@ -47,10 +48,12 @@ async function main() {
4748
filePaths: sourceFiles.normalizeFilePaths(filePathParams),
4849
globPattern: config.sourceGlob,
4950
})
50-
sourceFiles.validateFilesExist(filePaths)
51+
const { existing, deleted } = sourceFiles.partitionByExistence(filePaths)
5152

5253
return await runStageRecords({
53-
filePaths,
54+
existingFilePaths: existing,
55+
allFilePaths: filePaths,
56+
deletedFilePaths: deleted,
5457
recordsFilePath: config.recordsFile,
5558
})
5659
}
@@ -144,40 +147,58 @@ async function runOverwriteRecords({
144147
* Handles the `--stage-record-file` flag by checking provided files and updating the records file.
145148
*
146149
* If errors have increased, the process will exit with code 1 and the records file will not be updated.
150+
* Deleted files are automatically removed from the records.
147151
*/
148152
async function runStageRecords({
149-
filePaths,
153+
existingFilePaths,
154+
allFilePaths,
155+
deletedFilePaths,
150156
recordsFilePath,
151157
}: {
152-
filePaths: string[]
158+
existingFilePaths: string[]
159+
allFilePaths: string[]
160+
deletedFilePaths: string[]
153161
recordsFilePath: string
154162
}) {
155-
if (!filePaths.length) {
163+
if (!allFilePaths.length) {
156164
console.log('✅ No files to check')
157165
return
158166
}
159167

160-
console.log(
161-
`🔍 Checking ${filePaths.length} files for React Compiler errors and updating records…`,
162-
)
168+
if (deletedFilePaths.length > 0) {
169+
const deletedFileWord = pluralize(deletedFilePaths.length, 'file', 'files')
170+
const fileList = deletedFilePaths.map((f) => ` • ${f}`).join('\n')
171+
console.log(
172+
`🗑️ Removing ${deletedFilePaths.length} deleted ${deletedFileWord} from records:\n${fileList}`,
173+
)
174+
}
175+
176+
if (!existingFilePaths.length) {
177+
console.log('📁 No existing files to check.')
178+
} else {
179+
const fileWord = pluralize(existingFilePaths.length, 'file', 'files')
180+
console.log(
181+
`🔍 Checking ${existingFilePaths.length} ${fileWord} for React Compiler errors and updating records…`,
182+
)
183+
}
163184

164185
//
165-
// Compile files and update `compilerErrors` with `customReactCompilerLogger`
186+
// Compile only existing files and update `compilerErrors` with `customReactCompilerLogger`
166187
//
167188

168189
await babel.compileFiles({
169-
filePaths,
190+
filePaths: existingFilePaths,
170191
customReactCompilerLogger: customReactCompilerLogger,
171192
})
172193

173-
const records = exitIfErrorsIncreased({ filePaths, recordsFilePath })
194+
const records = exitIfErrorsIncreased({ filePaths: existingFilePaths, recordsFilePath })
174195

175196
//
176-
// Update and stage records file
197+
// Update and stage records file (includes deleted files so they get removed from records)
177198
//
178199

179200
recordsFile.save({
180-
filePaths,
201+
filePaths: allFilePaths,
181202
recordsPath: recordsFilePath,
182203
compilerErrors: Object.fromEntries(compilerErrors.entries()),
183204
records: records?.files ?? null,
@@ -211,7 +232,8 @@ async function runCheckFiles({
211232
return
212233
}
213234

214-
console.log(`🔍 Checking ${filePaths.length} files for React Compiler errors…`)
235+
const fileWord = pluralize(filePaths.length, 'file', 'files')
236+
console.log(`🔍 Checking ${filePaths.length} ${fileWord} for React Compiler errors…`)
215237

216238
//
217239
// Compile files and update `compilerErrors` with `customReactCompilerLogger`

src/source-files.mts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,22 @@ function validateFilesExist(filePaths: string[]) {
104104
}
105105
}
106106

107-
export { getAll, normalizeFilePaths, filterByGlob, validateFilesExist }
107+
/**
108+
* Partitions file paths into existing and deleted sets based on filesystem existence.
109+
*/
110+
function partitionByExistence(filePaths: string[]): { existing: string[]; deleted: string[] } {
111+
const existing: string[] = []
112+
const deleted: string[] = []
113+
114+
for (const filePath of filePaths) {
115+
if (existsSync(filePath)) {
116+
existing.push(filePath)
117+
} else {
118+
deleted.push(filePath)
119+
}
120+
}
121+
122+
return { existing, deleted }
123+
}
124+
125+
export { getAll, normalizeFilePaths, filterByGlob, validateFilesExist, partitionByExistence }

src/source-files.test.mts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { execSync } from 'node:child_process'
22
import { existsSync } from 'node:fs'
33
import { glob } from 'glob'
44
import { afterEach, describe, expect, it, vi } from 'vitest'
5-
import { filterByGlob, getAll, normalizeFilePaths, validateFilesExist } from './source-files.mjs'
5+
import {
6+
filterByGlob,
7+
getAll,
8+
normalizeFilePaths,
9+
partitionByExistence,
10+
validateFilesExist,
11+
} from './source-files.mjs'
612

713
vi.mock('node:child_process', () => ({
814
execSync: vi.fn(),
@@ -264,3 +270,46 @@ describe('validateFilesExist', () => {
264270
)
265271
})
266272
})
273+
274+
describe('partitionByExistence', () => {
275+
it('partitions files into existing and deleted sets', () => {
276+
vi.mocked(existsSync).mockImplementation(
277+
(path) => path === 'exists1.tsx' || path === 'exists2.tsx',
278+
)
279+
280+
const result = partitionByExistence([
281+
'exists1.tsx',
282+
'deleted.tsx',
283+
'exists2.tsx',
284+
'also-deleted.tsx',
285+
])
286+
287+
expect(result.existing).toEqual(['exists1.tsx', 'exists2.tsx'])
288+
expect(result.deleted).toEqual(['deleted.tsx', 'also-deleted.tsx'])
289+
})
290+
291+
it('returns all files as existing when all exist', () => {
292+
vi.mocked(existsSync).mockReturnValue(true)
293+
294+
const result = partitionByExistence(['file1.tsx', 'file2.tsx'])
295+
296+
expect(result.existing).toEqual(['file1.tsx', 'file2.tsx'])
297+
expect(result.deleted).toEqual([])
298+
})
299+
300+
it('returns all files as deleted when none exist', () => {
301+
vi.mocked(existsSync).mockReturnValue(false)
302+
303+
const result = partitionByExistence(['file1.tsx', 'file2.tsx'])
304+
305+
expect(result.existing).toEqual([])
306+
expect(result.deleted).toEqual(['file1.tsx', 'file2.tsx'])
307+
})
308+
309+
it('handles empty file list', () => {
310+
const result = partitionByExistence([])
311+
312+
expect(result.existing).toEqual([])
313+
expect(result.deleted).toEqual([])
314+
})
315+
})

src/utils.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function pluralize(count: number, singular: string, plural: string): string {
2+
return count === 1 ? singular : plural
3+
}

0 commit comments

Comments
 (0)