@cassiomc1/forgeloop 1.10.1 → 1.10.2

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.
@@ -0,0 +1,352 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ isPathWithin,
5
+ } from "../../core/filesystem.js";
6
+ import {
7
+ ADVISORY_CONTEXT_LIMITS,
8
+ normalizeAdvisoryRecallOptions,
9
+ } from "../../core/advisory-context/constants.js";
10
+ import {
11
+ E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
12
+ E_ADVISORY_CONTEXT_RESULT_INVALID,
13
+ } from "../../core/error-codes.js";
14
+
15
+ const METADATA_KEYS = Object.freeze([
16
+ "at",
17
+ "bundle",
18
+ "capped",
19
+ "confidence",
20
+ "corpus",
21
+ "est_tokens",
22
+ "kept",
23
+ "lens",
24
+ "margin_pct",
25
+ "parse_health",
26
+ "scored",
27
+ "sigs_capped",
28
+ "sigs_shown",
29
+ "sigs_total",
30
+ "truncated",
31
+ "unindexed",
32
+ "ambiguous",
33
+ "unresolved",
34
+ "unsupported_languages",
35
+ ]);
36
+
37
+ function adapterError(code, message) {
38
+ const error = new Error(message);
39
+ error.name = "RipwireAdapterError";
40
+ error.code = code;
41
+ return error;
42
+ }
43
+
44
+ function isRecord(value) {
45
+ return value !== null && typeof value === "object" && !Array.isArray(value);
46
+ }
47
+
48
+ function boundedText(value, maxLength = 240) {
49
+ if (typeof value !== "string") return null;
50
+ if (value.length === 0 || /\p{Cc}/u.test(value)) return null;
51
+ if (value.length <= maxLength) return value;
52
+ return `${value.slice(0, Math.max(1, maxLength - 1))}…`;
53
+ }
54
+
55
+ function safeString(value) {
56
+ if (typeof value === "string") return value;
57
+ if (value === undefined || value === null) return "";
58
+ try {
59
+ return String(value);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function fitText(value, maxLength) {
66
+ if (value.length <= maxLength) return value;
67
+ return `${value.slice(0, Math.max(1, maxLength - 1))}…`;
68
+ }
69
+
70
+ function metadataValue(value) {
71
+ if (typeof value === "string") return boundedText(value, 160);
72
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
73
+ if (typeof value === "boolean") return String(value);
74
+ return null;
75
+ }
76
+
77
+ function extractCandidateRows(raw) {
78
+ if (Array.isArray(raw.sigs)) return raw.sigs;
79
+ return null;
80
+ }
81
+
82
+ function normalizeReportedPath(reportedPath, projectPath) {
83
+ if (typeof reportedPath !== "string" || reportedPath.trim() === "" || /\p{Cc}/u.test(reportedPath)) {
84
+ return null;
85
+ }
86
+ const slashPath = reportedPath.replaceAll("\\", "/");
87
+ const windowsAbsolute = /^[A-Za-z]:\//u.test(slashPath) || slashPath.startsWith("//");
88
+ const posixAbsolute = slashPath.startsWith("/");
89
+ let relative = slashPath;
90
+
91
+ if (windowsAbsolute || posixAbsolute) {
92
+ if (!projectPath) return null;
93
+ const absolute = windowsAbsolute && process.platform !== "win32"
94
+ ? path.win32.normalize(slashPath)
95
+ : path.resolve(slashPath);
96
+ if (!isPathWithin(path.resolve(projectPath), absolute)) return null;
97
+ relative = path.relative(path.resolve(projectPath), absolute).replaceAll(path.sep, "/");
98
+ }
99
+
100
+ const segments = relative.split("/");
101
+ if (segments.some((segment) => segment === ".." || segment === "")) return null;
102
+ const normalized = path.posix.normalize(relative);
103
+ if (normalized === "." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) return null;
104
+ if (projectPath && !isPathWithin(path.resolve(projectPath), path.resolve(projectPath, normalized))) return null;
105
+ return normalized;
106
+ }
107
+
108
+ function rowPath(row) {
109
+ if (!isRecord(row)) return undefined;
110
+ return row.p ?? row.path ?? row.file ?? row.source ?? row.filePath;
111
+ }
112
+
113
+ function rowLine(row) {
114
+ const value = row.l ?? row.line ?? row.lineNumber;
115
+ if (value === undefined || value === null || value === "") return null;
116
+ if (!Number.isSafeInteger(value) || value < 1) return null;
117
+ return value;
118
+ }
119
+
120
+ function rowName(row) {
121
+ return row.n ?? row.name ?? row.symbol ?? row.id;
122
+ }
123
+
124
+ function rowSignature(row) {
125
+ return row.sig ?? row.signature ?? row.doc ?? row.text ?? row.annotation ?? row.body;
126
+ }
127
+
128
+ function rowRank(row) {
129
+ const value = row.r ?? row.rank;
130
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
131
+ }
132
+
133
+ function rowScore(row) {
134
+ const value = row.k ?? row.score ?? row.relevance;
135
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
136
+ }
137
+
138
+ function validConfidence(value) {
139
+ return typeof value === "number"
140
+ && Number.isFinite(value)
141
+ && value >= 0
142
+ && value <= 1;
143
+ }
144
+
145
+ function hasInvalidReportedLine(row, line) {
146
+ const reportedLine = row.l ?? row.line ?? row.lineNumber;
147
+ const lineWasReported = reportedLine !== undefined && reportedLine !== null && reportedLine !== "";
148
+ return lineWasReported && line === null;
149
+ }
150
+
151
+ function candidateSummary(row, signatureValue) {
152
+ const signature = boundedText(signatureValue, 1000);
153
+ const rank = rowRank(row);
154
+ const score = rowScore(row);
155
+ const annotations = [
156
+ rank === null ? null : `rank=${rank}`,
157
+ score === null ? null : `ranking_score=${score}`,
158
+ row.ambiguous === true ? "ambiguous=true" : null,
159
+ row.unresolved === true ? "unresolved=true" : null,
160
+ ].filter(Boolean);
161
+ const parts = [signature, ...annotations].filter(Boolean);
162
+ return parts.join(" | ") || "Ripwire returned a candidate without a signature";
163
+ }
164
+
165
+ function statusDetails(raw, totalCandidates, acceptedCandidates, rejectedCandidates, returnedCandidates) {
166
+ const details = [];
167
+ for (const key of METADATA_KEYS) {
168
+ const value = metadataValue(raw[key]);
169
+ if (value !== null) details.push(`${key}=${value}`);
170
+ }
171
+
172
+ const reportedTotal = Number.isSafeInteger(raw.sigs_total) ? raw.sigs_total : null;
173
+ const reportedShown = Number.isSafeInteger(raw.sigs_shown) ? raw.sigs_shown : null;
174
+ const capped = raw.capped === true || raw.truncated === true || raw.sigs_capped === true
175
+ || (reportedTotal !== null && reportedShown !== null && reportedShown < reportedTotal);
176
+ if (capped) details.push("output_capped_or_truncated=true");
177
+ if (raw.lens) details.push("unreported_lenses_remain_unknown=true");
178
+ if (raw.unindexed !== undefined) details.push("unindexed_content_is_not_proven_complete=true");
179
+ if (raw.ambiguous !== undefined || raw.unresolved !== undefined) details.push("ambiguous_or_unresolved_edges_may_be_missing=true");
180
+ if (rejectedCandidates > 0) details.push(`rejected_candidates=${rejectedCandidates}`);
181
+ if (totalCandidates === 0) details.push("no_symbol_candidates_returned; absence_is_not_proof_of_no_impact=true");
182
+ if (returnedCandidates === 0 && totalCandidates > 0) details.push("no_symbol_candidates_fit_the_requested_safety_or_budget=true");
183
+ return details;
184
+ }
185
+
186
+ function compactNotice(notice) {
187
+ return notice
188
+ .replace(/^adapter_omitted_candidates=/u, "omitted=")
189
+ .replace(/^candidate_text_shortened=/u, "shortened=");
190
+ }
191
+
192
+ function fitStatusWithSuffix(notices, suffix, maxLength) {
193
+ if (suffix.length > maxLength) return fitText(suffix, maxLength);
194
+ const prefix = notices.map(compactNotice).filter(Boolean).join("; ");
195
+ if (!prefix) return suffix;
196
+ const full = `${prefix}; ${suffix}`;
197
+ if (full.length <= maxLength) return full;
198
+ const first = `${prefix.split("; ", 1)[0]}; ${suffix}`;
199
+ return first.length <= maxLength ? first : suffix;
200
+ }
201
+
202
+ function buildStatusSummary(raw, totalCandidates, acceptedCandidates, rejectedCandidates, returnedCandidates, maxLength, notices = [], { noFit = false } = {}) {
203
+ const details = statusDetails(raw, totalCandidates, acceptedCandidates, rejectedCandidates, returnedCandidates);
204
+ const mandatory = `Ripwire advisory; approximate graph guidance only; no authority/evidence/action; index_completeness=unknown; candidates=${totalCandidates}; accepted=${acceptedCandidates}; returned=${returnedCandidates}`;
205
+ const complete = `${[...notices, mandatory, ...details].join("; ")}.`;
206
+ if (!noFit && complete.length <= maxLength) return complete;
207
+
208
+ const compactBase = noFit
209
+ ? "No symbol items fit; Ripwire advisory; approximate; completeness=unknown; diagnostics omitted."
210
+ : `Ripwire advisory; approximate; completeness=unknown; diagnostics_omitted=true; candidates=${totalCandidates}; returned=${returnedCandidates}.`;
211
+ if (compactBase.length <= maxLength) return fitStatusWithSuffix(notices, compactBase, maxLength);
212
+
213
+ const minimalBase = "Advisory; approximate; completeness=unknown; diagnostics omitted.";
214
+ return fitStatusWithSuffix(notices, minimalBase, maxLength);
215
+ }
216
+
217
+ function normalizeCandidate(row, {
218
+ projectPath,
219
+ sourcePathValidator,
220
+ } = {}) {
221
+ if (!isRecord(row)) return { candidate: null, rejected: true };
222
+ const rawPath = rowPath(row);
223
+ const relativePath = normalizeReportedPath(rawPath, projectPath);
224
+ if (rawPath !== undefined && relativePath === null) return { candidate: null, rejected: true };
225
+ if (relativePath && sourcePathValidator) {
226
+ let safe = false;
227
+ try {
228
+ safe = sourcePathValidator(relativePath) === true;
229
+ } catch {
230
+ safe = false;
231
+ }
232
+ if (!safe) return { candidate: null, rejected: true };
233
+ }
234
+ const line = rowLine(row);
235
+ if (hasInvalidReportedLine(row, line)) return { candidate: null, rejected: true };
236
+ const sourceRef = relativePath ? `${relativePath}${line === null ? "" : `:${line}`}` : undefined;
237
+ const rawName = rowName(row);
238
+ const normalizedName = rawName === undefined || rawName === null ? "Ripwire candidate" : safeString(rawName);
239
+ const normalizedSignature = safeString(rowSignature(row) ?? "");
240
+ if (normalizedName === null || normalizedSignature === null) return { candidate: null, rejected: true };
241
+ const title = boundedText(normalizedName, 180)
242
+ ?? "Ripwire candidate";
243
+ const summary = candidateSummary(row, normalizedSignature);
244
+ const confidence = validConfidence(row.confidence) ? row.confidence : undefined;
245
+ return {
246
+ rejected: false,
247
+ candidate: {
248
+ title,
249
+ summary,
250
+ ...(sourceRef ? { sourceRef } : {}),
251
+ ...(confidence === undefined ? {} : { confidence }),
252
+ },
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Convert Ripwire's documented --for --json shape into ForgeLoop advisory
258
+ * items. The status card is deliberate: the approximate graph, caps, omitted
259
+ * lenses, and unknown completeness must remain visible after core allowlist
260
+ * normalization discards provider-specific metadata.
261
+ */
262
+ export function normalizeRipwireResult(raw, {
263
+ projectPath,
264
+ limit,
265
+ maxItemChars,
266
+ maxTotalChars,
267
+ timeoutMs,
268
+ sourcePathValidator,
269
+ } = {}) {
270
+ if (!isRecord(raw)) {
271
+ throw adapterError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire JSON result must be an object");
272
+ }
273
+ const rows = extractCandidateRows(raw);
274
+ if (!rows) {
275
+ throw adapterError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire JSON result did not contain the supported flat sigs array");
276
+ }
277
+ if (rows.length > ADVISORY_CONTEXT_LIMITS.maxProviderReturnedItems) {
278
+ throw adapterError(
279
+ E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
280
+ `Ripwire returned ${rows.length} candidates, exceeding the raw item ceiling of ${ADVISORY_CONTEXT_LIMITS.maxProviderReturnedItems}`,
281
+ );
282
+ }
283
+ const options = normalizeAdvisoryRecallOptions({ limit, maxItemChars, maxTotalChars, timeoutMs });
284
+ const normalizedCandidates = [];
285
+ const seen = new Set();
286
+ let rejectedCandidates = 0;
287
+
288
+ for (const row of rows) {
289
+ const normalized = normalizeCandidate(row, { projectPath, sourcePathValidator });
290
+ if (normalized.rejected || !normalized.candidate) {
291
+ rejectedCandidates += 1;
292
+ continue;
293
+ }
294
+ const key = [normalized.candidate.title, normalized.candidate.summary, normalized.candidate.sourceRef ?? ""].join("\u0000");
295
+ if (seen.has(key)) continue;
296
+ seen.add(key);
297
+ normalizedCandidates.push(normalized.candidate);
298
+ }
299
+
300
+ const preparedCandidates = normalizedCandidates.map((candidate) => {
301
+ const summary = fitText(candidate.summary, options.maxItemChars);
302
+ return {
303
+ ...candidate,
304
+ summary,
305
+ summaryShortened: summary.length < candidate.summary.length,
306
+ };
307
+ });
308
+
309
+ const itemChars = (item) => (item.title?.length ?? 0) + item.summary.length + (item.sourceRef?.length ?? 0);
310
+ let selectedCount = -1;
311
+ let selectedStatus = null;
312
+ const maxCandidateCount = Math.min(preparedCandidates.length, Math.max(0, options.limit - 1));
313
+ for (let count = maxCandidateCount; count >= 0; count -= 1) {
314
+ const returnedCandidates = preparedCandidates.slice(0, count);
315
+ const notices = [];
316
+ if (preparedCandidates.length > count) notices.push(`adapter_omitted_candidates=${preparedCandidates.length - count}`);
317
+ const shortenedCandidates = returnedCandidates.filter((candidate) => candidate.summaryShortened).length;
318
+ if (shortenedCandidates > 0) notices.push(`candidate_text_shortened=${shortenedCandidates}`);
319
+ if (rejectedCandidates > 0) notices.push(`rejected_candidates=${rejectedCandidates}`);
320
+ const noFit = count === 0 && rows.length > 0;
321
+ const status = {
322
+ title: "Ripwire advisory status",
323
+ summary: buildStatusSummary(
324
+ raw,
325
+ rows.length,
326
+ preparedCandidates.length,
327
+ rejectedCandidates,
328
+ count,
329
+ options.maxItemChars,
330
+ notices,
331
+ { noFit },
332
+ ),
333
+ };
334
+ const totalChars = status.title.length + status.summary.length
335
+ + returnedCandidates.reduce((total, candidate) => total + itemChars(candidate), 0);
336
+ if (totalChars <= options.maxTotalChars) {
337
+ selectedCount = count;
338
+ selectedStatus = status;
339
+ break;
340
+ }
341
+ }
342
+
343
+ if (selectedCount < 0 || !selectedStatus) {
344
+ throw adapterError(E_ADVISORY_CONTEXT_OUTPUT_LIMIT, "Ripwire advisory status cannot fit the requested output budget");
345
+ }
346
+
347
+ const items = [selectedStatus, ...preparedCandidates.slice(0, selectedCount)];
348
+ for (const item of items) delete item.summaryShortened;
349
+ return { items };
350
+ }
351
+
352
+ export { extractCandidateRows, normalizeReportedPath, rowPath as getRipwireRowPath };
@@ -0,0 +1,248 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
6
+ E_ADVISORY_CONTEXT_PROVIDER_INVALID,
7
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
8
+ E_ADVISORY_CONTEXT_RESULT_INVALID,
9
+ E_ADVISORY_CONTEXT_TIMEOUT,
10
+ } from "../../core/error-codes.js";
11
+
12
+ export const RIPWIRE_PROCESS_LIMITS = Object.freeze({
13
+ maxStdoutBytes: 1024 * 1024,
14
+ maxStderrBytes: 64 * 1024,
15
+ terminationGraceMs: 100,
16
+ });
17
+
18
+ function processError(code, message) {
19
+ const error = new Error(message);
20
+ error.name = "RipwireProcessError";
21
+ error.code = code;
22
+ return error;
23
+ }
24
+
25
+ function assertSafeText(value, label) {
26
+ if (typeof value !== "string" || value.length === 0 || /\p{Cc}/u.test(value)) {
27
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, `${label} must be a non-empty portable string`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function assertAbsolutePath(value, label) {
33
+ assertSafeText(value, label);
34
+ if (!path.isAbsolute(value)) {
35
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, `${label} must be an absolute path`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function outputBytes(chunk) {
41
+ return Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(String(chunk));
42
+ }
43
+
44
+ function terminateChild(child) {
45
+ if (!child || typeof child.kill !== "function") return;
46
+ try {
47
+ child.kill("SIGTERM");
48
+ } catch {
49
+ // The process may have exited between the limit check and kill.
50
+ }
51
+ setTimeout(() => {
52
+ try {
53
+ child.kill("SIGKILL");
54
+ } catch {
55
+ // Preserve the original timeout or output-limit error.
56
+ }
57
+ }, RIPWIRE_PROCESS_LIMITS.terminationGraceMs).unref?.();
58
+ }
59
+
60
+ function waitForChildClose(child) {
61
+ return new Promise((resolve) => {
62
+ let settled = false;
63
+ const finish = () => {
64
+ if (settled) return;
65
+ settled = true;
66
+ clearTimeout(fallbackTimer);
67
+ resolve();
68
+ };
69
+ const finishAfterClose = () => {
70
+ // Windows can retain a child working directory for a short interval
71
+ // after close; allow the OS to release it before the caller tears down
72
+ // a temporary project tree.
73
+ setTimeout(finish, 500);
74
+ };
75
+ child.once?.("close", finishAfterClose);
76
+ const fallbackTimer = setTimeout(finish, RIPWIRE_PROCESS_LIMITS.terminationGraceMs + 1000);
77
+ });
78
+ }
79
+
80
+ function normalizeRunOptions({
81
+ cwd,
82
+ timeoutMs,
83
+ maxStdoutBytes,
84
+ maxStderrBytes,
85
+ spawnImpl,
86
+ env,
87
+ } = {}) {
88
+ assertAbsolutePath(cwd, "Ripwire project path");
89
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
90
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire timeout must be a positive integer");
91
+ }
92
+ if (!Number.isSafeInteger(maxStdoutBytes) || maxStdoutBytes < 1) {
93
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire stdout limit must be a positive integer");
94
+ }
95
+ if (!Number.isSafeInteger(maxStderrBytes) || maxStderrBytes < 1) {
96
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire stderr limit must be a positive integer");
97
+ }
98
+ if (typeof spawnImpl !== "function") {
99
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire spawn implementation must be a function");
100
+ }
101
+ if (env !== undefined && (!env || typeof env !== "object" || Array.isArray(env))) {
102
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire process environment must be an object");
103
+ }
104
+ return {
105
+ cwd,
106
+ timeoutMs,
107
+ maxStdoutBytes,
108
+ maxStderrBytes,
109
+ spawnImpl,
110
+ env,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Run one explicitly constructed Ripwire command.
116
+ *
117
+ * This helper is intentionally private to the adapter boundary: callers pass
118
+ * an absolute executable and an argv array, while this function always keeps
119
+ * shell execution disabled and never returns raw stderr in an error message.
120
+ */
121
+ export function runRipwireCommand(executablePath, args, options = {}) {
122
+ assertAbsolutePath(executablePath, "Ripwire executable path");
123
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || /\p{Cc}/u.test(arg))) {
124
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_INVALID, "Ripwire arguments must be a portable string array");
125
+ }
126
+
127
+ const runOptions = normalizeRunOptions({
128
+ cwd: options.cwd,
129
+ timeoutMs: options.timeoutMs,
130
+ maxStdoutBytes: options.maxStdoutBytes ?? RIPWIRE_PROCESS_LIMITS.maxStdoutBytes,
131
+ maxStderrBytes: options.maxStderrBytes ?? RIPWIRE_PROCESS_LIMITS.maxStderrBytes,
132
+ spawnImpl: options.spawnImpl ?? nodeSpawn,
133
+ env: options.env,
134
+ });
135
+
136
+ const startedAt = Date.now();
137
+ let child;
138
+ try {
139
+ child = runOptions.spawnImpl(executablePath, [...args], {
140
+ cwd: runOptions.cwd,
141
+ shell: false,
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ ...(runOptions.env ? { env: { ...process.env, ...runOptions.env } } : {}),
144
+ });
145
+ } catch (error) {
146
+ throw processError(
147
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
148
+ error?.code === "ENOENT" ? "Ripwire executable is unavailable" : "Unable to start Ripwire",
149
+ error,
150
+ );
151
+ }
152
+
153
+ if (!child || !child.stdout || !child.stderr || typeof child.on !== "function") {
154
+ terminateChild(child);
155
+ throw processError(E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE, "Ripwire process did not expose piped stdout and stderr");
156
+ }
157
+
158
+ return new Promise((resolve, reject) => {
159
+ let stdoutBytes = 0;
160
+ let stderrBytes = 0;
161
+ const stdoutChunks = [];
162
+ let settled = false;
163
+ let timer = null;
164
+
165
+ const cleanup = () => {
166
+ if (timer) clearTimeout(timer);
167
+ child.stdout?.removeAllListeners?.("data");
168
+ child.stderr?.removeAllListeners?.("data");
169
+ };
170
+
171
+ const fail = (error) => {
172
+ if (settled) return;
173
+ settled = true;
174
+ cleanup();
175
+ terminateChild(child);
176
+ child.stdout?.resume?.();
177
+ child.stderr?.resume?.();
178
+ waitForChildClose(child).then(() => reject(error));
179
+ };
180
+
181
+ const succeed = (code, signal) => {
182
+ if (settled) return;
183
+ settled = true;
184
+ cleanup();
185
+ if (code !== 0) {
186
+ reject(processError(
187
+ E_ADVISORY_CONTEXT_RESULT_INVALID,
188
+ `Ripwire exited unsuccessfully (${code ?? "null"}/${signal ?? "none"})`,
189
+ ));
190
+ return;
191
+ }
192
+ resolve({
193
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
194
+ stderrBytes,
195
+ stdoutBytes,
196
+ durationMs: Math.max(0, Date.now() - startedAt),
197
+ });
198
+ };
199
+
200
+ child.stdout.on("data", (chunk) => {
201
+ stdoutBytes += outputBytes(chunk);
202
+ if (stdoutBytes > runOptions.maxStdoutBytes) {
203
+ fail(processError(
204
+ E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
205
+ `Ripwire stdout exceeded ${runOptions.maxStdoutBytes} bytes`,
206
+ ));
207
+ return;
208
+ }
209
+ stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
210
+ });
211
+ child.stderr.on("data", (chunk) => {
212
+ stderrBytes += outputBytes(chunk);
213
+ if (stderrBytes > runOptions.maxStderrBytes) {
214
+ fail(processError(
215
+ E_ADVISORY_CONTEXT_OUTPUT_LIMIT,
216
+ `Ripwire stderr exceeded ${runOptions.maxStderrBytes} bytes`,
217
+ ));
218
+ }
219
+ });
220
+ child.on("error", (error) => {
221
+ fail(processError(
222
+ E_ADVISORY_CONTEXT_PROVIDER_UNAVAILABLE,
223
+ error?.code === "ENOENT" ? "Ripwire executable is unavailable" : "Ripwire process failed",
224
+ error,
225
+ ));
226
+ });
227
+ child.on("close", (code, signal) => succeed(code, signal));
228
+ timer = setTimeout(() => {
229
+ fail(processError(
230
+ E_ADVISORY_CONTEXT_TIMEOUT,
231
+ `Ripwire exceeded the ${runOptions.timeoutMs}ms timeout`,
232
+ ));
233
+ }, runOptions.timeoutMs);
234
+ });
235
+ }
236
+
237
+ export function parseRipwireVersion(stdout) {
238
+ if (typeof stdout !== "string") {
239
+ throw processError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire version output was not text");
240
+ }
241
+ const match = /^\s*ripwire\s+([^\s]+)(?:\s|$)/iu.exec(stdout);
242
+ if (!match) {
243
+ throw processError(E_ADVISORY_CONTEXT_RESULT_INVALID, "Ripwire version output did not contain a qualified version");
244
+ }
245
+ return match[1];
246
+ }
247
+
248
+ export { processError as ripwireProcessError };