test(hooks): update claude-code, comment-checker, and rules-injector tests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test"
|
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||||
import type { HookHttp } from "./types"
|
import type { HookHttp } from "./types"
|
||||||
import * as sharedLogger from "../../shared/logger"
|
import * as sharedModule from "../../shared"
|
||||||
|
|
||||||
const mockFetch = mock(() =>
|
const mockFetch = mock(() =>
|
||||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||||
@@ -15,8 +15,20 @@ async function importFreshExecuteHttpHook() {
|
|||||||
return import(modulePath)
|
return import(modulePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installSharedLogMock(logCalls: Array<{ message: string; data?: unknown }>): void {
|
||||||
|
const sharedMockFactory = () => ({
|
||||||
|
...sharedModule,
|
||||||
|
log: (message: string, data?: unknown) => {
|
||||||
|
logCalls.push({ message, data })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.module("../../shared", sharedMockFactory)
|
||||||
|
mock.module("../../shared/index.ts", sharedMockFactory)
|
||||||
|
}
|
||||||
|
|
||||||
describe("executeHttpHook TLS security", () => {
|
describe("executeHttpHook TLS security", () => {
|
||||||
let logSpy: ReturnType<typeof spyOn> | undefined
|
let logCalls: Array<{ message: string; data?: unknown }>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||||
@@ -24,13 +36,12 @@ describe("executeHttpHook TLS security", () => {
|
|||||||
mockFetch.mockImplementation(() =>
|
mockFetch.mockImplementation(() =>
|
||||||
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
Promise.resolve(new Response(JSON.stringify({}), { status: 200 }))
|
||||||
)
|
)
|
||||||
|
logCalls = []
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch
|
||||||
process.env = { ...originalEnv }
|
process.env = { ...originalEnv }
|
||||||
logSpy?.mockRestore()
|
|
||||||
logSpy = undefined
|
|
||||||
mockFetch.mockReset()
|
mockFetch.mockReset()
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
@@ -62,9 +73,9 @@ describe("executeHttpHook TLS security", () => {
|
|||||||
expect(mockFetch).not.toHaveBeenCalled()
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#when hook uses remote http:// URL #then logs warning before rejection", async () => {
|
it("#when hook uses remote http:// URL #then rejects with exit code 1", async () => {
|
||||||
// given
|
// given
|
||||||
logSpy = spyOn(sharedLogger, "log").mockImplementation(() => {})
|
installSharedLogMock(logCalls)
|
||||||
const { executeHttpHook } = await importFreshExecuteHttpHook()
|
const { executeHttpHook } = await importFreshExecuteHttpHook()
|
||||||
const hook: HookHttp = { type: "http", url: "http://tls-security-remote.invalid/hooks" }
|
const hook: HookHttp = { type: "http", url: "http://tls-security-remote.invalid/hooks" }
|
||||||
|
|
||||||
@@ -72,13 +83,8 @@ describe("executeHttpHook TLS security", () => {
|
|||||||
const result = await executeHttpHook(hook, "{}")
|
const result = await executeHttpHook(hook, "{}")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
const matchingCalls = logSpy.mock.calls.filter(([message, data]) => {
|
|
||||||
return message === "HTTP hook URL uses insecure protocol"
|
|
||||||
&& JSON.stringify(data) === JSON.stringify({ url: hook.url })
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.exitCode).toBe(1)
|
expect(result.exitCode).toBe(1)
|
||||||
expect(matchingCalls).toHaveLength(1)
|
expect(result.stderr).toContain("HTTP hook URL must use HTTPS")
|
||||||
expect(mockFetch).not.toHaveBeenCalled()
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +100,7 @@ describe("executeHttpHook TLS security", () => {
|
|||||||
|
|
||||||
it("#when hook uses http://localhost #then does not log insecure warning", async () => {
|
it("#when hook uses http://localhost #then does not log insecure warning", async () => {
|
||||||
// given
|
// given
|
||||||
logSpy = spyOn(sharedLogger, "log").mockImplementation(() => {})
|
installSharedLogMock(logCalls)
|
||||||
const { executeHttpHook } = await importFreshExecuteHttpHook()
|
const { executeHttpHook } = await importFreshExecuteHttpHook()
|
||||||
const hook: HookHttp = { type: "http", url: "http://localhost:49123/hooks" }
|
const hook: HookHttp = { type: "http", url: "http://localhost:49123/hooks" }
|
||||||
|
|
||||||
@@ -102,7 +108,7 @@ describe("executeHttpHook TLS security", () => {
|
|||||||
const result = await executeHttpHook(hook, "{}")
|
const result = await executeHttpHook(hook, "{}")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
const matchingCalls = logSpy.mock.calls.filter(([message, data]) => {
|
const matchingCalls = logCalls.filter(({ message, data }) => {
|
||||||
return message === "HTTP hook URL uses insecure protocol"
|
return message === "HTTP hook URL uses insecure protocol"
|
||||||
&& JSON.stringify(data) === JSON.stringify({ url: hook.url })
|
&& JSON.stringify(data) === JSON.stringify({ url: hook.url })
|
||||||
})
|
})
|
||||||
@@ -168,22 +174,19 @@ describe("executeHttpHook TLS security", () => {
|
|||||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#when hook uses plain remote http:// URL #then writes warning log", async () => {
|
it("#when hook uses plain remote http:// URL #then rejects with exit code 1", async () => {
|
||||||
// given
|
// given
|
||||||
logSpy = spyOn(sharedLogger, "log").mockImplementation(() => {})
|
installSharedLogMock(logCalls)
|
||||||
const { executeHttpHook } = await importFreshExecuteHttpHook()
|
const { executeHttpHook } = await importFreshExecuteHttpHook()
|
||||||
const hook: HookHttp = { type: "http", url: "http://tls-security-dev.invalid/hooks" }
|
const hook: HookHttp = { type: "http", url: "http://tls-security-dev.invalid/hooks" }
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await executeHttpHook(hook, "{}")
|
const result = await executeHttpHook(hook, "{}")
|
||||||
|
|
||||||
// then
|
// then
|
||||||
const matchingCalls = logSpy.mock.calls.filter(([message, data]) => {
|
expect(result.exitCode).toBe(1)
|
||||||
return message === "HTTP hook URL uses insecure protocol"
|
expect(result.stderr).toContain("HTTP hook URL must use HTTPS")
|
||||||
&& JSON.stringify(data) === JSON.stringify({ url: hook.url })
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
})
|
|
||||||
|
|
||||||
expect(matchingCalls).toHaveLength(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#when hook uses http://[::1] #then allows execution", async () => {
|
it("#when hook uses http://[::1] #then allows execution", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
|
||||||
|
import { processWithCli } from "./cli-runner"
|
||||||
import type { PendingCall } from "./types"
|
import type { PendingCall } from "./types"
|
||||||
|
|
||||||
function createMockInput() {
|
function createMockInput() {
|
||||||
@@ -151,20 +152,15 @@ exit 2
|
|||||||
getCommentCheckerPath: mock(async () => "/fake"),
|
getCommentCheckerPath: mock(async () => "/fake"),
|
||||||
startBackgroundInit: mock(() => {}),
|
startBackgroundInit: mock(() => {}),
|
||||||
})
|
})
|
||||||
mock.module("./cli", cliMockFactory)
|
const cliMocks = cliMockFactory()
|
||||||
mock.module("./cli.ts", cliMockFactory)
|
|
||||||
mock.module(new URL("./cli.ts", import.meta.url).href, cliMockFactory)
|
|
||||||
const concurrentRunnerBasePath = new URL("./cli-runner.ts", import.meta.url).pathname
|
|
||||||
const concurrentModulePath = `${concurrentRunnerBasePath}?semaphore-concurrent`
|
|
||||||
const { processWithCli } = await import(concurrentModulePath)
|
|
||||||
const pendingCall: PendingCall = {
|
const pendingCall: PendingCall = {
|
||||||
tool: "write",
|
tool: "write",
|
||||||
sessionID: "ses-1",
|
sessionID: "ses-1",
|
||||||
filePath: "/tmp/a.ts",
|
filePath: "/tmp/a.ts",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
const firstCall = processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {})
|
const firstCall = processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker })
|
||||||
const secondCall = processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {})
|
const secondCall = processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker })
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await secondCall
|
await secondCall
|
||||||
@@ -185,12 +181,7 @@ exit 2
|
|||||||
getCommentCheckerPath: mock(async () => "/fake"),
|
getCommentCheckerPath: mock(async () => "/fake"),
|
||||||
startBackgroundInit: mock(() => {}),
|
startBackgroundInit: mock(() => {}),
|
||||||
})
|
})
|
||||||
mock.module("./cli", cliMockFactory)
|
const cliMocks = cliMockFactory()
|
||||||
mock.module("./cli.ts", cliMockFactory)
|
|
||||||
mock.module(new URL("./cli.ts", import.meta.url).href, cliMockFactory)
|
|
||||||
const sequentialRunnerBasePath = new URL("./cli-runner.ts", import.meta.url).pathname
|
|
||||||
const sequentialModulePath = `${sequentialRunnerBasePath}?semaphore-sequential`
|
|
||||||
const { processWithCli } = await import(sequentialModulePath)
|
|
||||||
const pendingCall: PendingCall = {
|
const pendingCall: PendingCall = {
|
||||||
tool: "write",
|
tool: "write",
|
||||||
sessionID: "ses-1",
|
sessionID: "ses-1",
|
||||||
@@ -198,8 +189,8 @@ exit 2
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
// when
|
// when
|
||||||
await processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {})
|
await processWithCli({ tool: "write", sessionID: "ses-1", callID: "call-1" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker })
|
||||||
await processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {})
|
await processWithCli({ tool: "write", sessionID: "ses-2", callID: "call-2" }, pendingCall, { output: "" }, "/fake", undefined, () => {}, { runCommentChecker: cliMocks.runCommentChecker })
|
||||||
// then
|
// then
|
||||||
expect(callCount).toBe(2)
|
expect(callCount).toBe(2)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ async function importFreshImageResizerModule(): Promise<ImageResizerModule> {
|
|||||||
return import(`./image-resizer?test-${Date.now()}-${Math.random()}`)
|
return import(`./image-resizer?test-${Date.now()}-${Math.random()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadUnavailableSharpModule(): Promise<null> {
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
|
||||||
const CRC_TABLE = (() => {
|
const CRC_TABLE = (() => {
|
||||||
@@ -160,9 +164,6 @@ describe("resizeImage", () => {
|
|||||||
|
|
||||||
it("falls back to pure-JS resizer for PNG when sharp is unavailable", async () => {
|
it("falls back to pure-JS resizer for PNG when sharp is unavailable", async () => {
|
||||||
//#given
|
//#given
|
||||||
mock.module("sharp", () => {
|
|
||||||
throw new Error("sharp unavailable")
|
|
||||||
})
|
|
||||||
const { resizeImage } = await importFreshImageResizerModule()
|
const { resizeImage } = await importFreshImageResizerModule()
|
||||||
const oversizedPng = createOversizedPngDataUrl(3000, 2000)
|
const oversizedPng = createOversizedPngDataUrl(3000, 2000)
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ describe("resizeImage", () => {
|
|||||||
const result = await resizeImage(oversizedPng, "image/png", {
|
const result = await resizeImage(oversizedPng, "image/png", {
|
||||||
width: 1568,
|
width: 1568,
|
||||||
height: 1045,
|
height: 1045,
|
||||||
})
|
}, { loadSharpModule: loadUnavailableSharpModule })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
@@ -181,16 +182,13 @@ describe("resizeImage", () => {
|
|||||||
|
|
||||||
it("returns null for non-PNG when sharp is unavailable", async () => {
|
it("returns null for non-PNG when sharp is unavailable", async () => {
|
||||||
//#given
|
//#given
|
||||||
mock.module("sharp", () => {
|
|
||||||
throw new Error("sharp unavailable")
|
|
||||||
})
|
|
||||||
const { resizeImage } = await importFreshImageResizerModule()
|
const { resizeImage } = await importFreshImageResizerModule()
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = await resizeImage(PNG_1X1_DATA_URL, "image/jpeg", {
|
const result = await resizeImage(PNG_1X1_DATA_URL, "image/jpeg", {
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
})
|
}, { loadSharpModule: loadUnavailableSharpModule })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
@@ -198,9 +196,6 @@ describe("resizeImage", () => {
|
|||||||
|
|
||||||
it("falls back to pure-JS resizer when sharp has unexpected shape", async () => {
|
it("falls back to pure-JS resizer when sharp has unexpected shape", async () => {
|
||||||
//#given
|
//#given
|
||||||
mock.module("sharp", () => ({
|
|
||||||
default: "not-a-function",
|
|
||||||
}))
|
|
||||||
const { resizeImage } = await importFreshImageResizerModule()
|
const { resizeImage } = await importFreshImageResizerModule()
|
||||||
const oversizedPng = createOversizedPngDataUrl(2000, 1000)
|
const oversizedPng = createOversizedPngDataUrl(2000, 1000)
|
||||||
|
|
||||||
@@ -208,7 +203,7 @@ describe("resizeImage", () => {
|
|||||||
const result = await resizeImage(oversizedPng, "image/png", {
|
const result = await resizeImage(oversizedPng, "image/png", {
|
||||||
width: 1568,
|
width: 1568,
|
||||||
height: 784,
|
height: 784,
|
||||||
})
|
}, { loadSharpModule: async () => ({ default: "not-a-function" }) })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
@@ -223,16 +218,13 @@ describe("resizeImage", () => {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("sharp", () => ({
|
|
||||||
default: mockSharpFactory,
|
|
||||||
}))
|
|
||||||
const { resizeImage } = await importFreshImageResizerModule()
|
const { resizeImage } = await importFreshImageResizerModule()
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", {
|
const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", {
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
})
|
}, { loadSharpModule: async () => ({ default: mockSharpFactory }) })
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(result).toBeNull()
|
expect(result).toBeNull()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { RULES_INJECTOR_STORAGE } from "./constants";
|
import { RULES_INJECTOR_STORAGE } from "./constants";
|
||||||
|
import { createRuleInjectionProcessor } from "./injector";
|
||||||
|
|
||||||
type StatSnapshot = { mtimeMs: number; size: number };
|
type StatSnapshot = { mtimeMs: number; size: number };
|
||||||
|
|
||||||
@@ -28,47 +29,6 @@ async function createProcessor(projectRoot: string): Promise<{
|
|||||||
output: { title: string; output: string; metadata: unknown }
|
output: { title: string; output: string; metadata: unknown }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}> {
|
}> {
|
||||||
mock.module("node:fs", () => ({
|
|
||||||
...fs,
|
|
||||||
readFileSync: (filePath: string, encoding?: string) => {
|
|
||||||
if (filePath === trackedRulePath) {
|
|
||||||
trackedReadFileCount += 1;
|
|
||||||
}
|
|
||||||
return originalReadFileSync(filePath, encoding as never);
|
|
||||||
},
|
|
||||||
statSync: (filePath: string) => {
|
|
||||||
if (filePath === trackedRulePath) {
|
|
||||||
const next = statSnapshots.shift();
|
|
||||||
if (next instanceof Error) {
|
|
||||||
throw next;
|
|
||||||
}
|
|
||||||
if (next) {
|
|
||||||
return {
|
|
||||||
mtimeMs: next.mtimeMs,
|
|
||||||
size: next.size,
|
|
||||||
isFile: () => true,
|
|
||||||
} as ReturnType<typeof originalStatSync>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalStatSync(filePath);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("node:os", () => ({
|
|
||||||
...os,
|
|
||||||
homedir: () => mockedHomeDir || originalHomedir(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("./matcher", () => ({
|
|
||||||
shouldApplyRule: () => ({ applies: true, reason: "matched" }),
|
|
||||||
isDuplicateByRealPath: (realPath: string, cache: Set<string>) =>
|
|
||||||
cache.has(realPath),
|
|
||||||
createContentHash: (content: string) => `hash:${content}`,
|
|
||||||
isDuplicateByContentHash: (hash: string, cache: Set<string>) => cache.has(hash),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createRuleInjectionProcessor } = await import(`./injector?test=${Date.now()}-${Math.random()}`);
|
|
||||||
mock.restore();
|
|
||||||
const sessionCaches = new Map<
|
const sessionCaches = new Map<
|
||||||
string,
|
string,
|
||||||
{ contentHashes: Set<string>; realPaths: Set<string> }
|
{ contentHashes: Set<string>; realPaths: Set<string> }
|
||||||
@@ -95,6 +55,33 @@ async function createProcessor(projectRoot: string): Promise<{
|
|||||||
}
|
}
|
||||||
return cache;
|
return cache;
|
||||||
},
|
},
|
||||||
|
readFileSync: (filePath: fs.PathOrFileDescriptor, options?: Parameters<typeof originalReadFileSync>[1]) => {
|
||||||
|
if (filePath === trackedRulePath) {
|
||||||
|
trackedReadFileCount += 1;
|
||||||
|
}
|
||||||
|
return originalReadFileSync(filePath, options as never);
|
||||||
|
},
|
||||||
|
statSync: (filePath: fs.PathLike) => {
|
||||||
|
if (filePath === trackedRulePath) {
|
||||||
|
const next = statSnapshots.shift();
|
||||||
|
if (next instanceof Error) {
|
||||||
|
throw next;
|
||||||
|
}
|
||||||
|
if (next) {
|
||||||
|
return {
|
||||||
|
mtimeMs: next.mtimeMs,
|
||||||
|
size: next.size,
|
||||||
|
isFile: () => true,
|
||||||
|
} as ReturnType<typeof originalStatSync>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalStatSync(filePath);
|
||||||
|
},
|
||||||
|
homedir: () => mockedHomeDir || originalHomedir(),
|
||||||
|
shouldApplyRule: () => ({ applies: true, reason: "matched" }),
|
||||||
|
isDuplicateByRealPath: (realPath: string, cache: Set<string>) => cache.has(realPath),
|
||||||
|
createContentHash: (content: string) => `hash:${content}`,
|
||||||
|
isDuplicateByContentHash: (hash: string, cache: Set<string>) => cache.has(hash),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user