Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/migrate/src/helpers/package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import consola from 'consola'
import { createPatch } from 'diff'
import { detectIndentation } from '../../../../src/utils/format.ts'
import { detectIndentation } from '../../../../src/utils/json.ts'
import pkg from '../../package.json' with { type: 'json' }
import { outputDiff, renameKey } from '../utils.ts'

Expand Down
13 changes: 2 additions & 11 deletions src/features/pkg/exports.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { RE_CSS, RE_DTS, RE_NODE_MODULES } from 'rolldown-plugin-dts/internal'
import { detectIndentation } from '../../utils/format.ts'
import { stripExtname } from '../../utils/fs.ts'
import { matchPattern, slash, typeAssert } from '../../utils/general.ts'
import { writeJsonFile } from '../../utils/json.ts'
import type { NormalizedFormat, ResolvedConfig } from '../../config/types.ts'
import type {
ChunksByFormat,
Expand Down Expand Up @@ -155,15 +154,7 @@ export async function writeExports(
}
}

const original = readFileSync(pkg.packageJsonPath, 'utf8')
let contents = JSON.stringify(updatedPkg, null, detectIndentation(original))
if (original.includes('\r\n')) {
contents = contents.replaceAll('\n', '\r\n')
}
if (original.endsWith('\n')) contents += '\n'
if (contents !== original) {
writeFileSync(pkg.packageJsonPath, contents, 'utf8')
}
writeJsonFile(pkg.packageJsonPath, updatedPkg)
}

type SubExport = Partial<Record<'cjs' | 'es' | 'src', string>>
Expand Down
Empty file modified src/run.ts
100644 → 100755
Empty file.
25 changes: 0 additions & 25 deletions src/utils/format.test.ts

This file was deleted.

16 changes: 0 additions & 16 deletions src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,3 @@ export function formatBytes(bytes: number): string | undefined {
}
return `${(bytes / 1000).toFixed(2)} kB`
}

export function detectIndentation(jsonText: string): string | number {
const lines = jsonText.split(/\r?\n/)

for (const line of lines) {
const match = line.match(/^(\s+)\S/)
if (!match) continue

if (match[1].includes('\t')) {
return '\t'
}
return match[1].length
}

return 2
}
98 changes: 98 additions & 0 deletions src/utils/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, test } from 'vitest'
import { writeFixtures } from '../../tests/utils.ts'
import { detectIndentation, writeJsonFile } from './json.ts'

describe('writeJsonFile', () => {
test('creates a new file when it does not exist', async (context) => {
const { testDir } = await writeFixtures(context, { 'placeholder.txt': '' })
const filePath = path.join(testDir, 'new.json')
writeJsonFile(filePath, { foo: 'bar' })
expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "bar"\n}')
})

test('does not rewrite when keys are reordered but content is deeply equal', async (context) => {
const original = '{"b":1,\n"a":2}'
const { testDir } = await writeFixtures(context, { 'pkg.json': original })
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { a: 2, b: 1 })
expect(readFileSync(filePath, 'utf8')).toBe(original)
})

test('does not rewrite when content is identical', async (context) => {
const original = '{\t"foo":"bar"\n }'
const { testDir } = await writeFixtures(context, { 'pkg.json': original })
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'bar' })
expect(readFileSync(filePath, 'utf8')).toBe(original)
})

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't a test covering the case where content contains properties with value undefined (which JSON.stringify omits). With the current deep-equality check, this can cause a rewrite even if the serialized JSON would be identical to the existing file (relevant for callers that “remove” fields via foo: undefined). Adding a fixture/test for this scenario would prevent regressions.

Suggested change
test('does not rewrite when content only adds undefined properties', async (context) => {
const original = '{\n "foo": "bar"\n}'
const { testDir } = await writeFixtures(context, { 'pkg.json': original })
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'bar', removed: undefined })
expect(readFileSync(filePath, 'utf8')).toBe(original)
})

Copilot uses AI. Check for mistakes.
test('updates the file when content changes', async (context) => {
const { testDir } = await writeFixtures(context, {
'pkg.json': '{\n "foo": "bar"\n}',
})
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'baz' })
expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "baz"\n}')
})

test('preserves tab indentation', async (context) => {
const { testDir } = await writeFixtures(context, {
'pkg.json': '{\n\t"foo": "bar"\n}',
})
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'baz' })
expect(readFileSync(filePath, 'utf8')).toBe('{\n\t"foo": "baz"\n}')
})

test('preserves 4-space indentation', async (context) => {
const { testDir } = await writeFixtures(context, {
'pkg.json': '{\n "foo": "bar"\n}',
})
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'baz' })
expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "baz"\n}')
})

