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
55 changes: 55 additions & 0 deletions src/types/dir-feature-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { createMockLogger } from "../test-utils/mock-logger.js";
import { setupTestDirectory } from "../test-utils/test-directories.js";
import {
ensureDir,
readFileBufferOrNull,
readFileContentOrNull,
removeDirectory,
writeFileBuffer,
writeFileContent,
} from "../utils/file.js";
import { AiDir, AiDirFile } from "./ai-dir.js";
Expand All @@ -16,9 +18,11 @@ vi.mock("../utils/file.js", async () => {
return {
...actual,
readFileContentOrNull: vi.fn().mockResolvedValue(null),
readFileBufferOrNull: vi.fn().mockResolvedValue(null),
removeDirectory: vi.fn(),
ensureDir: vi.fn(),
writeFileContent: vi.fn(),
writeFileBuffer: vi.fn(),
};
});

Expand Down Expand Up @@ -218,6 +222,57 @@ describe("DirFeatureProcessor", () => {
expect(writeFileContent).toHaveBeenCalledTimes(1);
});

it("should write binary other files via the buffer path", async () => {
const binaryBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
const processor = new TestDirProcessor({ logger: createMockLogger(), outputRoot: testDir });

const otherFile: AiDirFile = {
relativeFilePathToDirPath: "image.jpg",
fileBuffer: binaryBuffer,
};
const dirs = [createMockDirWithFiles({ dirPath: "/path/to/dir1", otherFiles: [otherFile] })];

const result = await processor.writeAiDirs(dirs);

expect(result).toEqual({ count: 1, paths: ["/path/to/dir1/image.jpg"] });
expect(writeFileBuffer).toHaveBeenCalledWith("/path/to/dir1/image.jpg", binaryBuffer);
expect(writeFileContent).not.toHaveBeenCalled();
});

it("should skip unchanged binary other files", async () => {
const binaryBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
vi.mocked(readFileBufferOrNull).mockResolvedValue(binaryBuffer);
const processor = new TestDirProcessor({ logger: createMockLogger(), outputRoot: testDir });

const otherFile: AiDirFile = {
relativeFilePathToDirPath: "image.jpg",
fileBuffer: binaryBuffer,
};
const dirs = [createMockDirWithFiles({ dirPath: "/path/to/dir1", otherFiles: [otherFile] })];

const result = await processor.writeAiDirs(dirs);

expect(result).toEqual({ count: 0, paths: [] });
expect(writeFileBuffer).not.toHaveBeenCalled();
});

it("should detect changes in binary other files", async () => {
const binaryBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
vi.mocked(readFileBufferOrNull).mockResolvedValue(Buffer.from([0x00, 0x01, 0x02]));
const processor = new TestDirProcessor({ logger: createMockLogger(), outputRoot: testDir });

const otherFile: AiDirFile = {
relativeFilePathToDirPath: "image.jpg",
fileBuffer: binaryBuffer,
};
const dirs = [createMockDirWithFiles({ dirPath: "/path/to/dir1", otherFiles: [otherFile] })];

const result = await processor.writeAiDirs(dirs);

expect(result).toEqual({ count: 1, paths: ["/path/to/dir1/image.jpg"] });
expect(writeFileBuffer).toHaveBeenCalledTimes(1);
});

it("should return changed count without writing in dry-run mode", async () => {
vi.mocked(readFileContentOrNull).mockResolvedValue(null);
const processor = new TestDirProcessor({
Expand Down
53 changes: 37 additions & 16 deletions src/types/dir-feature-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { fileContentsEquivalent } from "../utils/content-equivalence.js";
import {
addTrailingNewline,
ensureDir,
isBinaryBuffer,
readFileBufferOrNull,
readFileContentOrNull,
removeDirectory,
writeFileBuffer,
writeFileContent,
} from "../utils/file.js";
import { stringifyFrontmatter } from "../utils/frontmatter.js";
Expand Down Expand Up @@ -96,23 +99,37 @@ export abstract class DirFeatureProcessor {
}
}

// Compute content for other files
// Compute content for other files. Text files are compared and written
// through the text path (trailing-newline normalization + structured
// equivalence); binary files (e.g. images in a skill dir) go through the
// buffer path so their bytes are never corrupted by a UTF-8 round-trip.
const otherFiles: AiDirFile[] = aiDir.getOtherFiles();
const otherFileContents: string[] = [];
const otherFileContents: (string | Buffer)[] = [];
for (const file of otherFiles) {
const contentWithNewline = addTrailingNewline(file.fileBuffer.toString("utf-8"));
otherFileContents.push(contentWithNewline);
if (!dirHasChanges) {
const filePath = join(dirPath, file.relativeFilePathToDirPath);
const existingContent = await readFileContentOrNull(filePath);
if (
!fileContentsEquivalent({
filePath,
expected: contentWithNewline,
existing: existingContent,
})
) {
dirHasChanges = true;
if (isBinaryBuffer(file.fileBuffer)) {
otherFileContents.push(file.fileBuffer);
if (!dirHasChanges) {
const filePath = join(dirPath, file.relativeFilePathToDirPath);
const existingBuffer = await readFileBufferOrNull(filePath);
if (!existingBuffer || !existingBuffer.equals(file.fileBuffer)) {
dirHasChanges = true;
}
}
} else {
const contentWithNewline = addTrailingNewline(file.fileBuffer.toString("utf-8"));
otherFileContents.push(contentWithNewline);
if (!dirHasChanges) {
const filePath = join(dirPath, file.relativeFilePathToDirPath);
const existingContent = await readFileContentOrNull(filePath);
if (
!fileContentsEquivalent({
filePath,
expected: contentWithNewline,
existing: existingContent,
})
) {
dirHasChanges = true;
}
}
}
}
Expand Down Expand Up @@ -155,7 +172,11 @@ export abstract class DirFeatureProcessor {
"This indicates a synchronization issue between otherFiles and otherFileContents arrays.",
);
}
await writeFileContent(filePath, content);
if (typeof content === "string") {
await writeFileContent(filePath, content);
} else {
await writeFileBuffer(filePath, content);
}
changedPaths.push(join(relativeDir, file.relativeFilePathToDirPath));
}
}
Expand Down
23 changes: 23 additions & 0 deletions src/utils/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,29 @@ export async function readFileBuffer(filepath: string): Promise<Buffer> {
return readFile(filepath);
}

/**
* Read file as a buffer if it exists, otherwise return null.
*/
export async function readFileBufferOrNull(filepath: string): Promise<Buffer | null> {
if (await fileExists(filepath)) {
return readFileBuffer(filepath);
}
return null;
}

/**
* Whether a UTF-8 text round-trip would corrupt the buffer.
*
* Used to decide between the text and binary write paths for skill
* "other files": valid UTF-8 text (including CJK) survives a
* `toString("utf-8")` + `Buffer.from(_, "utf-8")` round-trip unchanged,
* while binary content such as JPEG/GIF does not (invalid byte sequences
* are replaced with U+FFFD).
*/
export function isBinaryBuffer(buffer: Buffer): boolean {
return !Buffer.from(buffer.toString("utf-8"), "utf-8").equals(buffer);
}

/**
* Normalizes text to LF line endings and adds exactly one trailing newline.
* Removes any existing trailing whitespace and appends a single newline.
Expand Down
Loading