@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.
- package/README.md +44 -6
- package/global-template/components/catalog.json +4 -2
- package/global-template/components/orchestrator/orchestrator.md +1 -1
- package/global-template/core/orchestrator.md +1 -1
- package/package.json +3 -2
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/installer-smoke-test.sh +6 -4
- package/src/cli.js +60 -1
- package/src/global/adapters/pi.js +53 -0
- package/src/global/agent-capabilities/index.js +11 -1
- package/src/global/brand/index.js +4 -2
- package/src/global/dashboard-guidance.js +1 -1
- package/src/global/global-cli.js +1 -1
- package/src/global/ink/cockpit-controller.js +10 -0
- package/src/global/ink/cockpit-focus.js +18 -3
- package/src/global/ink/cockpit-models.js +8 -1
- package/src/global/ink/cockpit-reviews.js +62 -0
- package/src/global/ink/cockpit-runs.js +11 -2
- package/src/global/ink/cockpit-views.js +19 -2
- package/src/global/ink/orchestrator-app.js +23 -3
- package/src/global/ink/orchestrator-state.js +2 -0
- package/src/global/ink/use-orchestrator-data.js +30 -0
- package/src/global/integrations/engram-evidence.js +41 -3
- package/src/global/integrations/sdd-destinations.js +2 -2
- package/src/global/paths.js +1 -0
- package/src/global/registry.js +2 -1
- package/src/global/runtime/execution-adapters/codex.js +2 -1
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +1 -0
- package/src/global/runtime/execution-adapters/index.js +3 -1
- package/src/global/runtime/execution-adapters/pi.js +184 -0
- package/src/global/runtime/review/index.js +37 -0
- package/src/global/runtime/review/review-cli.js +150 -0
- package/src/global/runtime/review/review-codex.js +132 -0
- package/src/global/runtime/review/review-exec.js +136 -0
- package/src/global/runtime/review/review-fs.js +52 -0
- package/src/global/runtime/review/review-git.js +212 -0
- package/src/global/runtime/review/review-patch.js +122 -0
- package/src/global/runtime/review/review-pi.js +168 -0
- package/src/global/runtime/review/review-receipts.js +128 -0
- package/src/global/runtime/review/review-runner.js +119 -0
- package/src/global/runtime/review/review-types.js +108 -0
- package/src/global/runtime/review/review-validate.js +280 -0
- 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
|
+
}
|