full-reverse 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/THIRD_PARTY_NOTICES.md +63 -0
  4. package/VERIFICATION.md +156 -0
  5. package/bin/full-reverse.mjs +253 -0
  6. package/package.json +30 -4
  7. package/skills/full-reverse/LICENSE +21 -0
  8. package/skills/full-reverse/README.md +116 -0
  9. package/skills/full-reverse/SKILL.md +173 -0
  10. package/skills/full-reverse/THIRD_PARTY_NOTICES.md +63 -0
  11. package/skills/full-reverse/VERIFICATION.md +156 -0
  12. package/skills/full-reverse/agents/openai.yaml +3 -0
  13. package/skills/full-reverse/package-lock.json +75 -0
  14. package/skills/full-reverse/package.json +21 -0
  15. package/skills/full-reverse/references/investigation-contract.md +171 -0
  16. package/skills/full-reverse/references/workflow-example.md +148 -0
  17. package/skills/full-reverse/references/workspace-layout.md +83 -0
  18. package/skills/full-reverse/schemas/claims.schema.json +123 -0
  19. package/skills/full-reverse/schemas/common.schema.json +305 -0
  20. package/skills/full-reverse/schemas/evidence-index.schema.json +135 -0
  21. package/skills/full-reverse/schemas/examples/claims.example.json +355 -0
  22. package/skills/full-reverse/schemas/examples/evidence-index.example.json +254 -0
  23. package/skills/full-reverse/schemas/examples/external-evidence.example.json +33 -0
  24. package/skills/full-reverse/schemas/examples/inventory.example.json +106 -0
  25. package/skills/full-reverse/schemas/examples/investigation.example.json +159 -0
  26. package/skills/full-reverse/schemas/examples/review.example.json +75 -0
  27. package/skills/full-reverse/schemas/external-evidence.schema.json +68 -0
  28. package/skills/full-reverse/schemas/inventory.schema.json +91 -0
  29. package/skills/full-reverse/schemas/investigation.schema.json +142 -0
  30. package/skills/full-reverse/schemas/review.schema.json +78 -0
  31. package/skills/full-reverse/scripts/lib/cli.mjs +254 -0
  32. package/skills/full-reverse/scripts/lib/jsonscan.mjs +285 -0
  33. package/skills/full-reverse/scripts/lib/rea.mjs +496 -0
  34. package/skills/full-reverse/scripts/lib/schemas.mjs +79 -0
  35. package/skills/full-reverse/scripts/lib/scope.mjs +59 -0
  36. package/skills/full-reverse/scripts/lib/selectors.mjs +256 -0
  37. package/skills/full-reverse/scripts/lib/util.mjs +333 -0
  38. package/skills/full-reverse/scripts/lib/validate.mjs +1133 -0
  39. package/skills/full-reverse/scripts/lib/workspace.mjs +861 -0
  40. package/skills/full-reverse/scripts/validate-investigation.mjs +28 -0
  41. package/skills/full-reverse-inspect/SKILL.md +119 -0
  42. package/skills/full-reverse-inspect/agents/openai.yaml +5 -0
  43. package/skills/full-reverse-inspect/references/android.md +84 -0
  44. package/skills/full-reverse-inspect/references/evidence-workflows.md +170 -0
  45. package/skills/full-reverse-inspect/references/javascript-electron.md +146 -0
  46. package/skills/full-reverse-inspect/references/managed.md +59 -0
  47. package/skills/full-reverse-inspect/references/native.md +290 -0
  48. package/skills/full-reverse-inspect/references/packages-and-specialized-artifacts.md +93 -0
  49. package/skills/full-reverse-inspect/references/recorded-evidence.md +118 -0
  50. package/skills/full-reverse-inventory/SKILL.md +134 -0
  51. package/skills/full-reverse-inventory/agents/openai.yaml +5 -0
  52. package/skills/full-reverse-model/SKILL.md +148 -0
  53. package/skills/full-reverse-model/agents/openai.yaml +5 -0
  54. package/skills/full-reverse-model/references/claim-classification.md +85 -0
  55. package/skills/full-reverse-model/references/specification-template.md +199 -0
  56. package/skills/full-reverse-observe/SKILL.md +97 -0
  57. package/skills/full-reverse-observe/agents/openai.yaml +5 -0
  58. package/skills/full-reverse-observe/references/runtime-scope.md +301 -0
  59. package/skills/full-reverse-verify/SKILL.md +111 -0
  60. package/skills/full-reverse-verify/agents/openai.yaml +5 -0
  61. package/skills/full-reverse-verify/references/review-checklist.md +99 -0
