@kal-elsam/kairo-runtime 0.5.1 → 0.7.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 (44) hide show
  1. package/README.md +44 -6
  2. package/global-template/components/catalog.json +4 -2
  3. package/global-template/components/orchestrator/orchestrator.md +1 -1
  4. package/global-template/core/orchestrator.md +1 -1
  5. package/package.json +3 -2
  6. package/scripts/cockpit-smoke.mjs +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/installer-smoke-test.sh +6 -4
  9. package/src/cli.js +60 -1
  10. package/src/global/adapters/pi.js +53 -0
  11. package/src/global/agent-capabilities/index.js +11 -1
  12. package/src/global/brand/index.js +4 -2
  13. package/src/global/dashboard-guidance.js +1 -1
  14. package/src/global/global-cli.js +1 -1
  15. package/src/global/ink/cockpit-controller.js +10 -0
  16. package/src/global/ink/cockpit-focus.js +18 -3
  17. package/src/global/ink/cockpit-models.js +8 -1
  18. package/src/global/ink/cockpit-reviews.js +62 -0
  19. package/src/global/ink/cockpit-runs.js +11 -2
  20. package/src/global/ink/cockpit-views.js +19 -2
  21. package/src/global/ink/orchestrator-app.js +23 -3
  22. package/src/global/ink/orchestrator-state.js +2 -0
  23. package/src/global/ink/use-orchestrator-data.js +30 -0
  24. package/src/global/integrations/engram-evidence.js +41 -3
  25. package/src/global/integrations/sdd-destinations.js +2 -2
  26. package/src/global/paths.js +1 -0
  27. package/src/global/registry.js +2 -1
  28. package/src/global/runtime/execution-adapters/codex.js +2 -1
  29. package/src/global/runtime/execution-adapters/create-execution-adapter.js +1 -0
  30. package/src/global/runtime/execution-adapters/index.js +3 -1
  31. package/src/global/runtime/execution-adapters/pi.js +184 -0
  32. package/src/global/runtime/review/index.js +37 -0
  33. package/src/global/runtime/review/review-cli.js +150 -0
  34. package/src/global/runtime/review/review-codex.js +132 -0
  35. package/src/global/runtime/review/review-exec.js +136 -0
  36. package/src/global/runtime/review/review-fs.js +52 -0
  37. package/src/global/runtime/review/review-git.js +212 -0
  38. package/src/global/runtime/review/review-patch.js +122 -0
  39. package/src/global/runtime/review/review-pi.js +168 -0
  40. package/src/global/runtime/review/review-receipts.js +128 -0
  41. package/src/global/runtime/review/review-runner.js +119 -0
  42. package/src/global/runtime/review/review-types.js +108 -0
  43. package/src/global/runtime/review/review-validate.js +280 -0
  44. package/src/global/runtime/write-atomic-json.js +24 -20