test('preserves CRLF line endings', async (context) => {
const { testDir } = await writeFixtures(context, {
'pkg.json': '{\r\n "foo": "bar"\r\n}',
})
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'baz' })
expect(readFileSync(filePath, 'utf8')).toBe('{\r\n "foo": "baz"\r\n}')
})

test('preserves trailing newline', async (context) => {
const { testDir } = await writeFixtures(context, {
'pkg.json': '{\n "foo": "bar"\n}\n',
})
const filePath = path.join(testDir, 'pkg.json')
writeJsonFile(filePath, { foo: 'baz' })
expect(readFileSync(filePath, 'utf8')).toBe('{\n "foo": "baz"\n}\n')
})
})

describe('detectIndent', () => {
test('two spaces', ({ expect }) => {
expect(detectIndentation(stringifyJson(2))).toBe(2)
})
test('four spaces', ({ expect }) => {
expect(detectIndentation(stringifyJson(4))).toBe(4)
})
test('tab', ({ expect }) => {
expect(detectIndentation(stringifyJson('\t'))).toBe('\t')
})
test('empty', ({ expect }) => {
expect(detectIndentation('')).toBe(2)
})
test('empty line', ({ expect }) => {
expect(detectIndentation('{\n\n "foo": 42 }')).toBe(2)
})
})

function stringifyJson(indentation: string | number): string {
const contents = JSON.stringify({ foo: 42 }, null, indentation)
return contents
}
54 changes: 54 additions & 0 deletions src/utils/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { isDeepStrictEqual } from 'node:util'

export function writeJsonFile(filePath: string, content: unknown): void {
let originalContent: unknown = undefined
let originalIndent: string | number = 2
let originalEOL: string = '\n'
let originalHasTrailingNewline: boolean = false

try {
const text = readFileSync(filePath, 'utf8')
originalContent = JSON.parse(text)
originalIndent = detectIndentation(text)
if (text.includes('\r\n')) {
originalEOL = '\r\n'
}
if (text.endsWith('\n')) {
originalHasTrailingNewline = true
}
} catch {
// File doesn't exist or isn't valid JSON, we'll overwrite it with our content
Comment on lines +20 to +21
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare catch {} swallows all errors from readFileSync/JSON.parse, including permission errors, transient IO failures, and other unexpected conditions. That can mask real operational problems and then proceed to overwrite the file with default formatting. It would be safer to only ignore the specific cases you expect (e.g. missing file / invalid JSON) and rethrow anything else.

Suggested change
} catch {
// File doesn't exist or isn't valid JSON, we'll overwrite it with our content
} catch (error) {
if (error instanceof SyntaxError) {
// The file isn't valid JSON, we'll overwrite it with our content
} else if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// The file doesn't exist, we'll overwrite it with our content
} else {
throw error
}

Copilot uses AI. Check for mistakes.
}

if (originalContent && isDeepStrictEqual(originalContent, content)) {
// The content is the same. We just return without updating the file format
return
}
Comment on lines +24 to +27
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deep-equality short-circuit is guarded by a truthy check (originalContent && ...), which means valid JSON roots like null, false, 0, or "" will never be treated as equal and will be rewritten unnecessarily. Also, callers may include undefined properties (e.g. objects that rely on JSON.stringify omitting them), which will make isDeepStrictEqual fail even when the on-disk JSON and the serialized output are identical. Consider tracking whether parsing succeeded separately (instead of truthiness) and normalizing content to JSON-serializable form (e.g. by removing undefined keys) before comparing.

Copilot uses AI. Check for mistakes.

let jsonString = JSON.stringify(content, null, originalIndent)
if (originalEOL !== '\n') {
jsonString = jsonString.replaceAll('\n', originalEOL)
}
if (originalHasTrailingNewline) {
jsonString += originalEOL
}

writeFileSync(filePath, jsonString, 'utf8')
Comment on lines +29 to +37
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify can return undefined for inputs like undefined, functions, or symbols. In that case writeFileSync will throw with a less helpful error. Since this helper is exported and accepts unknown, consider narrowing the parameter type to JSON-serializable values and/or validating the result of JSON.stringify to throw a clearer error before writing.

Copilot uses AI. Check for mistakes.
}

export function detectIndentation(jsonText: string): string | number {
const lines = jsonText.split(/\r?\n/)

for (const line of lines) {
const match = line.match(/^(\s+)\S/)
if (!match) continue

if (match[1].includes('\t')) {
return '\t'
}
return match[1].length
}

return 2
}
Loading