Skip to content

Commit 4656284

Browse files
fix(memory-wiki): normalize source page stat guard
1 parent 8e61aaf commit 4656284

2 files changed

Lines changed: 51 additions & 5 deletions

File tree

extensions/memory-wiki/src/bridge.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,14 @@ describe("syncMemoryWikiBridgeSources", () => {
346346
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
347347
});
348348

349-
it("reports non-symlink bridge source write safety failures without symlink wording", async () => {
350-
const workspaceDir = await createBridgeWorkspace("not-file-workspace");
349+
async function createDirectoryCollisionFixture(params: {
350+
workspaceName: string;
351+
vaultName: string;
352+
populateDirectory?: boolean;
353+
}) {
354+
const workspaceDir = await createBridgeWorkspace(params.workspaceName);
351355
const { rootDir: vaultDir, config } = await createVault({
352-
rootDir: nextCaseRoot("not-file-vault"),
356+
rootDir: nextCaseRoot(params.vaultName),
353357
config: {
354358
vaultMode: "bridge",
355359
bridge: {
@@ -381,8 +385,19 @@ describe("syncMemoryWikiBridgeSources", () => {
381385
const pageAbsPath = path.join(vaultDir, pagePath);
382386
await fs.rm(pageAbsPath);
383387
await fs.mkdir(pageAbsPath);
384-
await fs.writeFile(path.join(pageAbsPath, "child.md"), "blocking child\n", "utf8");
388+
if (params.populateDirectory) {
389+
await fs.writeFile(path.join(pageAbsPath, "child.md"), "blocking child\n", "utf8");
390+
}
385391
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
392+
return { appConfig, config, pageAbsPath };
393+
}
394+
395+
it("reports non-symlink bridge source write safety failures without symlink wording", async () => {
396+
const { appConfig, config } = await createDirectoryCollisionFixture({
397+
workspaceName: "not-file-workspace",
398+
vaultName: "not-file-vault",
399+
populateDirectory: true,
400+
});
386401

387402
const second = syncMemoryWikiBridgeSources({ config, appConfig });
388403
await expect(second).rejects.toThrow(
@@ -391,6 +406,20 @@ describe("syncMemoryWikiBridgeSources", () => {
391406
await expect(second).rejects.not.toThrow("through symlink");
392407
});
393408

409+
it("does not remove empty directory bridge source collisions as hardlinks", async () => {
410+
const { appConfig, config, pageAbsPath } = await createDirectoryCollisionFixture({
411+
workspaceName: "empty-directory-workspace",
412+
vaultName: "empty-directory-vault",
413+
});
414+
415+
const second = syncMemoryWikiBridgeSources({ config, appConfig });
416+
await expect(second).rejects.toThrow(
417+
/Refusing to write imported source page \((not-file|path-mismatch)\): sources\//u,
418+
);
419+
await expect(second).rejects.not.toThrow("through symlink");
420+
await expect(fs.stat(pageAbsPath)).resolves.toSatisfy((stat) => stat.isDirectory());
421+
});
422+
394423
it("replaces bridge source page hardlinks without clobbering their target", async () => {
395424
const workspaceDir = await createBridgeWorkspace("hardlink-workspace");
396425
const { rootDir: vaultDir, config } = await createVault({

extensions/memory-wiki/src/source-page-shared.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ import {
88

99
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
1010

11+
type FileStatLike = {
12+
isFile?: unknown;
13+
nlink?: unknown;
14+
};
15+
16+
function isRegularFileStat(value: unknown): value is FileStatLike & { nlink: number } {
17+
if (!value || typeof value !== "object") {
18+
return false;
19+
}
20+
const stat = value as FileStatLike;
21+
const isFile =
22+
typeof stat.isFile === "function"
23+
? (stat.isFile as () => boolean).call(stat)
24+
: stat.isFile === true;
25+
return isFile && typeof stat.nlink === "number";
26+
}
27+
1128
export async function writeImportedSourcePage(params: {
1229
vaultRoot: string;
1330
syncKey: string;
@@ -51,7 +68,7 @@ export async function writeImportedSourcePage(params: {
5168
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
5269
if (existing !== rendered) {
5370
try {
54-
if (pageStat && pageStat.isFile && pageStat.nlink > 1) {
71+
if (isRegularFileStat(pageStat) && pageStat.nlink > 1) {
5572
await vault.remove(params.pagePath);
5673
}
5774
await vault.write(params.pagePath, rendered);

0 commit comments

Comments
 (0)