diff --git a/CHANGELOG.md b/CHANGELOG.md index b132a718..82b10e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.9] - 2026-07-23 + +### Added + +- **`comet native evidence format`**: New command that serializes acceptance evidence entries into the exact canonical Markdown block `verification.md` requires, so evidence blocks no longer need to be hand-formatted to match byte-for-byte and no longer trigger spurious "canonical serialization" rejections during Verify. + +### Fixed + +- **Plugin marketplace superpowers detection**: `comet init` no longer crashes with an `ENOTDIR` error when `~/.claude/plugins/cache/` (or the Codex equivalent) contains a stray file where a marketplace directory was expected. + +### Security + +- **Race-safe file reads**: Reading `.comet/current-change.json` (used on every Hook Router call, `comet doctor`, and `comet resume-probe`), Native lock files, and `comet native evidence format --entries` input could previously be tricked mid-read: swapping the file for a symlink between the check and the read leaked the link target's content, and a FIFO at the lock path hung the process. These reads now reject non-regular files before opening and verify the file is still the same one after opening and after reading, so a swapped file fails the read instead of being silently accepted. Windows, which lacks `O_NOFOLLOW`, gets the same protection through the identity checks. + ## What's Changed [0.4.0-beta.8] - 2026-07-22 ### Fixed diff --git a/assets/manifest.json b/assets/manifest.json index 28e0e124..f4cbb233 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.4.0-beta.8", + "version": "0.4.0-beta.9", "skills": [ "comet/SKILL.md", "comet-classic/SKILL.md", diff --git a/assets/skills-zh/comet-native/SKILL.md b/assets/skills-zh/comet-native/SKILL.md index 05310a79..81a00f5e 100644 --- a/assets/skills-zh/comet-native/SKILL.md +++ b/assets/skills-zh/comet-native/SKILL.md @@ -205,7 +205,7 @@ Runtime 无法证明 scope 完整时会停在 Build,返回 partial scope hash 根据 Acceptance examples、完整目标规格和风险运行验证。记录实际命令、结果、跳过项、规格一致性、已知限制和结论;未运行的检查不能写成通过。 -在 `verification.md` 的固定 acceptance evidence 块中逐项使用 Runtime 返回的 `acceptance_id`。每项只能记录项目相对 evidence refs,或记录诚实的 `skipped_reason`。格式见产物参考。 +在 `verification.md` 的固定 acceptance evidence 块中逐项使用 Runtime 返回的 `acceptance_id`。每项只能记录项目相对 evidence refs,或记录诚实的 `skipped_reason`。用 `comet native evidence format` 把条目序列化成规范文本再粘贴,不要手工排版这段 JSON——手写几乎不可能逐字节匹配规范序列化规则。格式见产物参考。 需要一份可重建的文本卫生证据时,可运行内置只读检查: diff --git a/assets/skills-zh/comet-native/reference/artifacts.md b/assets/skills-zh/comet-native/reference/artifacts.md index a3c505dc..06d4555e 100644 --- a/assets/skills-zh/comet-native/reference/artifacts.md +++ b/assets/skills-zh/comet-native/reference/artifacts.md @@ -155,7 +155,7 @@ Sequential 模式的 Open questions 同时保存一个最上游阻塞问题。Ba Runtime 最多从 brief 与拟议规格合计派生 1024 个验收项,超出就拒绝继续,不会先构造无界列表再截断。`acceptancePage` 每页最多 16 项;单项文字最多 512 UTF-8 字节、context 最多 4 项且每项最多 256 字节,整页最多 32 KiB。文字或 context 截断会显式标记,验收 ID 不会因分页或截断而丢失;cursor 绑定当前 acceptance hash,契约变化后旧 cursor 会失效。 -`# Acceptance evidence` 下必须恰好有一个固定机器块。ID 由 Runtime 从 brief/spec 派生,通过 Build 结果或 `status --details` 返回;不要自行计算或改写: +`# Acceptance evidence` 下必须恰好有一个固定机器块。ID 由 Runtime 从 brief/spec 派生,通过 Build 结果或 `status --details` 返回;不要自行计算或改写。生成这个块时用 `comet native evidence format` 把条目序列化成规范文本再粘贴进去,不要手工排版 JSON——手写几乎不可能逐字节匹配规范序列化规则,会被拒绝并报 "canonical serialization" 错误: ```text @@ -177,6 +177,12 @@ Runtime 最多从 brief 与拟议规格合计派生 1024 个验收项,超出 数组按 `acceptance_id` 排序,`evidence_refs` 也排序。每项只能二选一:至少一个项目相对 evidence ref,或空数组加非空 `skipped_reason`。不能同时给证据和跳过理由,也不能引用绝对路径、Native 外部路径、`.git` 或 `.env*`。 +```text +comet native evidence format [--entries ] +``` + +把上面这份条目数组(不带 markers)以 JSON 通过 stdin 传入,或用 `--entries ` 指定一个 JSON 文件;命令输出的文本已经包含 start/end markers 和规范排序、缩进,原样粘贴到 verification.md 的 `# Acceptance evidence` 一节即可。 + ## 内容寻址证据 - `baseline-manifest.json`:change 创建时的有界项目快照。它只记录项目相对路径、size、hash、capture provider 和省略事实,不保存文件内容。Git provider 纳入 tracked 和未被 ignore 的 untracked 文件,并把 submodule/gitlink 作为原子条目;非 Git 项目使用带前后枚举围栏的有界物理树 provider。创建时若项目所有范围内仍有省略项,`new` 会失败并清理未完成 change。 diff --git a/assets/skills-zh/comet-native/reference/commands.md b/assets/skills-zh/comet-native/reference/commands.md index 8f119ef6..8ac61681 100644 --- a/assets/skills-zh/comet-native/reference/commands.md +++ b/assets/skills-zh/comet-native/reference/commands.md @@ -62,6 +62,12 @@ comet native check `checkpoint` 只保存同阶段摘要、下一动作和内容寻址的产物 manifest;它通过 revision/CAS 防止覆盖,不改变 phase。`check` 只允许在 Verify 且已有 implementation scope 时运行 Comet 内置的有界只读文本扫描。它不调用 Git、shell、项目脚本、外部 Skill 或任何外部进程,不接受任意命令、路径、环境或超时参数,也不修改项目文件或 change/Run/trajectory;结果、issue 计数和 scope 新鲜度会写入独立的内容寻址 receipt。检查发现问题或 stale 返回 1,但 receipt 仍会落盘。 +```text +comet native evidence format [--entries ] +``` + +写 verification.md 的 `# Acceptance evidence` 机器块前,用这个命令把条目数组序列化成规范 Markdown 文本再粘贴,不要手工排版 JSON。默认从 stdin 读取条目 JSON,也可用 `--entries ` 指定文件;输出已包含 start/end markers、固定排序与缩进,原样粘贴即可通过 `next --result` 的规范序列化校验。 + ## 阶段推进 ```text diff --git a/assets/skills/comet-native/SKILL.md b/assets/skills/comet-native/SKILL.md index f6392e54..fe07e27a 100644 --- a/assets/skills/comet-native/SKILL.md +++ b/assets/skills/comet-native/SKILL.md @@ -205,7 +205,7 @@ Never edit snapshots or evidence, guess unenumerated paths, or present partial s Run verification appropriate to the Acceptance examples, complete target specifications, and risk. Record actual commands, results, skipped checks, specification consistency, known limitations, and the conclusion. Never record an unrun check as passed. -In the fixed acceptance evidence block of `verification.md`, use every Runtime-provided `acceptance_id`. Each item must contain either project-relative evidence refs or an honest `skipped_reason`. See the artifact reference for the exact format. +In the fixed acceptance evidence block of `verification.md`, use every Runtime-provided `acceptance_id`. Each item must contain either project-relative evidence refs or an honest `skipped_reason`. Serialize the entries with `comet native evidence format` and paste the result; never hand-format this JSON — a hand-typed block can almost never match the canonical serialization byte-for-byte. See the artifact reference for the exact format. When you need reproducible text-hygiene evidence, run the built-in read-only check: diff --git a/assets/skills/comet-native/reference/artifacts.md b/assets/skills/comet-native/reference/artifacts.md index 6e7ce4b3..ba92d414 100644 --- a/assets/skills/comet-native/reference/artifacts.md +++ b/assets/skills/comet-native/reference/artifacts.md @@ -155,7 +155,7 @@ Persist reviewable facts, not hidden reasoning. Put unrun checks under Skipped c The runtime derives at most 1024 acceptance items from the brief and proposed specifications. It rejects overflow rather than first creating an unbounded list and truncating it. Each `acceptancePage` contains at most 16 items. Text is capped at 512 UTF-8 bytes, context at four entries of at most 256 bytes each, and a full page at 32 KiB. Text or context truncation is marked explicitly, while acceptance IDs are never lost to paging or truncation. Cursors bind to the current acceptance hash and fail after the contract changes. -`# Acceptance evidence` must contain exactly one fixed machine block. The runtime derives IDs from the brief/specifications and returns them through Build or `status --details`; never calculate or rewrite them yourself: +`# Acceptance evidence` must contain exactly one fixed machine block. The runtime derives IDs from the brief/specifications and returns them through Build or `status --details`; never calculate or rewrite them yourself. Generate this block with `comet native evidence format` and paste the result; never hand-format the JSON — a hand-typed block can almost never match the canonical serialization byte-for-byte and will be rejected with a "canonical serialization" error: ```text @@ -177,6 +177,12 @@ The runtime derives at most 1024 acceptance items from the brief and proposed sp The array is sorted by `acceptance_id`, and every `evidence_refs` list is sorted. Each item chooses exactly one path: at least one project-relative evidence ref, or an empty array plus a non-empty `skipped_reason`. Never provide both evidence and a skip reason, and never reference an absolute path, a path outside Native, `.git`, or `.env*`. +```text +comet native evidence format [--entries ] +``` + +Pass the entry array above (without the markers) as JSON on stdin, or point `--entries ` at a JSON file. The command's output already includes the start/end markers plus the canonical order and indentation, so paste it as-is into the `# Acceptance evidence` section of verification.md. + ## Content-addressed evidence - `baseline-manifest.json`: a bounded project snapshot captured when the change is created. It records only project-relative paths, sizes, hashes, the capture provider, and omission facts, never file contents. The Git provider includes tracked and non-ignored untracked files and treats each submodule/gitlink as an atomic entry; non-Git projects use a bounded physical-tree provider with before/after enumeration fences. If project-owned entries are still omitted, `new` fails and removes the unfinished change. diff --git a/assets/skills/comet-native/reference/commands.md b/assets/skills/comet-native/reference/commands.md index baa82425..a56734f2 100644 --- a/assets/skills/comet-native/reference/commands.md +++ b/assets/skills/comet-native/reference/commands.md @@ -62,6 +62,12 @@ comet native check `checkpoint` stores only an in-phase summary, next action, and content-addressed artifact manifest. It uses revision/CAS to prevent overwrites and does not change the phase. `check` is available only in Verify after an implementation scope exists. It runs Comet's built-in bounded, read-only text scan. It does not invoke Git, a shell, project scripts, external Skills, or any external process; it accepts no arbitrary command, path, environment, or timeout options and does not modify project files, the change, Run, or trajectory. Results, issue counts, and scope freshness are written to an independent content-addressed receipt. A check that finds issues or becomes stale exits with 1, but still writes the receipt. +```text +comet native evidence format [--entries ] +``` + +Before writing the `# Acceptance evidence` machine block in verification.md, use this command to serialize the entry array into the canonical Markdown text and paste that, rather than hand-formatting the JSON yourself. It reads entries JSON from stdin by default, or from a file with `--entries `; the output already includes the start/end markers plus the fixed order and indentation, so pasting it as-is satisfies `next --result`'s canonical-serialization check. + ## Phase progression ```text diff --git a/assets/skills/comet-native/scripts/comet-native-runtime.mjs b/assets/skills/comet-native/scripts/comet-native-runtime.mjs index f28fa36c..b1cdfc9a 100644 --- a/assets/skills/comet-native/scripts/comet-native-runtime.mjs +++ b/assets/skills/comet-native/scripts/comet-native-runtime.mjs @@ -6915,14 +6915,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs33 = this.flowScalar(this.type); + const fs34 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs33, sep: [] }); + map.items.push({ start, key: fs34, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs33); + this.stack.push(fs34); } else { - Object.assign(it, { key: fs33, sep: [] }); + Object.assign(it, { key: fs34, sep: [] }); this.onKeyLine = true; } return; @@ -7050,13 +7050,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs33 = this.flowScalar(this.type); + const fs34 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs33, sep: [] }); + fc.items.push({ start: [], key: fs34, sep: [] }); else if (it.sep) - this.stack.push(fs33); + this.stack.push(fs34); else - Object.assign(it, { key: fs33, sep: [] }); + Object.assign(it, { key: fs34, sep: [] }); return; } case "flow-map-end": @@ -7370,9 +7370,127 @@ import { pathToFileURL } from "url"; // domains/comet-native/native-cli.ts import path47 from "path"; +// platform/fs/race-safe-read.ts +import { constants as fsConstants, promises as fs } from "fs"; + +// platform/fs/file-identity.ts +function hasPlatformIdentity(value) { + return value !== 0 && value !== 0n && value !== "0"; +} +function hasComparableFileObject(left, right) { + return hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev) && hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); +} +function sameFileObject(left, right) { + const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); + if (comparableDevice && left.dev !== right.dev) return false; + const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); + if (comparableInode && left.ino !== right.ino) return false; + if (comparableDevice && comparableInode) return true; + return left.birthtime === right.birthtime; +} + +// platform/fs/race-safe-read.ts +var RaceSafeReadError = class extends Error { + reason; + constructor(reason, message, options) { + super(message, options); + this.name = "RaceSafeReadError"; + this.reason = reason; + } +}; +function birthtimeOf(stat) { + return "birthtimeNs" in stat && typeof stat.birthtimeNs === "bigint" ? stat.birthtimeNs : stat.birthtimeMs; +} +function ctimeOf(stat) { + return "ctimeNs" in stat && typeof stat.ctimeNs === "bigint" ? stat.ctimeNs : stat.ctimeMs; +} +function identityOf(stat) { + return { dev: stat.dev, ino: stat.ino, birthtime: birthtimeOf(stat) }; +} +function sameStatIdentity(left, right) { + const leftObject = identityOf(left); + const rightObject = identityOf(right); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); + } + return sameFileObject(leftObject, rightObject) && birthtimeOf(left) === birthtimeOf(right) && ctimeOf(left) === ctimeOf(right) && left.size === right.size; +} +async function readFileRaceSafe(file, maxBytes, options = {}) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error("race-safe read byte limit must be a positive integer"); + } + const label = options.label ?? "file"; + const bigint = options.bigint === true; + const before = await fs.lstat(file, { bigint }); + if (!before.isFile() || before.isSymbolicLink()) { + throw new RaceSafeReadError("not-regular-file", `${label} must be a regular file`); + } + if (BigInt(before.size) > BigInt(maxBytes)) { + throw new RaceSafeReadError("too-large", `${label} exceeds ${maxBytes} bytes`); + } + const beforeRealPath = await fs.realpath(file); + await options.verify?.("pre-open", { realPath: beforeRealPath, identity: identityOf(before) }); + const flags = process.platform === "win32" ? fsConstants.O_RDONLY : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + let handle; + try { + handle = await fs.open(file, flags); + } catch (error) { + if (error.code === "ELOOP") { + throw new RaceSafeReadError("not-regular-file", `${label} must be a regular file`, { + cause: error + }); + } + throw error; + } + try { + const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ + handle.stat({ bigint }), + fs.lstat(file, { bigint }), + fs.realpath(file) + ]); + if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameStatIdentity(before, opened) || !sameStatIdentity(before, pathAfterOpen)) { + throw new RaceSafeReadError("changed", `${label} changed while opening`); + } + await options.verify?.("post-open", { + realPath: realPathAfterOpen, + identity: identityOf(opened) + }); + await options.hooks?.afterOpen?.(); + const chunks = []; + let total = 0; + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1)); + for (; ; ) { + const remaining = maxBytes + 1 - total; + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > maxBytes) { + throw new RaceSafeReadError("too-large", `${label} exceeds ${maxBytes} bytes`); + } + chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + } + await options.hooks?.beforeFinalCheck?.(); + const [afterHandle, afterPath, afterRealPath] = await Promise.all([ + handle.stat({ bigint }), + fs.lstat(file, { bigint }), + fs.realpath(file) + ]); + if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== beforeRealPath || !sameStatIdentity(before, afterHandle) || !sameStatIdentity(before, afterPath)) { + throw new RaceSafeReadError("changed", `${label} changed while reading`); + } + await options.verify?.("post-read", { + realPath: afterRealPath, + identity: identityOf(afterHandle) + }); + return { bytes: Buffer.concat(chunks, total), stat: afterHandle, realPath: afterRealPath }; + } finally { + await handle.close(); + } +} + // domains/comet-native/native-archive.ts import { randomUUID as randomUUID6 } from "crypto"; -import { promises as fs22 } from "fs"; +import { promises as fs23 } from "fs"; import path34 from "path"; import { isDeepStrictEqual as isDeepStrictEqual2 } from "util"; @@ -7510,17 +7628,17 @@ function recordOutcomeWithResolver(pkg, state, outcome, resolver, context) { } // domains/comet-native/native-artifacts.ts -import { promises as fs13 } from "fs"; +import { promises as fs14 } from "fs"; import path17 from "path"; // domains/comet-native/native-change.ts var import_yaml3 = __toESM(require_dist(), 1); -import { promises as fs12 } from "fs"; +import { promises as fs13 } from "fs"; import path16 from "path"; // domains/comet-native/native-bounded-file.ts import { createHash as createHash2 } from "node:crypto"; -import { promises as fs } from "node:fs"; +import { promises as fs2 } from "node:fs"; import path2 from "node:path"; import { TextDecoder } from "node:util"; @@ -7586,22 +7704,6 @@ function nativeSensitiveArtifactReason(paths, relativeRef) { return null; } -// domains/comet-native/native-file-identity.ts -function hasPlatformIdentity(value) { - return value !== 0 && value !== 0n && value !== "0"; -} -function hasComparableNativeFileObject(left, right) { - return hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev) && hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); -} -function sameNativeFileObject(left, right) { - const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); - if (comparableDevice && left.dev !== right.dev) return false; - const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); - if (comparableInode && left.ino !== right.ino) return false; - if (comparableDevice && comparableInode) return true; - return left.birthtime === right.birthtime; -} - // domains/comet-native/native-bounded-file.ts var DEFAULT_NATIVE_ARTIFACT_MAX_BYTES = 1024 * 1024; function isInside(parent, target) { @@ -7632,7 +7734,7 @@ function positiveLimit(value) { return value; } function sameDirectoryIdentity(identity, stat) { - return sameNativeFileObject( + return sameFileObject( { ...identity, birthtime: identity.birthtimeMs }, { ...stat, @@ -7643,19 +7745,19 @@ function sameDirectoryIdentity(identity, stat) { function sameFileIdentity(left, right) { const leftObject = { ...left, birthtime: left.birthtimeMs }; const rightObject = { ...right, birthtime: right.birthtimeMs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; } async function directoryIdentity(directory) { - const stat = await fs.lstat(directory); + const stat = await fs2.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native artifact parent must be a real directory: ${directory}`); } return { path: directory, - realPath: await fs.realpath(directory), + realPath: await fs2.realpath(directory), dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs @@ -7681,8 +7783,8 @@ async function captureDirectoryChain(root, directory) { } async function verifyDirectoryChain(chain) { for (const identity of chain) { - const stat = await fs.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity(identity, stat) || await fs.realpath(identity.path) !== identity.realPath) { + const stat = await fs2.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity(identity, stat) || await fs2.realpath(identity.path) !== identity.realPath) { throw new Error(`Native artifact parent changed while reading: ${identity.path}`); } } @@ -7693,21 +7795,21 @@ async function readNativeBoundedTextFile(options) { const file = path2.resolve(options.root, ...ref.split("/")); const chain = await captureDirectoryChain(options.root, path2.dirname(file)); await options.hooks?.afterParentChainCaptured?.(); - const before = await fs.lstat(file); + const before = await fs2.lstat(file); if (!before.isFile() || before.isSymbolicLink()) { throw new Error(`Native artifact must be a regular file: ${ref}`); } if (before.size > maxBytes) throw new Error(`Native artifact exceeds ${maxBytes} bytes: ${ref}`); - const realPath = await fs.realpath(file); + const realPath = await fs2.realpath(file); if (!isInside(chain[0].realPath, realPath)) { throw new Error(`Native artifact resolves outside its root: ${ref}`); } - const handle = await fs.open(file, "r"); + const handle = await fs2.open(file, "r"); try { const [opened, afterOpenPath, afterOpenRealPath] = await Promise.all([ handle.stat(), - fs.lstat(file), - fs.realpath(file) + fs2.lstat(file), + fs2.realpath(file) ]); await verifyDirectoryChain(chain); if (!opened.isFile() || !afterOpenPath.isFile() || afterOpenPath.isSymbolicLink() || afterOpenRealPath !== realPath || !sameFileIdentity(before, opened) || !sameFileIdentity(opened, afterOpenPath)) { @@ -7728,8 +7830,8 @@ async function readNativeBoundedTextFile(options) { await options.hooks?.beforeFinalCheck?.(); const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs.lstat(file), - fs.realpath(file) + fs2.lstat(file), + fs2.realpath(file) ]); await verifyDirectoryChain(chain); if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== realPath || !sameFileIdentity(opened, afterHandle) || !sameFileIdentity(opened, afterPath)) { @@ -7755,14 +7857,14 @@ async function readNativeBoundedTextFile(options) { // domains/comet-native/native-atomic-file.ts import { randomUUID } from "crypto"; -import { promises as fs2 } from "fs"; +import { promises as fs3 } from "fs"; import path3 from "path"; function isInside2(parent, target) { const relative = path3.relative(parent, target); return relative === "" || !path3.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path3.sep}`); } function sameDirectoryIdentity2(identity, stat) { - return sameNativeFileObject( + return sameFileObject( { ...identity, birthtime: identity.birthtimeMs }, { ...stat, @@ -7773,19 +7875,19 @@ function sameDirectoryIdentity2(identity, stat) { function sameFileIdentity2(left, right) { const leftObject = { ...left, birthtime: left.birthtimeMs }; const rightObject = { ...right, birthtime: right.birthtimeMs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; } async function captureDirectoryIdentity(directory) { - const stat = await fs2.lstat(directory); + const stat = await fs3.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native atomic write parent must be a real directory: ${directory}`); } return { path: directory, - realPath: await fs2.realpath(directory), + realPath: await fs3.realpath(directory), dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs @@ -7793,8 +7895,8 @@ async function captureDirectoryIdentity(directory) { } async function verifyDirectoryChain2(chain) { for (const identity of chain) { - const stat = await fs2.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity2(identity, stat) || await fs2.realpath(identity.path) !== identity.realPath) { + const stat = await fs3.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity2(identity, stat) || await fs3.realpath(identity.path) !== identity.realPath) { throw new Error(`Native atomic write parent changed before commit: ${identity.path}`); } } @@ -7812,7 +7914,7 @@ async function prepareContainedDirectoryChain(root, directory) { await verifyDirectoryChain2(chain); cursor = path3.join(cursor, segment); try { - await fs2.mkdir(cursor); + await fs3.mkdir(cursor); } catch (error) { if (error.code !== "EEXIST") throw error; } @@ -7828,7 +7930,7 @@ async function prepareContainedDirectoryChain(root, directory) { async function syncDirectory(directory) { let handle; try { - handle = await fs2.open(directory, "r"); + handle = await fs3.open(directory, "r"); await handle.sync(); } catch (error) { const code = error.code; @@ -7842,18 +7944,18 @@ async function syncDirectory(directory) { async function atomicWrite(file, content, options = {}) { const directory = path3.dirname(file); const directoryChain = options.containedRoot ? await prepareContainedDirectoryChain(options.containedRoot, directory) : null; - if (!directoryChain) await fs2.mkdir(directory, { recursive: true }); + if (!directoryChain) await fs3.mkdir(directory, { recursive: true }); const temporary = path3.join(directory, `.${path3.basename(file)}.${randomUUID()}.tmp`); let handle; let temporaryIdentity; try { await options.beforeTemporaryOpen?.(); - handle = await fs2.open(temporary, "wx"); + handle = await fs3.open(temporary, "wx"); temporaryIdentity = await handle.stat(); if (directoryChain) { const [temporaryPathStat, temporaryRealPath] = await Promise.all([ - fs2.lstat(temporary), - fs2.realpath(temporary) + fs3.lstat(temporary), + fs3.realpath(temporary) ]); await verifyDirectoryChain2(directoryChain); if (!temporaryPathStat.isFile() || temporaryPathStat.isSymbolicLink() || !sameFileIdentity2(temporaryIdentity, temporaryPathStat) || !isInside2(directoryChain[0].realPath, temporaryRealPath)) { @@ -7871,26 +7973,26 @@ async function atomicWrite(file, content, options = {}) { await options.beforeCommit?.(); if (directoryChain) { await verifyDirectoryChain2(directoryChain); - const temporaryStat = await fs2.lstat(temporary); + const temporaryStat = await fs3.lstat(temporary); if (!temporaryStat.isFile() || temporaryStat.isSymbolicLink() || !temporaryIdentity || !sameFileIdentity2(temporaryStat, temporaryIdentity)) { throw new Error("Native atomic write temporary file changed before commit"); } } if (options.exclusive) { - await fs2.link(temporary, file); - await fs2.unlink(temporary); + await fs3.link(temporary, file); + await fs3.unlink(temporary); } else { - await fs2.rename(temporary, file); + await fs3.rename(temporary, file); } await syncDirectory(directory); } catch (error) { await handle?.close(); if (!directoryChain) { - await fs2.rm(temporary, { force: true }); + await fs3.rm(temporary, { force: true }); } else { try { await verifyDirectoryChain2(directoryChain); - await fs2.rm(temporary, { force: true }); + await fs3.rm(temporary, { force: true }); } catch { } } @@ -7909,7 +8011,7 @@ async function atomicWriteJson(file, value, options = {}) { // domains/comet-native/native-config.ts var import_yaml2 = __toESM(require_dist(), 1); -import { promises as fs5 } from "fs"; +import { promises as fs6 } from "fs"; import path6 from "path"; // domains/workflow-contract/project-config.ts @@ -7985,7 +8087,7 @@ function renderStructuredProjectConfig(value, language) { // domains/comet-native/native-protected-file.ts import { createHash as createHash3 } from "node:crypto"; -import { constants as fsConstants, promises as fs3 } from "node:fs"; +import { constants as fsConstants2, promises as fs4 } from "node:fs"; import path4 from "node:path"; import { TextDecoder as TextDecoder2 } from "node:util"; function isInside3(parent, target) { @@ -7999,7 +8101,7 @@ function positiveLimit2(value) { return value; } function sameDirectoryIdentity3(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -8018,7 +8120,7 @@ function asFileIdentity(stat) { }; } function sameFileIdentity3(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -8027,13 +8129,13 @@ function sameFileIdentity3(expected, actual) { ) && expected.birthtimeMs === actual.birthtimeMs && expected.ctimeMs === actual.ctimeMs && expected.mtimeMs === actual.mtimeMs && expected.size === actual.size; } async function captureDirectoryIdentity2(directory, label) { - const stat = await fs3.lstat(directory); + const stat = await fs4.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`${label} parent must be a real directory: ${directory}`); } return { path: directory, - realPath: await fs3.realpath(directory), + realPath: await fs4.realpath(directory), dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs @@ -8061,8 +8163,8 @@ async function captureDirectoryChain2(root, directory, label) { } async function verifyDirectoryChain3(chain, label) { for (const identity of chain) { - const stat = await fs3.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity3(identity, stat) || await fs3.realpath(identity.path) !== identity.realPath) { + const stat = await fs4.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity3(identity, stat) || await fs4.realpath(identity.path) !== identity.realPath) { throw new Error(`${label} parent changed during I/O: ${identity.path}`); } } @@ -8092,27 +8194,27 @@ async function readNativeProtectedFile(options) { ); await options.hooks?.afterParentChainCaptured?.(); await verifyDirectoryChain3(chain, options.label); - const before = await fs3.lstat(file); + const before = await fs4.lstat(file); if (!before.isFile() || before.isSymbolicLink()) { throw new Error(`${options.label} must be a regular file`); } if (before.size > maxBytes) throw new Error(`${options.label} exceeds ${maxBytes} bytes`); const beforeIdentity = asFileIdentity(before); - const beforeRealPath = await fs3.realpath(file); + const beforeRealPath = await fs4.realpath(file); if (!isInside3(chain[0].realPath, beforeRealPath)) { throw new Error(`${options.label} resolves outside its managed root`); } if (forbidden.some((identity) => isInside3(identity.realPath, beforeRealPath))) { throw new Error(`${options.label} resolves inside an excluded root`); } - const flags = process.platform === "win32" ? fsConstants.O_RDONLY : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; - const handle = await fs3.open(file, flags); + const flags = process.platform === "win32" ? fsConstants2.O_RDONLY : fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW | fsConstants2.O_NONBLOCK; + const handle = await fs4.open(file, flags); try { const opened = await handle.stat(); await options.hooks?.afterOpen?.(); const [pathAfterOpen, realPathAfterOpen] = await Promise.all([ - fs3.lstat(file), - fs3.realpath(file) + fs4.lstat(file), + fs4.realpath(file) ]); await verifyDirectoryChain3(chain, options.label); await verifyDirectoryChain3(forbidden, options.label); @@ -8124,8 +8226,8 @@ async function readNativeProtectedFile(options) { await options.hooks?.beforeFinalCheck?.(); const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs3.lstat(file), - fs3.realpath(file) + fs4.lstat(file), + fs4.realpath(file) ]); await verifyDirectoryChain3(chain, options.label); await verifyDirectoryChain3(forbidden, options.label); @@ -8155,11 +8257,11 @@ async function readNativeProtectedDirectory(options) { const chain = await captureDirectoryChain2(options.root, options.directory, options.label); let entries; if (options.maxEntries === void 0) { - entries = await fs3.readdir(options.directory, { withFileTypes: true }); + entries = await fs4.readdir(options.directory, { withFileTypes: true }); } else { const maxEntries = positiveLimit2(options.maxEntries); entries = []; - const directory = await fs3.opendir(options.directory); + const directory = await fs4.opendir(options.directory); try { for await (const entry2 of directory) { entries.push(entry2); @@ -8200,25 +8302,25 @@ async function quarantineNativeProtectedDirectoryInternal(options) { } await options.beforeQuarantine?.(); await verifyDirectoryChain3(parentChain, options.label); - const current = await fs3.lstat(directory); - if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectoryIdentity3(identity, current) || await fs3.realpath(directory) !== identity.realPath) { + const current = await fs4.lstat(directory); + if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectoryIdentity3(identity, current) || await fs4.realpath(directory) !== identity.realPath) { throw new Error(`${options.label} changed before quarantine`); } try { - await fs3.lstat(quarantine); + await fs4.lstat(quarantine); throw new Error(`${options.label} quarantine path is occupied`); } catch (error) { if (error.code !== "ENOENT") throw error; } - await fs3.rename(directory, quarantine); + await fs4.rename(directory, quarantine); await verifyDirectoryChain3(parentChain, options.label); - const quarantined = await fs3.lstat(quarantine); + const quarantined = await fs4.lstat(quarantine); if (!quarantined.isDirectory() || quarantined.isSymbolicLink() || !sameDirectoryIdentity3(identity, quarantined)) { throw new Error(`${options.label} changed while quarantining`); } await options.afterQuarantine?.(quarantine); try { - await fs3.lstat(directory); + await fs4.lstat(directory); throw new Error(`${options.label} path was recreated while quarantining`); } catch (error) { if (error.code !== "ENOENT") throw error; @@ -8231,12 +8333,12 @@ async function quarantineNativeProtectedDirectory(options) { async function removeNativeProtectedFile(options) { const file = path4.resolve(options.file); const parentChain = await captureDirectoryChain2(options.root, path4.dirname(file), options.label); - const before = await fs3.lstat(file); + const before = await fs4.lstat(file); if (!before.isFile() || before.isSymbolicLink()) { throw new Error(`${options.label} must be a regular file`); } const identity = asFileIdentity(before); - const realPath = await fs3.realpath(file); + const realPath = await fs4.realpath(file); if (!isInside3(parentChain[0].realPath, realPath)) { throw new Error(`${options.label} resolves outside its managed root`); } @@ -8249,17 +8351,17 @@ async function removeNativeProtectedFile(options) { if (snapshot2.hash !== options.expectedHash || snapshot2.size !== options.expectedSize) { throw new Error(`${options.label} changed before removal`); } - const [afterRead, afterReadRealPath] = await Promise.all([fs3.lstat(file), fs3.realpath(file)]); + const [afterRead, afterReadRealPath] = await Promise.all([fs4.lstat(file), fs4.realpath(file)]); if (!afterRead.isFile() || afterRead.isSymbolicLink() || !sameFileIdentity3(identity, afterRead) || afterReadRealPath !== realPath) { throw new Error(`${options.label} changed while verifying removal`); } await options.beforeRemove?.(); await verifyDirectoryChain3(parentChain, options.label); - const [current, currentRealPath] = await Promise.all([fs3.lstat(file), fs3.realpath(file)]); + const [current, currentRealPath] = await Promise.all([fs4.lstat(file), fs4.realpath(file)]); if (!current.isFile() || current.isSymbolicLink() || !sameFileIdentity3(identity, current) || currentRealPath !== realPath) { throw new Error(`${options.label} changed before removal`); } - await fs3.rm(file); + await fs4.rm(file); await verifyDirectoryChain3(parentChain, options.label); } async function removeNativeProtectedEmptyDirectory(options) { @@ -8275,11 +8377,11 @@ async function removeNativeProtectedEmptyDirectory(options) { } await options.beforeRemove?.(); await verifyDirectoryChain3(parentChain, options.label); - const current = await fs3.lstat(directory); - if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectoryIdentity3(identity, current) || await fs3.realpath(directory) !== identity.realPath) { + const current = await fs4.lstat(directory); + if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectoryIdentity3(identity, current) || await fs4.realpath(directory) !== identity.realPath) { throw new Error(`${options.label} changed before removal`); } - await fs3.rmdir(directory); + await fs4.rmdir(directory); await verifyDirectoryChain3(parentChain, options.label); } async function ensureNativeProtectedDirectory(options) { @@ -8294,7 +8396,7 @@ async function ensureNativeProtectedDirectory(options) { await verifyDirectoryChain3(chain, options.label); cursor = path4.join(cursor, segment); try { - await fs3.mkdir(cursor); + await fs4.mkdir(cursor); } catch (error) { if (error.code !== "EEXIST") throw error; } @@ -8325,7 +8427,7 @@ async function moveNativeProtectedDirectory(options) { throw new Error(`${options.label} source resolves outside its managed root`); } try { - await fs3.lstat(target); + await fs4.lstat(target); throw new Error(`${options.label} target already exists`); } catch (error) { if (error.code !== "ENOENT") throw error; @@ -8335,21 +8437,21 @@ async function moveNativeProtectedDirectory(options) { verifyDirectoryChain3(sourceParentChain, options.label), verifyDirectoryChain3(targetParentChain, options.label) ]); - const current = await fs3.lstat(source); - if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectoryIdentity3(sourceIdentity, current) || await fs3.realpath(source) !== sourceIdentity.realPath) { + const current = await fs4.lstat(source); + if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectoryIdentity3(sourceIdentity, current) || await fs4.realpath(source) !== sourceIdentity.realPath) { throw new Error(`${options.label} source changed before move`); } - await fs3.rename(source, target); + await fs4.rename(source, target); await Promise.all([ verifyDirectoryChain3(sourceParentChain, options.label), verifyDirectoryChain3(targetParentChain, options.label) ]); - const moved = await fs3.lstat(target); - if (!moved.isDirectory() || moved.isSymbolicLink() || !sameDirectoryIdentity3(sourceIdentity, moved) || !isInside3(sourceParentChain[0].realPath, await fs3.realpath(target))) { + const moved = await fs4.lstat(target); + if (!moved.isDirectory() || moved.isSymbolicLink() || !sameDirectoryIdentity3(sourceIdentity, moved) || !isInside3(sourceParentChain[0].realPath, await fs4.realpath(target))) { throw new Error(`${options.label} source identity changed while moving`); } try { - await fs3.lstat(source); + await fs4.lstat(source); throw new Error(`${options.label} source was recreated while moving`); } catch (error) { if (error.code !== "ENOENT") throw error; @@ -8375,7 +8477,7 @@ async function copyNativeProtectedFile(options) { if (options.expectedTargetHash === void 0) return; if (options.expectedTargetHash === null) { try { - await fs3.lstat(options.target); + await fs4.lstat(options.target); } catch (error) { if (error.code === "ENOENT") return; throw error; @@ -8406,13 +8508,13 @@ async function copyNativeProtectedFile(options) { } // domains/comet-native/native-paths.ts -import { promises as fs4 } from "fs"; +import { promises as fs5 } from "fs"; import path5 from "path"; import os from "os"; var PROJECT_CONFIG_FILE = ".comet/config.yaml"; async function isFileOrDirectory(target) { try { - await fs4.lstat(target); + await fs5.lstat(target); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -8421,7 +8523,7 @@ async function isFileOrDirectory(target) { } async function declaresNativeProjectConfig(target) { try { - const source = await fs4.readFile(target, "utf8"); + const source = await fs5.readFile(target, "utf8"); return /^schema:\s*comet\.project\.v1\s*$/mu.test(source); } catch (error) { if (error.code === "ENOENT") return false; @@ -8441,12 +8543,12 @@ async function physicalPath(target) { missing.push(path5.basename(cursor)); cursor = parent; } - const existing = await fs4.realpath(cursor); + const existing = await fs5.realpath(cursor); return path5.resolve(existing, ...missing.reverse()); } async function isSymbolicLink(target) { try { - return (await fs4.lstat(target)).isSymbolicLink(); + return (await fs5.lstat(target)).isSymbolicLink(); } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -8455,7 +8557,7 @@ async function isSymbolicLink(target) { async function discoverNativeProject(startPath) { let cursor = path5.resolve(startPath); try { - if (!(await fs4.stat(cursor)).isDirectory()) cursor = path5.dirname(cursor); + if (!(await fs5.stat(cursor)).isDirectory()) cursor = path5.dirname(cursor); } catch (error) { if (error.code !== "ENOENT") throw error; } @@ -8494,7 +8596,7 @@ function normalizeArtifactRootRef(value) { async function resolveArtifactRoot(projectRoot, value) { const normalized = normalizeArtifactRootRef(value); const lexical = path5.resolve(projectRoot, ...normalized.split("/")); - const physicalProject = await fs4.realpath(projectRoot); + const physicalProject = await fs5.realpath(projectRoot); const physicalTarget = await physicalPath(lexical); if (!inside(physicalProject, physicalTarget)) { throw new Error("native.artifact_root resolves outside the project root"); @@ -8540,7 +8642,7 @@ async function ensureNativeDirectories(paths) { await Promise.all( directories.map(async (directory) => { await resolveContainedNativePath(paths.nativeRoot, directory); - await fs4.mkdir(directory, { recursive: true }); + await fs5.mkdir(directory, { recursive: true }); }) ); } @@ -8690,7 +8792,7 @@ async function readProjectConfig(projectRoot) { const canonical = path6.join(projectRoot, ...PROJECT_CONFIG_FILE.split("/")); const file = canonical; try { - await fs5.lstat(file); + await fs6.lstat(file); } catch (error) { if (error.code === "ENOENT") return null; throw error; @@ -8796,7 +8898,7 @@ async function writeProjectConfig(projectRoot, config) { } }; const canonical = path6.join(projectRoot, ...PROJECT_CONFIG_FILE.split("/")); - await fs5.mkdir(path6.dirname(canonical), { recursive: true }); + await fs6.mkdir(path6.dirname(canonical), { recursive: true }); await atomicWriteText( canonical, renderStructuredProjectConfig(document, validated.native.language === "zh-CN" ? "zh-CN" : "en") @@ -8825,13 +8927,13 @@ async function resolveNativeProject(options) { } // domains/comet-native/native-mutation-lock.ts -import { promises as fs8 } from "fs"; +import { promises as fs9 } from "fs"; import path9 from "path"; // domains/comet-native/native-lock.ts import { AsyncLocalStorage } from "async_hooks"; import { randomUUID as randomUUID2 } from "crypto"; -import { promises as fs6 } from "fs"; +import { promises as fs7 } from "fs"; import os2 from "os"; import path7 from "path"; var NATIVE_LOCK_MAX_BYTES = 16 * 1024; @@ -8866,10 +8968,10 @@ function nativeLockFileIdentity(stat) { function sameNativeLockObject(left, right) { const leftObject = { dev: left.device, ino: left.inode, birthtime: left.birthtimeNs }; const rightObject = { dev: right.device, ino: right.inode, birthtime: right.birthtimeNs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.size === right.size; } function sameNativeLockVersion(left, right) { return sameNativeLockObject(left, right) && left.size === right.size && left.birthtimeNs === right.birthtimeNs && left.ctimeNs === right.ctimeNs && left.mtimeNs === right.mtimeNs; @@ -8882,41 +8984,35 @@ function sameNativeLockDiagnosis(left, right) { return left.owner.id === right.owner.id && sameNativeLockVersion(left.identity, right.identity); } async function readNativeLockSnapshot(file) { - let handle; + let bytes; + let stat; try { - handle = await fs6.open(file, "r"); + const result2 = await readFileRaceSafe(file, NATIVE_LOCK_MAX_BYTES, { + bigint: true, + label: "Native lock" + }); + bytes = result2.bytes; + stat = result2.stat; } catch (error) { if (error.code === "ENOENT") return null; - throw error; - } - try { - const stat = await handle.stat({ bigint: true }); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`Native lock must be a regular file: ${file}`); - } - if (stat.size > BigInt(NATIVE_LOCK_MAX_BYTES)) { - throw new Error(`Native lock metadata exceeds ${NATIVE_LOCK_MAX_BYTES} bytes: ${file}`); - } - const source = await handle.readFile({ encoding: "utf8" }); - const pathStat = await fs6.lstat(file, { bigint: true }); - if (pathStat.isSymbolicLink() || !pathStat.isFile()) { - throw new Error(`Native lock must be a regular file: ${file}`); - } - const identity = nativeLockFileIdentity(stat); - if (!sameNativeLockObject(identity, nativeLockFileIdentity(pathStat))) { - throw new Error(`Native lock changed while reading: ${file}`); + if (error instanceof RaceSafeReadError) { + if (error.reason === "too-large") { + throw new Error(`Native lock metadata exceeds ${NATIVE_LOCK_MAX_BYTES} bytes: ${file}`, { + cause: error + }); + } + if (error.reason === "not-regular-file") { + throw new Error(`Native lock must be a regular file: ${file}`, { cause: error }); + } + throw new Error(`Native lock changed while reading: ${file}`, { cause: error }); } - return { - file, - owner: parseNativeLockOwner(JSON.parse(source), file), - identity - }; - } catch (error) { - if (error.code === "ENOENT") return null; throw error; - } finally { - await handle.close(); } + return { + file, + owner: parseNativeLockOwner(JSON.parse(bytes.toString("utf8")), file), + identity: nativeLockFileIdentity(stat) + }; } async function readNativeLock(file) { return (await readNativeLockSnapshot(file))?.owner ?? null; @@ -8935,13 +9031,13 @@ function diagnosisFromSnapshot(snapshot2) { } async function restoreQuarantinedNativeLock(quarantine, file) { try { - await fs6.lstat(file); + await fs7.lstat(file); return; } catch (error) { if (error.code !== "ENOENT") throw error; } try { - await fs6.rename(quarantine, file); + await fs7.rename(quarantine, file); } catch (error) { if (error.code !== "ENOENT") throw error; } @@ -8955,13 +9051,13 @@ async function removeBoundNativeLock(expected, quarantineDir) { if (!sameNativeLockVersion(current.identity, expected.identity)) { throw new Error(`Native lock identity changed: ${expected.file}`); } - await fs6.mkdir(quarantineDir, { recursive: true }); + await fs7.mkdir(quarantineDir, { recursive: true }); const quarantine = path7.join( quarantineDir, `${path7.basename(expected.file)}.${expected.owner.id}.${randomUUID2()}.removed` ); try { - await fs6.rename(expected.file, quarantine); + await fs7.rename(expected.file, quarantine); } catch (error) { if (error.code === "ENOENT") return "missing"; throw error; @@ -8971,7 +9067,7 @@ async function removeBoundNativeLock(expected, quarantineDir) { await restoreQuarantinedNativeLock(quarantine, expected.file); throw new Error(`Native lock changed before quarantine: ${expected.file}`); } - await fs6.rm(quarantine, { force: true }); + await fs7.rm(quarantine, { force: true }); return "removed"; } function newNativeLockOwner(operation) { @@ -8986,7 +9082,7 @@ function newNativeLockOwner(operation) { async function writeNativeLockFile(file, owner) { let handle; try { - handle = await fs6.open(file, "wx"); + handle = await fs7.open(file, "wx"); } catch (error) { if (error.code === "EEXIST") { const existing = await readNativeLock(file); @@ -9007,32 +9103,32 @@ async function writeNativeLockFile(file, owner) { } async function publishNativeCoordinatorClaim(paths, operation) { const locksDir = await resolveContainedNativePath(paths.nativeRoot, paths.locksDir); - await fs6.mkdir(locksDir, { recursive: true }); + await fs7.mkdir(locksDir, { recursive: true }); const coordinatorDir = await resolveContainedNativePath( paths.nativeRoot, path7.join(locksDir, NATIVE_LOCK_COORDINATOR_DIR) ); - await fs6.mkdir(coordinatorDir, { recursive: true }); + await fs7.mkdir(coordinatorDir, { recursive: true }); const owner = newNativeLockOwner(operation); const temporary = path7.join(coordinatorDir, `.${owner.id}.tmp`); const file = path7.join(coordinatorDir, `${owner.id}.claim`); try { const identity = await writeNativeLockFile(temporary, owner); - await fs6.rename(temporary, file); + await fs7.rename(temporary, file); const published = await readNativeLockSnapshot(file); if (!published || !sameNativeLockObject(identity, published.identity)) { throw new Error(`Native lock coordinator claim changed while publishing: ${file}`); } return { file, nativeRoot: paths.nativeRoot, locksDir, owner, identity: published.identity }; } finally { - await fs6.rm(temporary, { force: true }); + await fs7.rm(temporary, { force: true }); } } async function hasNativeCoordinatorPredecessor(claim) { const coordinatorDir = path7.dirname(claim.file); let predecessor = false; const claimName = path7.basename(claim.file); - for (const entry2 of await fs6.readdir(coordinatorDir, { withFileTypes: true })) { + for (const entry2 of await fs7.readdir(coordinatorDir, { withFileTypes: true })) { if (!entry2.isFile() || entry2.isSymbolicLink() || !entry2.name.endsWith(".claim")) continue; const file = path7.join(coordinatorDir, entry2.name); if (path7.resolve(file) === path7.resolve(claim.file)) continue; @@ -9118,7 +9214,7 @@ async function withNativeLockRecovery(pathEntries, operation, work) { async function acquireNativeLock(paths, name, operation) { return withNativeLockCoordinator(paths, `acquire ${name}`, async () => { const locksDir = await resolveContainedNativePath(paths.nativeRoot, paths.locksDir); - await fs6.mkdir(locksDir, { recursive: true }); + await fs7.mkdir(locksDir, { recursive: true }); const file = await resolveContainedNativePath( paths.nativeRoot, path7.join(locksDir, lockName(name)) @@ -9180,7 +9276,7 @@ async function takeOverNativeStaleLock(paths, file, expected) { } // domains/comet-native/native-transaction.ts -import { promises as fs7 } from "fs"; +import { promises as fs8 } from "fs"; import path8 from "path"; import { TextDecoder as TextDecoder3 } from "util"; var JOURNAL_KEYS = /* @__PURE__ */ new Set([ @@ -9642,7 +9738,7 @@ async function resolveRef(paths, ref) { } async function exists(file) { try { - await fs7.access(file); + await fs8.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -9652,7 +9748,7 @@ async function exists(file) { async function readEventLogSnapshot(paths, id, options = {}) { const tx = await resolveNativeTransactionPaths(paths, id); try { - await fs7.lstat(tx.events); + await fs8.lstat(tx.events); } catch (error) { if (error.code === "ENOENT") { return { @@ -9732,7 +9828,7 @@ async function appendNativeTransactionEvent(paths, id, type, operationId) { ...operationId ? { operationId } : {} }; parseEvent(event, event.sequence); - await fs7.mkdir(tx.directory, { recursive: true }); + await fs8.mkdir(tx.directory, { recursive: true }); await atomicWriteText(tx.events, canonicalEventLogSource([...snapshot2.events, event]), { containedRoot: paths.nativeRoot, beforeCommit: () => assertEventLogSnapshotUnchanged(paths, id, snapshot2) @@ -9745,8 +9841,8 @@ async function createNativeTransaction(paths, journal) { } journal = parseJournal(journal); const tx = await resolveNativeTransactionPaths(paths, journal.id); - await fs7.mkdir(tx.staged, { recursive: true }); - await fs7.mkdir(tx.backups, { recursive: true }); + await fs8.mkdir(tx.staged, { recursive: true }); + await fs8.mkdir(tx.backups, { recursive: true }); await atomicWriteJson(tx.journal, journal); await appendNativeTransactionEvent(paths, journal.id, "prepared"); } @@ -9970,7 +10066,7 @@ function nativeRootRef(paths, target) { async function hasUnfinishedTransaction(paths, allowedTransactionId) { let entries; try { - entries = await fs8.readdir(paths.transactionsDir, { withFileTypes: true }); + entries = await fs9.readdir(paths.transactionsDir, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -10046,7 +10142,7 @@ async function compareAndSwapNativeRevision(options) { // domains/comet-native/native-snapshot.ts import { createHash as createHash5 } from "crypto"; import { spawn } from "node:child_process"; -import { promises as fs9 } from "fs"; +import { promises as fs10 } from "fs"; import path10 from "path"; // domains/comet-native/native-hash.ts @@ -10507,7 +10603,7 @@ async function hasGitMetadataBoundary(projectRoot) { let cursor = path10.resolve(projectRoot); while (true) { try { - await fs9.lstat(path10.join(cursor, ".git")); + await fs10.lstat(path10.join(cursor, ".git")); return true; } catch (error) { if (error.code !== "ENOENT") throw error; @@ -10736,7 +10832,7 @@ async function nativePhysicalSnapshotSelection(options) { if (!hasExecutionBudget()) return; let handle; try { - handle = await fs9.opendir(directory); + handle = await fs10.opendir(directory); } catch (error) { if (!hasExecutionBudget()) return; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -10786,7 +10882,7 @@ async function nativePhysicalSnapshotSelection(options) { if (!hasExecutionBudget()) break; let stat; try { - stat = await fs9.lstat(target); + stat = await fs10.lstat(target); } catch (error) { if (!hasExecutionBudget()) break; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -10809,7 +10905,7 @@ async function nativePhysicalSnapshotSelection(options) { if (!hasExecutionBudget()) break; let realDirectory; try { - realDirectory = await fs9.realpath(target); + realDirectory = await fs10.realpath(target); } catch (error) { if (!hasExecutionBudget()) break; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -11023,16 +11119,16 @@ function takeLastCompactableOmission(omissions) { function sameFileIdentity4(left, right) { const leftObject = { ...left, birthtime: left.birthtimeMs }; const rightObject = { ...right, birthtime: right.birthtimeMs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; } async function sha256FileBounded(file, maxBytes, expected, execution) { if (!nativeSnapshotExecutionHasBudget(execution)) return { status: "budget-exhausted" }; let handle; try { - handle = await fs9.open(file, "r"); + handle = await fs10.open(file, "r"); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) return { status: "budget-exhausted" }; throw error; @@ -11606,9 +11702,9 @@ async function createNativeContentSnapshot(paths, options = {}) { throw new Error("Native snapshot limits must be positive"); } const projectRoot = path10.resolve(paths.projectRoot); - const physicalProjectRoot = await fs9.realpath(projectRoot); + const physicalProjectRoot = await fs10.realpath(projectRoot); const nativeRoot = path10.resolve(paths.nativeRoot); - const physicalNativeRoot = await fs9.realpath(nativeRoot); + const physicalNativeRoot = await fs10.realpath(nativeRoot); const configFile = path10.resolve(paths.configFile); const selectionFile = path10.join(projectRoot, ".comet", "current-change.json"); const denylist = normalizedDenylist(projectRoot, options.denylist ?? []); @@ -11680,7 +11776,7 @@ async function createNativeContentSnapshot(paths, options = {}) { if (!nativeSnapshotExecutionHasBudget(execution)) return; let realTarget; try { - realTarget = await fs9.realpath(target); + realTarget = await fs10.realpath(target); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) return; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -11721,9 +11817,9 @@ async function createNativeContentSnapshot(paths, options = {}) { return; } if (!nativeSnapshotExecutionHasBudget(execution)) return; - afterRealTarget = await fs9.realpath(target); + afterRealTarget = await fs10.realpath(target); if (!nativeSnapshotExecutionHasBudget(execution)) return; - after = await fs9.lstat(target); + after = await fs10.lstat(target); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) return; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -11757,11 +11853,11 @@ async function createNativeContentSnapshot(paths, options = {}) { let secondTarget; let after; try { - firstTarget = await fs9.readlink(target, { encoding: "buffer" }); + firstTarget = await fs10.readlink(target, { encoding: "buffer" }); if (!nativeSnapshotExecutionHasBudget(execution)) return; - after = await fs9.lstat(target); + after = await fs10.lstat(target); if (!nativeSnapshotExecutionHasBudget(execution)) return; - secondTarget = await fs9.readlink(target, { encoding: "buffer" }); + secondTarget = await fs10.readlink(target, { encoding: "buffer" }); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) return; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -11805,9 +11901,9 @@ async function createNativeContentSnapshot(paths, options = {}) { let realTarget2; let stat2; try { - realTarget2 = await fs9.realpath(validation2.target); + realTarget2 = await fs10.realpath(validation2.target); if (!nativeSnapshotExecutionHasBudget(execution)) return; - stat2 = await fs9.lstat(validation2.target); + stat2 = await fs10.lstat(validation2.target); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) return; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -11834,9 +11930,9 @@ async function createNativeContentSnapshot(paths, options = {}) { let stat2; let rawTarget; try { - stat2 = await fs9.lstat(validation2.target); + stat2 = await fs10.lstat(validation2.target); if (!nativeSnapshotExecutionHasBudget(execution)) return; - rawTarget = await fs9.readlink(validation2.target, { encoding: "buffer" }); + rawTarget = await fs10.readlink(validation2.target, { encoding: "buffer" }); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) return; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -11862,9 +11958,9 @@ async function createNativeContentSnapshot(paths, options = {}) { let realTarget; let stat; try { - realTarget = await fs9.realpath(validation2.target); + realTarget = await fs10.realpath(validation2.target); if (!nativeSnapshotExecutionHasBudget(execution)) return; - stat = await fs9.lstat(validation2.target); + stat = await fs10.lstat(validation2.target); } catch { if (!nativeSnapshotExecutionHasBudget(execution)) return; invalidateCapturedEntry(relative, { @@ -11920,7 +12016,7 @@ async function createNativeContentSnapshot(paths, options = {}) { for (const [relative, target] of capturedTrackedAbsences) { if (!nativeSnapshotExecutionHasBudget(execution)) return; try { - const stat = await fs9.lstat(target); + const stat = await fs10.lstat(target); if (!nativeSnapshotExecutionHasBudget(execution)) return; omit({ path: relative, @@ -11959,7 +12055,7 @@ async function createNativeContentSnapshot(paths, options = {}) { const target = path10.resolve(projectRoot, ...record8.path.split("/")); let stat; try { - stat = await fs9.lstat(target); + stat = await fs10.lstat(target); } catch (error) { if (!nativeSnapshotExecutionHasBudget(execution)) break; if (!isUnreadableError(error) && !isChangedDuringReadError(error)) throw error; @@ -12024,7 +12120,7 @@ async function createNativeContentSnapshot(paths, options = {}) { let realGitlink; let gitlinkStat; try { - [realGitlink, gitlinkStat] = await Promise.all([fs9.realpath(target), fs9.lstat(target)]); + [realGitlink, gitlinkStat] = await Promise.all([fs10.realpath(target), fs10.lstat(target)]); } catch { omit({ path: relative, @@ -12082,7 +12178,7 @@ async function createNativeContentSnapshot(paths, options = {}) { } let before; try { - before = await fs9.lstat(target); + before = await fs10.lstat(target); } catch (error) { if (isChangedDuringReadError(error) && gitSelection.tracked.has(relative)) { capturedTrackedAbsences.set(relative, target); @@ -12220,7 +12316,7 @@ function assertRunStorageLayout(storage) { // domains/comet-native/native-run-store.ts import { createHash as createHash6 } from "node:crypto"; -import { constants as fsConstants2, promises as fs10 } from "node:fs"; +import { constants as fsConstants3, promises as fs11 } from "node:fs"; import path13 from "node:path"; import { TextDecoder as TextDecoder4 } from "node:util"; @@ -12409,7 +12505,7 @@ function asIdentity(stat) { }; } function sameFileIdentity5(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -12418,7 +12514,7 @@ function sameFileIdentity5(expected, actual) { ) && expected.birthtimeMs === actual.birthtimeMs && expected.ctimeMs === actual.ctimeMs && expected.mtimeMs === actual.mtimeMs && expected.size === actual.size; } function sameDirectoryIdentity4(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -12437,13 +12533,13 @@ function runFile(changeDir, kind, relativePath) { return target; } async function directoryIdentity2(directory) { - const stat = await fs10.lstat(directory); + const stat = await fs11.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native Run parent must be a real directory: ${directory}`); } return { path: directory, - realPath: await fs10.realpath(directory), + realPath: await fs11.realpath(directory), dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs @@ -12475,8 +12571,8 @@ async function captureDirectoryChain3(root, directory) { } async function verifyDirectoryChain4(chain) { for (const identity of chain) { - const stat = await fs10.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity4(identity, stat) || await fs10.realpath(identity.path) !== identity.realPath) { + const stat = await fs11.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity4(identity, stat) || await fs11.realpath(identity.path) !== identity.realPath) { throw new Error(`Native Run parent changed during I/O: ${identity.path}`); } } @@ -12501,7 +12597,7 @@ async function readProtectedText(changeDir, file, maxBytes, label, hooks) { await hooks?.afterParentChainCaptured?.(); let before; try { - before = await fs10.lstat(file); + before = await fs11.lstat(file); } catch (error) { if (error.code === "ENOENT") return null; throw error; @@ -12511,22 +12607,22 @@ async function readProtectedText(changeDir, file, maxBytes, label, hooks) { } if (before.size > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`); const beforeIdentity = asIdentity(before); - const beforeRealPath = await fs10.realpath(file); + const beforeRealPath = await fs11.realpath(file); if (!isInside4(chain[0].realPath, beforeRealPath)) { throw new Error(`${label} resolves outside its change`); } - const flags = process.platform === "win32" ? fsConstants2.O_RDONLY : fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW | fsConstants2.O_NONBLOCK; + const flags = process.platform === "win32" ? fsConstants3.O_RDONLY : fsConstants3.O_RDONLY | fsConstants3.O_NOFOLLOW | fsConstants3.O_NONBLOCK; let handle; try { - handle = await fs10.open(file, flags); + handle = await fs11.open(file, flags); } catch (error) { throw new Error(`${label} changed while opening`, { cause: error }); } try { const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ handle.stat(), - fs10.lstat(file), - fs10.realpath(file) + fs11.lstat(file), + fs11.realpath(file) ]); await verifyDirectoryChain4(chain); if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameFileIdentity5(beforeIdentity, opened) || !sameFileIdentity5(beforeIdentity, pathAfterOpen)) { @@ -12537,8 +12633,8 @@ async function readProtectedText(changeDir, file, maxBytes, label, hooks) { await hooks?.beforeFinalCheck?.(); const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs10.lstat(file), - fs10.realpath(file) + fs11.lstat(file), + fs11.realpath(file) ]); await verifyDirectoryChain4(chain); if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== beforeRealPath || !sameFileIdentity5(beforeIdentity, afterHandle) || !sameFileIdentity5(beforeIdentity, afterPath)) { @@ -12563,13 +12659,13 @@ async function captureTarget(changeDir, file, label) { if (!chain) return { exists: false }; let stat; try { - stat = await fs10.lstat(file); + stat = await fs11.lstat(file); } catch (error) { if (error.code === "ENOENT") return { exists: false }; throw error; } if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label} must be a regular file`); - const realPath = await fs10.realpath(file); + const realPath = await fs11.realpath(file); if (!isInside4(chain[0].realPath, realPath)) throw new Error(`${label} resolves outside its change`); await verifyDirectoryChain4(chain); @@ -12583,12 +12679,12 @@ async function verifyTarget(changeDir, file, expected, label) { } let stat; try { - stat = await fs10.lstat(file); + stat = await fs11.lstat(file); } catch (error) { if (error.code === "ENOENT" && !expected.exists) return; throw new Error(`${label} changed before commit`, { cause: error }); } - if (!expected.exists || !stat.isFile() || stat.isSymbolicLink() || !sameFileIdentity5(expected.identity, stat) || await fs10.realpath(file) !== expected.realPath) { + if (!expected.exists || !stat.isFile() || stat.isSymbolicLink() || !sameFileIdentity5(expected.identity, stat) || await fs11.realpath(file) !== expected.realPath) { throw new Error(`${label} changed before commit`); } await verifyDirectoryChain4(chain); @@ -12897,7 +12993,7 @@ async function repairNativeTrajectoryTail(paths, name, hooks) { // domains/comet-native/native-workspace.ts import { createHash as createHash8 } from "node:crypto"; -import { promises as fs11 } from "node:fs"; +import { promises as fs12 } from "node:fs"; import path15 from "node:path"; var HASH_PATTERN2 = /^[a-f0-9]{64}$/u; var MAX_WORKSPACE_IDENTITY_BYTES = 16 * 1024; @@ -12936,8 +13032,8 @@ function identityHash(tag, value) { ${value}`).digest("hex"); } async function physicalDirectoryIdentity(tag, value) { - const realPath = await fs11.realpath(value); - const stat = await fs11.lstat(realPath); + const realPath = await fs12.realpath(value); + const stat = await fs12.lstat(realPath); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error("Native workspace identity requires a real directory"); } @@ -12948,8 +13044,8 @@ ${stat.ino} ${stat.birthtimeMs}`); } async function directoryPathIdentity(tag, value) { - const realPath = await fs11.realpath(value); - const stat = await fs11.lstat(realPath); + const realPath = await fs12.realpath(value); + const stat = await fs12.lstat(realPath); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error("Native workspace identity requires a real directory"); } @@ -13588,7 +13684,7 @@ async function hasPendingNativeSchemaMigration(paths, name) { const file = path16.join(nativeChangeDir(paths, name), "runtime", "schema-migration.json"); await resolveContainedNativePath(paths.nativeRoot, file); try { - await fs12.lstat(file); + await fs13.lstat(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -13599,7 +13695,7 @@ async function hasPendingNativeCheckpointRecovery(paths, name) { const file = path16.join(nativeChangeDir(paths, name), "runtime", "checkpoint-journal.json"); await resolveContainedNativePath(paths.nativeRoot, file); try { - await fs12.lstat(file); + await fs13.lstat(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -13620,13 +13716,13 @@ async function createNativeChangeLocked(options) { let createdChangeDir = false; try { try { - await fs12.mkdir(changeDir, { recursive: false }); + await fs13.mkdir(changeDir, { recursive: false }); createdChangeDir = true; } catch (error) { if (error.code === "ENOENT") { - await fs12.mkdir(options.paths.changesDir, { recursive: true }); + await fs13.mkdir(options.paths.changesDir, { recursive: true }); try { - await fs12.mkdir(changeDir, { recursive: false }); + await fs13.mkdir(changeDir, { recursive: false }); createdChangeDir = true; } catch (retryError) { if (retryError.code === "EEXIST") { @@ -13663,8 +13759,8 @@ async function createNativeChangeLocked(options) { run_id: null }; await Promise.all([ - fs12.mkdir(path16.join(changeDir, "specs"), { recursive: true }), - fs12.mkdir(path16.join(changeDir, "runtime", "checkpoints"), { recursive: true }), + fs13.mkdir(path16.join(changeDir, "specs"), { recursive: true }), + fs13.mkdir(path16.join(changeDir, "runtime", "checkpoints"), { recursive: true }), atomicWriteText(path16.join(changeDir, "brief.md"), NATIVE_BRIEF_TEMPLATE) ]); const baseline = await createNativeContentSnapshot(options.paths, { @@ -13697,7 +13793,7 @@ async function createNativeChangeLocked(options) { }); return state; } catch (error) { - if (createdChangeDir) await fs12.rm(changeDir, { recursive: true, force: true }); + if (createdChangeDir) await fs13.rm(changeDir, { recursive: true, force: true }); throw error; } } @@ -13747,7 +13843,7 @@ async function createNativeChangeFile(paths, state) { const file = path16.join(nativeChangeDir(paths, state.name), NATIVE_CHANGE_STATE_FILE); await resolveContainedNativePath(paths.nativeRoot, file); try { - await fs12.access(file); + await fs13.access(file); throw new Error(`Native change state already exists: ${state.name}`); } catch (error) { if (error.code !== "ENOENT") throw error; @@ -13845,12 +13941,12 @@ async function readContainedFile(root, relativeRef) { const target = path17.resolve(root, ...relativeRef.split(/[\\/]/u)); if (!isInsidePath(root, target)) throw new Error(`Artifact escapes Native change: ${relativeRef}`); - const realRoot = await fs13.realpath(root); - const realTarget = await fs13.realpath(target); + const realRoot = await fs14.realpath(root); + const realTarget = await fs14.realpath(target); if (!isInsidePath(realRoot, realTarget)) { throw new Error(`Artifact symlink escapes Native change: ${relativeRef}`); } - if (!(await fs13.stat(realTarget)).isFile()) + if (!(await fs14.stat(realTarget)).isFile()) throw new Error(`Artifact is not a file: ${relativeRef}`); return realTarget; } @@ -13992,7 +14088,7 @@ async function resolveNativeArtifactFile(changeDir, relativeRef) { } // domains/comet-native/native-archive-inspection.ts -import { promises as fs17 } from "node:fs"; +import { promises as fs18 } from "node:fs"; import path29 from "node:path"; // domains/comet-native/native-archive-preflight.ts @@ -14582,7 +14678,7 @@ function buildNativeConflictRadar(input) { // domains/comet-native/native-evidence-storage.ts import { createHash as createHash12 } from "node:crypto"; -import { promises as fs14 } from "node:fs"; +import { promises as fs15 } from "node:fs"; import path22 from "node:path"; // domains/comet-native/native-verification-scope.ts @@ -16086,7 +16182,7 @@ function isInside5(parent, target) { return relative === "" || !path22.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path22.sep}`); } function sameDirectoryIdentity5(identity, stat) { - return sameNativeFileObject( + return sameFileObject( { ...identity, birthtime: identity.birthtimeMs }, { ...stat, @@ -16097,19 +16193,19 @@ function sameDirectoryIdentity5(identity, stat) { function sameFileIdentity6(left, right) { const leftObject = { ...left, birthtime: left.birthtimeMs }; const rightObject = { ...right, birthtime: right.birthtimeMs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; } async function captureDirectoryIdentity3(directory) { - const stat = await fs14.lstat(directory); + const stat = await fs15.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native evidence parent must be a real directory: ${directory}`); } return { path: directory, - realPath: await fs14.realpath(directory), + realPath: await fs15.realpath(directory), dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs @@ -16135,8 +16231,8 @@ async function captureDirectoryChain4(root, directory) { } async function verifyDirectoryChain5(chain) { for (const identity of chain) { - const stat = await fs14.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity5(identity, stat) || await fs14.realpath(identity.path) !== identity.realPath) { + const stat = await fs15.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity5(identity, stat) || await fs15.realpath(identity.path) !== identity.realPath) { throw new Error(`Native evidence parent changed while reading: ${identity.path}`); } } @@ -16144,20 +16240,20 @@ async function verifyDirectoryChain5(chain) { async function readBoundedEvidenceJson(file, changeRoot, hooks = {}) { const chain = await captureDirectoryChain4(changeRoot, path22.dirname(file)); await hooks.afterParentChainCaptured?.(); - const lexical = await fs14.lstat(file); + const lexical = await fs15.lstat(file); if (!lexical.isFile() || lexical.isSymbolicLink()) { throw new Error("Native evidence document must be a regular file"); } - const realPath = await fs14.realpath(file); + const realPath = await fs15.realpath(file); if (!isInside5(chain[0].realPath, realPath)) { throw new Error("Native evidence document resolves outside its change"); } - const handle = await fs14.open(file, "r"); + const handle = await fs15.open(file, "r"); try { const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ handle.stat(), - fs14.lstat(file), - fs14.realpath(file) + fs15.lstat(file), + fs15.realpath(file) ]); await verifyDirectoryChain5(chain); if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== realPath || !sameFileIdentity6(opened, lexical) || !sameFileIdentity6(opened, pathAfterOpen)) { @@ -16187,8 +16283,8 @@ async function readBoundedEvidenceJson(file, changeRoot, hooks = {}) { await hooks.beforeFinalCheck?.(); const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs14.lstat(file), - fs14.realpath(file) + fs15.lstat(file), + fs15.realpath(file) ]); await verifyDirectoryChain5(chain); if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== realPath || !sameFileIdentity6(opened, afterHandle) || !sameFileIdentity6(opened, afterPath)) { @@ -16484,7 +16580,7 @@ async function inspectNativeChangeConflicts(paths, name) { // domains/comet-native/native-transition-journal.ts import { randomUUID as randomUUID3 } from "crypto"; -import { promises as fs15 } from "fs"; +import { promises as fs16 } from "fs"; import path23 from "path"; import { isDeepStrictEqual } from "util"; @@ -17942,7 +18038,7 @@ async function continueNativeTransitionLocked(paths, name, hooks) { `Native transition journal changed while continuing ${journal.change}; it was not removed` ); } - await fs15.rm(nativeTransitionJournalFile(paths, name), { force: true }); + await fs16.rm(nativeTransitionJournalFile(paths, name), { force: true }); return journal.nextState; } async function continueNativeTransition(paths, name, hooks) { @@ -18406,9 +18502,16 @@ function canonicalEvidencePayload(entries) { const validated = validateEvidenceEntries([...entries]).map((entry2) => ({ ...entry2, evidence_refs: [...entry2.evidence_refs].sort() })).sort((left, right) => left.acceptance_id.localeCompare(right.acceptance_id)); return JSON.stringify(validated, null, 2); } +function serializeNativeVerificationMachineBlock(entries) { + return [ + NATIVE_ACCEPTANCE_EVIDENCE_START_MARKER, + canonicalEvidencePayload(entries), + NATIVE_ACCEPTANCE_EVIDENCE_END_MARKER + ].join("\n"); +} // domains/comet-native/native-check-receipt-storage.ts -import { constants as fsConstants3, promises as fs16 } from "node:fs"; +import { constants as fsConstants4, promises as fs17 } from "node:fs"; import path26 from "node:path"; // domains/comet-native/native-check-receipt-model.ts @@ -18757,7 +18860,7 @@ var HASH_PATTERN11 = /^[a-f0-9]{64}$/u; var RECEIPT_REF_PATTERN = /^runtime\/evidence\/check-receipts\/([a-f0-9]{64})\.json$/u; var MAX_NATIVE_CHECK_RECEIPT_BYTES = 512 * 1024; function sameDirectoryIdentity6(identity, stat) { - return sameNativeFileObject( + return sameFileObject( { ...identity, birthtime: identity.birthtimeMs }, { ...stat, @@ -18768,10 +18871,10 @@ function sameDirectoryIdentity6(identity, stat) { function sameFileIdentity7(left, right) { const leftObject = { ...left, birthtime: left.birthtimeMs }; const rightObject = { ...right, birthtime: right.birthtimeMs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; } async function captureDirectoryChain5(root, directory) { const lexicalRoot = path26.resolve(root); @@ -18786,11 +18889,11 @@ async function captureDirectoryChain5(root, directory) { ...path26.relative(lexicalRoot, lexicalDirectory).split(path26.sep).filter(Boolean) ]) { if (segment) cursor = path26.join(cursor, segment); - const stat = await fs16.lstat(cursor); + const stat = await fs17.lstat(cursor); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native check receipt parent must be a real directory: ${cursor}`); } - const realPath = await fs16.realpath(cursor); + const realPath = await fs17.realpath(cursor); if (chain.length > 0 && !isInsidePath(chain[0].realPath, realPath)) { throw new Error("Native check receipt parent resolves outside the Native root"); } @@ -18806,8 +18909,8 @@ async function captureDirectoryChain5(root, directory) { } async function verifyDirectoryChain6(chain) { for (const identity of chain) { - const stat = await fs16.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity6(identity, stat) || await fs16.realpath(identity.path) !== identity.realPath) { + const stat = await fs17.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity6(identity, stat) || await fs17.realpath(identity.path) !== identity.realPath) { throw new Error(`Native check receipt parent changed while reading: ${identity.path}`); } } @@ -18826,7 +18929,7 @@ function receiptFile(paths, name, hash6) { } async function readBoundedReceipt(file, changeRoot, nativeRoot) { const chain = await captureDirectoryChain5(nativeRoot, path26.dirname(file)); - const before = await fs16.lstat(file); + const before = await fs17.lstat(file); if (!before.isFile() || before.isSymbolicLink()) { throw new Error("Native check receipt must be a regular file"); } @@ -18834,14 +18937,14 @@ async function readBoundedReceipt(file, changeRoot, nativeRoot) { throw new Error(`Native check receipt exceeds ${MAX_NATIVE_CHECK_RECEIPT_BYTES} bytes`); } const [realChangeRoot, beforeRealPath] = await Promise.all([ - fs16.realpath(changeRoot), - fs16.realpath(file) + fs17.realpath(changeRoot), + fs17.realpath(file) ]); if (!isInsidePath(realChangeRoot, beforeRealPath)) { throw new Error("Native check receipt resolves outside its change"); } - const openFlags = process.platform === "win32" ? "r" : fsConstants3.O_RDONLY | fsConstants3.O_NOFOLLOW | fsConstants3.O_NONBLOCK; - const handle = await fs16.open(file, openFlags).catch((error) => { + const openFlags = process.platform === "win32" ? "r" : fsConstants4.O_RDONLY | fsConstants4.O_NOFOLLOW | fsConstants4.O_NONBLOCK; + const handle = await fs17.open(file, openFlags).catch((error) => { if (error.code === "ELOOP" || error.code === "ENXIO") { throw new Error("Native check receipt became unsafe while opening"); } @@ -18850,8 +18953,8 @@ async function readBoundedReceipt(file, changeRoot, nativeRoot) { try { const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ handle.stat(), - fs16.lstat(file), - fs16.realpath(file) + fs17.lstat(file), + fs17.realpath(file) ]); await verifyDirectoryChain6(chain); if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameFileIdentity7(before, opened) || !sameFileIdentity7(opened, pathAfterOpen)) { @@ -18872,8 +18975,8 @@ async function readBoundedReceipt(file, changeRoot, nativeRoot) { } const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs16.lstat(file), - fs16.realpath(file) + fs17.lstat(file), + fs17.realpath(file) ]); await verifyDirectoryChain6(chain); if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== beforeRealPath || !isInsidePath(realChangeRoot, afterRealPath) || !sameFileIdentity7(opened, afterHandle) || !sameFileIdentity7(opened, afterPath)) { @@ -19406,7 +19509,7 @@ function archiveTargetRef(name, now) { } async function exists2(target) { try { - await fs17.lstat(target); + await fs18.lstat(target); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -19488,7 +19591,7 @@ async function inspectNativeArchivePreflight(options) { } // domains/comet-native/native-archive-content.ts -import { promises as fs18 } from "node:fs"; +import { promises as fs19 } from "node:fs"; import path30 from "node:path"; var TREE_HASH_TAG = "comet.native.archive-tree.v1"; var NATIVE_ARCHIVE_CONTENT_LIMITS = { @@ -19548,7 +19651,7 @@ async function walkArchiveTree(root, directory, entries, budget, limits, depth) await protectedDirectory.verify(); const target = path30.join(directory, child.name); const ref = path30.relative(root, target).replaceAll("\\", "/"); - const stat = await fs18.lstat(target); + const stat = await fs19.lstat(target); if (child.isSymbolicLink() || stat.isSymbolicLink()) { throw new Error(`Native Archive content must not contain symlinks or junctions: ${ref}`); } @@ -19595,7 +19698,7 @@ async function walkArchiveTree(root, directory, entries, budget, limits, depth) async function hashNativeArchiveTree(directory, requestedLimits = {}) { const limits = normalizedLimits(requestedLimits); directory = path30.resolve(directory); - const stat = await fs18.lstat(directory); + const stat = await fs19.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native Archive move source must be a real directory: ${directory}`); } @@ -19613,7 +19716,7 @@ async function inspectNativeArchiveContent(target, requestedLimits = {}) { target = path30.resolve(target); let stat; try { - stat = await fs18.lstat(target); + stat = await fs19.lstat(target); } catch (error) { if (error.code === "ENOENT") return null; throw error; @@ -19637,7 +19740,7 @@ async function inspectNativeArchiveContent(target, requestedLimits = {}) { } // domains/comet-native/native-archive-transaction.ts -import { promises as fs19 } from "node:fs"; +import { promises as fs20 } from "node:fs"; import path31 from "node:path"; var NATIVE_ARCHIVE_COPY_MAX_BYTES = 16 * 1024 * 1024; var NATIVE_ARCHIVE_JOURNAL_MAX_BYTES = 256 * 1024; @@ -19671,8 +19774,8 @@ async function assertContent(options) { async function createNativeArchiveTransactionV2(paths, journal) { const validated = parseNativeArchiveTransactionJournalV2(journal); const tx = await resolveNativeTransactionPaths(paths, validated.id); - await fs19.mkdir(tx.staged, { recursive: true }); - await fs19.mkdir(tx.backups, { recursive: true }); + await fs20.mkdir(tx.staged, { recursive: true }); + await fs20.mkdir(tx.backups, { recursive: true }); await atomicWriteJson(tx.journal, validated, { containedRoot: paths.nativeRoot }); await appendNativeTransactionEvent(paths, validated.id, "prepared"); } @@ -19711,8 +19814,8 @@ function fileVersion(stat) { size: stat.size }; } -function sameFileObject(expected, actual) { - return sameNativeFileObject( +function sameFileObject2(expected, actual) { + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -19721,11 +19824,11 @@ function sameFileObject(expected, actual) { ) && expected.birthtimeMs === actual.birthtimeMs; } function sameFileVersion(expected, actual) { - return sameFileObject(expected, actual) && expected.birthtimeMs === actual.birthtimeMs && expected.ctimeMs === actual.ctimeMs && expected.mtimeMs === actual.mtimeMs && expected.size === actual.size; + return sameFileObject2(expected, actual) && expected.birthtimeMs === actual.birthtimeMs && expected.ctimeMs === actual.ctimeMs && expected.mtimeMs === actual.mtimeMs && expected.size === actual.size; } async function pathExists(target) { try { - await fs19.lstat(target); + await fs20.lstat(target); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -19733,7 +19836,7 @@ async function pathExists(target) { } } async function captureStableArchiveFile(options) { - const before = await fs19.lstat(options.file); + const before = await fs20.lstat(options.file); if (!before.isFile() || before.isSymbolicLink()) { throw new Error(`${options.label} must be a regular file`); } @@ -19744,7 +19847,7 @@ async function captureStableArchiveFile(options) { maxBytes: NATIVE_ARCHIVE_COPY_MAX_BYTES, label: options.label }); - const after = await fs19.lstat(options.file); + const after = await fs20.lstat(options.file); if (!after.isFile() || after.isSymbolicLink() || !sameFileVersion(beforeVersion, fileVersion(after)) || snapshot2.hash !== options.expectedHash) { throw new Error(`${options.label} content changed or object identity changed while binding`); } @@ -19822,7 +19925,7 @@ async function readCasRecord(options) { async function persistCasRecord(options) { const existing = await readCasRecord(options); if (existing) { - if (!sameFileObject(existing.identity, options.identity)) { + if (!sameFileObject2(existing.identity, options.identity)) { throw new Error( `Archive CAS ${options.operation.id} ${options.role} object identity changed` ); @@ -19850,7 +19953,7 @@ async function persistCasRecord(options) { if (error.code !== "EEXIST") throw error; } const persisted = await readCasRecord(options); - if (!persisted || !sameFileObject(persisted.identity, options.identity)) { + if (!persisted || !sameFileObject2(persisted.identity, options.identity)) { throw new Error(`Archive CAS ${options.operation.id} ${options.role} record changed`); } return persisted; @@ -19862,7 +19965,7 @@ async function validateFileAgainstCasRecord(options) { expectedHash: options.record.hash, label: options.label }); - if (!sameFileObject(options.record.identity, current.identity)) { + if (!sameFileObject2(options.record.identity, current.identity)) { throw new Error(`${options.label} object identity changed`); } } @@ -19899,20 +20002,20 @@ async function restoreUnexpectedQuarantine(options) { }); await guard.verify(); try { - await fs19.link(options.quarantine, options.target); + await fs20.link(options.quarantine, options.target); } catch (error) { if (error.code === "EEXIST") return; throw error; } await guard.verify(); const [quarantine, target] = await Promise.all([ - fs19.lstat(options.quarantine), - fs19.lstat(options.target) + fs20.lstat(options.quarantine), + fs20.lstat(options.target) ]); - if (!sameFileObject(fileObjectIdentity(quarantine), fileObjectIdentity(target))) { + if (!sameFileObject2(fileObjectIdentity(quarantine), fileObjectIdentity(target))) { throw new Error(`Archive CAS ${options.operation.id} could not restore quarantined content`); } - await fs19.unlink(options.quarantine); + await fs20.unlink(options.quarantine); await guard.verify(); } async function quarantineBoundTarget(options) { @@ -19948,7 +20051,7 @@ async function quarantineBoundTarget(options) { label: `Archive ${options.phase} target ${options.operation.target}` }); try { - await fs19.rename(options.target, options.quarantine); + await fs20.rename(options.target, options.quarantine); await guard.verify(); await validateFileAgainstCasRecord({ paths: options.paths, @@ -19980,7 +20083,7 @@ async function removeExactCasFile(options) { }); await guard.verify(); await validateFileAgainstCasRecord(options); - await fs19.unlink(options.file); + await fs20.unlink(options.file); await guard.verify(); } async function ensureBackup(paths, operation, hooks) { @@ -20173,7 +20276,7 @@ async function ensureWriteInstalled(options) { expectedHash, label: `Archive write target ${options.operation.target}` }); - if (!sameFileObject(candidateIdentity2.identity, targetIdentity2.identity)) { + if (!sameFileObject2(candidateIdentity2.identity, targetIdentity2.identity)) { throw new Error( `Archive write target ${options.operation.target} is occupied by an external object` ); @@ -20228,7 +20331,7 @@ async function ensureWriteInstalled(options) { label: `Archive write candidate ${options.operation.target}` }); try { - await fs19.link(candidate, options.target); + await fs20.link(candidate, options.target); } catch (error) { if (error.code === "EEXIST") { throw new Error( @@ -20246,7 +20349,7 @@ async function ensureWriteInstalled(options) { expectedHash, label: `Archive write target ${options.operation.target}` }); - if (!sameFileObject(candidateIdentity.identity, targetIdentity.identity)) { + if (!sameFileObject2(candidateIdentity.identity, targetIdentity.identity)) { throw new Error(`Archive write target ${options.operation.target} changed during install`); } record8 = await persistCasRecord({ @@ -20332,8 +20435,8 @@ async function applyMove(paths, operation) { `Archive transaction move ${operation.id} content changed: source=${contentDescription(sourceContent)}, target=${contentDescription(targetContent)}` ); } - await fs19.mkdir(path31.dirname(target), { recursive: true }); - await fs19.rename(source, target); + await fs20.mkdir(path31.dirname(target), { recursive: true }); + await fs20.rename(source, target); await assertContent({ target, expectedHash: operation.expectedSourceHash, @@ -20535,7 +20638,7 @@ async function installOriginalTarget(options) { label: `Archive rollback original quarantine ${options.operation.target}` }); try { - await fs19.link(options.quarantine, options.target); + await fs20.link(options.quarantine, options.target); } catch (error) { if (error.code === "EEXIST") { throw new Error( @@ -20615,7 +20718,7 @@ async function rollbackWriteOrRemove(paths, journal, operation, hooks) { expectedHash: operation.stagedHash, label: `Archive rollback target ${operation.target}` }); - if (sameFileObject(candidate.identity, current.identity)) { + if (sameFileObject2(candidate.identity, current.identity)) { postRecord = await persistCasRecord({ paths, file: cas.postRecord, @@ -20729,8 +20832,8 @@ async function rollbackMove(paths, operation) { `Archive rollback move ${operation.id} content changed: source=${contentDescription(sourceContent)}, target=${contentDescription(targetContent)}` ); } - await fs19.mkdir(path31.dirname(source), { recursive: true }); - await fs19.rename(target, source); + await fs20.mkdir(path31.dirname(source), { recursive: true }); + await fs20.rename(target, source); } async function rollbackNativeArchiveTransactionV2(paths, journal, hooks) { const events = await readNativeTransactionEvents(paths, journal.id); @@ -20828,7 +20931,7 @@ async function finalizeNativeArchiveTransactionV2(paths, journal, event) { // domains/comet-native/native-checkpoint-journal.ts import { randomUUID as randomUUID4 } from "crypto"; -import { promises as fs20 } from "fs"; +import { promises as fs21 } from "fs"; // domains/comet-native/native-checkpoint-storage.ts import path32 from "path"; @@ -21364,7 +21467,7 @@ async function continueNativeCheckpointLocked(paths, name, hooks) { await hooks?.afterStateWritten?.(journal); await writeNativeProgressCheckpoint(paths, journal.checkpoint); await hooks?.afterProgressWritten?.(journal); - await fs20.rm(nativeCheckpointJournalFile(paths, name), { force: true }); + await fs21.rm(nativeCheckpointJournalFile(paths, name), { force: true }); return journal; } async function continueNativeCheckpoint(paths, name, hooks) { @@ -21386,7 +21489,7 @@ async function settleNativeChangeJournalsLocked(paths, name) { // domains/comet-entry/current-selection.ts import { randomUUID as randomUUID5 } from "crypto"; -import { promises as fs21 } from "fs"; +import { promises as fs22 } from "fs"; import path33 from "path"; var COMET_CURRENT_SELECTION_SCHEMA = "comet.selection.v2"; var COMET_CURRENT_SELECTION_MAX_BYTES = 16 * 1024; @@ -21449,16 +21552,11 @@ function parseSelection(source) { async function readCometCurrentSelection(projectRoot) { let source; try { - const stat = await fs21.lstat(cometCurrentSelectionFile(projectRoot)); - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error("current change selection must be a regular file"); - } - if (stat.size > COMET_CURRENT_SELECTION_MAX_BYTES) { - throw new Error( - `current change selection exceeds ${COMET_CURRENT_SELECTION_MAX_BYTES} bytes` - ); - } - source = await fs21.readFile(cometCurrentSelectionFile(projectRoot), "utf8"); + const file = cometCurrentSelectionFile(projectRoot); + const { bytes } = await readFileRaceSafe(file, COMET_CURRENT_SELECTION_MAX_BYTES, { + label: "current change selection" + }); + source = bytes.toString("utf8"); } catch (error) { if (error.code === "ENOENT") return { status: "missing" }; throw new Error( @@ -21474,18 +21572,18 @@ async function writeCometCurrentSelection(projectRoot, selection) { if (parsed.legacy) throw new Error("cannot write a legacy current change selection"); const file = cometCurrentSelectionFile(projectRoot); const temporary = `${file}.${randomUUID5()}.tmp`; - await fs21.mkdir(path33.dirname(file), { recursive: true }); + await fs22.mkdir(path33.dirname(file), { recursive: true }); try { - await fs21.writeFile(temporary, `${JSON.stringify(parsed.selection, null, 2)} + await fs22.writeFile(temporary, `${JSON.stringify(parsed.selection, null, 2)} `, "utf8"); - await fs21.rename(temporary, file); + await fs22.rename(temporary, file); } catch (error) { - await fs21.rm(temporary, { force: true }); + await fs22.rm(temporary, { force: true }); throw error; } } async function clearCometCurrentSelection(projectRoot) { - await fs21.rm(cometCurrentSelectionFile(projectRoot), { force: true }); + await fs22.rm(cometCurrentSelectionFile(projectRoot), { force: true }); } async function clearCometCurrentSelectionIf(projectRoot, workflow, change) { const current = await readCometCurrentSelection(projectRoot); @@ -21599,7 +21697,7 @@ function archiveTarget(paths, name, now) { } async function pathExists2(target) { try { - await fs22.access(target); + await fs23.access(target); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -21910,7 +22008,7 @@ async function recoverArchiveTransaction(options) { } // domains/comet-native/native-diagnostics.ts -import { promises as fs23 } from "fs"; +import { promises as fs24 } from "fs"; // domains/comet-native/native-run-consistency.ts import path35 from "path"; @@ -23070,7 +23168,7 @@ async function boundedNativeChangeNames(paths) { throw error; } const names = []; - const directory = await fs23.opendir(paths.changesDir); + const directory = await fs24.opendir(paths.changesDir); try { for await (const entry2 of directory) { if (!entry2.isDirectory() || entry2.isSymbolicLink()) continue; @@ -23156,18 +23254,18 @@ async function listNativeStatus(paths) { } // domains/comet-native/native-doctor.ts -import { promises as fs27 } from "fs"; +import { promises as fs28 } from "fs"; import path41 from "path"; // domains/comet-native/native-evidence-retention.ts import { createHash as createHash13, randomUUID as randomUUID8 } from "node:crypto"; -import { constants as fsConstants4, promises as fs25 } from "node:fs"; +import { constants as fsConstants5, promises as fs26 } from "node:fs"; import path39 from "node:path"; // domains/comet-native/native-schema-migration.ts var import_yaml4 = __toESM(require_dist(), 1); import { randomUUID as randomUUID7 } from "crypto"; -import { promises as fs24 } from "fs"; +import { promises as fs25 } from "fs"; import path38 from "path"; import { isDeepStrictEqual as isDeepStrictEqual3 } from "util"; var HASH_PATTERN14 = /^[a-f0-9]{64}$/u; @@ -23650,7 +23748,7 @@ async function continueTransitionSupersede(paths, journal, hooks) { if (transitionHash !== supersede.sourceHash) { throw new Error(`Native superseded transition changed before removal for ${journal.change}`); } - await fs24.rm(transitionFile); + await fs25.rm(transitionFile); await hooks?.afterTransitionSuperseded?.(journal); } } @@ -23732,7 +23830,7 @@ async function continueNativeSchemaMigrationLocked(paths, name, hooks) { } await continueTransitionSupersede(paths, journal, hooks); await continueRunRetreat(paths, journal, hooks); - await fs24.rm(nativeSchemaMigrationJournalFile(paths, name), { force: true }); + await fs25.rm(nativeSchemaMigrationJournalFile(paths, name), { force: true }); return journal.nextState; } async function stableEvidenceRetreat(options) { @@ -24038,7 +24136,7 @@ var MANAGED_KINDS = [ "check-receipts" ]; function sameObjectIdentity(left, right) { - return sameNativeFileObject( + return sameFileObject( { ...left, birthtime: left.birthtimeMs }, { ...right, @@ -24089,11 +24187,11 @@ async function captureDirectoryChain6(managedRoot, directory) { let cursor = root; for (const segment of ["", ...segments]) { if (segment) cursor = path39.join(cursor, segment); - const stat = await fs25.lstat(cursor); + const stat = await fs26.lstat(cursor); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`Native evidence retention parent must be a real directory: ${cursor}`); } - const realPath = await fs25.realpath(cursor); + const realPath = await fs26.realpath(cursor); if (chain.length > 0 && !isInsidePath(chain[0].realPath, realPath)) { throw new Error(`Native evidence retention parent resolves outside its change: ${cursor}`); } @@ -24103,8 +24201,8 @@ async function captureDirectoryChain6(managedRoot, directory) { } async function verifyDirectoryChain7(chain) { for (const identity of chain) { - const stat = await fs25.lstat(identity.path); - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameObjectIdentity(identity, stat) || await fs25.realpath(identity.path) !== identity.realPath) { + const stat = await fs26.lstat(identity.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameObjectIdentity(identity, stat) || await fs26.realpath(identity.path) !== identity.realPath) { throw new Error(`Native evidence retention parent changed: ${identity.path}`); } } @@ -24176,7 +24274,7 @@ function parseDocument4(kind, hash6, value, expectedChange) { return { canonical: receipt, dependencies: [] }; } async function readCanonicalDocument(options) { - const before = await fs25.lstat(options.file); + const before = await fs26.lstat(options.file); if (!before.isFile() || before.isSymbolicLink()) { throw new Error(`Native evidence entry is not a regular file: ${options.ref}`); } @@ -24184,12 +24282,12 @@ async function readCanonicalDocument(options) { if (before.size > maximumBytes) { throw new Error(`Native evidence entry exceeds its byte budget: ${options.ref}`); } - const beforeRealPath = await fs25.realpath(options.file); + const beforeRealPath = await fs26.realpath(options.file); if (!isInsidePath(options.directoryChain[0].realPath, beforeRealPath)) { throw new Error(`Native evidence entry resolves outside its change: ${options.ref}`); } - const openFlags = process.platform === "win32" ? "r" : fsConstants4.O_RDONLY | fsConstants4.O_NOFOLLOW | fsConstants4.O_NONBLOCK; - const handle = await fs25.open(options.file, openFlags).catch((error) => { + const openFlags = process.platform === "win32" ? "r" : fsConstants5.O_RDONLY | fsConstants5.O_NOFOLLOW | fsConstants5.O_NONBLOCK; + const handle = await fs26.open(options.file, openFlags).catch((error) => { if (error.code === "ELOOP" || error.code === "ENXIO") { throw new Error(`Native evidence entry became unsafe while opening: ${options.ref}`); } @@ -24198,8 +24296,8 @@ async function readCanonicalDocument(options) { try { const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ handle.stat(), - fs25.lstat(options.file), - fs25.realpath(options.file) + fs26.lstat(options.file), + fs26.realpath(options.file) ]); await verifyDirectoryChain7(options.directoryChain); if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameIdentity(fileIdentity2(before), opened) || !sameIdentity(fileIdentity2(opened), pathAfterOpen)) { @@ -24220,8 +24318,8 @@ async function readCanonicalDocument(options) { } const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs25.lstat(options.file), - fs25.realpath(options.file) + fs26.lstat(options.file), + fs26.realpath(options.file) ]); await verifyDirectoryChain7(options.directoryChain); if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== beforeRealPath || !sameIdentity(fileIdentity2(opened), afterHandle) || !sameIdentity(fileIdentity2(opened), afterPath)) { @@ -24258,7 +24356,7 @@ async function readCanonicalDocument(options) { } async function pathIsMissing(file) { try { - await fs25.lstat(file); + await fs26.lstat(file); return false; } catch (error) { if (error.code === "ENOENT") return true; @@ -24272,7 +24370,7 @@ async function inspectCleanupQuarantines(options) { let rootEntries; try { const rootChain = await captureDirectoryChain6(changeRoot, evidenceRoot); - rootEntries = await fs25.readdir(evidenceRoot, { withFileTypes: true }); + rootEntries = await fs26.readdir(evidenceRoot, { withFileTypes: true }); await verifyDirectoryChain7(rootChain); } catch (error) { if (error.code === "ENOENT") { @@ -24285,7 +24383,7 @@ async function inspectCleanupQuarantines(options) { if (!rootEntries.some((entry2) => entry2.name === kind)) continue; const directory = path39.join(evidenceRoot, kind); const directoryChain = await captureDirectoryChain6(changeRoot, directory); - const entries = (await fs25.readdir(directory, { withFileTypes: true })).sort( + const entries = (await fs26.readdir(directory, { withFileTypes: true })).sort( (left, right) => compareText8(left.name, right.name) ); const byOriginal = /* @__PURE__ */ new Map(); @@ -24388,20 +24486,20 @@ async function inspectCleanupQuarantines(options) { `Native evidence cleanup original or quarantine changed during recovery: ${recovery.ref}` ); } - await fs25.rm(recovery.quarantine); + await fs26.rm(recovery.quarantine); await verifyDirectoryChain7(recovery.document.directoryChain); continue; } if (!await pathIsMissing(recovery.original)) { throw new Error(`Native evidence cleanup original appeared during recovery: ${recovery.ref}`); } - const quarantineStat = await fs25.lstat(recovery.quarantine); + const quarantineStat = await fs26.lstat(recovery.quarantine); if (!quarantineStat.isFile() || quarantineStat.isSymbolicLink() || !sameIdentity(recovery.document.identity, quarantineStat)) { throw new Error( `Native evidence cleanup quarantine changed during recovery: ${recovery.ref}` ); } - await fs25.link(recovery.quarantine, recovery.original); + await fs26.link(recovery.quarantine, recovery.original); await options.hooks.afterRecoveryLink?.({ ref: recovery.ref, original: recovery.original, @@ -24428,7 +24526,7 @@ async function inspectCleanupQuarantines(options) { if (!sameContentVersionAfterLinkOrRename(recovery.document.identity, restored.identity) || !sameIdentity(restored.identity, linkedQuarantine.identity)) { throw new Error(`Native evidence cleanup recovery identity changed: ${recovery.ref}`); } - await fs25.rm(recovery.quarantine); + await fs26.rm(recovery.quarantine); await verifyDirectoryChain7(recovery.document.directoryChain); } return { @@ -24452,7 +24550,7 @@ async function scanEvidenceStore(paths, name) { let rootEntries; try { const rootChain = await captureDirectoryChain6(changeRoot, evidenceRoot); - rootEntries = await fs25.readdir(evidenceRoot, { withFileTypes: true }); + rootEntries = await fs26.readdir(evidenceRoot, { withFileTypes: true }); await verifyDirectoryChain7(rootChain); } catch (error) { if (error.code === "ENOENT") return []; @@ -24472,7 +24570,7 @@ async function scanEvidenceStore(paths, name) { if (!rootEntries.some((entry2) => entry2.name === kind)) continue; const directory = path39.join(evidenceRoot, kind); const directoryChain = await captureDirectoryChain6(changeRoot, directory); - const entries = (await fs25.readdir(directory, { withFileTypes: true })).sort( + const entries = (await fs26.readdir(directory, { withFileTypes: true })).sort( (left, right) => compareText8(left.name, right.name) ); await verifyDirectoryChain7(directoryChain); @@ -24616,13 +24714,13 @@ function summaryFinding(name, plan, repaired, evidenceRoot) { } async function restoreQuarantineIfSafe(quarantine, original) { try { - await fs25.lstat(original); + await fs26.lstat(original); return; } catch (error) { if (error.code !== "ENOENT") return; } try { - await fs25.rename(quarantine, original); + await fs26.rename(quarantine, original); } catch { } } @@ -24638,8 +24736,8 @@ async function deleteCandidate(candidate, hooks) { name: candidate.change, directoryChain: candidate.directoryChain }); - const before = await fs25.lstat(candidate.file); - const beforeRealPath = await fs25.realpath(candidate.file); + const before = await fs26.lstat(candidate.file); + const beforeRealPath = await fs26.realpath(candidate.file); if (!before.isFile() || before.isSymbolicLink() || !sameIdentity(candidate.identity, before) || !sameIdentity(candidate.identity, beforeDocument.identity) || !isInsidePath(candidate.directoryChain[0].realPath, beforeRealPath)) { throw new Error(`Native evidence candidate changed before cleanup: ${candidate.ref}`); } @@ -24647,18 +24745,18 @@ async function deleteCandidate(candidate, hooks) { path39.dirname(candidate.file), `.${path39.basename(candidate.file)}.${randomUUID8()}.gc` ); - await fs25.rename(candidate.file, quarantine); + await fs26.rename(candidate.file, quarantine); try { const [moved, movedRealPath] = await Promise.all([ - fs25.lstat(quarantine), - fs25.realpath(quarantine) + fs26.lstat(quarantine), + fs26.realpath(quarantine) ]); await verifyDirectoryChain7(candidate.directoryChain); if (!moved.isFile() || moved.isSymbolicLink() || !sameContentVersionAfterLinkOrRename(candidate.identity, moved) || !isInsidePath(candidate.directoryChain[0].realPath, movedRealPath)) { await restoreQuarantineIfSafe(quarantine, candidate.file); throw new Error(`Native evidence candidate changed during cleanup: ${candidate.ref}`); } - await fs25.rm(quarantine); + await fs26.rm(quarantine); await verifyDirectoryChain7(candidate.directoryChain); } catch (error) { await restoreQuarantineIfSafe(quarantine, candidate.file); @@ -24670,7 +24768,7 @@ async function hasPendingRelocationOrArchive(paths) { if (config?.native.pending_root_move) return true; let entries; try { - entries = await fs25.readdir(paths.transactionsDir, { withFileTypes: true }); + entries = await fs26.readdir(paths.transactionsDir, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -24690,7 +24788,7 @@ async function changeNames(paths, requested) { if (requested) return [requested]; let entries; try { - entries = await fs25.readdir(paths.changesDir, { withFileTypes: true }); + entries = await fs26.readdir(paths.changesDir, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return []; throw error; @@ -24820,14 +24918,14 @@ async function inspectNativeEvidenceRetention(options) { // domains/comet-native/native-root-move.ts import { createHash as createHash14, randomUUID as randomUUID9 } from "crypto"; -import { promises as fs26 } from "fs"; +import { promises as fs27 } from "fs"; import path40 from "path"; var NATIVE_ROOT_MOVE_MAX_FILE_BYTES = 64 * 1024 * 1024; var NATIVE_ROOT_MOVE_MAX_JOURNAL_BYTES = 256 * 1024; var NATIVE_ROOT_MOVE_MAX_MANIFEST_BYTES = 16 * 1024 * 1024; async function exists3(target) { try { - await fs26.access(target); + await fs27.access(target); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -24837,7 +24935,7 @@ async function exists3(target) { async function assertNoUnfinishedTransactions(paths) { let entries; try { - entries = await fs26.readdir(paths.transactionsDir, { withFileTypes: true }); + entries = await fs27.readdir(paths.transactionsDir, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return; throw error; @@ -24862,7 +24960,7 @@ async function assertNoUnfinishedTransactions(paths) { } } async function assertNoOtherLocks(paths, ownedLock) { - for (const entry2 of await fs26.readdir(paths.locksDir, { withFileTypes: true })) { + for (const entry2 of await fs27.readdir(paths.locksDir, { withFileTypes: true })) { const file = path40.join(paths.locksDir, entry2.name); if (path40.resolve(file) === path40.resolve(ownedLock)) continue; if (entry2.isFile() || entry2.isSymbolicLink()) { @@ -24873,7 +24971,7 @@ async function assertNoOtherLocks(paths, ownedLock) { async function refreshNativeWorkspaceIdentities(paths) { let entries; try { - entries = await fs26.readdir(paths.changesDir, { withFileTypes: true }); + entries = await fs27.readdir(paths.changesDir, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return; throw error; @@ -24913,7 +25011,7 @@ async function walkTree(root, options) { await protectedDirectory.verify(); continue; } - const stat = await fs26.lstat(target); + const stat = await fs27.lstat(target); if (entry2.isSymbolicLink() || stat.isSymbolicLink()) { if (options.rejectSymlinks) throw new Error(`Native root contains a symlink: ${target}`); await protectedDirectory.verify(); @@ -24967,7 +25065,7 @@ async function copyTree(source, target, excludedFile, targetRoot) { await protectedDirectory.verify(); continue; } - const stat = await fs26.lstat(sourceEntry); + const stat = await fs27.lstat(sourceEntry); if (entry2.isSymbolicLink() || stat.isSymbolicLink()) { throw new Error(`Native root contains a symlink: ${sourceEntry}`); } @@ -25414,7 +25512,7 @@ async function finishForwardMove(options) { options.staging, options.lockFile ); - await fs26.rename(options.staging, options.destinationPaths.nativeRoot); + await fs27.rename(options.staging, options.destinationPaths.nativeRoot); } config = await setPendingStage({ projectRoot: options.projectRoot, @@ -25734,7 +25832,7 @@ async function inspectSelection(paths, repair) { } } if (repair) { - await fs27.rm(file, { force: true }); + await fs28.rm(file, { force: true }); return [ { severity: "info", @@ -26321,7 +26419,7 @@ async function doctorNativeProject(options) { // domains/comet-native/native-check-receipt.ts import { createHash as createHash15 } from "node:crypto"; -import { constants as fsConstants5, promises as fs28 } from "node:fs"; +import { constants as fsConstants6, promises as fs29 } from "node:fs"; import path42 from "node:path"; import { TextDecoder as TextDecoder5 } from "node:util"; var ISSUE_KIND_RANK = { @@ -26408,7 +26506,7 @@ function staleReasons(before, after, scope) { } function sameDirectoryIdentity7(identity, stat) { const stableMetadata = identity.birthtimeMs === stat.birthtimeMs && identity.ctimeMs === stat.ctimeMs; - return stableMetadata && sameNativeFileObject( + return stableMetadata && sameFileObject( { ...identity, birthtime: identity.birthtimeMs }, { ...stat, @@ -26418,7 +26516,7 @@ function sameDirectoryIdentity7(identity, stat) { } function sameFileIdentity8(left, right) { const stableMetadata = left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.mtimeMs === right.mtimeMs && left.size === right.size; - return stableMetadata && sameNativeFileObject( + return stableMetadata && sameFileObject( { ...left, birthtime: left.birthtimeMs }, { ...right, @@ -26441,7 +26539,7 @@ async function captureProjectDirectoryChain(projectRoot, directory) { if (segment) cursor = path42.join(cursor, segment); let stat; try { - stat = await fs28.lstat(cursor); + stat = await fs29.lstat(cursor); } catch (error) { if (error.code === "ENOENT") { throw new ScopedFileError("scope-mismatch", "Scoped file parent no longer exists"); @@ -26451,7 +26549,7 @@ async function captureProjectDirectoryChain(projectRoot, directory) { if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new ScopedFileError("unsafe-file", "Scoped file parent is not a real directory"); } - const realPath = await fs28.realpath(cursor); + const realPath = await fs29.realpath(cursor); if (chain.length > 0 && !isInsidePath(chain[0].realPath, realPath)) { throw new ScopedFileError("unsafe-file", "Scoped file parent resolves outside the project"); } @@ -26470,14 +26568,14 @@ async function verifyProjectDirectoryChain(chain) { for (const identity of chain) { let stat; try { - stat = await fs28.lstat(identity.path); + stat = await fs29.lstat(identity.path); } catch (error) { if (error.code === "ENOENT") { throw new ScopedFileError("unsafe-file", "Scoped file parent changed while reading"); } throw error; } - if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity7(identity, stat) || await fs28.realpath(identity.path) !== identity.realPath) { + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameDirectoryIdentity7(identity, stat) || await fs29.realpath(identity.path) !== identity.realPath) { throw new ScopedFileError("unsafe-file", "Scoped file parent changed while reading"); } } @@ -26493,7 +26591,7 @@ async function readScopedFile(options) { ); let before; try { - before = await fs28.lstat(lexicalFile); + before = await fs29.lstat(lexicalFile); } catch (error) { if (error.code === "ENOENT") { throw new ScopedFileError("scope-mismatch", "Scoped file no longer exists"); @@ -26509,12 +26607,12 @@ async function readScopedFile(options) { "Scoped file size no longer matches its projection" ); } - const beforeRealPath = await fs28.realpath(lexicalFile); + const beforeRealPath = await fs29.realpath(lexicalFile); if (!isInsidePath(physicalRoot, beforeRealPath)) { throw new ScopedFileError("unsafe-file", "Scoped file resolves outside the project root"); } - const openFlags = process.platform === "win32" ? "r" : fsConstants5.O_RDONLY | fsConstants5.O_NOFOLLOW | fsConstants5.O_NONBLOCK; - const handle = await fs28.open(lexicalFile, openFlags).catch((error) => { + const openFlags = process.platform === "win32" ? "r" : fsConstants6.O_RDONLY | fsConstants6.O_NOFOLLOW | fsConstants6.O_NONBLOCK; + const handle = await fs29.open(lexicalFile, openFlags).catch((error) => { if (error.code === "ENOENT") { throw new ScopedFileError("scope-mismatch", "Scoped file no longer exists"); } @@ -26526,8 +26624,8 @@ async function readScopedFile(options) { try { const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ handle.stat(), - fs28.lstat(lexicalFile), - fs28.realpath(lexicalFile) + fs29.lstat(lexicalFile), + fs29.realpath(lexicalFile) ]); await verifyProjectDirectoryChain(chain); if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameFileIdentity8(before, opened) || !sameFileIdentity8(opened, pathAfterOpen)) { @@ -26554,8 +26652,8 @@ async function readScopedFile(options) { } const [afterHandle, afterPath, afterRealPath] = await Promise.all([ handle.stat(), - fs28.lstat(lexicalFile), - fs28.realpath(lexicalFile) + fs29.lstat(lexicalFile), + fs29.realpath(lexicalFile) ]); await verifyProjectDirectoryChain(chain); if (total !== options.file.expected.size || digest.digest("hex") !== options.file.expected.hash) { @@ -26869,7 +26967,7 @@ async function checkpointNativeChange(options) { } // domains/comet-native/native-specs.ts -import { promises as fs29 } from "fs"; +import { promises as fs30 } from "fs"; import path43 from "path"; var MAX_NATIVE_PROPOSED_SPEC_DIRECTORY_ENTRIES = NATIVE_CONTRACT_FILE_LIMITS.maxSpecs * 4; async function optionalHash3(file) { @@ -26895,7 +26993,7 @@ async function proposedCapabilities(paths, name) { } const capabilities = []; let entryCount = 0; - const directory = await fs29.opendir(specsDir); + const directory = await fs30.opendir(specsDir); try { for await (const entry2 of directory) { entryCount += 1; @@ -26913,7 +27011,7 @@ async function proposedCapabilities(paths, name) { await resolveContainedNativePath(paths.nativeRoot, source); let stat; try { - stat = await fs29.lstat(source); + stat = await fs30.lstat(source); } catch (error) { if (error.code === "ENOENT") continue; throw error; @@ -27133,7 +27231,7 @@ async function readNativeProposedSpecs(paths, name) { import { randomUUID as randomUUID11 } from "crypto"; // domains/comet-native/native-guards.ts -import { promises as fs30 } from "fs"; +import { promises as fs31 } from "fs"; import path44 from "path"; function validation(findings) { return { valid: findings.length === 0, findings }; @@ -27166,7 +27264,7 @@ async function validateBuildArtifacts(paths, evidence) { continue; } try { - await fs30.access(target); + await fs31.access(target); } catch { findings.push({ code: "build-artifact-missing", @@ -27250,7 +27348,7 @@ async function inspectNativeGuard(options) { } // domains/comet-native/native-build-evidence.ts -import { promises as fs31 } from "node:fs"; +import { promises as fs32 } from "node:fs"; import path45 from "node:path"; var NATIVE_BUILD_EVIDENCE_LIMITS = { maxDeclaredArtifacts: 128, @@ -27326,12 +27424,12 @@ async function inspectDeclaredArtifact(paths, baseline, rawRef) { } await resolveContainedNativePath(paths.projectRoot, target); try { - const stat = await fs31.lstat(target); + const stat = await fs32.lstat(target); if (stat.isSymbolicLink()) { throw new Error(`Native build artifact must not be a symlink or junction: ${artifactRef}`); } - const realTarget = await fs31.realpath(target); - const realProjectRoot = await fs31.realpath(paths.projectRoot); + const realTarget = await fs32.realpath(target); + const realProjectRoot = await fs32.realpath(paths.projectRoot); if (!isInsidePath(realProjectRoot, realTarget)) { throw new Error(`Native build artifact resolves outside the project: ${artifactRef}`); } @@ -28039,7 +28137,7 @@ async function advanceNativeChangeLocked(options) { } // domains/comet-native/native-hook-guard.ts -import { promises as fs32 } from "fs"; +import { promises as fs33 } from "fs"; import path46 from "path"; // domains/comet-entry/hook-adapter.ts @@ -28204,7 +28302,7 @@ async function activeNativeContext(projectRoot) { const paths = await nativeProjectPaths(projectRoot, config.native.artifact_root); let entries; try { - entries = await fs32.readdir(paths.changesDir, { withFileTypes: true }); + entries = await fs33.readdir(paths.changesDir, { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return { paths, changes: [] }; throw error; @@ -28325,6 +28423,7 @@ Commands: select checkpoint --summary --next-action [--artifact ] [--expect-revision ] check + evidence format [--entries ] next --summary [--confirmed] [--artifact ] [--no-code-reason ] [--allow-partial-scope --partial-reason ] [--result pass|fail] [--report ] [--receipt ] [--failure-category ] [--failed-check ] [--override-repair --override-summary ] archive --dry-run archive --expect-preflight @@ -28405,6 +28504,44 @@ async function doctorPaths(projectRoot) { function success(command, data, text) { return { command, exitCode: 0, data, text: text ?? JSON.stringify(data, null, 2) + "\n" }; } +async function readBoundedEvidenceFile(filePath, maxBytes) { + try { + const { bytes } = await readFileRaceSafe(filePath, maxBytes, { + label: "Acceptance evidence entries file" + }); + return bytes.toString("utf8"); + } catch (error) { + if (error instanceof RaceSafeReadError) { + if (error.reason === "not-regular-file") { + throw new Error(`Acceptance evidence entries path is not a regular file: ${filePath}`, { + cause: error + }); + } + if (error.reason === "too-large") { + throw new Error(`Acceptance evidence entries file exceeds ${maxBytes} bytes: ${filePath}`, { + cause: error + }); + } + throw new Error(`Acceptance evidence entries file changed while reading: ${filePath}`, { + cause: error + }); + } + throw error; + } +} +async function readBoundedEvidenceStdin(maxBytes) { + const chunks = []; + let total = 0; + for await (const chunk of process.stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + total += buffer.byteLength; + if (total > maxBytes) { + throw new Error(`Acceptance evidence entries on stdin exceed ${maxBytes} bytes`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} async function dispatch(rawArgs, explicitProjectRoot) { if (rawArgs.length === 0 || rawArgs[0] === "--help" || rawArgs[0] === "help") { return { command: rawArgs[0] ?? null, exitCode: 0, data: { usage: USAGE }, text: USAGE }; @@ -28688,6 +28825,43 @@ async function dispatch(rawArgs, explicitProjectRoot) { ` }; } + if (command === "evidence") { + const subcommand = requiredPositional(rawArgs, "evidence subcommand"); + if (subcommand === "format") { + const entriesPath = takeOption(rawArgs, "--entries"); + assertNoArguments(rawArgs); + let raw; + if (entriesPath) { + raw = await readBoundedEvidenceFile( + path47.resolve(entriesPath), + MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES + ); + } else { + if (process.stdin.isTTY) { + throw new NativeUsageError( + "evidence format requires acceptance evidence entries JSON on stdin, or --entries " + ); + } + raw = await readBoundedEvidenceStdin(MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES); + } + let entries; + try { + entries = JSON.parse(raw); + } catch (error) { + throw new Error( + `Acceptance evidence entries must be valid JSON: ${error.message}`, + { cause: error } + ); + } + if (!Array.isArray(entries)) { + throw new Error("Acceptance evidence entries must be a JSON array"); + } + const block = serializeNativeVerificationMachineBlock(entries); + return success("evidence format", { block }, `${block} +`); + } + throw new NativeUsageError(`Unknown evidence command: ${subcommand}`); + } if (command === "next") { const name = requiredPositional(rawArgs, "change name"); const summary = takeOption(rawArgs, "--summary"); diff --git a/assets/skills/comet/scripts/comet-entry-runtime.mjs b/assets/skills/comet/scripts/comet-entry-runtime.mjs index d972484b..4212a73e 100644 --- a/assets/skills/comet/scripts/comet-entry-runtime.mjs +++ b/assets/skills/comet/scripts/comet-entry-runtime.mjs @@ -7375,11 +7375,11 @@ import path3 from "path"; // domains/workflow-contract/project-config.ts var import_yaml = __toESM(require_dist(), 1); -// domains/comet-native/native-file-identity.ts +// platform/fs/file-identity.ts function hasPlatformIdentity(value) { return value !== 0 && value !== 0n && value !== "0"; } -function sameNativeFileObject(left, right) { +function sameFileObject(left, right) { const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); if (comparableDevice && left.dev !== right.dev) return false; const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); @@ -7404,7 +7404,7 @@ function positiveLimit(value) { return value; } function sameDirectoryIdentity(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -7423,7 +7423,7 @@ function asFileIdentity(stat) { }; } function sameFileIdentity(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, diff --git a/assets/skills/comet/scripts/comet-hook-router.mjs b/assets/skills/comet/scripts/comet-hook-router.mjs index 09024f36..fa8f3150 100644 --- a/assets/skills/comet/scripts/comet-hook-router.mjs +++ b/assets/skills/comet/scripts/comet-hook-router.mjs @@ -7964,8 +7964,127 @@ import { promises as fs6 } from "fs"; import path6 from "path"; // domains/comet-entry/current-selection.ts -import { promises as fs3 } from "fs"; import path3 from "path"; + +// platform/fs/race-safe-read.ts +import { constants as fsConstants, promises as fs3 } from "fs"; + +// platform/fs/file-identity.ts +function hasPlatformIdentity(value) { + return value !== 0 && value !== 0n && value !== "0"; +} +function hasComparableFileObject(left, right) { + return hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev) && hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); +} +function sameFileObject(left, right) { + const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); + if (comparableDevice && left.dev !== right.dev) return false; + const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); + if (comparableInode && left.ino !== right.ino) return false; + if (comparableDevice && comparableInode) return true; + return left.birthtime === right.birthtime; +} + +// platform/fs/race-safe-read.ts +var RaceSafeReadError = class extends Error { + reason; + constructor(reason, message, options) { + super(message, options); + this.name = "RaceSafeReadError"; + this.reason = reason; + } +}; +function birthtimeOf(stat) { + return "birthtimeNs" in stat && typeof stat.birthtimeNs === "bigint" ? stat.birthtimeNs : stat.birthtimeMs; +} +function ctimeOf(stat) { + return "ctimeNs" in stat && typeof stat.ctimeNs === "bigint" ? stat.ctimeNs : stat.ctimeMs; +} +function identityOf(stat) { + return { dev: stat.dev, ino: stat.ino, birthtime: birthtimeOf(stat) }; +} +function sameStatIdentity(left, right) { + const leftObject = identityOf(left); + const rightObject = identityOf(right); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); + } + return sameFileObject(leftObject, rightObject) && birthtimeOf(left) === birthtimeOf(right) && ctimeOf(left) === ctimeOf(right) && left.size === right.size; +} +async function readFileRaceSafe(file, maxBytes, options = {}) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error("race-safe read byte limit must be a positive integer"); + } + const label = options.label ?? "file"; + const bigint = options.bigint === true; + const before = await fs3.lstat(file, { bigint }); + if (!before.isFile() || before.isSymbolicLink()) { + throw new RaceSafeReadError("not-regular-file", `${label} must be a regular file`); + } + if (BigInt(before.size) > BigInt(maxBytes)) { + throw new RaceSafeReadError("too-large", `${label} exceeds ${maxBytes} bytes`); + } + const beforeRealPath = await fs3.realpath(file); + await options.verify?.("pre-open", { realPath: beforeRealPath, identity: identityOf(before) }); + const flags = process.platform === "win32" ? fsConstants.O_RDONLY : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + let handle; + try { + handle = await fs3.open(file, flags); + } catch (error) { + if (error.code === "ELOOP") { + throw new RaceSafeReadError("not-regular-file", `${label} must be a regular file`, { + cause: error + }); + } + throw error; + } + try { + const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ + handle.stat({ bigint }), + fs3.lstat(file, { bigint }), + fs3.realpath(file) + ]); + if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameStatIdentity(before, opened) || !sameStatIdentity(before, pathAfterOpen)) { + throw new RaceSafeReadError("changed", `${label} changed while opening`); + } + await options.verify?.("post-open", { + realPath: realPathAfterOpen, + identity: identityOf(opened) + }); + await options.hooks?.afterOpen?.(); + const chunks = []; + let total = 0; + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1)); + for (; ; ) { + const remaining = maxBytes + 1 - total; + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > maxBytes) { + throw new RaceSafeReadError("too-large", `${label} exceeds ${maxBytes} bytes`); + } + chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + } + await options.hooks?.beforeFinalCheck?.(); + const [afterHandle, afterPath, afterRealPath] = await Promise.all([ + handle.stat({ bigint }), + fs3.lstat(file, { bigint }), + fs3.realpath(file) + ]); + if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== beforeRealPath || !sameStatIdentity(before, afterHandle) || !sameStatIdentity(before, afterPath)) { + throw new RaceSafeReadError("changed", `${label} changed while reading`); + } + await options.verify?.("post-read", { + realPath: afterRealPath, + identity: identityOf(afterHandle) + }); + return { bytes: Buffer.concat(chunks, total), stat: afterHandle, realPath: afterRealPath }; + } finally { + await handle.close(); + } +} + +// domains/comet-entry/current-selection.ts var COMET_CURRENT_SELECTION_SCHEMA = "comet.selection.v2"; var COMET_CURRENT_SELECTION_MAX_BYTES = 16 * 1024; function cometCurrentSelectionFile(projectRoot) { @@ -8027,16 +8146,11 @@ function parseSelection(source) { async function readCometCurrentSelection(projectRoot) { let source; try { - const stat = await fs3.lstat(cometCurrentSelectionFile(projectRoot)); - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error("current change selection must be a regular file"); - } - if (stat.size > COMET_CURRENT_SELECTION_MAX_BYTES) { - throw new Error( - `current change selection exceeds ${COMET_CURRENT_SELECTION_MAX_BYTES} bytes` - ); - } - source = await fs3.readFile(cometCurrentSelectionFile(projectRoot), "utf8"); + const file = cometCurrentSelectionFile(projectRoot); + const { bytes } = await readFileRaceSafe(file, COMET_CURRENT_SELECTION_MAX_BYTES, { + label: "current change selection" + }); + source = bytes.toString("utf8"); } catch (error) { if (error.code === "ENOENT") return { status: "missing" }; throw new Error( @@ -9054,22 +9168,6 @@ function nativeSensitiveRelativePathReason(relativeRef) { return null; } -// domains/comet-native/native-file-identity.ts -function hasPlatformIdentity(value) { - return value !== 0 && value !== 0n && value !== "0"; -} -function hasComparableNativeFileObject(left, right) { - return hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev) && hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); -} -function sameNativeFileObject(left, right) { - const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); - if (comparableDevice && left.dev !== right.dev) return false; - const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); - if (comparableInode && left.ino !== right.ino) return false; - if (comparableDevice && comparableInode) return true; - return left.birthtime === right.birthtime; -} - // domains/comet-native/native-bounded-file.ts var DEFAULT_NATIVE_ARTIFACT_MAX_BYTES = 1024 * 1024; function isInside(parent, target) { @@ -9100,7 +9198,7 @@ function positiveLimit(value) { return value; } function sameDirectoryIdentity(identity, stat) { - return sameNativeFileObject( + return sameFileObject( { ...identity, birthtime: identity.birthtimeMs }, { ...stat, @@ -9111,10 +9209,10 @@ function sameDirectoryIdentity(identity, stat) { function sameFileIdentity(left, right) { const leftObject = { ...left, birthtime: left.birthtimeMs }; const rightObject = { ...right, birthtime: right.birthtimeMs }; - if (hasComparableNativeFileObject(leftObject, rightObject)) { - return sameNativeFileObject(leftObject, rightObject); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); } - return sameNativeFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; + return sameFileObject(leftObject, rightObject) && left.birthtimeMs === right.birthtimeMs && left.ctimeMs === right.ctimeMs && left.size === right.size; } async function directoryIdentity(directory) { const stat = await fs8.lstat(directory); @@ -9231,7 +9329,7 @@ var import_yaml3 = __toESM(require_dist(), 1); // domains/comet-native/native-protected-file.ts import { createHash as createHash2 } from "node:crypto"; -import { constants as fsConstants, promises as fs9 } from "node:fs"; +import { constants as fsConstants2, promises as fs9 } from "node:fs"; import path9 from "node:path"; import { TextDecoder as TextDecoder2 } from "node:util"; function isInside2(parent, target) { @@ -9245,7 +9343,7 @@ function positiveLimit2(value) { return value; } function sameDirectoryIdentity2(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -9264,7 +9362,7 @@ function asFileIdentity(stat) { }; } function sameFileIdentity2(expected, actual) { - return sameNativeFileObject( + return sameFileObject( { ...expected, birthtime: expected.birthtimeMs }, { ...actual, @@ -9351,7 +9449,7 @@ async function readNativeProtectedFile(options) { if (forbidden.some((identity) => isInside2(identity.realPath, beforeRealPath))) { throw new Error(`${options.label} resolves inside an excluded root`); } - const flags = process.platform === "win32" ? fsConstants.O_RDONLY : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + const flags = process.platform === "win32" ? fsConstants2.O_RDONLY : fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW | fsConstants2.O_NONBLOCK; const handle = await fs9.open(file, flags); try { const opened = await handle.stat(); diff --git a/assets/skills/comet/scripts/comet-runtime.mjs b/assets/skills/comet/scripts/comet-runtime.mjs index fe312f3f..5cc545d8 100644 --- a/assets/skills/comet/scripts/comet-runtime.mjs +++ b/assets/skills/comet/scripts/comet-runtime.mjs @@ -6927,14 +6927,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs23 = this.flowScalar(this.type); + const fs24 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs23, sep: [] }); + map.items.push({ start, key: fs24, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs23); + this.stack.push(fs24); } else { - Object.assign(it, { key: fs23, sep: [] }); + Object.assign(it, { key: fs24, sep: [] }); this.onKeyLine = true; } return; @@ -7062,13 +7062,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs23 = this.flowScalar(this.type); + const fs24 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs23, sep: [] }); + fc.items.push({ start: [], key: fs24, sep: [] }); else if (it.sep) - this.stack.push(fs23); + this.stack.push(fs24); else - Object.assign(it, { key: fs23, sep: [] }); + Object.assign(it, { key: fs24, sep: [] }); return; } case "flow-map-end": @@ -7546,7 +7546,7 @@ import { pathToFileURL } from "url"; // domains/comet-classic/classic-archive.ts import { createHash as createHash3 } from "crypto"; import { spawnSync } from "child_process"; -import { promises as fs14 } from "fs"; +import { promises as fs15 } from "fs"; import path15 from "path"; // domains/comet-classic/classic-paths.ts @@ -9531,13 +9531,133 @@ function applyClassicTransition(current, event, options = {}) { } // domains/comet-classic/classic-current-change.ts -import { promises as fs13 } from "fs"; +import { promises as fs14 } from "fs"; import path14 from "path"; // domains/comet-entry/current-selection.ts import { randomUUID as randomUUID6 } from "crypto"; -import { promises as fs11 } from "fs"; +import { promises as fs12 } from "fs"; import path12 from "path"; + +// platform/fs/race-safe-read.ts +import { constants as fsConstants, promises as fs11 } from "fs"; + +// platform/fs/file-identity.ts +function hasPlatformIdentity(value) { + return value !== 0 && value !== 0n && value !== "0"; +} +function hasComparableFileObject(left, right) { + return hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev) && hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); +} +function sameFileObject(left, right) { + const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); + if (comparableDevice && left.dev !== right.dev) return false; + const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); + if (comparableInode && left.ino !== right.ino) return false; + if (comparableDevice && comparableInode) return true; + return left.birthtime === right.birthtime; +} + +// platform/fs/race-safe-read.ts +var RaceSafeReadError = class extends Error { + reason; + constructor(reason, message, options) { + super(message, options); + this.name = "RaceSafeReadError"; + this.reason = reason; + } +}; +function birthtimeOf(stat) { + return "birthtimeNs" in stat && typeof stat.birthtimeNs === "bigint" ? stat.birthtimeNs : stat.birthtimeMs; +} +function ctimeOf(stat) { + return "ctimeNs" in stat && typeof stat.ctimeNs === "bigint" ? stat.ctimeNs : stat.ctimeMs; +} +function identityOf(stat) { + return { dev: stat.dev, ino: stat.ino, birthtime: birthtimeOf(stat) }; +} +function sameStatIdentity(left, right) { + const leftObject = identityOf(left); + const rightObject = identityOf(right); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); + } + return sameFileObject(leftObject, rightObject) && birthtimeOf(left) === birthtimeOf(right) && ctimeOf(left) === ctimeOf(right) && left.size === right.size; +} +async function readFileRaceSafe(file, maxBytes, options = {}) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error("race-safe read byte limit must be a positive integer"); + } + const label = options.label ?? "file"; + const bigint = options.bigint === true; + const before = await fs11.lstat(file, { bigint }); + if (!before.isFile() || before.isSymbolicLink()) { + throw new RaceSafeReadError("not-regular-file", `${label} must be a regular file`); + } + if (BigInt(before.size) > BigInt(maxBytes)) { + throw new RaceSafeReadError("too-large", `${label} exceeds ${maxBytes} bytes`); + } + const beforeRealPath = await fs11.realpath(file); + await options.verify?.("pre-open", { realPath: beforeRealPath, identity: identityOf(before) }); + const flags = process.platform === "win32" ? fsConstants.O_RDONLY : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + let handle; + try { + handle = await fs11.open(file, flags); + } catch (error) { + if (error.code === "ELOOP") { + throw new RaceSafeReadError("not-regular-file", `${label} must be a regular file`, { + cause: error + }); + } + throw error; + } + try { + const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ + handle.stat({ bigint }), + fs11.lstat(file, { bigint }), + fs11.realpath(file) + ]); + if (!opened.isFile() || !pathAfterOpen.isFile() || pathAfterOpen.isSymbolicLink() || realPathAfterOpen !== beforeRealPath || !sameStatIdentity(before, opened) || !sameStatIdentity(before, pathAfterOpen)) { + throw new RaceSafeReadError("changed", `${label} changed while opening`); + } + await options.verify?.("post-open", { + realPath: realPathAfterOpen, + identity: identityOf(opened) + }); + await options.hooks?.afterOpen?.(); + const chunks = []; + let total = 0; + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1)); + for (; ; ) { + const remaining = maxBytes + 1 - total; + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > maxBytes) { + throw new RaceSafeReadError("too-large", `${label} exceeds ${maxBytes} bytes`); + } + chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + } + await options.hooks?.beforeFinalCheck?.(); + const [afterHandle, afterPath, afterRealPath] = await Promise.all([ + handle.stat({ bigint }), + fs11.lstat(file, { bigint }), + fs11.realpath(file) + ]); + if (!afterPath.isFile() || afterPath.isSymbolicLink() || afterRealPath !== beforeRealPath || !sameStatIdentity(before, afterHandle) || !sameStatIdentity(before, afterPath)) { + throw new RaceSafeReadError("changed", `${label} changed while reading`); + } + await options.verify?.("post-read", { + realPath: afterRealPath, + identity: identityOf(afterHandle) + }); + return { bytes: Buffer.concat(chunks, total), stat: afterHandle, realPath: afterRealPath }; + } finally { + await handle.close(); + } +} + +// domains/comet-entry/current-selection.ts var COMET_CURRENT_SELECTION_SCHEMA = "comet.selection.v2"; var COMET_CURRENT_SELECTION_MAX_BYTES = 16 * 1024; function cometCurrentSelectionFile(projectRoot2) { @@ -9599,16 +9719,11 @@ function parseSelection(source) { async function readCometCurrentSelection(projectRoot2) { let source; try { - const stat = await fs11.lstat(cometCurrentSelectionFile(projectRoot2)); - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error("current change selection must be a regular file"); - } - if (stat.size > COMET_CURRENT_SELECTION_MAX_BYTES) { - throw new Error( - `current change selection exceeds ${COMET_CURRENT_SELECTION_MAX_BYTES} bytes` - ); - } - source = await fs11.readFile(cometCurrentSelectionFile(projectRoot2), "utf8"); + const file = cometCurrentSelectionFile(projectRoot2); + const { bytes } = await readFileRaceSafe(file, COMET_CURRENT_SELECTION_MAX_BYTES, { + label: "current change selection" + }); + source = bytes.toString("utf8"); } catch (error) { if (error.code === "ENOENT") return { status: "missing" }; throw new Error( @@ -9624,18 +9739,18 @@ async function writeCometCurrentSelection(projectRoot2, selection) { if (parsed.legacy) throw new Error("cannot write a legacy current change selection"); const file = cometCurrentSelectionFile(projectRoot2); const temporary = `${file}.${randomUUID6()}.tmp`; - await fs11.mkdir(path12.dirname(file), { recursive: true }); + await fs12.mkdir(path12.dirname(file), { recursive: true }); try { - await fs11.writeFile(temporary, `${JSON.stringify(parsed.selection, null, 2)} + await fs12.writeFile(temporary, `${JSON.stringify(parsed.selection, null, 2)} `, "utf8"); - await fs11.rename(temporary, file); + await fs12.rename(temporary, file); } catch (error) { - await fs11.rm(temporary, { force: true }); + await fs12.rm(temporary, { force: true }); throw error; } } async function clearCometCurrentSelection(projectRoot2) { - await fs11.rm(cometCurrentSelectionFile(projectRoot2), { force: true }); + await fs12.rm(cometCurrentSelectionFile(projectRoot2), { force: true }); } async function clearCometCurrentSelectionIf(projectRoot2, workflow, change) { const current = await readCometCurrentSelection(projectRoot2); @@ -9650,7 +9765,7 @@ async function clearCometCurrentSelectionIf(projectRoot2, workflow, change) { var import_yaml3 = __toESM(require_dist(), 1); import { execFileSync } from "child_process"; import { randomUUID as randomUUID7 } from "crypto"; -import { promises as fs12 } from "fs"; +import { promises as fs13 } from "fs"; import path13 from "path"; function liveGitBranch(cwd) { try { @@ -9692,7 +9807,7 @@ function evaluateBranchBinding(input) { } async function resolveBranchBinding(changeDir, options) { const file = path13.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml3.parseDocument)(await fs12.readFile(file, "utf8"), { uniqueKeys: false }); + const document = (0, import_yaml3.parseDocument)(await fs13.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new Error(`Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -9711,14 +9826,14 @@ async function resolveBranchBinding(changeDir, options) { } async function healBoundBranch(changeDir, branch) { const file = path13.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml3.parseDocument)(await fs12.readFile(file, "utf8"), { uniqueKeys: false }); + const document = (0, import_yaml3.parseDocument)(await fs13.readFile(file, "utf8"), { uniqueKeys: false }); document.set("bound_branch", branch); const temporary = `${file}.${randomUUID7()}.tmp`; try { - await fs12.writeFile(temporary, document.toString(), "utf8"); - await fs12.rename(temporary, file); + await fs13.writeFile(temporary, document.toString(), "utf8"); + await fs13.rename(temporary, file); } catch (error) { - await fs12.rm(temporary, { force: true }); + await fs13.rm(temporary, { force: true }); throw error; } } @@ -9744,7 +9859,7 @@ async function validateActiveChange(projectRoot2, changeName) { assertOpenSpecChangeName(changeName); const changeDir = changeDirectory(projectRoot2, changeName); try { - await fs13.access(path14.join(changeDir, ".comet.yaml")); + await fs14.access(path14.join(changeDir, ".comet.yaml")); } catch (error) { if (error.code === "ENOENT") { throw new Error( @@ -9882,7 +9997,7 @@ var ArchiveOutput = class { }; async function exists2(file) { try { - await fs14.access(file); + await fs15.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -9936,10 +10051,10 @@ async function findArchiveDir(change, preferred) { if (await exists2(preferred)) return preferred; const archiveRoot = "openspec/changes/archive"; if (!await exists2(archiveRoot)) return null; - for (const entry2 of (await fs14.readdir(archiveRoot)).sort()) { + for (const entry2 of (await fs15.readdir(archiveRoot)).sort()) { if (!entry2.endsWith(`-${change}`)) continue; const candidate = `${archiveRoot}/${entry2}`; - if ((await fs14.stat(candidate)).isDirectory()) return candidate; + if ((await fs15.stat(candidate)).isDirectory()) return candidate; } return null; } @@ -9969,9 +10084,9 @@ async function annotateFrontmatter(output, file, archiveName, extraFields, dryRu output.stepsTotal += 1; return; } - const original = await fs14.readFile(file, "utf8"); + const original = await fs15.readFile(file, "utf8"); const updated = annotatedMarkdown(original, archiveName, extraFields); - await fs14.writeFile(file, updated); + await fs15.writeFile(file, updated); output.stderr.push(green(` [OK] Annotated: ${file}`)); output.stepsOk += 1; output.stepsTotal += 1; @@ -9980,10 +10095,10 @@ async function verifyMainSpecsClean() { const specsRoot = "openspec/specs"; if (!await exists2(specsRoot)) return; let found = false; - for (const entry2 of await fs14.readdir(specsRoot)) { + for (const entry2 of await fs15.readdir(specsRoot)) { const specFile = `${specsRoot}/${entry2}/spec.md`; if (!await exists2(specFile)) continue; - const matches = (await fs14.readFile(specFile, "utf8")).split(/\r?\n/u).map((line, index) => ({ line, number: index + 1 })).filter((item) => /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/u.test(item.line)); + const matches = (await fs15.readFile(specFile, "utf8")).split(/\r?\n/u).map((line, index) => ({ line, number: index + 1 })).filter((item) => /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/u.test(item.line)); if (matches.length > 0) { found = true; process.stderr.write( @@ -10238,7 +10353,7 @@ var classicArchiveCommand = async (args) => { var import_yaml6 = __toESM(require_dist(), 1); import { spawnSync as spawnSync2 } from "child_process"; import { createHash as createHash4 } from "crypto"; -import { existsSync, promises as fs18, readFileSync } from "fs"; +import { existsSync, promises as fs19, readFileSync } from "fs"; import path19 from "path"; // domains/comet-classic/classic-command-checks.ts @@ -10420,7 +10535,7 @@ async function inspectClassicChange(changeDir, name) { // domains/comet-classic/classic-validate-command.ts var import_yaml4 = __toESM(require_dist(), 1); -import { promises as fs15 } from "fs"; +import { promises as fs16 } from "fs"; import path17 from "path"; var GREEN2 = "\x1B[32m"; var RED2 = "\x1B[31m"; @@ -10471,7 +10586,7 @@ function color(code, message) { } async function exists3(file) { try { - await fs15.access(file); + await fs16.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -10506,7 +10621,7 @@ var classicValidateCommand = async (args) => { }; let source; try { - source = await fs15.readFile(yamlFile, "utf8"); + source = await fs16.readFile(yamlFile, "utf8"); } catch (error) { if (error.code === "ENOENT") { fail3(".comet.yaml does not exist"); @@ -10580,23 +10695,23 @@ var classicValidateCommand = async (args) => { // domains/comet-classic/classic-project-config.ts var import_yaml5 = __toESM(require_dist(), 1); import os from "os"; -import { promises as fs17 } from "fs"; +import { promises as fs18 } from "fs"; import path18 from "path"; // platform/fs/file-system.ts -import { promises as fs16 } from "fs"; +import { promises as fs17 } from "fs"; async function fileExists3(filePath) { try { - await fs16.access(filePath); + await fs17.access(filePath); return true; } catch (error) { - if (isNotFoundError(error)) return false; + if (isMissingPathError(error)) return false; throw error; } } async function readDir(dirPath) { try { - return await fs16.readdir(dirPath); + return await fs17.readdir(dirPath); } catch (error) { const code = error?.code; if (code === "ENOENT" || code === "ENOTDIR") { @@ -10605,8 +10720,9 @@ async function readDir(dirPath) { throw error; } } -function isNotFoundError(error) { - return error?.code === "ENOENT"; +function isMissingPathError(error) { + const code = error?.code; + return code === "ENOENT" || code === "ENOTDIR"; } // domains/comet-classic/classic-project-config.ts @@ -10627,7 +10743,7 @@ function configCandidates(options = {}) { async function readClassicConfigValue(field2, options = {}) { for (const candidate of configCandidates(options)) { if (!await fileExists3(candidate.file)) continue; - const document = (0, import_yaml5.parseDocument)(await fs17.readFile(candidate.file, "utf8"), { + const document = (0, import_yaml5.parseDocument)(await fs18.readFile(candidate.file, "utf8"), { uniqueKeys: false }); const root = document.toJS(); @@ -10705,7 +10821,7 @@ var GuardOutput = class { }; async function exists4(file) { try { - await fs18.access(file); + await fs19.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -10714,7 +10830,7 @@ async function exists4(file) { } async function nonempty(file) { try { - return (await fs18.stat(file)).size > 0; + return (await fs19.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -10729,7 +10845,7 @@ async function resolveChangeDir(name) { } async function readField(changeDir, field2) { const file = path19.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml6.parseDocument)(await fs18.readFile(file, "utf8"), { uniqueKeys: false }); + const document = (0, import_yaml6.parseDocument)(await fs19.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new GuardFailure(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -10770,7 +10886,7 @@ function countEnglishWords(source) { } async function documentLanguageMatchesConfigured(changeDir, file) { const language = await configuredLanguage(changeDir); - const source = stripFencedCodeBlocks(await fs18.readFile(file, "utf8")); + const source = stripFencedCodeBlocks(await fs19.readFile(file, "utf8")); const cjk = countCjkChars(source); const englishWords = countEnglishWords(source); if (language === "zh-CN" && cjk < 20 && englishWords >= 20) { @@ -10794,7 +10910,7 @@ async function handoffSourceFiles(changeDir) { const files = [`${changeDir}/proposal.md`, `${changeDir}/design.md`, `${changeDir}/tasks.md`]; const specs = `${changeDir}/specs`; if (await exists4(specs)) { - for (const entry2 of (await fs18.readdir(specs)).sort()) { + for (const entry2 of (await fs19.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (await exists4(spec)) files.push(spec); } @@ -10886,7 +11002,7 @@ var INFERRED_COMMAND_SOURCES = [ async function removedProjectCommandField(field2) { const config = path19.join(".comet", "config.yaml"); if (!await exists4(config)) return false; - const document = (0, import_yaml6.parseDocument)(await fs18.readFile(config, "utf8")); + const document = (0, import_yaml6.parseDocument)(await fs19.readFile(config, "utf8")); if (document.errors.length > 0) { throw new Error( `.comet/config.yaml is invalid YAML (${document.errors[0].message}); cannot check for removed "${field2}" field. Fix the config and retry.` @@ -10974,7 +11090,7 @@ async function tasksAllDone(changeDir) { Next: restore or create tasks.md for this change before leaving build.` ); } - const source = await fs18.readFile(tasks, "utf8"); + const source = await fs19.readFile(tasks, "utf8"); if (!/- \[x\]/u.test(source)) { return fail( "tasks.md has no completed tasks.\nNext: complete implementation tasks and mark them with '- [x]'." @@ -10993,7 +11109,7 @@ Next: complete or explicitly remove unfinished tasks, then mark tasks.md with '- async function tasksHasAny(changeDir) { const tasks = path19.join(changeDir, "tasks.md"); if (!await exists4(tasks)) return false; - return /- \[/u.test(await fs18.readFile(tasks, "utf8")); + return /- \[/u.test(await fs19.readFile(tasks, "utf8")); } async function planTasksAllDone(changeDir) { const plan = await readField(changeDir, "plan"); @@ -11004,7 +11120,7 @@ async function planTasksAllDone(changeDir) { Next: restore the Superpowers plan file or update .comet.yaml plan before leaving build.` ); } - const source = await fs18.readFile(plan, "utf8"); + const source = await fs19.readFile(plan, "utf8"); const unfinished = source.split(/\r?\n/u).map((line, index) => ({ line, number: index + 1 })).filter((entry2) => /^\s*- \[ \]/u.test(entry2.line)); if (unfinished.length > 0) { return fail( @@ -11118,7 +11234,7 @@ async function archivedIsTrue(changeDir) { return await readField(changeDir, "archived") === "true"; } async function designDocFrontmatterHas(designDoc, field2, expected) { - const source = (await fs18.readFile(designDoc, "utf8")).replace(/^\uFEFF/u, ""); + const source = (await fs19.readFile(designDoc, "utf8")).replace(/^\uFEFF/u, ""); let inFrontmatter = false; for (const line of source.split(/\r?\n/u)) { if (!inFrontmatter) { @@ -11183,7 +11299,7 @@ async function designHandoffMarkdownTraceable(changeDir) { const markdown = `${context.replace(/\.json$/u, "")}.md`; if (!await nonempty(markdown)) return fail(`design handoff markdown is missing or empty: ${markdown}`); - const source = await fs18.readFile(markdown, "utf8"); + const source = await fs19.readFile(markdown, "utf8"); const lines = new Set(source.split(/\r?\n/u)); const problems = []; if (!/^Generated-by: comet-handoff\.sh$/mu.test(source)) { @@ -11211,7 +11327,7 @@ async function betaSpecJsonStructurallyValid(changeDir) { const context = await readField(changeDir, "handoff_context"); if (!context || context === "null") return fail("handoff_context is missing from .comet.yaml"); if (!await nonempty(context)) return fail(`spec-context.json is missing or empty: ${context}`); - const source = await fs18.readFile(context, "utf8"); + const source = await fs19.readFile(context, "utf8"); const problems = []; let parsed; try { @@ -11511,7 +11627,7 @@ Valid phases: open, design, build, verify, archive` // domains/comet-classic/classic-handoff.ts var import_yaml7 = __toESM(require_dist(), 1); import { createHash as createHash5 } from "crypto"; -import { promises as fs19, readFileSync as readFileSync2 } from "fs"; +import { promises as fs20, readFileSync as readFileSync2 } from "fs"; import path20 from "path"; var GREEN4 = "\x1B[32m"; var RED4 = "\x1B[31m"; @@ -11546,7 +11662,7 @@ var HandoffOutput = class { }; async function exists5(file) { try { - await fs19.access(file); + await fs20.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -11555,7 +11671,7 @@ async function exists5(file) { } async function nonempty2(file) { try { - return (await fs19.stat(file)).size > 0; + return (await fs20.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -11584,7 +11700,7 @@ async function handoffSourceFiles2(changeDir) { const files = [`${changeDir}/proposal.md`, `${changeDir}/design.md`, `${changeDir}/tasks.md`]; const specs = `${changeDir}/specs`; if (await exists5(specs)) { - for (const entry2 of (await fs19.readdir(specs)).sort()) { + for (const entry2 of (await fs20.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (await exists5(spec)) files.push(spec); } @@ -11632,7 +11748,7 @@ async function writeMarkdownContext(changeDir, change, mode, contextHash, output ]; for (const file of await handoffSourceFiles2(changeDir)) { if (!await exists5(file)) continue; - const content = await fs19.readFile(file, "utf8"); + const content = await fs20.readFile(file, "utf8"); const total = lineCount(content); lines.push( `## ${file}`, @@ -11657,7 +11773,7 @@ async function writeMarkdownContext(changeDir, change, mode, contextHash, output } lines.push(""); } - await fs19.writeFile(output, lines.join("\n")); + await fs20.writeFile(output, lines.join("\n")); } async function writeJsonContext(changeDir, change, mode, contextHash, output) { const entries = []; @@ -11680,7 +11796,7 @@ async function writeJsonContext(changeDir, change, mode, contextHash, output) { "}", "" ].join("\n"); - await fs19.writeFile(output, document); + await fs20.writeFile(output, document); } async function writeSpecProjectionForFile(file, content) { return [ @@ -11720,11 +11836,11 @@ async function writeSpecMarkdownContext(changeDir, change, contextHash, output) const specs = `${changeDir}/specs`; let projected = false; if (await exists5(specs)) { - for (const entry2 of (await fs19.readdir(specs)).sort()) { + for (const entry2 of (await fs20.readdir(specs)).sort()) { const spec = `${specs}/${entry2}/spec.md`; if (!await exists5(spec)) continue; projected = true; - lines.push(...await writeSpecProjectionForFile(spec, await fs19.readFile(spec, "utf8"))); + lines.push(...await writeSpecProjectionForFile(spec, await fs20.readFile(spec, "utf8"))); } } if (!projected) { @@ -11733,7 +11849,7 @@ async function writeSpecMarkdownContext(changeDir, change, contextHash, output) lines.push( "Full source files remain canonical. If a required heading or scenario is missing here, regenerate the handoff or read the source spec directly. Supporting files (proposal, design, tasks) are referenced by hash only." ); - await fs19.writeFile(output, lines.join("\n")); + await fs20.writeFile(output, lines.join("\n")); } async function writeSpecJsonContext(changeDir, change, contextHash, output) { const entries = []; @@ -11742,7 +11858,7 @@ async function writeSpecJsonContext(changeDir, change, contextHash, output) { const role = /\/specs\/[^/]+\/spec\.md$/u.test(file) ? "spec" : "supporting"; entries.push({ path: file, sha256: hashFile2(file), role }); } - await fs19.writeFile( + await fs20.writeFile( output, `${JSON.stringify( { @@ -11762,7 +11878,7 @@ async function writeSpecJsonContext(changeDir, change, contextHash, output) { } async function readField2(changeDir, field2) { const file = path20.join(changeDir, ".comet.yaml"); - const document = (0, import_yaml7.parseDocument)(await fs19.readFile(file, "utf8"), { uniqueKeys: false }); + const document = (0, import_yaml7.parseDocument)(await fs20.readFile(file, "utf8"), { uniqueKeys: false }); if (document.errors.length > 0) { throw new HandoffFailure(`ERROR: Invalid .comet.yaml: ${document.errors[0].message}`); } @@ -11796,7 +11912,7 @@ async function completedHandoffIsCurrent(changeDir, run, contextHash, contextJso readCheckpoint(changeDir, run.checkpointRef) ]); if (!await exists5(contextJson) || !await exists5(contextMd)) return false; - if (context !== await fs19.readFile(contextMd, "utf8")) return false; + if (context !== await fs20.readFile(contextMd, "utf8")) return false; if (artifacts.handoff_context !== contextJson || artifacts.handoff_markdown !== contextMd) { return false; } @@ -11919,7 +12035,7 @@ var classicHandoffCommand = async (args) => { run: pendingRun, unknownKeys: (await readClassicState(changeDir)).unknownKeys }); - await fs19.mkdir(handoffDir, { recursive: true }); + await fs20.mkdir(handoffDir, { recursive: true }); if (handoffMode === "beta") { await writeSpecMarkdownContext(changeDir, change, contextHash, contextMd); await writeSpecJsonContext(changeDir, change, contextHash, contextJson); @@ -11927,7 +12043,7 @@ var classicHandoffCommand = async (args) => { await writeMarkdownContext(changeDir, change, handoffMode, contextHash, contextMd); await writeJsonContext(changeDir, change, handoffMode, contextHash, contextJson); } - const context = await fs19.readFile(contextMd, "utf8"); + const context = await fs20.readFile(contextMd, "utf8"); await writeContext(changeDir, pendingRun.contextRef, context); const artifacts = { ...await readArtifacts(changeDir, pendingRun.artifactsRef), @@ -11984,7 +12100,7 @@ var classicHandoffCommand = async (args) => { }; // domains/comet-classic/classic-hook-guard.ts -import { existsSync as existsSync2, promises as fs20, readFileSync as readFileSync3 } from "fs"; +import { existsSync as existsSync2, promises as fs21, readFileSync as readFileSync3 } from "fs"; import path21 from "path"; function result(exitCode, message) { return { exitCode, stderr: message + "\n" }; @@ -12029,7 +12145,7 @@ async function physicalPathForPossiblyMissingTarget(target) { let cursor = resolved; while (cursor && cursor !== root) { try { - const physicalBase = await fs20.realpath(cursor); + const physicalBase = await fs21.realpath(cursor); return path21.join(physicalBase, ...missingSegments.reverse()); } catch (error) { const code = error.code; @@ -12039,7 +12155,7 @@ async function physicalPathForPossiblyMissingTarget(target) { } } try { - const physicalRoot = await fs20.realpath(root); + const physicalRoot = await fs21.realpath(root); return path21.join(physicalRoot, ...missingSegments.reverse()); } catch { return null; @@ -12052,7 +12168,7 @@ async function projectRelative(target, projectRoot2) { if (rootRelative !== null) return rootRelative; try { const physicalCandidate = await physicalPathForPossiblyMissingTarget(rawCandidate); - const physicalRoot = await fs20.realpath(projectRoot2); + const physicalRoot = await fs21.realpath(projectRoot2); if (physicalCandidate) { const physicalRootRelative = relativeToProjectRoot(physicalCandidate, physicalRoot); if (physicalRootRelative !== null) return physicalRootRelative; @@ -12092,7 +12208,7 @@ async function activeChanges(projectRoot2) { const changesDir = path21.join(projectRoot2, "openspec", "changes"); const governingChanges = []; if (!existsSync2(changesDir)) return governingChanges; - for (const entry2 of (await fs20.readdir(changesDir, { withFileTypes: true })).sort( + for (const entry2 of (await fs21.readdir(changesDir, { withFileTypes: true })).sort( (left, right) => left.name.localeCompare(right.name) )) { if (!entry2.isDirectory() || entry2.name === "archive") continue; @@ -12877,7 +12993,7 @@ var classicIntentCommand = async (args, _options) => { // domains/comet-classic/classic-resume-probe.ts import path22 from "path"; -import { promises as fs21 } from "fs"; +import { promises as fs22 } from "fs"; import { spawn } from "child_process"; var COMET_RESUME_PROBE_SCHEMA_VERSION = "comet.resume_probe.v1"; function isRecord3(value) { @@ -12920,7 +13036,7 @@ function result3(action, change, confidence, reason, evidence = []) { } async function readIfExists(filePath) { if (!await fileExists3(filePath)) return ""; - return fs21.readFile(filePath, "utf8"); + return fs22.readFile(filePath, "utf8"); } async function changeSearchText(changeDir, classic) { const files = ["proposal.md", "design.md", "tasks.md"]; @@ -13000,7 +13116,7 @@ async function discoverActiveChanges(projectRoot2) { for (const entry2 of entries) { if (entry2 === "archive") continue; const changeDir = path22.join(changesDir, entry2); - const stat = await fs21.stat(changeDir).catch(() => null); + const stat = await fs22.stat(changeDir).catch(() => null); if (!stat?.isDirectory()) continue; const hasCometState = await fileExists3(path22.join(changeDir, ".comet.yaml")); if (!hasCometState) { @@ -13305,7 +13421,7 @@ var classicResumeProbeCommand = async (args) => { var import_yaml8 = __toESM(require_dist(), 1); import { spawnSync as spawnSync3 } from "child_process"; import { randomUUID as randomUUID8 } from "crypto"; -import { existsSync as existsSync3, promises as fs22 } from "fs"; +import { existsSync as existsSync3, promises as fs23 } from "fs"; import path23 from "path"; init_state(); var GREEN5 = "\x1B[32m"; @@ -13420,7 +13536,7 @@ function validateRelativePath(value, field2) { } async function exists6(file) { try { - await fs22.access(file); + await fs23.access(file); return true; } catch (error) { if (error.code === "ENOENT") return false; @@ -13429,7 +13545,7 @@ async function exists6(file) { } async function nonempty3(file) { try { - return (await fs22.stat(file)).size > 0; + return (await fs23.stat(file)).size > 0; } catch (error) { if (error.code === "ENOENT") return false; throw error; @@ -13441,7 +13557,7 @@ async function changeDirectory2(name) { async function readDocument2(file) { let source; try { - source = await fs22.readFile(file, "utf8"); + source = await fs23.readFile(file, "utf8"); } catch (error) { if (error.code === "ENOENT") { fail2( @@ -13455,13 +13571,13 @@ async function readDocument2(file) { return document; } async function atomicWrite2(file, content) { - await fs22.mkdir(path23.dirname(file), { recursive: true }); + await fs23.mkdir(path23.dirname(file), { recursive: true }); const temporary = `${file}.${randomUUID8()}.tmp`; try { - await fs22.writeFile(temporary, content, "utf8"); - await fs22.rename(temporary, file); + await fs23.writeFile(temporary, content, "utf8"); + await fs23.rename(temporary, file); } catch (error) { - await fs22.rm(temporary, { force: true }); + await fs23.rm(temporary, { force: true }); throw error; } } @@ -13735,7 +13851,7 @@ async function init(output, name, workflow) { validateEnum(workflow, PROFILES); const { file, label, directory } = await stateFile(name); if (await exists6(file)) fail2(`ERROR: .comet.yaml already exists at ${label}/.comet.yaml`); - await fs22.mkdir(directory, { recursive: true }); + await fs23.mkdir(directory, { recursive: true }); const preset = workflow !== "full"; const reviewMode = preset ? "off" : await reviewModeDefault(); const document = new import_yaml8.Document({ @@ -13965,7 +14081,7 @@ async function taskCheckoff(output, taskFile, taskText) { if (!taskText) fail2("ERROR: Task text cannot be empty"); const file = path23.resolve(taskFile); if (!await exists6(file)) fail2(`ERROR: Task file not found: ${taskFile}`); - const lines = (await fs22.readFile(file, "utf8")).split(/\r?\n/u); + const lines = (await fs23.readFile(file, "utf8")).split(/\r?\n/u); const matches = lines.filter( (line) => [`- [ ] ${taskText}`, `- [x] ${taskText}`, `- [X] ${taskText}`].includes(line) ); @@ -14141,14 +14257,14 @@ async function recoverBuild(output, name, directory, workflow) { ); return; } - const lines = (await fs22.readFile(tasks, "utf8")).split(/\r?\n/u); + const lines = (await fs23.readFile(tasks, "utf8")).split(/\r?\n/u); const total = lines.filter((line) => /^\s*- \[[ xX]\] /u.test(line)).length; const done = lines.filter((line) => /^\s*- \[[xX]\] /u.test(line)).length; const pending = total - done; let planTotal = 0; let planDone = 0; if (plan && plan !== "null" && await exists6(path23.resolve(plan))) { - const planLines = (await fs22.readFile(path23.resolve(plan), "utf8")).split(/\r?\n/u); + const planLines = (await fs23.readFile(path23.resolve(plan), "utf8")).split(/\r?\n/u); planTotal = planLines.filter((line) => /^\s*- \[[ xX]\] /u.test(line)).length; planDone = planLines.filter((line) => /^\s*- \[[xX]\] /u.test(line)).length; } @@ -14280,18 +14396,18 @@ async function scale(output, name) { const { file, directory, label } = await stateFile(name); if (!await exists6(file)) fail2(`ERROR: .comet.yaml not found at ${label}/.comet.yaml`); const tasksFile = path23.join(directory, "tasks.md"); - const taskCount = await exists6(tasksFile) ? (await fs22.readFile(tasksFile, "utf8")).split(/\r?\n/u).filter((line) => /^- \[/u.test(line)).length : 0; + const taskCount = await exists6(tasksFile) ? (await fs23.readFile(tasksFile, "utf8")).split(/\r?\n/u).filter((line) => /^- \[/u.test(line)).length : 0; const specs = path23.join(directory, "specs"); let deltaSpecs = 0; if (await exists6(specs)) { - for (const entry2 of await fs22.readdir(specs)) { + for (const entry2 of await fs23.readdir(specs)) { if (await exists6(path23.join(specs, entry2, "spec.md"))) deltaSpecs += 1; } } const plan = await readField3(name, "plan"); let baseRef = ""; if (plan && plan !== "null" && await exists6(path23.resolve(plan))) { - const match = (await fs22.readFile(path23.resolve(plan), "utf8")).match(/^base-ref:\s*(.+)$/mu); + const match = (await fs23.readFile(path23.resolve(plan), "utf8")).match(/^base-ref:\s*(.+)$/mu); baseRef = match?.[1].trim() ?? ""; } if (!baseRef) baseRef = await readField3(name, "base_ref"); diff --git a/domains/comet-entry/current-selection.ts b/domains/comet-entry/current-selection.ts index 43aa6587..7823634f 100644 --- a/domains/comet-entry/current-selection.ts +++ b/domains/comet-entry/current-selection.ts @@ -2,6 +2,8 @@ import { randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; +import { readFileRaceSafe } from '../../platform/fs/race-safe-read.js'; + import type { CometWorkflow } from './types.js'; export const COMET_CURRENT_SELECTION_SCHEMA = 'comet.selection.v2' as const; @@ -91,16 +93,11 @@ export async function readCometCurrentSelection( ): Promise { let source: string; try { - const stat = await fs.lstat(cometCurrentSelectionFile(projectRoot)); - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error('current change selection must be a regular file'); - } - if (stat.size > COMET_CURRENT_SELECTION_MAX_BYTES) { - throw new Error( - `current change selection exceeds ${COMET_CURRENT_SELECTION_MAX_BYTES} bytes`, - ); - } - source = await fs.readFile(cometCurrentSelectionFile(projectRoot), 'utf8'); + const file = cometCurrentSelectionFile(projectRoot); + const { bytes } = await readFileRaceSafe(file, COMET_CURRENT_SELECTION_MAX_BYTES, { + label: 'current change selection', + }); + source = bytes.toString('utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }; throw new Error( diff --git a/domains/comet-native/native-cli.ts b/domains/comet-native/native-cli.ts index 41cd3f37..d0837571 100644 --- a/domains/comet-native/native-cli.ts +++ b/domains/comet-native/native-cli.ts @@ -1,10 +1,13 @@ import path from 'path'; +import { RaceSafeReadError, readFileRaceSafe } from '../../platform/fs/race-safe-read.js'; + import { archiveNativeChange, NativeArchivePreflightError, NativeSpecConflictError, } from './native-archive.js'; +import { serializeNativeVerificationMachineBlock } from './native-acceptance.js'; import { inspectNativeArchivePreflight } from './native-archive-inspection.js'; import { createNativeChange, @@ -41,6 +44,7 @@ import { } from './native-specs.js'; import { readNativeBoundedTextFile } from './native-bounded-file.js'; import { NATIVE_CONTRACT_FILE_LIMITS } from './native-contract-files.js'; +import { MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES } from './native-verification-scope.js'; import { advanceNativeChange } from './native-transitions.js'; import { inspectNativeHookGuard, readNativeHookRequest } from './native-hook-guard.js'; import type { @@ -88,6 +92,7 @@ Commands: select checkpoint --summary --next-action [--artifact ] [--expect-revision ] check + evidence format [--entries ] next --summary [--confirmed] [--artifact ] [--no-code-reason ] [--allow-partial-scope --partial-reason ] [--result pass|fail] [--report ] [--receipt ] [--failure-category ] [--failed-check ] [--override-repair --override-summary ] archive --dry-run archive --expect-preflight @@ -183,6 +188,46 @@ function success(command: string, data: unknown, text?: string): DispatchResult return { command, exitCode: 0, data, text: text ?? JSON.stringify(data, null, 2) + '\n' }; } +async function readBoundedEvidenceFile(filePath: string, maxBytes: number): Promise { + try { + const { bytes } = await readFileRaceSafe(filePath, maxBytes, { + label: 'Acceptance evidence entries file', + }); + return bytes.toString('utf8'); + } catch (error) { + if (error instanceof RaceSafeReadError) { + if (error.reason === 'not-regular-file') { + throw new Error(`Acceptance evidence entries path is not a regular file: ${filePath}`, { + cause: error, + }); + } + if (error.reason === 'too-large') { + throw new Error(`Acceptance evidence entries file exceeds ${maxBytes} bytes: ${filePath}`, { + cause: error, + }); + } + throw new Error(`Acceptance evidence entries file changed while reading: ${filePath}`, { + cause: error, + }); + } + throw error; + } +} + +async function readBoundedEvidenceStdin(maxBytes: number): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of process.stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + total += buffer.byteLength; + if (total > maxBytes) { + throw new Error(`Acceptance evidence entries on stdin exceed ${maxBytes} bytes`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + async function dispatch( rawArgs: string[], explicitProjectRoot: string | undefined, @@ -471,6 +516,42 @@ async function dispatch( text: `Native check ${passed ? 'passed' : 'failed'}: ${checked.ref}\n`, }; } + if (command === 'evidence') { + const subcommand = requiredPositional(rawArgs, 'evidence subcommand'); + if (subcommand === 'format') { + const entriesPath = takeOption(rawArgs, '--entries'); + assertNoArguments(rawArgs); + let raw: string; + if (entriesPath) { + raw = await readBoundedEvidenceFile( + path.resolve(entriesPath), + MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES, + ); + } else { + if (process.stdin.isTTY) { + throw new NativeUsageError( + 'evidence format requires acceptance evidence entries JSON on stdin, or --entries ', + ); + } + raw = await readBoundedEvidenceStdin(MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES); + } + let entries: unknown; + try { + entries = JSON.parse(raw); + } catch (error) { + throw new Error( + `Acceptance evidence entries must be valid JSON: ${(error as Error).message}`, + { cause: error }, + ); + } + if (!Array.isArray(entries)) { + throw new Error('Acceptance evidence entries must be a JSON array'); + } + const block = serializeNativeVerificationMachineBlock(entries); + return success('evidence format', { block }, `${block}\n`); + } + throw new NativeUsageError(`Unknown evidence command: ${subcommand}`); + } if (command === 'next') { const name = requiredPositional(rawArgs, 'change name'); const summary = takeOption(rawArgs, '--summary'); diff --git a/domains/comet-native/native-file-identity.ts b/domains/comet-native/native-file-identity.ts index 6f07706c..b137d7fb 100644 --- a/domains/comet-native/native-file-identity.ts +++ b/domains/comet-native/native-file-identity.ts @@ -1,37 +1,8 @@ -export type NativeFileIdentityScalar = number | bigint | string; - -export interface NativeFileObjectIdentity { - dev: NativeFileIdentityScalar; - ino: NativeFileIdentityScalar; - birthtime: NativeFileIdentityScalar; -} - -function hasPlatformIdentity(value: NativeFileIdentityScalar): boolean { - return value !== 0 && value !== 0n && value !== '0'; -} - -export function hasComparableNativeFileObject( - left: NativeFileObjectIdentity, - right: NativeFileObjectIdentity, -): boolean { - return ( - hasPlatformIdentity(left.dev) && - hasPlatformIdentity(right.dev) && - hasPlatformIdentity(left.ino) && - hasPlatformIdentity(right.ino) - ); -} - -export function sameNativeFileObject( - left: NativeFileObjectIdentity, - right: NativeFileObjectIdentity, -): boolean { - const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); - if (comparableDevice && left.dev !== right.dev) return false; - - const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); - if (comparableInode && left.ino !== right.ino) return false; - - if (comparableDevice && comparableInode) return true; - return left.birthtime === right.birthtime; -} +export type { + FileIdentityScalar as NativeFileIdentityScalar, + FileObjectIdentity as NativeFileObjectIdentity, +} from '../../platform/fs/file-identity.js'; +export { + hasComparableFileObject as hasComparableNativeFileObject, + sameFileObject as sameNativeFileObject, +} from '../../platform/fs/file-identity.js'; diff --git a/domains/comet-native/native-lock.ts b/domains/comet-native/native-lock.ts index ff096d79..f4d87927 100644 --- a/domains/comet-native/native-lock.ts +++ b/domains/comet-native/native-lock.ts @@ -4,6 +4,8 @@ import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; +import { RaceSafeReadError, readFileRaceSafe } from '../../platform/fs/race-safe-read.js'; + import { resolveContainedNativePath } from './native-paths.js'; import { hasComparableNativeFileObject, sameNativeFileObject } from './native-file-identity.js'; import type { NativeProjectPaths } from './native-types.js'; @@ -132,41 +134,35 @@ function sameNativeLockDiagnosis(left: NativeLockDiagnosis, right: NativeLockDia } async function readNativeLockSnapshot(file: string): Promise { - let handle: Awaited>; + let bytes: Buffer; + let stat: import('fs').BigIntStats; try { - handle = await fs.open(file, 'r'); + const result = await readFileRaceSafe(file, NATIVE_LOCK_MAX_BYTES, { + bigint: true, + label: 'Native lock', + }); + bytes = result.bytes; + stat = result.stat as import('fs').BigIntStats; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw error; - } - try { - const stat = await handle.stat({ bigint: true }); - if (!stat.isFile() || stat.isSymbolicLink()) { - throw new Error(`Native lock must be a regular file: ${file}`); - } - if (stat.size > BigInt(NATIVE_LOCK_MAX_BYTES)) { - throw new Error(`Native lock metadata exceeds ${NATIVE_LOCK_MAX_BYTES} bytes: ${file}`); - } - const source = await handle.readFile({ encoding: 'utf8' }); - const pathStat = await fs.lstat(file, { bigint: true }); - if (pathStat.isSymbolicLink() || !pathStat.isFile()) { - throw new Error(`Native lock must be a regular file: ${file}`); - } - const identity = nativeLockFileIdentity(stat); - if (!sameNativeLockObject(identity, nativeLockFileIdentity(pathStat))) { - throw new Error(`Native lock changed while reading: ${file}`); + if (error instanceof RaceSafeReadError) { + if (error.reason === 'too-large') { + throw new Error(`Native lock metadata exceeds ${NATIVE_LOCK_MAX_BYTES} bytes: ${file}`, { + cause: error, + }); + } + if (error.reason === 'not-regular-file') { + throw new Error(`Native lock must be a regular file: ${file}`, { cause: error }); + } + throw new Error(`Native lock changed while reading: ${file}`, { cause: error }); } - return { - file, - owner: parseNativeLockOwner(JSON.parse(source) as unknown, file), - identity, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; - } finally { - await handle.close(); } + return { + file, + owner: parseNativeLockOwner(JSON.parse(bytes.toString('utf8')) as unknown, file), + identity: nativeLockFileIdentity(stat), + }; } export async function readNativeLock(file: string): Promise { diff --git a/package-lock.json b/package-lock.json index 6d7a1f92..8cbe514c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.8", + "version": "0.4.0-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rpamis/comet", - "version": "0.4.0-beta.8", + "version": "0.4.0-beta.9", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7f2bdd80..40334154 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.8", + "version": "0.4.0-beta.9", "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", diff --git a/platform/fs/file-identity.ts b/platform/fs/file-identity.ts new file mode 100644 index 00000000..425f1f35 --- /dev/null +++ b/platform/fs/file-identity.ts @@ -0,0 +1,34 @@ +export type FileIdentityScalar = number | bigint | string; + +export interface FileObjectIdentity { + dev: FileIdentityScalar; + ino: FileIdentityScalar; + birthtime: FileIdentityScalar; +} + +function hasPlatformIdentity(value: FileIdentityScalar): boolean { + return value !== 0 && value !== 0n && value !== '0'; +} + +export function hasComparableFileObject( + left: FileObjectIdentity, + right: FileObjectIdentity, +): boolean { + return ( + hasPlatformIdentity(left.dev) && + hasPlatformIdentity(right.dev) && + hasPlatformIdentity(left.ino) && + hasPlatformIdentity(right.ino) + ); +} + +export function sameFileObject(left: FileObjectIdentity, right: FileObjectIdentity): boolean { + const comparableDevice = hasPlatformIdentity(left.dev) && hasPlatformIdentity(right.dev); + if (comparableDevice && left.dev !== right.dev) return false; + + const comparableInode = hasPlatformIdentity(left.ino) && hasPlatformIdentity(right.ino); + if (comparableInode && left.ino !== right.ino) return false; + + if (comparableDevice && comparableInode) return true; + return left.birthtime === right.birthtime; +} diff --git a/platform/fs/file-system.ts b/platform/fs/file-system.ts index b7517427..fe306995 100644 --- a/platform/fs/file-system.ts +++ b/platform/fs/file-system.ts @@ -60,7 +60,7 @@ export async function fileExists(filePath: string): Promise { await fs.access(filePath); return true; } catch (error) { - if (isNotFoundError(error)) return false; + if (isMissingPathError(error)) return false; throw error; } } @@ -100,13 +100,17 @@ export async function readDir(dirPath: string): Promise { } /** - * Returns true when an error means the path was simply not found. ENOENT is - * the only non-fatal outcome for the removal helpers below; all other errors - * (permissions, IO) are reported as failures instead of being masked as - * "already gone". + * Returns true when an error means a path lookup found a file where it + * expected to be able to descend into a directory (ENOTDIR) — e.g. a stray + * `.DS_Store` sitting where a plugin marketplace directory was expected — + * treated the same as the path simply not being there (ENOENT). Only for + * read-only existence checks: a removal call hitting ENOTDIR means the + * on-disk shape is not what the caller expected, which is a real anomaly + * worth surfacing rather than masking as "already gone". */ -function isNotFoundError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; +function isMissingPathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; } /** @@ -119,7 +123,7 @@ export async function removeFile(filePath: string): Promise { await fs.unlink(filePath); return true; } catch (error) { - if (isNotFoundError(error)) return false; + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false; throw error; } } @@ -140,7 +144,7 @@ export async function removeDir(dirPath: string): Promise { await fs.rm(dirPath, { recursive: true, force: false }); return true; } catch (error) { - if (isNotFoundError(error)) return false; + if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return false; throw error; } } diff --git a/platform/fs/race-safe-read.ts b/platform/fs/race-safe-read.ts new file mode 100644 index 00000000..2e76a0a4 --- /dev/null +++ b/platform/fs/race-safe-read.ts @@ -0,0 +1,195 @@ +import { constants as fsConstants, promises as fs } from 'fs'; +import type { BigIntStats, Stats } from 'fs'; + +import { + hasComparableFileObject, + sameFileObject, + type FileObjectIdentity, +} from './file-identity.js'; + +export type RaceSafeReadCheckpoint = 'pre-open' | 'post-open' | 'post-read'; + +export interface RaceSafeReadContext { + realPath: string; + identity: FileObjectIdentity; +} + +export type RaceSafeReadFailureReason = 'not-regular-file' | 'too-large' | 'changed'; + +export class RaceSafeReadError extends Error { + readonly reason: RaceSafeReadFailureReason; + + constructor(reason: RaceSafeReadFailureReason, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'RaceSafeReadError'; + this.reason = reason; + } +} + +export interface RaceSafeReadOptions { + /** Use BigIntStats precision (dev/ino/size/mtimeNs/ctimeNs) end to end. */ + bigint?: boolean; + /** Prefixed into built-in error messages, e.g. `${label} changed while reading`. */ + label?: string; + /** Test-only synchronization points; behavior is passed through unchanged. */ + hooks?: { + afterOpen?: () => void | Promise; + beforeFinalCheck?: () => void | Promise; + }; + /** + * Called inside the same synchronization window as the built-in identity + * checks at each checkpoint. A thrown error aborts the read exactly like an + * identity mismatch. Use this to stack domain-specific checks (for example + * Native directory-chain verification) without opening a second race window. + */ + verify?: ( + checkpoint: RaceSafeReadCheckpoint, + context: RaceSafeReadContext, + ) => void | Promise; +} + +export interface RaceSafeReadResult { + bytes: Buffer; + stat: Stats | BigIntStats; + realPath: string; +} + +type AnyStats = Stats | BigIntStats; + +function birthtimeOf(stat: AnyStats): number | bigint { + return 'birthtimeNs' in stat && typeof stat.birthtimeNs === 'bigint' + ? stat.birthtimeNs + : stat.birthtimeMs; +} + +function ctimeOf(stat: AnyStats): number | bigint { + return 'ctimeNs' in stat && typeof stat.ctimeNs === 'bigint' ? stat.ctimeNs : stat.ctimeMs; +} + +function identityOf(stat: AnyStats): FileObjectIdentity { + return { dev: stat.dev, ino: stat.ino, birthtime: birthtimeOf(stat) }; +} + +function sameStatIdentity(left: AnyStats, right: AnyStats): boolean { + const leftObject = identityOf(left); + const rightObject = identityOf(right); + if (hasComparableFileObject(leftObject, rightObject)) { + return sameFileObject(leftObject, rightObject); + } + return ( + sameFileObject(leftObject, rightObject) && + birthtimeOf(left) === birthtimeOf(right) && + ctimeOf(left) === ctimeOf(right) && + left.size === right.size + ); +} + +/** + * Read a regular file through one descriptor with identity re-verification + * before open, after open, and after the bounded read, so a path swapped for + * a symlink or replacement file between checks is always rejected. + * + * ENOENT (and every other system error except ELOOP) propagates unchanged so + * callers can keep their own missing-file semantics. + */ +export async function readFileRaceSafe( + file: string, + maxBytes: number, + options: RaceSafeReadOptions = {}, +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error('race-safe read byte limit must be a positive integer'); + } + const label = options.label ?? 'file'; + const bigint = options.bigint === true; + + const before = await fs.lstat(file, { bigint }); + if (!before.isFile() || before.isSymbolicLink()) { + throw new RaceSafeReadError('not-regular-file', `${label} must be a regular file`); + } + if (BigInt(before.size) > BigInt(maxBytes)) { + throw new RaceSafeReadError('too-large', `${label} exceeds ${maxBytes} bytes`); + } + const beforeRealPath = await fs.realpath(file); + await options.verify?.('pre-open', { realPath: beforeRealPath, identity: identityOf(before) }); + + // POSIX: O_NOFOLLOW rejects a symlink swapped in since the lstat above and + // O_NONBLOCK prevents a FIFO from blocking the open. Windows: Node exposes + // neither flag, so this branch opens with plain O_RDONLY and the + // lstat/realpath identity checks below - which run identically on every + // platform - are the sole and sufficient guard against both swaps. + const flags = + process.platform === 'win32' + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + let handle: Awaited>; + try { + handle = await fs.open(file, flags); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new RaceSafeReadError('not-regular-file', `${label} must be a regular file`, { + cause: error, + }); + } + throw error; + } + try { + const [opened, pathAfterOpen, realPathAfterOpen] = await Promise.all([ + handle.stat({ bigint }), + fs.lstat(file, { bigint }), + fs.realpath(file), + ]); + if ( + !opened.isFile() || + !pathAfterOpen.isFile() || + pathAfterOpen.isSymbolicLink() || + realPathAfterOpen !== beforeRealPath || + !sameStatIdentity(before, opened) || + !sameStatIdentity(before, pathAfterOpen) + ) { + throw new RaceSafeReadError('changed', `${label} changed while opening`); + } + await options.verify?.('post-open', { + realPath: realPathAfterOpen, + identity: identityOf(opened), + }); + await options.hooks?.afterOpen?.(); + + const chunks: Buffer[] = []; + let total = 0; + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1)); + for (;;) { + const remaining = maxBytes + 1 - total; + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > maxBytes) { + throw new RaceSafeReadError('too-large', `${label} exceeds ${maxBytes} bytes`); + } + chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + } + await options.hooks?.beforeFinalCheck?.(); + + const [afterHandle, afterPath, afterRealPath] = await Promise.all([ + handle.stat({ bigint }), + fs.lstat(file, { bigint }), + fs.realpath(file), + ]); + if ( + !afterPath.isFile() || + afterPath.isSymbolicLink() || + afterRealPath !== beforeRealPath || + !sameStatIdentity(before, afterHandle) || + !sameStatIdentity(before, afterPath) + ) { + throw new RaceSafeReadError('changed', `${label} changed while reading`); + } + await options.verify?.('post-read', { + realPath: afterRealPath, + identity: identityOf(afterHandle), + }); + return { bytes: Buffer.concat(chunks, total), stat: afterHandle, realPath: afterRealPath }; + } finally { + await handle.close(); + } +} diff --git a/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index 7a605fdf..17cac4af 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -35,10 +35,10 @@ describe('CLI help text', () => { expect(help.status, help.stderr).toBe(0); expect(help.stdout).toContain(tagline); expect(packageJson.description).toBe(tagline); - expect(packageJson.version).toBe('0.4.0-beta.8'); - expect(packageLock.version).toBe('0.4.0-beta.8'); - expect(packageLock.packages[''].version).toBe('0.4.0-beta.8'); - expect(assetsManifest.version).toBe('0.4.0-beta.8'); + expect(packageJson.version).toBe('0.4.0-beta.9'); + expect(packageLock.version).toBe('0.4.0-beta.9'); + expect(packageLock.packages[''].version).toBe('0.4.0-beta.9'); + expect(assetsManifest.version).toBe('0.4.0-beta.9'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { diff --git a/test/domains/comet-entry/current-selection.test.ts b/test/domains/comet-entry/current-selection.test.ts index 387b15b6..2f8e6924 100644 --- a/test/domains/comet-entry/current-selection.test.ts +++ b/test/domains/comet-entry/current-selection.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { promises as fs } from 'fs'; +import { promises as fs, renameSync, symlinkSync, unlinkSync } from 'fs'; +import { execFileSync } from 'node:child_process'; import os from 'os'; import path from 'path'; @@ -103,4 +104,122 @@ describe('shared Comet current selection', () => { await fs.mkdir(file); await expect(readCometCurrentSelection(root)).rejects.toThrow('regular file'); }); + + it('rejects a symlink at the selection path instead of following it', async () => { + const outside = path.join(root, 'outside-secret.json'); + await fs.writeFile( + outside, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'not-the-real-selection', + branch: null, + }), + ); + + const file = cometCurrentSelectionFile(root); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.symlink(outside, file); + + await expect(readCometCurrentSelection(root)).rejects.toThrow(/regular file|symbolic link/); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects a FIFO at the selection path without blocking on open', + async () => { + const file = cometCurrentSelectionFile(root); + await fs.mkdir(path.dirname(file), { recursive: true }); + execFileSync('mkfifo', [file]); + + await expect(readCometCurrentSelection(root)).rejects.toThrow('regular file'); + }, + ); + + it('never returns content read past the byte limit when the file is swapped for a bigger one mid-read', async () => { + const file = cometCurrentSelectionFile(root); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile( + file, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'small-before-swap', + branch: null, + }), + ); + + const oversized = path.join(root, 'oversized.json'); + const oversizedChange = 'x'.repeat(16 * 1024); + await fs.writeFile( + oversized, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: oversizedChange, + branch: null, + }), + ); + + // Start the read (dispatches its first fs call asynchronously), then swap + // the file for an over-limit one synchronously before yielding back to + // the event loop. The synchronous rename below is guaranteed to run + // before any pending async fs callback for this read is delivered. + const pending = readCometCurrentSelection(root); + renameSync(oversized, file); + + const result = await pending.catch((error: unknown) => error as Error); + if (result instanceof Error) { + // Depending on where the swap lands relative to the pre-open lstat, + // the read is rejected either for size or because the file identity + // changed between checkpoints. Both refuse the oversized content. + expect(result.message).toMatch(/exceeds 16384 bytes|changed while (opening|reading)/); + } else { + // If the open/read happened to land on the swapped-in file entirely + // (rather than racing mid-read), that is fine too, as long as it was + // still rejected for size rather than silently accepted. + expect(result).not.toMatchObject({ + status: 'selected', + selection: { change: oversizedChange }, + }); + } + }); + + it('does not follow a symlink swapped in after the regular-file check', async () => { + const file = cometCurrentSelectionFile(root); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile( + file, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'small-before-swap', + branch: null, + }), + ); + + const outside = path.join(root, 'outside-secret.json'); + await fs.writeFile( + outside, + JSON.stringify({ + schema: 'comet.selection.v2', + workflow: 'native', + change: 'read-through-symlink', + branch: null, + }), + ); + + const pending = readCometCurrentSelection(root); + unlinkSync(file); + symlinkSync(outside, file); + + const result = await pending.catch((error: unknown) => error as Error); + if (result instanceof Error) { + expect(result.message).toMatch(/regular file|symbolic link|changed/); + } else { + expect(result).not.toMatchObject({ + status: 'selected', + selection: { change: 'read-through-symlink' }, + }); + } + }); }); diff --git a/test/domains/comet-native/native-cli.test.ts b/test/domains/comet-native/native-cli.test.ts index f9314b6e..e3167625 100644 --- a/test/domains/comet-native/native-cli.test.ts +++ b/test/domains/comet-native/native-cli.test.ts @@ -4,10 +4,15 @@ import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + NATIVE_ACCEPTANCE_EVIDENCE_START_MARKER, + parseNativeVerificationMachineBlock, +} from '../../../domains/comet-native/native-acceptance.js'; import { runNativeCli } from '../../../domains/comet-native/native-cli.js'; import { NATIVE_CONTRACT_FILE_LIMITS } from '../../../domains/comet-native/native-contract-files.js'; import { acquireNativeLock, releaseNativeLock } from '../../../domains/comet-native/native-lock.js'; import { nativeProjectPaths } from '../../../domains/comet-native/native-paths.js'; +import { MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES } from '../../../domains/comet-native/native-verification-scope.js'; const brief = `# Outcome Add sentence counting. @@ -686,4 +691,134 @@ Pass. expect(json(retried)).toMatchObject({ data: { name: 'storage-failure' } }); }, ); + + it('formats acceptance evidence entries into the exact canonical verification.md block', async () => { + const acceptanceId = `acceptance-${'a'.repeat(64)}`; + const entriesPath = path.join(projectRoot, 'entries.json'); + await fs.writeFile( + entriesPath, + JSON.stringify([{ acceptance_id: acceptanceId, evidence_refs: ['b.ts', 'a.ts'] }]), + ); + + const result = await runNativeCli([ + 'evidence', + 'format', + '--entries', + entriesPath, + '--json', + ...projectArgs(), + ]); + + expect(result.exitCode).toBe(0); + const { block } = json(result).data as { block: string }; + expect(block).toContain(NATIVE_ACCEPTANCE_EVIDENCE_START_MARKER); + expect(parseNativeVerificationMachineBlock(block)).toEqual([ + { acceptance_id: acceptanceId, evidence_refs: ['a.ts', 'b.ts'] }, + ]); + + const handWritten = block.replace(' {', ' {'); + expect(() => parseNativeVerificationMachineBlock(handWritten)).toThrow( + 'canonical serialization', + ); + }); + + it('rejects evidence format with no entries source when stdin is a TTY', async () => { + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + const result = await runNativeCli(['evidence', 'format', '--json', ...projectArgs()]); + expect(result.exitCode).toBe(64); + expect(json(result)).toMatchObject({ error: { code: 'usage' } }); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + } + }); + + it.each([ + ['malformed JSON', '{not json'], + ['a non-array JSON value', '{}'], + ])('rejects %s as invalid acceptance evidence entries', async (_label, contents) => { + const entriesPath = path.join(projectRoot, 'entries.json'); + await fs.writeFile(entriesPath, contents); + + const result = await runNativeCli([ + 'evidence', + 'format', + '--entries', + entriesPath, + '--json', + ...projectArgs(), + ]); + + expect(result.exitCode).toBe(65); + expect(json(result)).toMatchObject({ error: { code: 'invalid-data' } }); + }); + + it('rejects an entries file larger than the Native evidence document limit', async () => { + const entriesPath = path.join(projectRoot, 'entries.json'); + await fs.writeFile( + entriesPath, + 'x'.repeat(MAX_NATIVE_IMPLEMENTATION_EVIDENCE_DOCUMENT_BYTES + 1), + ); + + const result = await runNativeCli([ + 'evidence', + 'format', + '--entries', + entriesPath, + '--json', + ...projectArgs(), + ]); + + expect(result.exitCode).toBe(65); + expect(json(result)).toMatchObject({ + error: { code: 'invalid-data', message: expect.stringContaining('exceeds') }, + }); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects a FIFO entries source without blocking on open', + async () => { + const fifoPath = path.join(projectRoot, 'entries.fifo'); + execFileSync('mkfifo', [fifoPath]); + + const result = await runNativeCli([ + 'evidence', + 'format', + '--entries', + fifoPath, + '--json', + ...projectArgs(), + ]); + + expect(result.exitCode).toBe(65); + expect(json(result)).toMatchObject({ + error: { code: 'invalid-data', message: expect.stringContaining('not a regular file') }, + }); + }, + ); + + it('rejects a symlink entries source instead of following it', async () => { + const target = path.join(projectRoot, 'entries-target.json'); + await fs.writeFile(target, JSON.stringify([])); + const linkPath = path.join(projectRoot, 'entries-link.json'); + await fs.symlink(target, linkPath); + + const result = await runNativeCli([ + 'evidence', + 'format', + '--entries', + linkPath, + '--json', + ...projectArgs(), + ]); + + expect(result.exitCode).toBe(65); + expect(json(result)).toMatchObject({ + error: { code: 'invalid-data', message: expect.stringContaining('not a regular file') }, + }); + }); }); diff --git a/test/domains/comet-native/native-lock.test.ts b/test/domains/comet-native/native-lock.test.ts index 3a18bbc0..5172d379 100644 --- a/test/domains/comet-native/native-lock.test.ts +++ b/test/domains/comet-native/native-lock.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; @@ -60,6 +61,26 @@ describe('Native operation locks', () => { await fs.rm(displaced, { force: true }); }); + it('rejects a symlinked lock file instead of following it', async () => { + const lock = await acquireNativeLock(paths, 'archive', 'archive example'); + const displaced = `${lock.file}.real`; + await fs.rename(lock.file, displaced); + await fs.symlink(displaced, lock.file); + + await expect(readNativeLock(lock.file)).rejects.toThrow(/regular file/u); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects a FIFO at the lock path without blocking on open', + async () => { + const lock = await acquireNativeLock(paths, 'archive', 'archive example'); + await fs.rm(lock.file); + execFileSync('mkfifo', [lock.file]); + + await expect(readNativeLock(lock.file)).rejects.toThrow(/regular file/u); + }, + ); + it.each([ { fileName: 'root-move.lock', diff --git a/test/platform/detect.test.ts b/test/platform/detect.test.ts index d1c554ce..c2520171 100644 --- a/test/platform/detect.test.ts +++ b/test/platform/detect.test.ts @@ -487,6 +487,32 @@ describe('detect', () => { delete process.env.CLAUDE_CONFIG_DIR; } }); + + it('skips a stray non-directory entry in the plugin cache instead of crashing', async () => { + const origEnv = process.env.CLAUDE_CONFIG_DIR; + const pluginDir = path.join(tmpDir, '.claude'); + process.env.CLAUDE_CONFIG_DIR = pluginDir; + + const cacheDir = path.join(pluginDir, 'plugins', 'cache'); + await fs.mkdir(cacheDir, { recursive: true }); + // A stray file (e.g. macOS .DS_Store) sitting where a marketplace + // directory is expected used to make fs.access throw ENOTDIR and crash + // comet init instead of being skipped like a missing marketplace. + await fs.writeFile(path.join(cacheDir, '.DS_Store'), ''); + + const skillsDir = path.join(cacheDir, 'test-marketplace', 'superpowers', '5.0.0', 'skills'); + await fs.mkdir(skillsDir, { recursive: true }); + await fs.mkdir(path.join(skillsDir, 'brainstorming')); + await fs.mkdir(path.join(skillsDir, 'using-superpowers')); + + await expect(hasPluginSuperpowers()).resolves.toBe(true); + + if (origEnv !== undefined) { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + }); }); describe('hasOpenCodePluginSuperpowers', () => { diff --git a/test/platform/file-system.test.ts b/test/platform/file-system.test.ts index 46180ac7..4ccb001c 100644 --- a/test/platform/file-system.test.ts +++ b/test/platform/file-system.test.ts @@ -232,6 +232,17 @@ describe('file-system utils', () => { } }); + it('propagates removeFile ENOTDIR errors instead of treating them as already gone', async () => { + const error = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }); + const unlinkSpy = vi.spyOn(fs, 'unlink').mockRejectedValue(error); + + try { + await expect(removeFile(path.join(tmpDir, 'blocked.txt'))).rejects.toBe(error); + } finally { + unlinkSpy.mockRestore(); + } + }); + it('propagates removeDir permission errors', async () => { const dirPath = path.join(tmpDir, 'blocked'); await fs.mkdir(dirPath); @@ -244,6 +255,19 @@ describe('file-system utils', () => { rmSpy.mockRestore(); } }); + + it('propagates removeDir ENOTDIR errors instead of treating them as already gone', async () => { + const dirPath = path.join(tmpDir, 'blocked'); + await fs.mkdir(dirPath); + const error = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }); + const rmSpy = vi.spyOn(fs, 'rm').mockRejectedValue(error); + + try { + await expect(removeDir(dirPath)).rejects.toBe(error); + } finally { + rmSpy.mockRestore(); + } + }); }); describe('isDirEmpty', () => { diff --git a/test/platform/race-safe-read.test.ts b/test/platform/race-safe-read.test.ts new file mode 100644 index 00000000..3137ae5d --- /dev/null +++ b/test/platform/race-safe-read.test.ts @@ -0,0 +1,189 @@ +import { execFileSync } from 'node:child_process'; +import { promises as fs, symlinkSync, unlinkSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { RaceSafeReadError, readFileRaceSafe } from '../../platform/fs/race-safe-read.js'; + +// Windows note: the primitive's win32 branch opens with plain O_RDONLY +// (Node exposes no O_NOFOLLOW there) and relies on the same lstat/realpath +// identity checks that run on POSIX. That parity conclusion is covered by +// reasoning over the shared code path, not by an actual Windows test run. +describe('race-safe bounded file read', () => { + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'race-safe-read-')); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it('reads a regular file within the limit and returns bytes, stat, and realPath', async () => { + const file = path.join(root, 'data.json'); + await fs.writeFile(file, '{"ok":true}'); + + const result = await readFileRaceSafe(file, 1024); + + expect(result.bytes.toString('utf8')).toBe('{"ok":true}'); + expect(result.realPath).toBe(await fs.realpath(file)); + expect(result.stat.isFile()).toBe(true); + }); + + it('rejects a pre-existing symlink before opening it', async () => { + const target = path.join(root, 'target.txt'); + await fs.writeFile(target, 'secret'); + const file = path.join(root, 'link.txt'); + await fs.symlink(target, file); + + await expect(readFileRaceSafe(file, 1024, { label: 'test file' })).rejects.toMatchObject({ + name: 'RaceSafeReadError', + reason: 'not-regular-file', + message: 'test file must be a regular file', + }); + }); + + it('rejects a directory path', async () => { + const dir = path.join(root, 'subdir'); + await fs.mkdir(dir); + + await expect(readFileRaceSafe(dir, 1024)).rejects.toMatchObject({ + reason: 'not-regular-file', + }); + }); + + it.skipIf(process.platform === 'win32')('rejects a FIFO without blocking on open', async () => { + const file = path.join(root, 'pipe'); + execFileSync('mkfifo', [file]); + + await expect(readFileRaceSafe(file, 1024)).rejects.toMatchObject({ + reason: 'not-regular-file', + }); + }); + + it('rejects a file over the byte limit before reading past it', async () => { + const file = path.join(root, 'big.txt'); + await fs.writeFile(file, 'x'.repeat(1025)); + + await expect(readFileRaceSafe(file, 1024, { label: 'test file' })).rejects.toMatchObject({ + reason: 'too-large', + message: 'test file exceeds 1024 bytes', + }); + }); + + it('propagates ENOENT with its original code for missing files', async () => { + const error = await readFileRaceSafe(path.join(root, 'missing.txt'), 1024).catch( + (caught: unknown) => caught as NodeJS.ErrnoException, + ); + + expect(error.code).toBe('ENOENT'); + }); + + it('calls verify at pre-open, post-open, and post-read with a stable context', async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'content'); + const checkpoints: string[] = []; + + await readFileRaceSafe(file, 1024, { + verify: (checkpoint, context) => { + checkpoints.push(checkpoint); + expect(context.realPath.length).toBeGreaterThan(0); + expect(context.identity.ino).not.toBe(undefined); + }, + }); + + expect(checkpoints).toEqual(['pre-open', 'post-open', 'post-read']); + }); + + it('aborts the read when verify throws at a checkpoint', async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'content'); + + await expect( + readFileRaceSafe(file, 1024, { + verify: (checkpoint) => { + if (checkpoint === 'post-open') throw new Error('chain violated'); + }, + }), + ).rejects.toThrow('chain violated'); + }); + + it('rejects a same-path replacement made while the file is open', async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'original'); + const replacement = path.join(root, 'replacement.txt'); + await fs.writeFile(replacement, 'replaced'); + + await expect( + readFileRaceSafe(file, 1024, { + label: 'test file', + hooks: { + afterOpen: async () => { + await fs.rename(replacement, file); + }, + }, + }), + ).rejects.toMatchObject({ + reason: 'changed', + message: 'test file changed while reading', + }); + }); + + it('does not follow a symlink swapped in after the pre-open check', async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'original'); + const outside = path.join(root, 'outside.txt'); + await fs.writeFile(outside, 'outside-content'); + + // Start the read (its first lstat is dispatched asynchronously), then swap + // in a symlink synchronously before yielding back to the event loop. + const pending = readFileRaceSafe(file, 1024); + unlinkSync(file); + symlinkSync(outside, file); + + const result = await pending.catch((error: unknown) => error as Error); + if (result instanceof Error) { + expect(result.message).toMatch(/regular file|changed/); + } else { + expect(result.bytes.toString('utf8')).not.toBe('outside-content'); + } + }); + + it('enforces the byte limit against growth through the same descriptor after open', async () => { + const file = path.join(root, 'grow.txt'); + await fs.writeFile(file, 'start'); + + await expect( + readFileRaceSafe(file, 1024, { + hooks: { + afterOpen: async () => { + await fs.appendFile(file, 'x'.repeat(2048)); + }, + }, + }), + ).rejects.toMatchObject({ reason: 'too-large' }); + }); + + it('returns BigIntStats precision when bigint mode is requested', async () => { + const file = path.join(root, 'data.txt'); + await fs.writeFile(file, 'content'); + + const result = await readFileRaceSafe(file, 1024, { bigint: true }); + + expect(typeof (result.stat as import('fs').BigIntStats).mtimeNs).toBe('bigint'); + expect(typeof result.stat.size).toBe('bigint'); + expect(result.bytes.toString('utf8')).toBe('content'); + }); + + it('exposes RaceSafeReadError as an Error subclass with a stable name', async () => { + const file = path.join(root, 'big.txt'); + await fs.writeFile(file, 'x'.repeat(2)); + + const error = await readFileRaceSafe(file, 1, {}).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(RaceSafeReadError); + expect(error).toBeInstanceOf(Error); + }); +});