@@ -0,0 +1,256 @@
1
+ // Evaluation of evidence selectors against saved files.
2
+ // Outcomes are verified | failed | unverified; syntax-only checks are never "verified".
3
+ import fsp from "node:fs/promises";
4
+ import fs from "node:fs";
5
+ import { scanFile } from "./jsonscan.mjs";
6
+ import { pointerSegments, resolvePointer } from "./rea.mjs";
7
+ import { canonicalDigest, jsonEqual } from "./util.mjs";
8
+
9
+ const verified = (extra = {}) => ({ status: "verified", ...extra });
10
+ const failed = (reason, extra = {}) => ({ status: "failed", reason, ...extra });
11
+ const unverified = (reason, extra = {}) => ({ status: "unverified", reason, ...extra });
12
+
13
+ /** Canonical-record pointer store: parses small files once, scans large ones for requested pointers. */
14
+ export class PointerStore {
15
+ constructor({ maxParseBytes, maxCaptureBytes = 1 << 20 }) {
16
+ this.maxParseBytes = maxParseBytes;
17
+ this.maxCaptureBytes = maxCaptureBytes;
18
+ this.docs = new Map(); // abs -> { doc }
19
+ this.scans = new Map(); // abs -> { balanced, captures }
20
+ this.failures = new Map(); // abs -> reason
21
+ }
22
+
23
+ /** requests: Map(abs -> { bytes, pointers:Set<string> }) */
24
+ async prepare(requests) {
25
+ for (const [abs, request] of requests) {
26
+ if (request.bytes <= this.maxParseBytes) continue; // parsed lazily in get()
27
+ const pointers = [...request.pointers].filter((p) => {
28
+ const segments = pointerSegments(p);
29
+ return segments !== null && !segments.includes("*");
30
+ });
31
+ try {
32
+ const scan = await scanFile(abs, pointers, { maxCaptureBytes: this.maxCaptureBytes });
33
+ this.scans.set(abs, scan);
34
+ } catch (error) {
35
+ this.failures.set(abs, `scan_failed:${error.code ?? error.message}`);
36
+ }
37
+ }
38
+ }
39
+
40
+ async doc(abs) {
41
+ if (this.docs.has(abs)) return this.docs.get(abs);
42
+ let value;
43
+ try {
44
+ value = JSON.parse(await fsp.readFile(abs, "utf8"));
45
+ } catch (error) {
46
+ value = { error: error.message };
47
+ }
48
+ if (this.docs.size >= 2) this.docs.delete(this.docs.keys().next().value);
49
+ const holder = { doc: value };
50
+ this.docs.set(abs, holder);
51
+ return holder;
52
+ }
53
+
54
+ /** Look up the absolute pointer (record pointer + selector pointer) in a file. */
55
+ async lookup(abs, bytes, absolutePointer) {
56
+ if (pointerSegments(absolutePointer) === null) return { kind: "invalid" };
57
+ if (bytes <= this.maxParseBytes) {
58
+ const { doc } = await this.doc(abs);
59
+ if (doc && doc.error && Object.keys(doc).length === 1) return { kind: "unparsable" };
60
+ const result = resolvePointer(doc, absolutePointer);
61
+ return result.found ? { kind: "found", value: result.value } : { kind: "missing" };
62
+ }
63
+ if (this.failures.has(abs)) return { kind: "unverified", reason: this.failures.get(abs) };
64
+ const scan = this.scans.get(abs);
65
+ if (!scan) return { kind: "unverified", reason: "file_exceeds_parse_limit" };
66
+ if (pointerSegments(absolutePointer).includes("*")) return { kind: "unverified", reason: "wildcard_pointer_unsupported" };
67
+ if (!scan.balanced) return { kind: "unverified", reason: "json_structure_incomplete" };
68
+ const capture = scan.captures.get(absolutePointer);
69
+ if (!capture) return { kind: "missing" };
70
+ if (capture.oversized || capture.text === null) return { kind: "unverified", reason: "value_exceeds_capture_limit" };
71
+ try {
72
+ return { kind: "found", value: JSON.parse(capture.text) };
73
+ } catch {
74
+ return { kind: "unverified", reason: "captured_value_unparsable" };
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Evaluate one json-pointer selector against a looked-up value. */
80
+ export function judgePointer(selector, lookup) {
81
+ switch (lookup.kind) {
82
+ case "invalid":
83
+ return failed("invalid_rfc6901_pointer");
84
+ case "unparsable":
85
+ return unverified("evidence_file_unparsable");
86
+ case "unverified":
87
+ return unverified(lookup.reason);
88
+ case "missing":
89
+ return failed("pointer_not_found");
90
+ default:
91
+ break;
92
+ }
93
+ const expect = selector.expect;
94
+ if (!expect) return verified({ detail: "pointer resolves; no expectation stated" });
95
+ if ("exists" in expect) return verified({ detail: "pointer resolves" });
96
+ if ("equals" in expect) {
97
+ return jsonEqual(lookup.value, expect.equals) ? verified({ detail: "value equals expectation" }) : failed("expect_equals_mismatch");
98
+ }
99
+ if ("sha256" in expect) {
100
+ let digest;
101
+ try {
102
+ digest = canonicalDigest(lookup.value);
103
+ } catch {
104
+ return unverified("value_not_canonicalizable");
105
+ }
106
+ return digest === expect.sha256 ? verified({ detail: "value digest matches" }) : failed("expect_sha256_mismatch");
107
+ }
108
+ return unverified("unsupported_expectation");
109
+ }
110
+
111
+ // --- external selectors ------------------------------------------------------------
112
+
113
+ async function countLines(abs) {
114
+ let lines = 0;
115
+ let nonEmpty = 0;
116
+ let lineHasContent = false;
117
+ let lastByte = 0x0a;
118
+ for await (const chunk of fs.createReadStream(abs, { highWaterMark: 1 << 20 })) {
119
+ for (let i = 0; i < chunk.length; i += 1) {
120
+ const b = chunk[i];
121
+ if (b === 0x0a) {
122
+ lines += 1;
123
+ if (lineHasContent) nonEmpty += 1;
124
+ lineHasContent = false;
125
+ } else if (b !== 0x0d && b !== 0x20 && b !== 0x09) lineHasContent = true;
126
+ lastByte = b;
127
+ }
128
+ }
129
+ if (lastByte !== 0x0a) {
130
+ lines += 1;
131
+ if (lineHasContent) nonEmpty += 1;
132
+ }
133
+ return { lines, nonEmpty };
134
+ }
135
+
136
+ async function imageSize(abs) {
137
+ const handle = await fsp.open(abs, "r");
138
+ try {
139
+ const head = Buffer.alloc(1 << 20);
140
+ const { bytesRead } = await handle.read(head, 0, head.length, 0);
141
+ const buf = head.subarray(0, bytesRead);
142
+ if (buf.length >= 24 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) && buf.toString("ascii", 12, 16) === "IHDR") {
143
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
144
+ }
145
+ if (buf.length >= 10 && (buf.toString("ascii", 0, 6) === "GIF87a" || buf.toString("ascii", 0, 6) === "GIF89a")) {
146
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8), format: "gif" };
147
+ }
148
+ if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
149
+ let offset = 2;
150
+ while (offset + 9 < buf.length) {
151
+ if (buf[offset] !== 0xff) {
152
+ offset += 1;
153
+ continue;
154
+ }
155
+ const marker = buf[offset + 1];
156
+ if (marker === 0xff) {
157
+ offset += 1;
158
+ continue;
159
+ }
160
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
161
+ offset += 2;
162
+ continue;
163
+ }
164
+ const length = buf.readUInt16BE(offset + 2);
165
+ if ((marker >= 0xc0 && marker <= 0xcf) && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
166
+ return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7), format: "jpeg" };
167
+ }
168
+ offset += 2 + length;
169
+ }
170
+ }
171
+ return null;
172
+ } finally {
173
+ await handle.close();
174
+ }
175
+ }
176
+
177
+ /** Lazily computed, cached facts about an external file. */
178
+ export class ExternalFacts {
179
+ constructor({ maxParseBytes }) {
180
+ this.maxParseBytes = maxParseBytes;
181
+ this.cache = new Map();
182
+ }
183
+
184
+ async facts(abs, bytes, mediaType) {
185
+ if (this.cache.has(abs)) return this.cache.get(abs);
186
+ const facts = { bytes, mediaType, lines: null, image: null, jsonArrayLength: null, jsonlEventCount: null, jsonlReason: null };
187
+ const sniff = Buffer.alloc(Math.min(8192, bytes));
188
+ if (bytes > 0) {
189
+ const handle = await fsp.open(abs, "r");
190
+ try {
191
+ await handle.read(sniff, 0, sniff.length, 0);
192
+ } finally {
193
+ await handle.close();
194
+ }
195
+ }
196
+ facts.text = !sniff.includes(0);
197
+ if (facts.text) facts.lines = bytes > 0 ? await countLines(abs) : { lines: 0, nonEmpty: 0 };
198
+ facts.image = await imageSize(abs).catch(() => null);
199
+ if (facts.text && bytes > 0 && bytes <= this.maxParseBytes && /\.json$/i.test(abs)) {
200
+ try {
201
+ const doc = JSON.parse(await fsp.readFile(abs, "utf8"));
202
+ if (Array.isArray(doc)) facts.jsonArrayLength = doc.length;
203
+ } catch {
204
+ /* not JSON */
205
+ }
206
+ }
207
+ if (facts.text && /\.(jsonl|ndjson)$/i.test(abs)) {
208
+ if (bytes > this.maxParseBytes) facts.jsonlReason = "event_parse_limit_exceeded";
209
+ else {
210
+ try {
211
+ const lines = (await fsp.readFile(abs, "utf8")).split("\n").filter((line) => line.trim() !== "");
212
+ for (const line of lines) JSON.parse(line);
213
+ facts.jsonlEventCount = lines.length;
214
+ } catch {
215
+ facts.jsonlReason = "invalid_jsonl_event";
216
+ }
217
+ }
218
+ }
219
+ this.cache.set(abs, facts);
220
+ return facts;
221
+ }
222
+ }
223
+
224
+ export async function judgeExternal(selector, abs, entry, externalFacts) {
225
+ const facts = await externalFacts.facts(abs, entry.bytes, entry.external?.media_type ?? null);
226
+ switch (selector.type) {
227
+ case "whole-file":
228
+ return verified({ detail: "file digest matches" });
229
+ case "byte-range":
230
+ if (selector.start >= selector.end) return failed("empty_or_inverted_range");
231
+ return selector.end <= facts.bytes ? verified({ detail: `file has ${facts.bytes} bytes` }) : failed("range_beyond_file_size");
232
+ case "line-range":
233
+ if (!facts.text) return unverified("file_is_not_text");
234
+ if (selector.start > selector.end) return failed("inverted_range");
235
+ return selector.end <= facts.lines.lines ? verified({ detail: `file has ${facts.lines.lines} lines` }) : failed("range_beyond_line_count");
236
+ case "image-region":
237
+ if (!facts.image) return unverified("image_format_unsupported_for_bounds_check");
238
+ return selector.x + selector.width <= facts.image.width && selector.y + selector.height <= facts.image.height
239
+ ? verified({ detail: `${facts.image.format} ${facts.image.width}x${facts.image.height}` })
240
+ : failed("region_outside_image");
241
+ case "event-ordinal": {
242
+ if (/\.(jsonl|ndjson)$/i.test(abs) && facts.lines) {
243
+ if (facts.jsonlEventCount === null) return unverified(facts.jsonlReason ?? "event_structure_unknown");
244
+ return selector.ordinal < facts.jsonlEventCount ? verified({ detail: `${facts.jsonlEventCount} events` }) : failed("ordinal_beyond_event_count");
245
+ }
246
+ if (facts.jsonArrayLength !== null) {
247
+ return selector.ordinal < facts.jsonArrayLength ? verified({ detail: `${facts.jsonArrayLength} events` }) : failed("ordinal_beyond_event_count");
248
+ }
249
+ return unverified("event_structure_unknown");
250
+ }
251
+ case "time-range":
252
+ return unverified("no_time_base_for_file_type");
253
+ default:
254
+ return unverified("unsupported_selector_type");
255
+ }
256
+ }
@@ -0,0 +1,333 @@
1
+ // Shared low-level helpers: errors, hashing, canonical JSON, safe paths, atomic writes.
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import fsp from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ export const EXIT = Object.freeze({
8
+ OK: 0,
9
+ FAILED: 1,
10
+ USAGE: 2,
11
+ PREREQUISITE: 3,
12
+ BLOCKED: 4,
13
+ IO: 5,
14
+ });
15
+
16
+ /** Error carrying a CLI exit code and a stable machine-readable code. */
17
+ export class CliError extends Error {
18
+ constructor(message, { exit = EXIT.FAILED, code = "error", details } = {}) {
19
+ super(message);
20
+ this.name = "CliError";
21
+ this.exit = exit;
22
+ this.code = code;
23
+ this.details = details;
24
+ }
25
+ }
26
+
27
+ export const usage = (message) => new CliError(message, { exit: EXIT.USAGE, code: "usage" });
28
+
29
+ export const nowIso = () => new Date().toISOString();
30
+
31
+ /**
32
+ * Open a path for reading only if it is a regular file. The path is stat-ed
33
+ * first (no open side effects on devices) and the opened descriptor is checked
34
+ * again, so a FIFO/device swapped in after the first check is refused and
35
+ * O_NONBLOCK keeps the open itself from blocking on a FIFO.
36
+ */
37
+ export async function openRegularFile(file) {
38
+ const before = await fsp.stat(file);
39
+ if (!before.isFile()) throw new CliError(`not a regular file: ${file}`, { code: "not_regular_file" });
40
+ const handle = await fsp.open(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
41
+ try {
42
+ const stat = await handle.stat();
43
+ if (!stat.isFile()) throw new CliError(`not a regular file: ${file}`, { code: "not_regular_file" });
44
+ return { handle, stat };
45
+ } catch (error) {
46
+ await handle.close();
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ /** Stream a regular file through SHA-256 without loading it; refuses FIFOs, devices and directories. */
52
+ export async function hashFile(file) {
53
+ const { handle } = await openRegularFile(file);
54
+ const hash = createHash("sha256");
55
+ let bytes = 0;
56
+ const stream = handle.createReadStream({ highWaterMark: 1 << 20 });
57
+ for await (const chunk of stream) {
58
+ hash.update(chunk);
59
+ bytes += chunk.length;
60
+ }
61
+ return { sha256: hash.digest("hex"), bytes };
62
+ }
63
+
64
+ export const sha256Buffer = (buffer) => createHash("sha256").update(buffer).digest("hex");
65
+
66
+ /**
67
+ * REA canonical JSON: object keys sorted by UTF-16 code unit, scalars through
68
+ * JSON.stringify, no whitespace. Matches REA's own digestCanonicalValue; values
69
+ * must be JSON data (finite numbers, no undefined).
70
+ */
71
+ export function canonicalJson(value) {
72
+ if (value === null || typeof value !== "object") {
73
+ if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("non-finite number");
74
+ const encoded = JSON.stringify(value);
75
+ if (encoded === undefined) throw new TypeError("unsupported value");
76
+ return encoded;
77
+ }
78
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item === undefined ? null : item)).join(",")}]`;
79
+ const parts = [];
80
+ for (const key of Object.keys(value).sort()) {
81
+ if (value[key] === undefined) continue;
82
+ parts.push(`${JSON.stringify(key)}:${canonicalJson(value[key])}`);
83
+ }
84
+ return `{${parts.join(",")}}`;
85
+ }
86
+
87
+ export const canonicalDigest = (value) => createHash("sha256").update(canonicalJson(value)).digest("hex");
88
+
89
+ /** Deep JSON equality through canonical form. */
90
+ export const jsonEqual = (a, b) => {
91
+ try {
92
+ return canonicalJson(a) === canonicalJson(b);
93
+ } catch {
94
+ return false;
95
+ }
96
+ };
97
+
98
+ const BAD_SEGMENT = /[\u0000\\]/;
99
+
100
+ /**
101
+ * Normalize a workspace-relative path. Rejects absolute paths, drive letters,
102
+ * backslashes, NUL, empty and dot segments and `..` traversal. Pure string check.
103
+ */
104
+ export function normalizeRelPath(rel) {
105
+ if (typeof rel !== "string" || rel.length === 0 || rel.length > 1024) return null;
106
+ if (rel.startsWith("/") || /^[A-Za-z]:/.test(rel) || BAD_SEGMENT.test(rel)) return null;
107
+ const segments = rel.split("/");
108
+ for (const segment of segments) {
109
+ if (segment === "" || segment === "." || segment === "..") return null;
110
+ }
111
+ return segments.join("/");
112
+ }
113
+
114
+ /**
115
+ * Resolve a relative path inside a workspace without following symlinks:
116
+ * every existing component is lstat-ed and a symlink anywhere is refused.
117
+ * Returns { abs, rel, exists, stat }.
118
+ */
119
+ export async function resolveInWorkspace(workspaceAbs, rel, { allowMissing = true } = {}) {
120
+ const normalized = normalizeRelPath(rel);
121
+ if (normalized === null) {
122
+ throw new CliError(`unsafe workspace-relative path: ${JSON.stringify(rel)}`, { code: "unsafe_path" });
123
+ }
124
+ let current = workspaceAbs;
125
+ let stat = null;
126
+ const segments = normalized.split("/");
127
+ for (let index = 0; index < segments.length; index += 1) {
128
+ current = path.join(current, segments[index]);
129
+ try {
130
+ stat = await fsp.lstat(current);
131
+ } catch (error) {
132
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") {
133
+ if (!allowMissing) throw new CliError(`path does not exist: ${normalized}`, { code: "missing" });
134
+ return { abs: path.join(workspaceAbs, ...segments), rel: normalized, exists: false, stat: null };
135
+ }
136
+ throw error;
137
+ }
138
+ if (stat.isSymbolicLink()) {
139
+ throw new CliError(`symbolic link refused inside workspace: ${segments.slice(0, index + 1).join("/")}`, {
140
+ code: "symlink_refused",
141
+ });
142
+ }
143
+ if (index < segments.length - 1 && !stat.isDirectory()) {
144
+ throw new CliError(`path component is not a directory: ${segments.slice(0, index + 1).join("/")}`, {
145
+ code: "not_directory",
146
+ });
147
+ }
148
+ }
149
+ return { abs: current, rel: normalized, exists: true, stat };
150
+ }
151
+
152
+ /**
153
+ * Resolve and verify the workspace root itself. The root (and any symlinked
154
+ * ancestor) is canonicalized once with realpath; every path below it is then
155
+ * refused if it is a symlink. Callers must compare paths against the returned
156
+ * canonical `abs`, never against the lexical argument.
157
+ */
158
+ export async function openWorkspace(workspaceArg) {
159
+ if (!workspaceArg) throw usage("--workspace DIR is required");
160
+ const abs = path.resolve(workspaceArg);
161
+ let real;
162
+ try {
163
+ real = await fsp.realpath(abs);
164
+ } catch {
165
+ throw new CliError(`workspace does not exist: ${abs}`, { code: "workspace_missing" });
166
+ }
167
+ const stat = await fsp.stat(real);
168
+ if (!stat.isDirectory()) throw new CliError(`workspace is not a directory: ${abs}`, { code: "workspace_missing" });
169
+ return { abs: real };
170
+ }
171
+
172
+ const fsyncDir = async (dir) => {
173
+ let handle;
174
+ try {
175
+ handle = await fsp.open(dir, "r");
176
+ await handle.sync();
177
+ } catch {
178
+ /* directory fsync is best-effort on platforms that refuse it */
179
+ } finally {
180
+ await handle?.close();
181
+ }
182
+ };
183
+
184
+ const tempSibling = (dest) =>
185
+ path.join(path.dirname(dest), `.${path.basename(dest)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
186
+
187
+ /**
188
+ * Atomically replace (or create) a mutable file: write a sibling temp file,
189
+ * fsync, rename. The published destination is never truncated first.
190
+ */
191
+ export async function atomicReplace(dest, data, { mode = 0o644 } = {}) {
192
+ const temp = tempSibling(dest);
193
+ const handle = await fsp.open(temp, "wx", mode);
194
+ try {
195
+ await handle.writeFile(data);
196
+ await handle.sync();
197
+ } catch (error) {
198
+ await handle.close();
199
+ await fsp.rm(temp, { force: true });
200
+ throw error;
201
+ }
202
+ await handle.close();
203
+ try {
204
+ await fsp.rename(temp, dest);
205
+ } catch (error) {
206
+ await fsp.rm(temp, { force: true });
207
+ throw error;
208
+ }
209
+ await fsyncDir(path.dirname(dest));
210
+ }
211
+
212
+ /**
213
+ * Publish an immutable snapshot exactly once without changing or hard-linking
214
+ * the caller's source inode. The source is opened once as a regular file and
215
+ * streamed into a private sibling copy while hashing; with expectedSha256 the
216
+ * copy must match it (the source changed after inspection otherwise). Only the
217
+ * private copy is linked into place, atomically and exclusively.
218
+ */
219
+ export async function publishExclusive(source, dest, { mode = 0o444, expectedSha256 = null } = {}) {
220
+ const temp = tempSibling(dest);
221
+ try {
222
+ const { handle: input } = await openRegularFile(source);
223
+ const hash = createHash("sha256");
224
+ try {
225
+ const output = await fsp.open(temp, "wx", 0o600);
226
+ try {
227
+ for await (const chunk of input.createReadStream({ highWaterMark: 1 << 20 })) {
228
+ hash.update(chunk);
229
+ for (let offset = 0; offset < chunk.length; ) {
230
+ offset += (await output.write(chunk, offset, chunk.length - offset)).bytesWritten;
231
+ }
232
+ }
233
+ await output.chmod(mode);
234
+ await output.sync();
235
+ } finally {
236
+ await output.close();
237
+ }
238
+ } finally {
239
+ await input.close().catch(() => {});
240
+ }
241
+ if (expectedSha256 && hash.digest("hex") !== expectedSha256) {
242
+ throw new CliError(`source ${source} changed after inspection; nothing was published`, { code: "source_changed", exit: EXIT.IO });
243
+ }
244
+ try {
245
+ await fsp.link(temp, dest);
246
+ } catch (error) {
247
+ if (error.code === "EEXIST") return "exists";
248
+ throw error;
249
+ }
250
+ await fsyncDir(path.dirname(dest));
251
+ return "created";
252
+ } finally {
253
+ await fsp.rm(temp, { force: true });
254
+ }
255
+ }
256
+
257
+ /** Run fn while holding an exclusive lock file; never breaks an existing lock. */
258
+ export async function withLock(lockPath, fn) {
259
+ let handle;
260
+ try {
261
+ handle = await fsp.open(lockPath, "wx", 0o600);
262
+ } catch (error) {
263
+ if (error.code === "EEXIST") {
264
+ throw new CliError(`another helper operation holds ${lockPath}; remove it only after confirming no helper is running`, {
265
+ code: "locked",
266
+ exit: EXIT.IO,
267
+ });
268
+ }
269
+ throw error;
270
+ }
271
+ try {
272
+ await handle.writeFile(`${process.pid}\n`);
273
+ return await fn();
274
+ } finally {
275
+ await handle.close();
276
+ await fsp.rm(lockPath, { force: true });
277
+ }
278
+ }
279
+
280
+ export async function readJson(file) {
281
+ return JSON.parse(await fsp.readFile(file, "utf8"));
282
+ }
283
+
284
+ export const slugify = (text, fallback = "output") => {
285
+ const slug = String(text ?? "")
286
+ .toLowerCase()
287
+ .replace(/[^a-z0-9]+/g, "-")
288
+ .replace(/^-+|-+$/g, "")
289
+ .slice(0, 40);
290
+ return slug || fallback;
291
+ };
292
+
293
+ export const stableStringify = (value) => `${JSON.stringify(value, null, 2)}\n`;
294
+
295
+ /** Parse "--flag value" style arguments; flags listed in booleans take no value. */
296
+ export function parseArgs(argv, { booleans = [], multi = [] } = {}) {
297
+ const flags = {};
298
+ const positionals = [];
299
+ for (let index = 0; index < argv.length; index += 1) {
300
+ const arg = argv[index];
301
+ if (arg === "--") {
302
+ positionals.push(...argv.slice(index + 1));
303
+ break;
304
+ }
305
+ if (arg.startsWith("--")) {
306
+ const eq = arg.indexOf("=");
307
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
308
+ let value;
309
+ if (booleans.includes(name)) {
310
+ value = true;
311
+ } else if (eq !== -1) {
312
+ value = arg.slice(eq + 1);
313
+ } else {
314
+ index += 1;
315
+ if (index >= argv.length) throw usage(`--${name} requires a value`);
316
+ value = argv[index];
317
+ }
318
+ if (multi.includes(name)) (flags[name] ??= []).push(value);
319
+ else if (name in flags) throw usage(`--${name} given more than once`);
320
+ else flags[name] = value;
321
+ } else {
322
+ positionals.push(arg);
323
+ }
324
+ }
325
+ return { flags, positionals };
326
+ }
327
+
328
+ export function toInt(value, name, { min = 0 } = {}) {
329
+ if (value === undefined) return undefined;
330
+ const n = Number(value);
331
+ if (!Number.isSafeInteger(n) || n < min) throw usage(`--${name} must be an integer >= ${min}`);
332
+ return n;
333
+ }