@@ -0,0 +1,150 @@
1
+ import { resolveHomeDir } from "../../paths.js";
2
+ import { printJson } from "../../json-output.js";
3
+ import { commandHeader } from "../../brand/index.js";
4
+ import { formatCliCommand } from "../../brand/cli.js";
5
+ import {
6
+ isInteractiveTerminal, promptApplyConfirmation
7
+ } from "../../apply-confirmation.js";
8
+ import {
9
+ REVIEW_EXIT_CODES, REVIEW_SEVERITIES,
10
+ assertReceiptSecretFree, assertSafeReviewId,
11
+ listReviewReceipts, loadReviewReceipt
12
+ } from "./index.js";
13
+ import { runReview } from "./review-runner.js";
14
+
15
+ const FAIL_ON = new Set(Object.values(REVIEW_SEVERITIES));
16
+
17
+ function parseFailOn(value) {
18
+ if (value == null || value === "") return null;
19
+ const normalized = String(value).trim().toLowerCase();
20
+ if (!FAIL_ON.has(normalized)) {
21
+ throw new Error(`Invalid --fail-on "${value}". Use high, medium, or low.`);
22
+ }
23
+ return normalized;
24
+ }
25
+
26
+ async function resolvePrivateConfirmed(options, { prompt = promptApplyConfirmation } = {}) {
27
+ if (!options.includePrivate) return { privateConfirmed: false, cancelled: false };
28
+ if (options.yes || options.confirm) return { privateConfirmed: true, cancelled: false };
29
+ if (!isInteractiveTerminal(options.interactive)) {
30
+ throw new Error(
31
+ "Including private paths requires --include-private with --yes/--confirm, or a TTY confirmation."
32
+ );
33
+ }
34
+ const ok = await prompt({
35
+ command: "review",
36
+ question: "Include private paths in this review? [Y/n]: "
37
+ });
38
+ return { privateConfirmed: Boolean(ok), cancelled: !ok };
39
+ }
40
+
41
+ function publicReceipt(receipt) {
42
+ return assertReceiptSecretFree(receipt);
43
+ }
44
+
45
+ function printReviewHuman(receipt, exitCode) {
46
+ const counts = { high: 0, medium: 0, low: 0 };
47
+ for (const f of receipt.findings ?? []) {
48
+ if (counts[f.severity] != null) counts[f.severity] += 1;
49
+ }
50
+ console.log(commandHeader(`review ${receipt.reviewId}`));
51
+ console.log(`Agent: ${receipt.agentId} · state: ${receipt.state} · exit: ${exitCode}`);
52
+ console.log(
53
+ `Findings: ${(receipt.findings ?? []).length}`
54
+ + ` (high ${counts.high}, medium ${counts.medium}, low ${counts.low})`
55
+ );
56
+ console.log(
57
+ `Snapshot: ${receipt.snapshot.mode} · files ${receipt.snapshot.totals.fileCount}`
58
+ + ` · ${receipt.snapshot.fingerprint.slice(0, 12)}`
59
+ );
60
+ if ((receipt.warnings ?? []).length) console.log(`Warnings: ${receipt.warnings.length}`);
61
+ }
62
+
63
+ export async function runGlobalReview(options, packageManifest, deps = {}) {
64
+ const homeDir = deps.homeDir ?? resolveHomeDir();
65
+ try {
66
+ if (!options.agent) {
67
+ throw new Error(`Missing --agent. Use: ${formatCliCommand("review --agent codex|pi")}`);
68
+ }
69
+ const failOn = parseFailOn(options.failOn);
70
+ const consent = await resolvePrivateConfirmed(options, { prompt: deps.prompt });
71
+ if (consent.cancelled) {
72
+ if (options.json) {
73
+ printJson({
74
+ ok: false, cancelled: true, exitCode: REVIEW_EXIT_CODES.ERROR,
75
+ error: "Private path inclusion cancelled."
76
+ });
77
+ } else {
78
+ console.log("Review cancelled: private paths not included.");
79
+ }
80
+ process.exitCode = REVIEW_EXIT_CODES.ERROR;
81
+ return { cancelled: true, exitCode: REVIEW_EXIT_CODES.ERROR };
82
+ }
83
+
84
+ const result = await (deps.runReview ?? runReview)({
85
+ cwd: options.cwd, agent: options.agent, base: options.base ?? null,
86
+ commit: options.commit ?? null, model: options.model ?? null,
87
+ includePrivate: Boolean(options.includePrivate),
88
+ privateConfirmed: consent.privateConfirmed, failOn,
89
+ homeDir, cliVersion: packageManifest?.version ?? null
90
+ });
91
+ const receipt = publicReceipt(result.receipt);
92
+ if (options.json) printJson({ ok: result.exitCode === 0, exitCode: result.exitCode, receipt });
93
+ else printReviewHuman(receipt, result.exitCode);
94
+ process.exitCode = result.exitCode;
95
+ return { receipt, exitCode: result.exitCode };
96
+ } catch (error) {
97
+ const exitCode = REVIEW_EXIT_CODES.ERROR;
98
+ const message = String(error?.message ?? error);
99
+ if (options.json) printJson({ ok: false, exitCode, error: message, code: error?.code ?? null });
100
+ else console.error(message);
101
+ process.exitCode = exitCode;
102
+ return { exitCode, error };
103
+ }
104
+ }
105
+
106
+ export async function runGlobalReviews(options, _packageManifest, deps = {}) {
107
+ const homeDir = deps.homeDir ?? resolveHomeDir();
108
+ try {
109
+ const action = options.reviewsAction ?? "list";
110
+ if (action === "list") {
111
+ const receipts = (await listReviewReceipts({ homeDir, limit: options.limit }))
112
+ .map((r) => publicReceipt(r));
113
+ if (options.json) printJson({ receipts });
114
+ else {
115
+ console.log(commandHeader("reviews"));
116
+ if (receipts.length === 0) console.log(" (no reviews yet)");
117
+ for (const r of receipts) {
118
+ console.log(
119
+ ` ${r.reviewId} ${String(r.state).padEnd(10)} ${String(r.agentId).padEnd(6)} ${r.createdAt}`
120
+ );
121
+ }
122
+ }
123
+ return { receipts };
124
+ }
125
+ if (action === "show") {
126
+ if (!options.reviewId) {
127
+ throw new Error(`Missing review id. Use: ${formatCliCommand("reviews show <reviewId>")}`);
128
+ }
129
+ try { assertSafeReviewId(options.reviewId); }
130
+ catch { throw new Error(`Invalid review id "${options.reviewId}".`); }
131
+ let receipt;
132
+ try {
133
+ receipt = publicReceipt(await loadReviewReceipt(options.reviewId, { homeDir }));
134
+ } catch {
135
+ throw new Error(`Review receipt not found: ${options.reviewId}`);
136
+ }
137
+ if (options.json) printJson({ receipt });
138
+ else printReviewHuman(receipt, REVIEW_EXIT_CODES.OK);
139
+ return { receipt };
140
+ }
141
+ throw new Error(`Unknown reviews action "${action}". Use list or show.`);
142
+ } catch (error) {
143
+ const exitCode = REVIEW_EXIT_CODES.ERROR;
144
+ const message = String(error?.message ?? error);
145
+ if (options.json) printJson({ ok: false, exitCode, error: message, code: error?.code ?? null });
146
+ else console.error(message);
147
+ process.exitCode = exitCode;
148
+ return { exitCode, error };
149
+ }
150
+ }
@@ -0,0 +1,132 @@
1
+ import { REVIEW_AGENTS, REVIEW_SCOPE_MODES } from "./review-types.js";
2
+ import { ReviewExecError, assertBoundedProcessOk, runBoundedProcess } from "./review-exec.js";
3
+ import { validateReviewOutput } from "./review-validate.js";
4
+
5
+ export const REVIEW_CODEX_ERROR_CODES = Object.freeze({
6
+ INVALID_JSONL: "invalid_jsonl", MISSING_AGENT_MESSAGE: "missing_agent_message",
7
+ STREAM_ERROR: "stream_error", INVALID_CWD: "invalid_cwd"
8
+ });
9
+
10
+ const EXECUTABLE = "codex";
11
+ const ENV_INHERIT_NONE = "shell_environment_policy.inherit=none";
12
+ const CLI_ENV_KEYS = Object.freeze([
13
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TERM",
14
+ "CODEX_HOME", "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE",
15
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"
16
+ ]);
17
+
18
+ function requireSnapshotCwd(snapshot) {
19
+ const cwd = snapshot?.cwd;
20
+ if (typeof cwd !== "string" || !cwd) {
21
+ throw new ReviewExecError("Codex review requires snapshot.cwd.", {
22
+ code: REVIEW_CODEX_ERROR_CODES.INVALID_CWD
23
+ });
24
+ }
25
+ return cwd;
26
+ }
27
+
28
+ /** Global approval before `exec`; read-only/ephemeral after; prompt via `-`. */
29
+ export function buildCodexReviewArgs(snapshot, { model = null } = {}) {
30
+ const cwd = requireSnapshotCwd(snapshot);
31
+ const args = [
32
+ "--ask-for-approval", "never",
33
+ "exec", "--json", "--ephemeral", "--ignore-user-config",
34
+ "--sandbox", "read-only", "-C", cwd, "-c", ENV_INHERIT_NONE
35
+ ];
36
+ if (model) args.push("-m", String(model));
37
+ args.push("-");
38
+ return args;
39
+ }
40
+
41
+ /** Preserve only env needed to start/authenticate the CLI. */
42
+ export function buildCodexCliEnv(sourceEnv = process.env) {
43
+ const env = Object.create(null);
44
+ for (const key of CLI_ENV_KEYS) {
45
+ if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
46
+ }
47
+ return env;
48
+ }
49
+
50
+ function scopeRefLine(snapshot) {
51
+ if (snapshot?.mode === REVIEW_SCOPE_MODES.BASE) return `base=${snapshot.base ?? ""}`;
52
+ if (snapshot?.mode === REVIEW_SCOPE_MODES.COMMIT) return `commit=${snapshot.commit ?? ""}`;
53
+ return "ref=working-tree";
54
+ }
55
+
56
+ /** Prompt is mode + exact scope ref + snapshot paths — no diffs/transcripts. */
57
+ export function buildCodexReviewPrompt(snapshot) {
58
+ const files = Array.isArray(snapshot?.files) ? snapshot.files : [];
59
+ return [
60
+ `Bounded review mode=${snapshot?.mode ?? "working-tree"} ${scopeRefLine(snapshot)}.`,
61
+ "Respond JSON only: {\"findings\":[{\"severity\":\"high|medium|low\",\"title\":\"...\",\"path\":\"...\",\"line\":null,\"problem\":\"...\",\"recommendation\":\"...\"}],\"warnings\":[]}.",
62
+ "Cite only snapshot paths:",
63
+ ...(files.length ? files.map((f) => `- ${f.path} (${f.status})`) : ["(none)"])
64
+ ].join("\n");
65
+ }
66
+
67
+ /** Parse Codex JSONL: last agent_message + usage. Trailing partial line ignored. */
68
+ export function parseCodexReviewJsonl(stdout) {
69
+ let agentText = null;
70
+ let usage = null;
71
+ let streamError = null;
72
+ const raw = String(stdout ?? "");
73
+ const parts = raw.split(/\r?\n/);
74
+ const complete = raw.endsWith("\n") || raw.endsWith("\r\n") ? parts : parts.slice(0, -1);
75
+ for (const line of complete) {
76
+ const trimmed = line.trim();
77
+ if (!trimmed) continue;
78
+ let event;
79
+ try { event = JSON.parse(trimmed); }
80
+ catch (error) {
81
+ throw new ReviewExecError(`Malformed Codex JSONL: ${error.message}`, {
82
+ code: REVIEW_CODEX_ERROR_CODES.INVALID_JSONL
83
+ });
84
+ }
85
+ if (!event || typeof event !== "object") {
86
+ throw new ReviewExecError("Malformed Codex JSONL event.", {
87
+ code: REVIEW_CODEX_ERROR_CODES.INVALID_JSONL
88
+ });
89
+ }
90
+ if (event.type === "error") streamError = String(event.message ?? "Codex stream error.");
91
+ else if (event.type === "turn.failed") streamError = String(event.error?.message ?? "Codex turn failed.");
92
+ else if (event.type === "turn.completed" && event.usage && typeof event.usage === "object") {
93
+ const input = Number.isFinite(event.usage.input_tokens) ? event.usage.input_tokens : null;
94
+ const output = Number.isFinite(event.usage.output_tokens) ? event.usage.output_tokens : null;
95
+ usage = {
96
+ inputTokens: input, outputTokens: output,
97
+ totalTokens: input != null && output != null ? input + output : null, cost: null
98
+ };
99
+ } else if (event.type === "item.completed" && event.item?.type === "agent_message"
100
+ && typeof event.item.text === "string") {
101
+ agentText = event.item.text;
102
+ }
103
+ }
104
+ if (streamError) throw new ReviewExecError(streamError, { code: REVIEW_CODEX_ERROR_CODES.STREAM_ERROR });
105
+ if (typeof agentText !== "string" || agentText.trim() === "") {
106
+ throw new ReviewExecError("Codex JSONL missing final agent_message.", {
107
+ code: REVIEW_CODEX_ERROR_CODES.MISSING_AGENT_MESSAGE
108
+ });
109
+ }
110
+ return { agentText, usage };
111
+ }
112
+
113
+ /** Run Codex read-only review bound to snapshot.cwd. No receipts/drift/raw streams. */
114
+ export async function runCodexReview({
115
+ snapshot, model = null, env = process.env, spawnImpl,
116
+ timeoutMs, terminationGraceMs, killGraceMs, runProcess = runBoundedProcess
117
+ } = {}) {
118
+ const cwd = requireSnapshotCwd(snapshot);
119
+ const requestedModel = model == null || model === "" ? null : String(model);
120
+ const result = await runProcess({
121
+ command: EXECUTABLE, args: buildCodexReviewArgs(snapshot, { model: requestedModel }), cwd,
122
+ env: buildCodexCliEnv(env), stdin: buildCodexReviewPrompt(snapshot), spawnImpl,
123
+ timeoutMs, terminationGraceMs, killGraceMs
124
+ });
125
+ assertBoundedProcessOk(result);
126
+ const parsed = parseCodexReviewJsonl(result.stdout);
127
+ const validated = validateReviewOutput(parsed.agentText, snapshot);
128
+ return {
129
+ agentId: REVIEW_AGENTS.CODEX, model: requestedModel,
130
+ findings: validated.findings, warnings: validated.warnings, usage: parsed.usage
131
+ };
132
+ }
@@ -0,0 +1,136 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const REVIEW_EXEC_ERROR_CODES = Object.freeze({
4
+ NONZERO_EXIT: "nonzero_exit", TIMEOUT: "timeout", OUTPUT_OVERFLOW: "output_overflow",
5
+ TERMINATION_FAILED: "termination_failed", SPAWN_FAILED: "spawn_failed"
6
+ });
7
+ export const REVIEW_EXEC_LIMITS = Object.freeze({
8
+ STDOUT: 1_048_576, STDERR: 16_384, DEFAULT_TIMEOUT_MS: 180_000,
9
+ TERMINATION_GRACE_MS: 1_000, KILL_GRACE_MS: 1_000
10
+ });
11
+
12
+ export class ReviewExecError extends Error {
13
+ constructor(message, { code, details = null } = {}) {
14
+ super(message);
15
+ this.name = "ReviewExecError";
16
+ this.code = code;
17
+ this.details = details;
18
+ }
19
+ }
20
+
21
+ /** Spawn without shell; stdin; capped streams; SIGTERM→SIGKILL. */
22
+ export function runBoundedProcess({
23
+ command, args = [], cwd, env, stdin = null, spawnImpl = spawn,
24
+ timeoutMs = REVIEW_EXEC_LIMITS.DEFAULT_TIMEOUT_MS,
25
+ terminationGraceMs = REVIEW_EXEC_LIMITS.TERMINATION_GRACE_MS,
26
+ killGraceMs = REVIEW_EXEC_LIMITS.KILL_GRACE_MS,
27
+ stdoutLimit = REVIEW_EXEC_LIMITS.STDOUT, stderrLimit = REVIEW_EXEC_LIMITS.STDERR
28
+ } = {}) {
29
+ if (typeof command !== "string" || !command) {
30
+ return Promise.reject(new ReviewExecError("runBoundedProcess requires command.", {
31
+ code: REVIEW_EXEC_ERROR_CODES.SPAWN_FAILED
32
+ }));
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ let child;
36
+ try {
37
+ child = spawnImpl(command, args, { cwd, env, shell: false, stdio: ["pipe", "pipe", "pipe"] });
38
+ } catch (error) {
39
+ reject(new ReviewExecError(`Failed to spawn "${command}": ${error.message}`, {
40
+ code: REVIEW_EXEC_ERROR_CODES.SPAWN_FAILED, details: { command }
41
+ }));
42
+ return;
43
+ }
44
+ let stdout = "";
45
+ let stderr = "";
46
+ let stdoutOverflow = false;
47
+ let stderrOverflow = false;
48
+ let settled = false;
49
+ let timedOut = false;
50
+ let timers = [];
51
+ const clearTimers = () => { for (const t of timers) clearTimeout(t); timers = []; };
52
+ const schedule = (fn, ms) => { timers.push(setTimeout(fn, ms)); };
53
+ const detachIo = () => {
54
+ child.stdout?.off?.("data", onStdout);
55
+ child.stderr?.off?.("data", onStderr);
56
+ child.off?.("close", onClose);
57
+ };
58
+ const settle = (result) => {
59
+ if (settled) return;
60
+ settled = true; clearTimers(); detachIo(); resolve(result);
61
+ };
62
+ const finish = (extra = {}) => settle({
63
+ status: null, signal: null, timedOut, terminationFailed: false,
64
+ stdoutOverflow, stderrOverflow, stdout, stderr, ...extra
65
+ });
66
+ const onStdout = (chunk) => {
67
+ const next = appendLimited(stdout, chunk, stdoutLimit);
68
+ stdout = next.text; if (next.overflow) stdoutOverflow = true;
69
+ };
70
+ const onStderr = (chunk) => {
71
+ const next = appendLimited(stderr, chunk, stderrLimit);
72
+ stderr = next.text; if (next.overflow) stderrOverflow = true;
73
+ };
74
+ const onError = (error) => {
75
+ if (settled) return;
76
+ settled = true; clearTimers(); detachIo();
77
+ reject(new ReviewExecError(`Spawn error for "${command}": ${error.message}`, {
78
+ code: REVIEW_EXEC_ERROR_CODES.SPAWN_FAILED, details: { command }
79
+ }));
80
+ };
81
+ const onClose = (status, signal) => finish({ status: status ?? null, signal: signal ?? null });
82
+ schedule(() => {
83
+ if (settled) return;
84
+ timedOut = true; safeKill(child, "SIGTERM");
85
+ schedule(() => {
86
+ if (settled) return;
87
+ safeKill(child, "SIGKILL");
88
+ schedule(() => {
89
+ if (settled) return;
90
+ try { child.unref?.(); } catch { /* ignore */ }
91
+ finish({ terminationFailed: true });
92
+ }, killGraceMs);
93
+ }, terminationGraceMs);
94
+ }, timeoutMs);
95
+ child.stdout?.on("data", onStdout);
96
+ child.stderr?.on("data", onStderr);
97
+ child.on("error", onError);
98
+ child.on("close", onClose);
99
+ child.stdin?.on?.("error", () => {}); // absorb async EPIPE
100
+ try { child.stdin?.end(stdin == null ? undefined : String(stdin)); }
101
+ catch { try { child.stdin?.end(); } catch { /* ignore */ } }
102
+ });
103
+ }
104
+
105
+ /** Nonzero/overflow/timeout fail even when output exists. */
106
+ export function assertBoundedProcessOk(result) {
107
+ const fail = (code, message, details = null) => {
108
+ throw new ReviewExecError(message, { code, details });
109
+ };
110
+ if (result.terminationFailed) fail(REVIEW_EXEC_ERROR_CODES.TERMINATION_FAILED, "Process did not terminate after SIGTERM/SIGKILL.");
111
+ if (result.timedOut) fail(REVIEW_EXEC_ERROR_CODES.TIMEOUT, "Process timed out.", { signal: result.signal });
112
+ if (result.stdoutOverflow || result.stderrOverflow) {
113
+ fail(REVIEW_EXEC_ERROR_CODES.OUTPUT_OVERFLOW, "Process output exceeded capture limits.", {
114
+ stdoutOverflow: result.stdoutOverflow, stderrOverflow: result.stderrOverflow
115
+ });
116
+ }
117
+ if (result.status !== 0) {
118
+ fail(REVIEW_EXEC_ERROR_CODES.NONZERO_EXIT, `Process exited with status ${result.status}.`, {
119
+ status: result.status, signal: result.signal
120
+ });
121
+ }
122
+ return result;
123
+ }
124
+
125
+ function appendLimited(current, chunk, limit) {
126
+ if (current.length >= limit) return { text: current, overflow: true };
127
+ const next = current + String(chunk);
128
+ return next.length <= limit
129
+ ? { text: next, overflow: false }
130
+ : { text: next.slice(0, limit), overflow: true };
131
+ }
132
+
133
+ function safeKill(child, signal) {
134
+ try { return typeof child.kill === "function" ? child.kill(signal) !== false : false; }
135
+ catch { return false; }
136
+ }
@@ -0,0 +1,52 @@
1
+ import { open, lstat } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+
4
+ /** lstat + open; bind handle to validated inode via fstat (portable vs O_NOFOLLOW). */
5
+ export async function readReviewRegularFile(absPath, {
6
+ lstatImpl = lstat, openImpl = open
7
+ } = {}) {
8
+ let st;
9
+ try { st = await lstatImpl(absPath); }
10
+ catch (error) {
11
+ error.code = error.code ?? "ENOENT";
12
+ throw error;
13
+ }
14
+ if (st.isSymbolicLink()) {
15
+ const error = new Error(`Refusing symlink "${absPath}".`);
16
+ error.code = "REVIEW_SYMLINK";
17
+ throw error;
18
+ }
19
+ if (!st.isFile()) {
20
+ const error = new Error(`Refusing non-regular file "${absPath}".`);
21
+ error.code = "REVIEW_NON_REGULAR";
22
+ throw error;
23
+ }
24
+ const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
25
+ let handle;
26
+ try {
27
+ handle = await openImpl(absPath, flags);
28
+ } catch (error) {
29
+ if (error?.code === "ELOOP" || error?.code === "EMLINK") {
30
+ const wrapped = new Error(`Refusing symlink "${absPath}".`);
31
+ wrapped.code = "REVIEW_SYMLINK";
32
+ throw wrapped;
33
+ }
34
+ throw error;
35
+ }
36
+ try {
37
+ const opened = await handle.stat();
38
+ if (!opened.isFile() || (typeof opened.isSymbolicLink === "function" && opened.isSymbolicLink())) {
39
+ const error = new Error(`Refusing non-regular handle "${absPath}".`);
40
+ error.code = "REVIEW_NON_REGULAR";
41
+ throw error;
42
+ }
43
+ if (opened.dev !== st.dev || opened.ino !== st.ino) {
44
+ const error = new Error(`Refusing identity change for "${absPath}".`);
45
+ error.code = "REVIEW_IDENTITY_CHANGED";
46
+ throw error;
47
+ }
48
+ return await handle.readFile();
49
+ } finally {
50
+ await handle.close();
51
+ }
52
+ }
@@ -0,0 +1,212 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { join } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import {
6
+ REVIEW_SCOPE_MODES,
7
+ REVIEW_SNAPSHOT_ERROR_CODES,
8
+ ReviewSnapshotError,
9
+ assertReviewPathSafe,
10
+ assertWithinReviewLimits,
11
+ canonicalFingerprint,
12
+ isBinaryContent,
13
+ isReviewPrivatePath,
14
+ requirePrivateConsent,
15
+ resolveReviewScopeMode
16
+ } from "./review-types.js";
17
+ import { readReviewRegularFile } from "./review-fs.js";
18
+
19
+ export { readReviewRegularFile } from "./review-fs.js";
20
+
21
+ const defaultExecFile = promisify(execFileCb);
22
+
23
+ async function git(cwd, args, execFileImpl) {
24
+ try {
25
+ const { stdout } = await execFileImpl("git", args, {
26
+ cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024
27
+ });
28
+ return stdout ?? "";
29
+ } catch (error) {
30
+ throw new ReviewSnapshotError(String(error?.stderr ?? error?.message ?? error).trim() || "git failed", { code: REVIEW_SNAPSHOT_ERROR_CODES.INVALID_REF, details: { args, status: error?.code ?? null } });
31
+ }
32
+ }
33
+
34
+ async function assertGitRepo(cwd, execFileImpl) {
35
+ try {
36
+ if ((await git(cwd, ["rev-parse", "--is-inside-work-tree"], execFileImpl)).trim() !== "true") {
37
+ throw new Error("not git");
38
+ }
39
+ } catch (error) {
40
+ if (error instanceof ReviewSnapshotError && error.code === REVIEW_SNAPSHOT_ERROR_CODES.INVALID_REF) {
41
+ throw new ReviewSnapshotError("Not a git repository.", {
42
+ code: REVIEW_SNAPSHOT_ERROR_CODES.NOT_A_GIT_REPO
43
+ });
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ function unquotePath(path) {
50
+ if (path.startsWith("\"") && path.endsWith("\"")) {
51
+ try { return JSON.parse(path); } catch { return path.slice(1, -1); }
52
+ }
53
+ return path;
54
+ }
55
+
56
+ function parseNumstat(text) {
57
+ const byPath = new Map();
58
+ for (const line of text.split("\n")) {
59
+ if (!line.trim()) continue;
60
+ const [addedRaw, deletedRaw, pathRaw] = line.split("\t");
61
+ if (!pathRaw) continue;
62
+ const path = pathRaw.includes(" => ") ? pathRaw.split(" => ").at(-1) : pathRaw;
63
+ const n = (addedRaw === "-" ? 0 : Number(addedRaw)) + (deletedRaw === "-" ? 0 : Number(deletedRaw));
64
+ byPath.set(path, (byPath.get(path) ?? 0) + n);
65
+ }
66
+ return byPath;
67
+ }
68
+
69
+ function parsePorcelain(text) {
70
+ return text.split("\n").filter(Boolean).map((line) => {
71
+ const status = line.slice(0, 2);
72
+ const rest = line.slice(3);
73
+ if (rest.includes(" -> ")) {
74
+ const [from, to] = rest.split(" -> ");
75
+ return { status, sourcePath: unquotePath(from), path: unquotePath(to) };
76
+ }
77
+ return { status, sourcePath: null, path: unquotePath(rest) };
78
+ });
79
+ }
80
+
81
+ function parseNameStatus(text) {
82
+ return text.split("\n").filter((l) => l.trim()).map((line) => {
83
+ const parts = line.split("\t");
84
+ if (parts.length >= 3) {
85
+ return { status: parts[0], sourcePath: unquotePath(parts[1]), path: unquotePath(parts[2]) };
86
+ }
87
+ return { status: parts[0], sourcePath: null, path: unquotePath(parts[1]) };
88
+ });
89
+ }
90
+
91
+ function fingerprintPayload(s) {
92
+ return {
93
+ mode: s.mode, headSha: s.headSha, base: s.base ?? null, commit: s.commit ?? null,
94
+ files: s.files.map((f) => ({
95
+ path: f.path, sourcePath: f.sourcePath ?? null, status: f.status, hash: f.hash, changedLines: f.changedLines
96
+ })),
97
+ excluded: s.excluded.map((e) => ({ path: e.path, reason: e.reason }))
98
+ };
99
+ }
100
+
101
+ /** Bounded Git review snapshot via argv-only git (no shell, no repo writes). */
102
+ export async function resolveReviewSnapshot({
103
+ cwd, base = null, commit = null, includePrivate = false, privateConfirmed = false,
104
+ execFileImpl = defaultExecFile
105
+ } = {}) {
106
+ const mode = resolveReviewScopeMode({ base, commit });
107
+ await assertGitRepo(cwd, execFileImpl);
108
+ const headSha = (await git(cwd, ["rev-parse", "HEAD"], execFileImpl)).trim();
109
+ let rawEntries = [];
110
+ let numstat = new Map();
111
+ let diffBytes = 0;
112
+
113
+ if (mode === REVIEW_SCOPE_MODES.WORKING_TREE) {
114
+ rawEntries = parsePorcelain(await git(cwd, ["status", "--porcelain=v1", "-uall"], execFileImpl));
115
+ numstat = new Map([
116
+ ...parseNumstat(await git(cwd, ["diff", "--numstat"], execFileImpl)),
117
+ ...parseNumstat(await git(cwd, ["diff", "--cached", "--numstat"], execFileImpl))
118
+ ]);
119
+ diffBytes = Buffer.byteLength(await git(cwd, ["diff"], execFileImpl), "utf8")
120
+ + Buffer.byteLength(await git(cwd, ["diff", "--cached"], execFileImpl), "utf8");
121
+ } else if (mode === REVIEW_SCOPE_MODES.BASE) {
122
+ const range = `${base}...HEAD`;
123
+ await git(cwd, ["rev-parse", "--verify", base], execFileImpl);
124
+ rawEntries = parseNameStatus(await git(cwd, ["diff", "--name-status", range], execFileImpl));
125
+ numstat = parseNumstat(await git(cwd, ["diff", "--numstat", range], execFileImpl));
126
+ diffBytes = Buffer.byteLength(await git(cwd, ["diff", range], execFileImpl), "utf8");
127
+ } else {
128
+ await git(cwd, ["rev-parse", "--verify", `${commit}^{commit}`], execFileImpl);
129
+ rawEntries = parseNameStatus(
130
+ await git(cwd, ["diff-tree", "--no-commit-id", "--name-status", "-r", commit], execFileImpl)
131
+ );
132
+ numstat = parseNumstat(
133
+ await git(cwd, ["diff-tree", "--no-commit-id", "--numstat", "-r", commit], execFileImpl)
134
+ );
135
+ diffBytes = Buffer.byteLength(await git(cwd, ["show", "--format=", "--patch", commit], execFileImpl), "utf8");
136
+ }
137
+
138
+ const excluded = [];
139
+ const privateCandidates = [];
140
+ const files = [];
141
+
142
+ for (const entry of rawEntries) {
143
+ const path = assertReviewPathSafe(entry.path);
144
+ const sourcePath = entry.sourcePath != null ? assertReviewPathSafe(entry.sourcePath) : null;
145
+ const privateEnds = [path, sourcePath].filter(Boolean).filter((p) => isReviewPrivatePath(p));
146
+ if (privateEnds.length > 0) {
147
+ if (includePrivate) privateCandidates.push(...privateEnds);
148
+ else { excluded.push({ path, reason: "private" }); continue; }
149
+ }
150
+
151
+ let hash;
152
+ let changedLines = numstat.get(path) ?? 0;
153
+ let bytes = 0;
154
+ const deleted = /D/.test(entry.status);
155
+
156
+ if (mode === REVIEW_SCOPE_MODES.WORKING_TREE && !deleted) {
157
+ let buffer;
158
+ try { buffer = await readReviewRegularFile(join(cwd, path)); }
159
+ catch (error) {
160
+ if (error?.code === "REVIEW_SYMLINK") { excluded.push({ path, reason: "symlink" }); continue; }
161
+ if (error?.code === "REVIEW_NON_REGULAR" || error?.code === "REVIEW_IDENTITY_CHANGED") {
162
+ excluded.push({ path, reason: "non-regular" }); continue;
163
+ }
164
+ throw error;
165
+ }
166
+ if (isBinaryContent(buffer)) { excluded.push({ path, reason: "binary" }); continue; }
167
+ hash = createHash("sha256").update(buffer).digest("hex");
168
+ bytes = buffer.length;
169
+ if (!numstat.has(path)) changedLines = buffer.toString("utf8").split(/\r?\n/).length;
170
+ if (entry.status === "??") diffBytes += bytes;
171
+ } else {
172
+ try { hash = (await git(cwd, ["rev-parse", `HEAD:${path}`], execFileImpl)).trim(); }
173
+ catch { hash = createHash("sha256").update(`${entry.status}:${path}`).digest("hex"); }
174
+ }
175
+
176
+ files.push({
177
+ path, sourcePath, status: entry.status.trim(), hash, changedLines, bytes
178
+ });
179
+ }
180
+
181
+ requirePrivateConsent({ includePrivate, privateConfirmed, privatePaths: privateCandidates });
182
+ files.sort((a, b) => a.path.localeCompare(b.path));
183
+ excluded.sort((a, b) => a.path.localeCompare(b.path));
184
+ const changedLines = files.reduce((sum, f) => sum + f.changedLines, 0);
185
+ assertWithinReviewLimits({ fileCount: files.length, changedLines, diffBytes });
186
+
187
+ const snapshot = {
188
+ version: 1, mode, cwd, headSha, base: base ?? null, commit: commit ?? null,
189
+ files, excluded, totals: { fileCount: files.length, changedLines, diffBytes }, fingerprint: null
190
+ };
191
+ snapshot.fingerprint = canonicalFingerprint(fingerprintPayload(snapshot));
192
+ return snapshot;
193
+ }
194
+
195
+ export function fingerprintReviewSnapshot(snapshot) {
196
+ return canonicalFingerprint(fingerprintPayload(snapshot));
197
+ }
198
+
199
+ export async function detectReviewSnapshotDrift(previous, options = {}) {
200
+ const next = await resolveReviewSnapshot({
201
+ cwd: previous.cwd, base: previous.base, commit: previous.commit,
202
+ includePrivate: options.includePrivate ?? false,
203
+ privateConfirmed: options.privateConfirmed ?? false,
204
+ execFileImpl: options.execFileImpl
205
+ });
206
+ return {
207
+ stale: next.fingerprint !== previous.fingerprint,
208
+ previousFingerprint: previous.fingerprint,
209
+ nextFingerprint: next.fingerprint,
210
+ next
211
+ };
212
+ }