From 87b7bf8db12a558b262402b6880a60d12f45095e Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Sat, 10 Oct 2026 16:59:30 +0000 Subject: [PATCH] fix(reference-source): distinguish Git API probes from failed reads --- src/application/ReferenceSourceVcsAdapter.ts | 20 +++++++++- tests/boundary/cli/jsonInputSize.test.ts | 37 +++++++++++------ .../filesystem/referenceSourceVcs.test.ts | 40 ++++++++++++++++++- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts index ef136a2ad..d2ad87d66 100644 --- a/src/application/ReferenceSourceVcsAdapter.ts +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -53,9 +53,25 @@ const resolveSourceHead = async ( ...fs.promises, // isomorphic-git treats every read failure as absence. Preserve real // failures so a readable packed ref cannot hide an unreadable loose ref. - readFile: async (path: string) => { + readFile: async (path?: string, options?: unknown) => { + // isomorphic-git probes promise support with readFile() before reading + // metadata. Its rejected probe must not count as a failed file read. + if (path === undefined) + throw new TypeError("Git metadata path is required"); try { - return await readRegularFile(path, { signal }); + const bytes = await readRegularFile(path, { signal }); + const encoding = + typeof options === "string" + ? options + : typeof options === "object" && + options !== null && + "encoding" in options + ? options.encoding + : undefined; + if (encoding === undefined || encoding === null) return bytes; + if (typeof encoding !== "string" || !Buffer.isEncoding(encoding)) + throw new TypeError("Unsupported Git metadata text encoding"); + return bytes.toString(encoding); } catch (cause: unknown) { if (errorCode(cause) !== "ENOENT") failedRead ??= { cause }; throw cause; diff --git a/tests/boundary/cli/jsonInputSize.test.ts b/tests/boundary/cli/jsonInputSize.test.ts index 13e99eafa..18f9b14f5 100644 --- a/tests/boundary/cli/jsonInputSize.test.ts +++ b/tests/boundary/cli/jsonInputSize.test.ts @@ -8,23 +8,18 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; cliTest( - "rejects malformed oversized CLI input and preserves Evidence decoder resource failures", + "rejects malformed oversized input through CLI and Evidence readers", async ({ cli, processes }) => { const root = await createTestTempDirectory("rea-json-string-limit-"); const input = join(root, "oversized.json"); const file = await open(input, "wx"); try { - // Sparse zero bytes are valid UTF-8. The Evidence reader reaches the - // decoded-length limit; the streamed CLI rejects their invalid JSON syntax. + // Sparse zero bytes are valid UTF-8. Both streaming readers reject + // their invalid JSON syntax without assembling a whole-document string. await file.truncate(constants.MAX_STRING_LENGTH + 1); } finally { await file.close(); } - const limits = { - input_path: input, - input_bytes: constants.MAX_STRING_LENGTH + 1, - max_string_code_units: constants.MAX_STRING_LENGTH, - }; // Run readers sequentially in owned child processes, releasing each large // read buffer when its process exits instead of retaining it in Vitest. const result = await cli.run({ @@ -48,11 +43,11 @@ cliTest( input, ]); expect(JSON.parse(evidence.stdout)).toMatchObject({ - code: "resource_constraint", + code: "evidence_integrity_mismatch", details: { - operation: "read_evidence_file", - resource: "memory", - reported_limits: limits, + operation: "read", + reason: "invalid-json", + path: input, }, }); expect(evidence.stderr).toBe(""); @@ -123,7 +118,7 @@ cliTest( cliTest( "returns a typed string assembly constraint under a bounded heap", - async ({ cli }) => { + async ({ cli, processes }) => { const root = await createTestTempDirectory("rea-json-string-headroom-"); const input = join(root, "long-string.json"); const file = await open(input, "wx"); @@ -155,5 +150,21 @@ cliTest( action: expect.stringContaining("re-analyze a smaller selection"), }, }); + const evidence = await processes.run(process.execPath, [ + "--max-old-space-size=768", + "--input-type=module", + "-e", + `import { readJsonFile } from "./dist/application/JsonFiles.js"; + import { projectAnalysisError } from "./dist/domain/analysisErrorProjection.js"; + const result = await readJsonFile(process.argv[1]); + console.log(JSON.stringify(result.ok ? result : projectAnalysisError(result.error)));`, + input, + ]); + expect(JSON.parse(evidence.stdout)).toMatchObject({ + code: "resource_constraint", + details: { operation: "read_evidence_file", resource: "memory" }, + }); + expect(evidence.stderr).toBe(""); + expect(evidence.exitCode).toBe(0); }, ); diff --git a/tests/boundary/filesystem/referenceSourceVcs.test.ts b/tests/boundary/filesystem/referenceSourceVcs.test.ts index ad04133bf..ac0602382 100644 --- a/tests/boundary/filesystem/referenceSourceVcs.test.ts +++ b/tests/boundary/filesystem/referenceSourceVcs.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -92,3 +92,41 @@ it("reads a linked worktree's own branch and detached HEAD through shared Git re dirty: null, }); }); + +it.skipIf(process.platform === "win32")( + "keeps unreadable loose refs unknown instead of falling back to packed refs", + async () => { + const root = await createTestTempDirectory("rea-reference-loose-ref-"); + const gitdir = join(root, ".git"); + const looseRef = join(gitdir, "refs", "heads", "main"); + await mkdir(join(gitdir, "refs", "heads"), { recursive: true }); + const head = "1234567890abcdef1234567890abcdef12345678"; + await writeFile(join(gitdir, "HEAD"), "ref: refs/heads/main\n"); + await writeFile(join(gitdir, "packed-refs"), `${head} refs/heads/main\n`); + expect(await readReferenceSourceVcs(root)).toEqual({ + kind: "git", + head, + dirty: null, + }); + // A non-file loose ref is an actual read failure, not an absent ref. + await mkdir(looseRef); + expect(await readReferenceSourceVcs(root)).toEqual({ + kind: "unknown", + head: null, + dirty: null, + }); + await rm(looseRef, { recursive: true }); + if (process.getuid?.() === 0) return; + await writeFile(looseRef, `${head}\n`); + await chmod(looseRef, 0); + try { + expect(await readReferenceSourceVcs(root)).toEqual({ + kind: "unknown", + head: null, + dirty: null, + }); + } finally { + await chmod(looseRef, 0o600); + } + }, +);