diff --git a/package-lock.json b/package-lock.json index 6b9f0e81c0..babe0b105f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20914,6 +20914,10 @@ }, "engines": { "node": ">=22.0.0" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" } }, "packages/loopover-miner": { @@ -21031,6 +21035,23 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "packages/loopover-mcp/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/loopover-mcp/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/loopover-mcp/lib/cli-error.d.ts b/packages/loopover-mcp/lib/cli-error.d.ts new file mode 100644 index 0000000000..0450a2b865 --- /dev/null +++ b/packages/loopover-mcp/lib/cli-error.d.ts @@ -0,0 +1,7 @@ +/** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on + * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ +export declare function reportCliFailure(wantsJson: boolean, message: string, exitCode?: number): number; +/** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */ +export declare function argsWantJson(args: Array): boolean; +/** Normalize a thrown value to a safe error string for CLI output. */ +export declare function describeCliError(error: unknown): string; diff --git a/packages/loopover-mcp/lib/cli-error.js b/packages/loopover-mcp/lib/cli-error.js index 0e8d7032c6..99823c92e2 100644 --- a/packages/loopover-mcp/lib/cli-error.js +++ b/packages/loopover-mcp/lib/cli-error.js @@ -1,27 +1,20 @@ /** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ - -/** - * @param {boolean} wantsJson - * @param {string} message - * @param {number} [exitCode] - * @returns {number} - */ export function reportCliFailure(wantsJson, message, exitCode = 2) { - if (wantsJson) { - console.log(JSON.stringify({ ok: false, error: message }, null, 2)); - } else { - console.error(message); - } - return exitCode; + if (wantsJson) { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + } + else { + console.error(message); + } + return exitCode; } - /** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */ export function argsWantJson(args) { - return args.some((arg) => arg === "--json" || arg?.startsWith("--json=")); + return args.some((arg) => arg === "--json" || arg?.startsWith("--json=") === true); } - /** Normalize a thrown value to a safe error string for CLI output. */ export function describeCliError(error) { - return error instanceof Error ? error.message : String(error); + return error instanceof Error ? error.message : String(error); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xpLWVycm9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2xpLWVycm9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO3FHQUNxRztBQUVyRyxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsU0FBa0IsRUFBRSxPQUFlLEVBQUUsUUFBUSxHQUFHLENBQUM7SUFDaEYsSUFBSSxTQUFTLEVBQUUsQ0FBQztRQUNkLE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxFQUFFLEVBQUUsRUFBRSxLQUFLLEVBQUUsS0FBSyxFQUFFLE9BQU8sRUFBRSxFQUFFLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ3RFLENBQUM7U0FBTSxDQUFDO1FBQ04sT0FBTyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQztJQUN6QixDQUFDO0lBQ0QsT0FBTyxRQUFRLENBQUM7QUFDbEIsQ0FBQztBQUVELGlHQUFpRztBQUNqRyxNQUFNLFVBQVUsWUFBWSxDQUFDLElBQXNDO0lBQ2pFLE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsR0FBRyxLQUFLLFFBQVEsSUFBSSxHQUFHLEVBQUUsVUFBVSxDQUFDLFNBQVMsQ0FBQyxLQUFLLElBQUksQ0FBQyxDQUFDO0FBQ3JGLENBQUM7QUFFRCxzRUFBc0U7QUFDdEUsTUFBTSxVQUFVLGdCQUFnQixDQUFDLEtBQWM7SUFDN0MsT0FBTyxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDaEUsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-mcp/lib/cli-error.ts b/packages/loopover-mcp/lib/cli-error.ts new file mode 100644 index 0000000000..d7e7022ecc --- /dev/null +++ b/packages/loopover-mcp/lib/cli-error.ts @@ -0,0 +1,21 @@ +/** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on + * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ + +export function reportCliFailure(wantsJson: boolean, message: string, exitCode = 2): number { + if (wantsJson) { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + } else { + console.error(message); + } + return exitCode; +} + +/** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */ +export function argsWantJson(args: Array): boolean { + return args.some((arg) => arg === "--json" || arg?.startsWith("--json=") === true); +} + +/** Normalize a thrown value to a safe error string for CLI output. */ +export function describeCliError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/loopover-mcp/lib/format-table.d.ts b/packages/loopover-mcp/lib/format-table.d.ts new file mode 100644 index 0000000000..9931d91aee --- /dev/null +++ b/packages/loopover-mcp/lib/format-table.d.ts @@ -0,0 +1,23 @@ +export type FormatTableAlign = "left" | "right"; +export type FormatTableHeader = string | { + key: string; + label?: string; + align?: FormatTableAlign; +}; +export type FormatTableRow = Record | unknown[]; +export type FormatTableInput = FormatTableRow[] | { + headers?: FormatTableHeader[]; + rows?: FormatTableRow[]; +}; +export type FormatTableOptions = { + align?: Record; + gap?: number; +}; +/** + * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). + * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` + * headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`; + * `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies. + * Returns "" when there are no columns. + */ +export declare function formatTable(input: FormatTableInput, opts?: FormatTableOptions): string; diff --git a/packages/loopover-mcp/lib/format-table.js b/packages/loopover-mcp/lib/format-table.js index da589fc771..eefae4390a 100644 --- a/packages/loopover-mcp/lib/format-table.js +++ b/packages/loopover-mcp/lib/format-table.js @@ -1,39 +1,39 @@ // Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands // (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its // CLI/MCP entrypoint on import, so importable helpers live here instead. - // Normalize either an array of row objects or an explicit { headers, rows } shape into a common // { headers, rows } form. For an array of objects the column set is the union of keys in first-seen // order, and each key doubles as its own header label. function normalizeInput(input) { - if (Array.isArray(input)) { - const keys = []; - for (const row of input) { - for (const key of Object.keys(row ?? {})) if (!keys.includes(key)) keys.push(key); + if (Array.isArray(input)) { + const keys = []; + for (const row of input) { + if (!row || Array.isArray(row) || typeof row !== "object") + continue; + for (const key of Object.keys(row)) + if (!keys.includes(key)) + keys.push(key); + } + return { headers: keys.map((key) => ({ key, label: key })), rows: input }; } - return { headers: keys.map((key) => ({ key, label: key })), rows: input }; - } - const headers = (input?.headers ?? []).map((header) => - typeof header === "string" ? { key: header, label: header } : { key: header.key, label: header.label ?? header.key, align: header.align }, - ); - return { headers, rows: input?.rows ?? [] }; + const headers = (input.headers ?? []).map((header) => typeof header === "string" + ? { key: header, label: header } + : { key: header.key, label: header.label ?? header.key, ...(header.align !== undefined ? { align: header.align } : {}) }); + return { headers, rows: input.rows ?? [] }; } - function stringifyCell(value) { - return value === undefined || value === null ? "" : String(value); + return value === undefined || value === null ? "" : String(value); } - // A row is either an object keyed by column key or a positional array; read the matching cell. function readCell(row, header, columnIndex) { - if (Array.isArray(row)) return row[columnIndex]; - return row?.[header.key]; + if (Array.isArray(row)) + return row[columnIndex]; + return row[header.key]; } - function resolveAlign(header, opts) { - const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); - return header.align ?? fromOpts ?? "left"; + const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); + return header.align ?? fromOpts ?? "left"; } - /** * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` @@ -42,17 +42,19 @@ function resolveAlign(header, opts) { * Returns "" when there are no columns. */ export function formatTable(input, opts = {}) { - const { headers, rows } = normalizeInput(input); - if (headers.length === 0) return ""; - const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); - const aligns = headers.map((header) => resolveAlign(header, opts)); - // Precompute every cell's text so column widths and the rendered rows read the same strings. - const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); - const widths = headers.map((header, column) => - Math.max(header.label.length, ...bodyCells.map((cells) => cells[column].length), 0), - ); - const renderRow = (cells) => + const { headers, rows } = normalizeInput(input); + if (headers.length === 0) + return ""; + const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); + const aligns = headers.map((header) => resolveAlign(header, opts)); + // Precompute every cell's text so column widths and the rendered rows read the same strings. + const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); + const widths = headers.map((header, column) => Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]?.length ?? 0), 0)); // Trim trailing padding so a left-aligned final column never emits dangling spaces. - cells.map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column]) : text.padEnd(widths[column]))).join(gap).replace(/\s+$/, ""); - return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); + const renderRow = (cells) => cells + .map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column] ?? 0) : text.padEnd(widths[column] ?? 0))) + .join(gap) + .replace(/\s+$/, ""); + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZm9ybWF0LXRhYmxlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZm9ybWF0LXRhYmxlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGtHQUFrRztBQUNsRyxtR0FBbUc7QUFDbkcseUVBQXlFO0FBNEJ6RSxnR0FBZ0c7QUFDaEcsb0dBQW9HO0FBQ3BHLHVEQUF1RDtBQUN2RCxTQUFTLGNBQWMsQ0FBQyxLQUF1QjtJQUM3QyxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQztRQUN6QixNQUFNLElBQUksR0FBYSxFQUFFLENBQUM7UUFDMUIsS0FBSyxNQUFNLEdBQUcsSUFBSSxLQUFLLEVBQUUsQ0FBQztZQUN4QixJQUFJLENBQUMsR0FBRyxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDLElBQUksT0FBTyxHQUFHLEtBQUssUUFBUTtnQkFBRSxTQUFTO1lBQ3BFLEtBQUssTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUM7Z0JBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDO29CQUFFLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7UUFDOUUsQ0FBQztRQUNELE9BQU8sRUFBRSxPQUFPLEVBQUUsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsQ0FBQyxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsR0FBRyxFQUFFLENBQUMsQ0FBQyxFQUFFLElBQUksRUFBRSxLQUFLLEVBQUUsQ0FBQztJQUM1RSxDQUFDO0lBQ0QsTUFBTSxPQUFPLEdBQUcsQ0FBQyxLQUFLLENBQUMsT0FBTyxJQUFJLEVBQUUsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBb0IsRUFBRSxDQUNyRSxPQUFPLE1BQU0sS0FBSyxRQUFRO1FBQ3hCLENBQUMsQ0FBQyxFQUFFLEdBQUcsRUFBRSxNQUFNLEVBQUUs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RUFBRSxFQUFFLENBQ3BDLEtBQUs7U0FDRixHQUFHLENBQUMsQ0FBQyxJQUFJLEVBQUUsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsS0FBSyxPQUFPLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO1NBQzNILElBQUksQ0FBQyxHQUFHLENBQUM7U0FDVCxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDO0lBQ3pCLE9BQU8sQ0FBQyxTQUFTLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsR0FBRyxTQUFTLENBQUMsR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQ3BHLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/format-table.ts b/packages/loopover-mcp/lib/format-table.ts new file mode 100644 index 0000000000..3662965897 --- /dev/null +++ b/packages/loopover-mcp/lib/format-table.ts @@ -0,0 +1,90 @@ +// Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands +// (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its +// CLI/MCP entrypoint on import, so importable helpers live here instead. + +export type FormatTableAlign = "left" | "right"; + +export type FormatTableHeader = + | string + | { + key: string; + label?: string; + align?: FormatTableAlign; + }; + +export type FormatTableRow = Record | unknown[]; + +export type FormatTableInput = + | FormatTableRow[] + | { + headers?: FormatTableHeader[]; + rows?: FormatTableRow[]; + }; + +export type FormatTableOptions = { + align?: Record; + gap?: number; +}; + +type NormalizedHeader = { key: string; label: string; align?: FormatTableAlign }; + +// Normalize either an array of row objects or an explicit { headers, rows } shape into a common +// { headers, rows } form. For an array of objects the column set is the union of keys in first-seen +// order, and each key doubles as its own header label. +function normalizeInput(input: FormatTableInput): { headers: NormalizedHeader[]; rows: FormatTableRow[] } { + if (Array.isArray(input)) { + const keys: string[] = []; + for (const row of input) { + if (!row || Array.isArray(row) || typeof row !== "object") continue; + for (const key of Object.keys(row)) if (!keys.includes(key)) keys.push(key); + } + return { headers: keys.map((key) => ({ key, label: key })), rows: input }; + } + const headers = (input.headers ?? []).map((header): NormalizedHeader => + typeof header === "string" + ? { key: header, label: header } + : { key: header.key, label: header.label ?? header.key, ...(header.align !== undefined ? { align: header.align } : {}) }, + ); + return { headers, rows: input.rows ?? [] }; +} + +function stringifyCell(value: unknown): string { + return value === undefined || value === null ? "" : String(value); +} + +// A row is either an object keyed by column key or a positional array; read the matching cell. +function readCell(row: FormatTableRow, header: NormalizedHeader, columnIndex: number): unknown { + if (Array.isArray(row)) return row[columnIndex]; + return row[header.key]; +} + +function resolveAlign(header: NormalizedHeader, opts: FormatTableOptions): FormatTableAlign { + const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); + return header.align ?? fromOpts ?? "left"; +} + +/** + * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). + * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` + * headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`; + * `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies. + * Returns "" when there are no columns. + */ +export function formatTable(input: FormatTableInput, opts: FormatTableOptions = {}): string { + const { headers, rows } = normalizeInput(input); + if (headers.length === 0) return ""; + const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); + const aligns = headers.map((header) => resolveAlign(header, opts)); + // Precompute every cell's text so column widths and the rendered rows read the same strings. + const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); + const widths = headers.map((header, column) => + Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]?.length ?? 0), 0), + ); + // Trim trailing padding so a left-aligned final column never emits dangling spaces. + const renderRow = (cells: string[]) => + cells + .map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column] ?? 0) : text.padEnd(widths[column] ?? 0))) + .join(gap) + .replace(/\s+$/, ""); + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); +} diff --git a/packages/loopover-mcp/lib/local-branch.d.ts b/packages/loopover-mcp/lib/local-branch.d.ts new file mode 100644 index 0000000000..1afcf18ade --- /dev/null +++ b/packages/loopover-mcp/lib/local-branch.d.ts @@ -0,0 +1,158 @@ +import { isCodeFile, isTestPath as isTestFile } from "@loopover/engine/signals/test-evidence"; +import { redactLocalPath } from "./redact-local-path.js"; +export { isCodeFile, isTestFile }; +export { redactLocalPath }; +type NormalizedWorkspaceRoot = { + path: string; +}; +type ChangedFile = { + path: string; + previousPath?: string | undefined; + additions: number; + deletions: number; + status: string; + binary?: boolean; +}; +type BranchInput = { + cwd?: unknown; + baseRef?: string; + login?: string; + workspaceRoots?: unknown; + repoFullName?: string; + branchName?: string; + headRef?: string; + pendingCommitCount?: number; + ciStatusHints?: string[]; + commitMessages?: string[]; + title?: string; + body?: string; + linkedIssues?: number[]; + labels?: unknown; + validation?: unknown; + pendingMergedPrCount?: unknown; + pendingClosedPrCount?: unknown; + approvedPrCount?: unknown; + expectedOpenPrCount?: unknown; + expectedOpenPrCountAfterMerge?: unknown; + projectedCredibility?: unknown; + scenarioNotes?: unknown; + branchEligibility?: unknown; + scorePreviewCommand?: unknown; +}; +type BranchMetadata = { + login?: string | undefined; + repoFullName: string; + baseRef: string; + headRef: string; + branchName: string; + baseSha?: string | undefined; + headSha?: string | undefined; + mergeBaseSha?: string | undefined; + remoteTrackingSha?: string | undefined; + commitMessages: string[]; + changedFiles: ChangedFile[]; + validation?: unknown; + linkedIssues: number[]; + labels?: unknown; + title?: string | undefined; + body?: string | undefined; + pendingMergedPrCount?: unknown; + pendingClosedPrCount?: unknown; + approvedPrCount?: unknown; + expectedOpenPrCountAfterMerge?: unknown; + projectedCredibility?: unknown; + scenarioNotes?: unknown; + pendingCommitCount: number; + ciStatusHints: string[]; + branchEligibility?: unknown; + repoRoot?: string; + cwd?: string; +}; +type ScorePreviewMetadata = Record & { + repoRoot?: string; + cwd?: string; +}; +type ScorerStatus = { + ok?: boolean; + payload?: ScorerPayload; + code?: string; + reason?: string; + stderr?: string; + scorerCommand?: string; + exitCode?: number; + [key: string]: unknown; +}; +type ScorerPayload = Record & { + source?: { + tokenScore?: unknown; + lines?: unknown; + }; + total?: { + tokenScore?: unknown; + }; + tests?: { + tokenScore?: unknown; + }; + nonCode?: { + tokenScore?: unknown; + }; +}; +export declare function parseGitRemote(remoteUrl: unknown): string | undefined; +export declare function collectLocalDiff(cwd: string, baseRef: string, workspaceRoots: unknown): { + title: string; + commitMessage: string; + changedFiles: string[]; + changedLineCount: number; + testFiles: string[]; + codeFiles: string[]; +}; +export declare function collectLocalBranchMetadata(input: BranchInput): BranchMetadata; +export declare function collectPendingCommitCount(cwd: string, baseRef: string): number; +export declare function collectCiStatusHints(cwd: string, baseRef: string, changedFiles?: ChangedFile[]): string[]; +export declare function buildBranchAnalysisPayload(input: BranchInput): { + localScorer: ScorerPayload; + localScorerStatus: ScorerStatus; + login?: string | undefined; + repoFullName: string; + baseRef: string; + headRef: string; + branchName: string; + baseSha?: string | undefined; + headSha?: string | undefined; + mergeBaseSha?: string | undefined; + remoteTrackingSha?: string | undefined; + commitMessages: string[]; + changedFiles: ChangedFile[]; + validation?: unknown; + linkedIssues: number[]; + labels?: unknown; + title?: string | undefined; + body?: string | undefined; + pendingMergedPrCount?: unknown; + pendingClosedPrCount?: unknown; + approvedPrCount?: unknown; + expectedOpenPrCountAfterMerge?: unknown; + projectedCredibility?: unknown; + scenarioNotes?: unknown; + pendingCommitCount: number; + ciStatusHints: string[]; + branchEligibility?: unknown; + repoRoot?: string; + cwd?: string; +}; +export declare function resolveWorkspaceCwd(input?: Pick): { + cwd: string; + rootsAvailable: boolean; + rootCount: number; +}; +export declare function normalizeMcpWorkspaceRoots(roots: unknown): NormalizedWorkspaceRoot[]; +export declare function resolveScorePreviewCommand(input?: Pick): string | undefined; +export declare function referenceScorePreviewExample(kind?: "metadata" | "gittensor"): string; +export declare function redactScorerCommand(command: unknown): string; +export declare function sanitizeLocalScorerStatus(status: ScorerStatus): ScorerStatus; +export declare function sanitizeLocalScorerStatus(status: unknown): unknown; +export declare function runExternalScorePreview(metadata: ScorePreviewMetadata, scorerCommand: string | undefined): ScorerStatus; +export declare function setupGuidanceForLocalScorer(status: ScorerStatus): string[]; +export declare function probeLocalScorer(scorerCommand?: string | undefined): ScorerStatus; +export declare function gitLines(cwd: string, args: string[]): string[]; +export declare function extractLinkedIssues(text: unknown): number[]; diff --git a/packages/loopover-mcp/lib/local-branch.js b/packages/loopover-mcp/lib/local-branch.js index 58bf125b4f..243d4c5de5 100644 --- a/packages/loopover-mcp/lib/local-branch.js +++ b/packages/loopover-mcp/lib/local-branch.js @@ -1,592 +1,581 @@ import { execFileSync } from "node:child_process"; import { realpathSync } from "node:fs"; -import { isAbsolute, join, relative, resolve } from "node:path"; +import { isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isCodeFile, isTestPath as isTestFile } from "@loopover/engine/signals/test-evidence"; import { redactLocalPath } from "./redact-local-path.js"; - export { isCodeFile, isTestFile }; export { redactLocalPath }; - function stripTrailingSlashes(value) { - let end = value.length; - while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1; - return end === value.length ? value : value.slice(0, end); + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47) + end -= 1; + return end === value.length ? value : value.slice(0, end); } - export function parseGitRemote(remoteUrl) { - const trimmed = stripTrailingSlashes(String(remoteUrl ?? "").trim()); - const patterns = [ - /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/, - /^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, - /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, - ]; - for (const pattern of patterns) { - const match = trimmed.match(pattern); - if (match?.[1] && match[2]) return `${match[1]}/${match[2].replace(/\.git$/, "")}`; - } - return undefined; -} - + const trimmed = stripTrailingSlashes(String(remoteUrl ?? "").trim()); + const patterns = [ + /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/, + /^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, + ]; + for (const pattern of patterns) { + const match = trimmed.match(pattern); + if (match?.[1] && match[2]) + return `${match[1]}/${match[2].replace(/\.git$/, "")}`; + } + return undefined; +} export function collectLocalDiff(cwd, baseRef, workspaceRoots) { - const metadata = collectLocalBranchMetadata({ cwd, baseRef, login: "local", workspaceRoots }); - return { - title: metadata.title ?? "Local diff preflight", - commitMessage: metadata.commitMessages.join("\n\n").trim(), - changedFiles: metadata.changedFiles.map((file) => file.path), - changedLineCount: metadata.changedFiles.reduce((sum, file) => sum + (file.additions ?? 0) + (file.deletions ?? 0), 0), - testFiles: metadata.changedFiles.map((file) => file.path).filter(isTestFile), - codeFiles: metadata.changedFiles.map((file) => file.path).filter(isCodeFile), - }; -} - + const metadata = collectLocalBranchMetadata({ cwd, baseRef, login: "local", workspaceRoots }); + return { + title: metadata.title ?? "Local diff preflight", + commitMessage: metadata.commitMessages.join("\n\n").trim(), + changedFiles: metadata.changedFiles.map((file) => file.path), + changedLineCount: metadata.changedFiles.reduce((sum, file) => sum + (file.additions ?? 0) + (file.deletions ?? 0), 0), + testFiles: metadata.changedFiles.map((file) => file.path).filter(isTestFile), + codeFiles: metadata.changedFiles.map((file) => file.path).filter(isCodeFile), + }; +} export function collectLocalBranchMetadata(input) { - assertSourceUploadDisabled(); - const workspace = resolveWorkspaceCwd(input); - const cwd = workspace.cwd; - const baseRef = input.baseRef ?? defaultBaseRef(cwd); - const remoteUrl = gitLines(cwd, ["config", "--get", "remote.origin.url"])[0] ?? ""; - const repoFullName = input.repoFullName ?? parseGitRemote(remoteUrl); - if (!repoFullName) throw new Error("Could not infer repoFullName from git remote; pass --repo owner/repo."); - const branchName = input.branchName ?? gitLines(cwd, ["branch", "--show-current"])[0] ?? "local-branch"; - const headRef = input.headRef ?? gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])[0] ?? branchName; - const baseSha = gitLines(cwd, ["rev-parse", "--verify", baseRef])[0]; - const headSha = gitLines(cwd, ["rev-parse", "--verify", "HEAD"])[0]; - const mergeBaseSha = gitLines(cwd, ["merge-base", baseRef, "HEAD"])[0]; - const remoteTrackingSha = collectRemoteTrackingSha(cwd, baseRef); - const changedFiles = collectChangedFiles(cwd, baseRef); - const pendingCommitCount = input.pendingCommitCount ?? collectPendingCommitCount(cwd, baseRef); - const ciStatusHints = input.ciStatusHints ?? collectCiStatusHints(cwd, baseRef, changedFiles); - const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); - const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); - const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"))])].sort( - (left, right) => left - right, - ); - const payload = { - login: input.login, - repoFullName, - baseRef, - headRef, - branchName, - baseSha, - headSha, - mergeBaseSha, - remoteTrackingSha, - commitMessages, - changedFiles, - validation: input.validation, - linkedIssues, - labels: input.labels, - title, - body: input.body, - pendingMergedPrCount: input.pendingMergedPrCount, - pendingClosedPrCount: input.pendingClosedPrCount, - approvedPrCount: input.approvedPrCount, - expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, - projectedCredibility: input.projectedCredibility, - scenarioNotes: input.scenarioNotes, - pendingCommitCount, - ciStatusHints, - branchEligibility: input.branchEligibility, - }; - return stripUndefined(payload); -} - + assertSourceUploadDisabled(); + const workspace = resolveWorkspaceCwd(input); + const cwd = workspace.cwd; + const baseRef = input.baseRef ?? defaultBaseRef(cwd); + const remoteUrl = gitLines(cwd, ["config", "--get", "remote.origin.url"])[0] ?? ""; + const repoFullName = input.repoFullName ?? parseGitRemote(remoteUrl); + if (!repoFullName) + throw new Error("Could not infer repoFullName from git remote; pass --repo owner/repo."); + const branchName = input.branchName ?? gitLines(cwd, ["branch", "--show-current"])[0] ?? "local-branch"; + const headRef = input.headRef ?? gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])[0] ?? branchName; + const baseSha = gitLines(cwd, ["rev-parse", "--verify", baseRef])[0]; + const headSha = gitLines(cwd, ["rev-parse", "--verify", "HEAD"])[0]; + const mergeBaseSha = gitLines(cwd, ["merge-base", baseRef, "HEAD"])[0]; + const remoteTrackingSha = collectRemoteTrackingSha(cwd, baseRef); + const changedFiles = collectChangedFiles(cwd, baseRef); + const pendingCommitCount = input.pendingCommitCount ?? collectPendingCommitCount(cwd, baseRef); + const ciStatusHints = input.ciStatusHints ?? collectCiStatusHints(cwd, baseRef, changedFiles); + const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); + const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"))])].sort((left, right) => left - right); + const payload = { + login: input.login, + repoFullName, + baseRef, + headRef, + branchName, + baseSha, + headSha, + mergeBaseSha, + remoteTrackingSha, + commitMessages, + changedFiles, + validation: input.validation, + linkedIssues, + labels: input.labels, + title, + body: input.body, + pendingMergedPrCount: input.pendingMergedPrCount, + pendingClosedPrCount: input.pendingClosedPrCount, + approvedPrCount: input.approvedPrCount, + expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, + projectedCredibility: input.projectedCredibility, + scenarioNotes: input.scenarioNotes, + pendingCommitCount, + ciStatusHints, + branchEligibility: input.branchEligibility, + }; + return stripUndefined(payload); +} export function collectPendingCommitCount(cwd, baseRef) { - const count = gitLines(cwd, ["rev-list", "--count", `${baseRef}..HEAD`])[0]; - const parsed = Number(count); - return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0; + const count = gitLines(cwd, ["rev-list", "--count", `${baseRef}..HEAD`])[0]; + const parsed = Number(count); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0; } - export function collectCiStatusHints(cwd, baseRef, changedFiles = []) { - const hints = []; - const paths = changedFiles.map((file) => file.path).filter(Boolean); - if (paths.some((path) => /^\.github\/workflows\//i.test(path))) { - hints.push("Workflow files changed; CI required-check behavior may change after merge."); - } - if (paths.some((path) => /(^|\/)(Makefile|Dockerfile|package\.json|pyproject\.toml|go\.mod|Cargo\.toml)$/i.test(path))) { - hints.push("Build or dependency manifests changed; rerun the repo's standard validation commands."); - } - const pendingCommits = collectPendingCommitCount(cwd, baseRef); - if (pendingCommits > 0) { - hints.push(`${pendingCommits} local commit(s) ahead of ${baseRef}; push or rebase before reviewers rely on the latest diff.`); - } - return hints; -} - + const hints = []; + const paths = changedFiles.map((file) => file.path).filter(Boolean); + if (paths.some((path) => /^\.github\/workflows\//i.test(path))) { + hints.push("Workflow files changed; CI required-check behavior may change after merge."); + } + if (paths.some((path) => /(^|\/)(Makefile|Dockerfile|package\.json|pyproject\.toml|go\.mod|Cargo\.toml)$/i.test(path))) { + hints.push("Build or dependency manifests changed; rerun the repo's standard validation commands."); + } + const pendingCommits = collectPendingCommitCount(cwd, baseRef); + if (pendingCommits > 0) { + hints.push(`${pendingCommits} local commit(s) ahead of ${baseRef}; push or rebase before reviewers rely on the latest diff.`); + } + return hints; +} export function buildBranchAnalysisPayload(input) { - const workspace = resolveWorkspaceCwd(input); - const metadata = collectLocalBranchMetadata({ ...input, cwd: workspace.cwd }); - const scorerMetadata = { ...metadata, repoRoot: workspace.cwd }; - const scorerCommand = resolveScorePreviewCommand(input); - const externalPreview = runExternalScorePreview(scorerMetadata, scorerCommand); - const localScorer = externalPreview.ok ? normalizeScorerOutput(externalPreview.payload) : metadataOnlyScorer(externalPreview); - return { - ...metadata, - localScorer, - localScorerStatus: sanitizeLocalScorerStatus(externalPreview), - }; -} - + const workspace = resolveWorkspaceCwd(input); + const metadata = collectLocalBranchMetadata({ ...input, cwd: workspace.cwd }); + const scorerMetadata = { ...metadata, repoRoot: workspace.cwd }; + const scorerCommand = resolveScorePreviewCommand(input); + const externalPreview = runExternalScorePreview(scorerMetadata, scorerCommand); + const localScorer = externalPreview.ok ? normalizeScorerOutput(externalPreview.payload) : metadataOnlyScorer(externalPreview); + return { + ...metadata, + localScorer, + localScorerStatus: sanitizeLocalScorerStatus(externalPreview), + }; +} export function resolveWorkspaceCwd(input = {}) { - const workspaceRoots = normalizeMcpWorkspaceRoots(input.workspaceRoots); - if (workspaceRoots.length === 0) { + const workspaceRoots = normalizeMcpWorkspaceRoots(input.workspaceRoots); + if (workspaceRoots.length === 0) { + return { + cwd: safeResolvedPath(input.cwd ?? process.cwd()), + rootsAvailable: false, + rootCount: 0, + }; + } + const selectedRoot = workspaceRoots[0]; + const requestedCwd = input.cwd === undefined || input.cwd === null || input.cwd === "" + ? selectedRoot.path + : isAbsolute(String(input.cwd)) + ? String(input.cwd) + : resolve(selectedRoot.path, String(input.cwd)); + const cwd = safeResolvedPath(requestedCwd); + const containingRoot = workspaceRoots.find((root) => pathIsInside(cwd, root.path)); + if (!containingRoot) { + throw new Error("Selected workspace is outside the MCP roots exposed by the client."); + } return { - cwd: safeResolvedPath(input.cwd ?? process.cwd()), - rootsAvailable: false, - rootCount: 0, + cwd, + rootsAvailable: true, + rootCount: workspaceRoots.length, }; - } - - const selectedRoot = workspaceRoots[0]; - const requestedCwd = - input.cwd === undefined || input.cwd === null || input.cwd === "" - ? selectedRoot.path - : isAbsolute(String(input.cwd)) - ? String(input.cwd) - : resolve(selectedRoot.path, String(input.cwd)); - const cwd = safeResolvedPath(requestedCwd); - const containingRoot = workspaceRoots.find((root) => pathIsInside(cwd, root.path)); - if (!containingRoot) { - throw new Error("Selected workspace is outside the MCP roots exposed by the client."); - } - - return { - cwd, - rootsAvailable: true, - rootCount: workspaceRoots.length, - }; -} - +} export function normalizeMcpWorkspaceRoots(roots) { - if (!Array.isArray(roots)) return []; - const normalized = []; - const seen = new Set(); - for (const root of roots) { - const uri = typeof root?.uri === "string" ? root.uri : ""; - if (!uri.startsWith("file:")) continue; - try { - const path = safeResolvedPath(fileURLToPath(uri)); - if (seen.has(path)) continue; - seen.add(path); - normalized.push({ path }); - } catch { - // Ignore non-local or malformed root URIs. Clients without usable roots fall back to cwd. + if (!Array.isArray(roots)) + return []; + const normalized = []; + const seen = new Set(); + for (const root of roots) { + const uri = typeof root?.uri === "string" ? root.uri : ""; + if (!uri.startsWith("file:")) + continue; + try { + const path = safeResolvedPath(fileURLToPath(uri)); + if (seen.has(path)) + continue; + seen.add(path); + normalized.push({ path }); + } + catch { + // Ignore non-local or malformed root URIs. Clients without usable roots fall back to cwd. + } } - } - return normalized; + return normalized; } - function safeResolvedPath(path) { - const resolved = resolve(String(path)); - try { - return realpathSync(resolved); - } catch { - return resolved; - } -} - + const resolved = resolve(String(path)); + try { + return realpathSync(resolved); + } + catch { + return resolved; + } +} function pathIsInside(candidate, root) { - const child = safeResolvedPath(candidate); - const parent = safeResolvedPath(root); - const childRelativeToParent = relative(parent, child); - return childRelativeToParent === "" || (!!childRelativeToParent && !childRelativeToParent.startsWith("..") && !isAbsolute(childRelativeToParent)); + const child = safeResolvedPath(candidate); + const parent = safeResolvedPath(root); + const childRelativeToParent = relative(parent, child); + return childRelativeToParent === "" || (!!childRelativeToParent && !childRelativeToParent.startsWith("..") && !isAbsolute(childRelativeToParent)); } - export function resolveScorePreviewCommand(input = {}) { - const explicit = input.scorePreviewCommand ?? process.env.GITTENSOR_SCORE_PREVIEW_CMD; - if (typeof explicit === "string" && explicit.trim()) return explicit.trim(); - return undefined; + const explicit = input.scorePreviewCommand ?? process.env.GITTENSOR_SCORE_PREVIEW_CMD; + if (typeof explicit === "string" && explicit.trim()) + return explicit.trim(); + return undefined; } - export function referenceScorePreviewExample(kind = "metadata") { - const script = kind === "gittensor" ? "gittensor-score-preview.py" : "gittensor-score-preview.mjs"; - const interpreter = kind === "gittensor" ? "python3" : "node"; - return `${interpreter} ./node_modules/@loopover/mcp/scripts/${script}`; + const script = kind === "gittensor" ? "gittensor-score-preview.py" : "gittensor-score-preview.mjs"; + const interpreter = kind === "gittensor" ? "python3" : "node"; + return `${interpreter} ./node_modules/@loopover/mcp/scripts/${script}`; } - export function redactScorerCommand(command) { - const text = String(command ?? "").trim(); - if (!text) return text; - const parts = splitCommand(text); - const interpreter = parts[0]?.split(/[\\/]/).pop() ?? "command"; - const script = parts.at(-1)?.split(/[\\/]/).pop(); - if (script && /\.(mjs|js|cjs|py)$/i.test(script)) return `${interpreter} /${script}`; - return ""; -} - + const text = String(command ?? "").trim(); + if (!text) + return text; + const parts = splitCommand(text); + const interpreter = parts[0]?.split(/[\\/]/).pop() ?? "command"; + const script = parts.at(-1)?.split(/[\\/]/).pop(); + if (script && /\.(mjs|js|cjs|py)$/i.test(script)) + return `${interpreter} /${script}`; + return ""; +} export function sanitizeLocalScorerStatus(status) { - if (!status || typeof status !== "object") return status; - return stripUndefined({ - ...status, - reason: status.reason ? redactLocalPath(String(status.reason)) : undefined, - stderr: status.stderr ? redactLocalPath(String(status.stderr)) : undefined, - scorerCommand: status.scorerCommand ? redactScorerCommand(status.scorerCommand) : undefined, - }); -} - -export function runExternalScorePreview(metadata, scorerCommand) { - const timeoutMs = scorePreviewTimeoutMs(); - if (!scorerCommand) { - return scorerFailure("missing_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is not configured."); - } - const parts = splitCommand(scorerCommand); - const command = parts[0]; - const args = parts.slice(1); - if (!command) { - return scorerFailure("empty_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is empty."); - } - - const startedAt = Date.now(); - try { - const output = execFileSync(command, args, { - input: JSON.stringify({ - ...metadata, - repoRoot: metadata.repoRoot ?? metadata.cwd, - gittensorRoot: process.env.GITTENSOR_ROOT, - }), - encoding: "utf8", - timeout: timeoutMs, - stdio: ["pipe", "pipe", "pipe"], + if (!status || typeof status !== "object") + return status; + const scorerStatus = status; + return stripUndefined({ + ...scorerStatus, + reason: scorerStatus.reason ? redactLocalPath(String(scorerStatus.reason)) : undefined, + stderr: scorerStatus.stderr ? redactLocalPath(String(scorerStatus.stderr)) : undefined, + scorerCommand: scorerStatus.scorerCommand ? redactScorerCommand(scorerStatus.scorerCommand) : undefined, }); - const durationMs = Date.now() - startedAt; - let payload; - try { - payload = JSON.parse(output); - } catch { - return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { - durationMs, - stderr: truncateText(output), - fallbackMode: "metadata_only", - }); +} +export function runExternalScorePreview(metadata, scorerCommand) { + const timeoutMs = scorePreviewTimeoutMs(); + if (!scorerCommand) { + return scorerFailure("missing_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is not configured."); } - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return scorerFailure("malformed_json", "External scorer stdout must be a JSON object.", { - durationMs, - fallbackMode: "metadata_only", - }); + const parts = splitCommand(scorerCommand); + const command = parts[0]; + const args = parts.slice(1); + if (!command) { + return scorerFailure("empty_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is empty."); } - const normalized = normalizeScorerOutput(payload); - if (normalized.sourceTokenScore === undefined && normalized.totalTokenScore === undefined) { - return scorerFailure("malformed_json", "External scorer JSON must include sourceTokenScore or totalTokenScore.", { - durationMs, - fallbackMode: "metadata_only", - }); + const startedAt = Date.now(); + try { + const output = execFileSync(command, args, { + input: JSON.stringify({ + ...metadata, + repoRoot: metadata.repoRoot ?? metadata.cwd, + gittensorRoot: process.env.GITTENSOR_ROOT, + }), + encoding: "utf8", + timeout: timeoutMs, + stdio: ["pipe", "pipe", "pipe"], + }); + const durationMs = Date.now() - startedAt; + let payload; + try { + payload = JSON.parse(output); + } + catch { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(output), + fallbackMode: "metadata_only", + }); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return scorerFailure("malformed_json", "External scorer stdout must be a JSON object.", { + durationMs, + fallbackMode: "metadata_only", + }); + } + const normalized = normalizeScorerOutput(payload); + if (normalized.sourceTokenScore === undefined && normalized.totalTokenScore === undefined) { + return scorerFailure("malformed_json", "External scorer JSON must include sourceTokenScore or totalTokenScore.", { + durationMs, + fallbackMode: "metadata_only", + }); + } + return stripUndefined({ + ok: true, + code: "success", + reason: "external_scorer_succeeded", + durationMs, + payload, + fallbackMode: "external_command", + }); + } + catch (error) { + return classifyScorerExecFailure(error, Date.now() - startedAt, scorerCommand); } - return stripUndefined({ - ok: true, - code: "success", - reason: "external_scorer_succeeded", - durationMs, - payload, - fallbackMode: "external_command", - }); - } catch (error) { - return classifyScorerExecFailure(error, Date.now() - startedAt, scorerCommand); - } } - export function setupGuidanceForLocalScorer(status) { - if (status.ok) return []; - const safeStatus = sanitizeLocalScorerStatus(status); - const code = safeStatus.code ?? inferScorerCode(safeStatus.reason); - const guidance = [ - "LoopOver used metadata-only analysis because no external scorer succeeded.", - ]; - switch (code) { - case "missing_scorer_command": - guidance.push(`Set GITTENSOR_SCORE_PREVIEW_CMD, for example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("metadata")}"`); - guidance.push(`For tree-sitter scoring with a local gittensor checkout: export GITTENSOR_ROOT= && export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("gittensor")}"`); - break; - case "empty_scorer_command": - guidance.push("GITTENSOR_SCORE_PREVIEW_CMD is set but empty; provide a command that reads branch metadata JSON from stdin."); - break; - case "timeout": - guidance.push(`External scorer exceeded ${scorePreviewTimeoutMs()}ms; simplify the scorer or raise GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS.`); - break; - case "malformed_json": - guidance.push("External scorer must print one JSON object with sourceTokenScore/totalTokenScore fields to stdout."); - if (safeStatus.stderr) guidance.push(`Last scorer stdout snippet: ${truncateText(safeStatus.stderr, 160)}`); - break; - case "non_zero_exit": - guidance.push("External scorer exited with a non-zero status; inspect stderr and run loopover-mcp doctor."); - if (safeStatus.stderr) guidance.push(`Scorer stderr: ${truncateText(safeStatus.stderr, 160)}`); - if (typeof safeStatus.exitCode === "number") guidance.push(`Exit code: ${safeStatus.exitCode}`); - break; - default: - guidance.push("Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON."); - if (safeStatus.reason) guidance.push(`Last scorer error: ${safeStatus.reason}`); - break; - } - guidance.push("Local scorer output stays on your machine; LoopOver never uploads source contents."); - return guidance; -} - + if (status.ok) + return []; + const safeStatus = sanitizeLocalScorerStatus(status); + const code = safeStatus.code ?? inferScorerCode(safeStatus.reason); + const guidance = [ + "LoopOver used metadata-only analysis because no external scorer succeeded.", + ]; + switch (code) { + case "missing_scorer_command": + guidance.push(`Set GITTENSOR_SCORE_PREVIEW_CMD, for example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("metadata")}"`); + guidance.push(`For tree-sitter scoring with a local gittensor checkout: export GITTENSOR_ROOT= && export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("gittensor")}"`); + break; + case "empty_scorer_command": + guidance.push("GITTENSOR_SCORE_PREVIEW_CMD is set but empty; provide a command that reads branch metadata JSON from stdin."); + break; + case "timeout": + guidance.push(`External scorer exceeded ${scorePreviewTimeoutMs()}ms; simplify the scorer or raise GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS.`); + break; + case "malformed_json": + guidance.push("External scorer must print one JSON object with sourceTokenScore/totalTokenScore fields to stdout."); + if (safeStatus.stderr) + guidance.push(`Last scorer stdout snippet: ${truncateText(safeStatus.stderr, 160)}`); + break; + case "non_zero_exit": + guidance.push("External scorer exited with a non-zero status; inspect stderr and run loopover-mcp doctor."); + if (safeStatus.stderr) + guidance.push(`Scorer stderr: ${truncateText(safeStatus.stderr, 160)}`); + if (typeof safeStatus.exitCode === "number") + guidance.push(`Exit code: ${safeStatus.exitCode}`); + break; + default: + guidance.push("Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON."); + if (safeStatus.reason) + guidance.push(`Last scorer error: ${safeStatus.reason}`); + break; + } + guidance.push("Local scorer output stays on your machine; LoopOver never uploads source contents."); + return guidance; +} export function probeLocalScorer(scorerCommand = resolveScorePreviewCommand()) { - return sanitizeLocalScorerStatus( - runExternalScorePreview( - { - repoFullName: "JSONbored/loopover", - branchName: "doctor-probe", - changedFiles: [{ path: "src/example.ts", additions: 12, deletions: 2, status: "modified" }], - repoRoot: process.cwd(), - }, - scorerCommand, - ), - ); -} - + return sanitizeLocalScorerStatus(runExternalScorePreview({ + repoFullName: "JSONbored/loopover", + branchName: "doctor-probe", + changedFiles: [{ path: "src/example.ts", additions: 12, deletions: 2, status: "modified" }], + repoRoot: process.cwd(), + }, scorerCommand)); +} function gitOutput(cwd, args) { - try { - return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); - } catch { - return ""; - } + try { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); + } + catch { + return ""; + } } - export function gitLines(cwd, args) { - return gitOutput(cwd, args) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + return gitOutput(cwd, args) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); } - function collectChangedFiles(cwd, baseRef) { - // Read both halves with `-z`: the human format quotes non-ASCII/control-char paths, so a quoted - // name-status key would never match the verbatim numstat key and the file's stats would be lost. - const numstat = new Map(parseNumstat(cwd, baseRef).map((entry) => [entry.path, entry])); - return parseNameStatus(cwd, baseRef).map((entry) => { - const stats = numstat.get(entry.path) ?? { additions: 0, deletions: 0, binary: false }; - return stripUndefined({ - path: entry.path, - previousPath: entry.previousPath, - additions: stats.additions, - deletions: stats.deletions, - status: statusFromCode(entry.code), - binary: stats.binary, + // Read both halves with `-z`: the human format quotes non-ASCII/control-char paths, so a quoted + // name-status key would never match the verbatim numstat key and the file's stats would be lost. + const numstat = new Map(parseNumstat(cwd, baseRef).map((entry) => [entry.path, entry])); + return parseNameStatus(cwd, baseRef).map((entry) => { + const stats = numstat.get(entry.path) ?? { additions: 0, deletions: 0, binary: false }; + return stripUndefined({ + path: entry.path, + previousPath: entry.previousPath, + additions: stats.additions, + deletions: stats.deletions, + status: statusFromCode(entry.code), + binary: stats.binary, + }); }); - }); } - function parseNameStatus(cwd, baseRef) { - // `-z`: the status code is its own field and paths are verbatim; a rename is followed by the old - // then the new path, any other status by a single path. - const records = gitOutput(cwd, ["diff", "--name-status", "-M", "-z", baseRef, "--"]).split("\0"); - const entries = []; - for (let index = 0; index < records.length; index += 1) { - const code = records[index]; - if (!code) continue; - const isRename = code.startsWith("R"); - const previousPath = isRename ? records[index + 1] : undefined; - const path = records[index + (isRename ? 2 : 1)]; - index += isRename ? 2 : 1; - entries.push({ code, path, previousPath }); - } - return entries; -} - + // `-z`: the status code is its own field and paths are verbatim; a rename is followed by the old + // then the new path, any other status by a single path. + const records = gitOutput(cwd, ["diff", "--name-status", "-M", "-z", baseRef, "--"]).split("\0"); + const entries = []; + for (let index = 0; index < records.length; index += 1) { + const code = records[index]; + if (!code) + continue; + const isRename = code.startsWith("R"); + const previousPath = isRename ? records[index + 1] : undefined; + const path = records[index + (isRename ? 2 : 1)]; + index += isRename ? 2 : 1; + entries.push({ code, path: path, previousPath }); + } + return entries; +} function parseNumstat(cwd, baseRef) { - // `-z`: paths are verbatim and a rename emits old/new as separate fields, not the lossy - // "{a => b}" / "a => b" human form that left cross-directory renames keyed by an unmatchable string. - const records = gitOutput(cwd, ["diff", "--numstat", "-M", "-z", baseRef, "--"]).split("\0"); - const entries = []; - for (let index = 0; index < records.length; index += 1) { - const stat = records[index]; - if (!stat) continue; - const [added, deleted, inlinePath] = splitNumstatStat(stat); - // An empty inline path marks a rename: the new path is the second of the two following fields. - let path = inlinePath; - if (inlinePath === "") { - path = records[index + 2]; - index += 2; + // `-z`: paths are verbatim and a rename emits old/new as separate fields, not the lossy + // "{a => b}" / "a => b" human form that left cross-directory renames keyed by an unmatchable string. + const records = gitOutput(cwd, ["diff", "--numstat", "-M", "-z", baseRef, "--"]).split("\0"); + const entries = []; + for (let index = 0; index < records.length; index += 1) { + const stat = records[index]; + if (!stat) + continue; + const [added, deleted, inlinePath] = splitNumstatStat(stat); + // An empty inline path marks a rename: the new path is the second of the two following fields. + let path = inlinePath; + if (inlinePath === "") { + path = records[index + 2]; + index += 2; + } + const binary = added === "-"; + entries.push({ path: path, additions: binary ? 0 : Number(added), deletions: binary ? 0 : Number(deleted), binary }); } - const binary = added === "-"; - entries.push({ path, additions: binary ? 0 : Number(added), deletions: binary ? 0 : Number(deleted), binary }); - } - return entries; + return entries; } - function splitNumstatStat(stat) { - // "\t\t" -- keep the path slice intact even if it contains tabs. - const firstTab = stat.indexOf("\t"); - const secondTab = stat.indexOf("\t", firstTab + 1); - return [stat.slice(0, firstTab), stat.slice(firstTab + 1, secondTab), stat.slice(secondTab + 1)]; + // "\t\t" -- keep the path slice intact even if it contains tabs. + const firstTab = stat.indexOf("\t"); + const secondTab = stat.indexOf("\t", firstTab + 1); + return [stat.slice(0, firstTab), stat.slice(firstTab + 1, secondTab), stat.slice(secondTab + 1)]; } - function collectCommitMessages(cwd, baseRef) { - const rangeMessages = gitLines(cwd, ["log", "--pretty=%B%x1e", `${baseRef}..HEAD`]).join("\n"); - const messages = rangeMessages - .split("\u001e") - .map((message) => message.trim()) - .filter(Boolean); - if (messages.length > 0) return messages.slice(0, 30); - const last = gitLines(cwd, ["log", "-1", "--pretty=%B"]).join("\n").trim(); - return last ? [last] : []; -} - + const rangeMessages = gitLines(cwd, ["log", "--pretty=%B%x1e", `${baseRef}..HEAD`]).join("\n"); + const messages = rangeMessages + .split("\u001e") + .map((message) => message.trim()) + .filter(Boolean); + if (messages.length > 0) + return messages.slice(0, 30); + const last = gitLines(cwd, ["log", "-1", "--pretty=%B"]).join("\n").trim(); + return last ? [last] : []; +} function defaultBaseRef(cwd) { - const originHead = gitLines(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])[0]; - if (originHead) return originHead; - if (gitLines(cwd, ["rev-parse", "--verify", "origin/main"]).length > 0) return "origin/main"; - if (gitLines(cwd, ["rev-parse", "--verify", "origin/master"]).length > 0) return "origin/master"; - return "HEAD"; + const originHead = gitLines(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])[0]; + if (originHead) + return originHead; + if (gitLines(cwd, ["rev-parse", "--verify", "origin/main"]).length > 0) + return "origin/main"; + if (gitLines(cwd, ["rev-parse", "--verify", "origin/master"]).length > 0) + return "origin/master"; + return "HEAD"; } - function collectRemoteTrackingSha(cwd, baseRef) { - const match = String(baseRef ?? "").replace(/^refs\/remotes\//, "").match(/^origin\/(.+)$/); - const branch = match?.[1]; - if (!branch) return undefined; - const remoteRow = gitLines(cwd, ["ls-remote", "--heads", "origin", branch])[0]; - return remoteRow?.split(/\s+/)[0]; + const match = String(baseRef ?? "").replace(/^refs\/remotes\//, "").match(/^origin\/(.+)$/); + const branch = match?.[1]; + if (!branch) + return undefined; + const remoteRow = gitLines(cwd, ["ls-remote", "--heads", "origin", branch])[0]; + return remoteRow?.split(/\s+/)[0]; } - function normalizeScorerOutput(payload) { - return stripUndefined({ - mode: "external_command", - activeModel: stringValue(payload.activeModel ?? payload.active_model), - sourceTokenScore: numberValue(payload.sourceTokenScore ?? payload.source_token_score ?? payload.source?.tokenScore), - totalTokenScore: numberValue(payload.totalTokenScore ?? payload.total_token_score ?? payload.total?.tokenScore), - sourceLines: numberValue(payload.sourceLines ?? payload.source_lines ?? payload.source?.lines), - testTokenScore: numberValue(payload.testTokenScore ?? payload.test_token_score ?? payload.tests?.tokenScore), - nonCodeTokenScore: numberValue(payload.nonCodeTokenScore ?? payload.non_code_token_score ?? payload.nonCode?.tokenScore), - warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : undefined, - }); -} - + return stripUndefined({ + mode: "external_command", + activeModel: stringValue(payload.activeModel ?? payload.active_model), + sourceTokenScore: numberValue(payload.sourceTokenScore ?? payload.source_token_score ?? payload.source?.tokenScore), + totalTokenScore: numberValue(payload.totalTokenScore ?? payload.total_token_score ?? payload.total?.tokenScore), + sourceLines: numberValue(payload.sourceLines ?? payload.source_lines ?? payload.source?.lines), + testTokenScore: numberValue(payload.testTokenScore ?? payload.test_token_score ?? payload.tests?.tokenScore), + nonCodeTokenScore: numberValue(payload.nonCodeTokenScore ?? payload.non_code_token_score ?? payload.nonCode?.tokenScore), + warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : undefined, + }); +} function metadataOnlyScorer(status) { - return { - mode: "metadata_only", - warnings: [status.reason ?? status.code ?? "external_scorer_unavailable"], - }; + return { + mode: "metadata_only", + warnings: [status.reason ?? status.code ?? "external_scorer_unavailable"], + }; } - function scorerFailure(code, reason, extra = {}) { - return stripUndefined({ - ok: false, - code, - reason, - fallbackMode: "metadata_only", - ...extra, - }); -} - -function classifyScorerExecFailure(error, durationMs, scorerCommand) { - const execError = error && typeof error === "object" ? error : undefined; - const stdout = String(execError?.stdout ?? execError?.output?.[1] ?? "").trim(); - const stderr = truncateText(execError?.stderr ?? execError?.output?.[2] ?? ""); - const exitCode = typeof execError?.status === "number" ? execError.status : undefined; - if (stdout && !looksLikeScorerJson(stdout)) { - return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { - durationMs, - stderr: truncateText(stdout), - scorerCommand: redactScorerCommand(scorerCommand), - fallbackMode: "metadata_only", - }); - } - if (execError?.code === "ETIMEDOUT" || (execError?.killed && execError?.signal === "SIGTERM")) { - return scorerFailure("timeout", `External scorer timed out after ${scorePreviewTimeoutMs()}ms.`, { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); - } - if (typeof exitCode === "number" && exitCode !== 0) { - return scorerFailure("non_zero_exit", `External scorer exited with status ${exitCode}.`, { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); - } - const message = error instanceof Error ? error.message : "external_scorer_failed"; - if (/JSON/i.test(message)) { - return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); - } - if (stderr && !looksLikeScorerJson(stderr)) { - return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { - durationMs, - stderr: truncateText(stderr), - scorerCommand: redactScorerCommand(scorerCommand), - fallbackMode: "metadata_only", + return stripUndefined({ + ok: false, + code, + reason, + fallbackMode: "metadata_only", + ...extra, }); - } - return scorerFailure("scorer_failed", redactLocalPath(message), { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); } - +function classifyScorerExecFailure(error, durationMs, scorerCommand) { + const execError = error && typeof error === "object" ? error : undefined; + const stdout = String(execError?.stdout ?? execError?.output?.[1] ?? "").trim(); + const stderr = truncateText(execError?.stderr ?? execError?.output?.[2] ?? ""); + const exitCode = typeof execError?.status === "number" ? execError.status : undefined; + if (stdout && !looksLikeScorerJson(stdout)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(stdout), + scorerCommand: redactScorerCommand(scorerCommand), + fallbackMode: "metadata_only", + }); + } + if (execError?.code === "ETIMEDOUT" || (execError?.killed && execError?.signal === "SIGTERM")) { + return scorerFailure("timeout", `External scorer timed out after ${scorePreviewTimeoutMs()}ms.`, { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); + } + if (typeof exitCode === "number" && exitCode !== 0) { + return scorerFailure("non_zero_exit", `External scorer exited with status ${exitCode}.`, { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); + } + const message = error instanceof Error ? error.message : "external_scorer_failed"; + if (/JSON/i.test(message)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); + } + if (stderr && !looksLikeScorerJson(stderr)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(stderr), + scorerCommand: redactScorerCommand(scorerCommand), + fallbackMode: "metadata_only", + }); + } + return scorerFailure("scorer_failed", redactLocalPath(message), { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); +} function looksLikeScorerJson(output) { - try { - const payload = JSON.parse(output); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; - const normalized = normalizeScorerOutput(payload); - return normalized.sourceTokenScore !== undefined || normalized.totalTokenScore !== undefined; - } catch { - return false; - } -} - + try { + const payload = JSON.parse(output); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return false; + const normalized = normalizeScorerOutput(payload); + return normalized.sourceTokenScore !== undefined || normalized.totalTokenScore !== undefined; + } + catch { + return false; + } +} function inferScorerCode(reason) { - const text = String(reason ?? ""); - if (text.includes("missing_scorer_command")) return "missing_scorer_command"; - if (text.includes("empty_scorer_command")) return "empty_scorer_command"; - if (/timed out|ETIMEDOUT/i.test(text)) return "timeout"; - if (/JSON/i.test(text)) return "malformed_json"; - if (/status \d+/i.test(text)) return "non_zero_exit"; - return "scorer_failed"; -} - + const text = String(reason ?? ""); + if (text.includes("missing_scorer_command")) + return "missing_scorer_command"; + if (text.includes("empty_scorer_command")) + return "empty_scorer_command"; + if (/timed out|ETIMEDOUT/i.test(text)) + return "timeout"; + if (/JSON/i.test(text)) + return "malformed_json"; + if (/status \d+/i.test(text)) + return "non_zero_exit"; + return "scorer_failed"; +} function scorePreviewTimeoutMs() { - const parsed = Number(process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS ?? 15000); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000; + const parsed = Number(process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS ?? 15000); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000; } - function truncateText(value, maxLength = 240) { - const text = String(value ?? "").trim(); - if (!text) return undefined; - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`; + const text = String(value ?? "").trim(); + if (!text) + return undefined; + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`; } - function splitCommand(command) { - return String(command).match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) ?? []; + return String(command).match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) ?? []; } - function assertSourceUploadDisabled() { - if (/^(1|true|yes)$/i.test(process.env.LOOPOVER_UPLOAD_SOURCE ?? "false")) { - throw new Error("LOOPOVER_UPLOAD_SOURCE=true is not supported in v1; local MCP sends metadata only."); - } + if (/^(1|true|yes)$/i.test(process.env.LOOPOVER_UPLOAD_SOURCE ?? "false")) { + throw new Error("LOOPOVER_UPLOAD_SOURCE=true is not supported in v1; local MCP sends metadata only."); + } } - // Word-boundary the closing keywords (as the server-side extractors in src/db/repositories.ts and // src/signals/engine.ts already do) so a keyword embedded in a longer word does not spuriously link an // issue: without \b, `hotfix 5` / `prefixes 12` matched the `fix`/`fixes` substring and captured the // trailing number. The bare `#` branch stays boundary-free so `#123` still matches anywhere. export function extractLinkedIssues(text) { - const issues = []; - for (const match of String(text).matchAll(/(?:\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)|#)\s*#?(\d+)/gi)) issues.push(Number(match[1])); - return issues.filter((issue) => Number.isInteger(issue) && issue > 0); + const issues = []; + for (const match of String(text).matchAll(/(?:\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)|#)\s*#?(\d+)/gi)) + issues.push(Number(match[1])); + return issues.filter((issue) => Number.isInteger(issue) && issue > 0); } - function statusFromCode(code) { - if (code.startsWith("A")) return "added"; - if (code.startsWith("M")) return "modified"; - if (code.startsWith("D")) return "deleted"; - if (code.startsWith("R")) return "renamed"; - if (code.startsWith("C")) return "copied"; - return "unknown"; -} - + if (code.startsWith("A")) + return "added"; + if (code.startsWith("M")) + return "modified"; + if (code.startsWith("D")) + return "deleted"; + if (code.startsWith("R")) + return "renamed"; + if (code.startsWith("C")) + return "copied"; + return "unknown"; +} function titleFromBranch(branchName) { - return String(branchName ?? "") - .replace(/^[-/_.\w]+\/(?=[^/]+$)/, "") - .replace(/[-_]+/g, " ") - .trim(); + return String(branchName ?? "") + .replace(/^[-/_.\w]+\/(?=[^/]+$)/, "") + .replace(/[-_]+/g, " ") + .trim(); } - function firstCommitTitle(messages) { - return messages.find((message) => message.trim().length > 0)?.split("\n")[0]?.trim(); + return messages.find((message) => message.trim().length > 0)?.split("\n")[0]?.trim(); } - function numberValue(value) { - const number = Number(value); - return Number.isFinite(number) ? number : undefined; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; } - function stringValue(value) { - return typeof value === "string" && value.trim() ? value : undefined; + return typeof value === "string" && value.trim() ? value : undefined; } - function stripUndefined(value) { - if (Array.isArray(value)) return value.map(stripUndefined); - if (!value || typeof value !== "object") return value; - return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).map(([key, entry]) => [key, stripUndefined(entry)])); + if (Array.isArray(value)) + return value.map(stripUndefined); + if (!value || typeof value !== "object") + return value; + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).map(([key, entry]) => [key, stripUndefined(entry)])); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibG9jYWwtYnJhbmNoLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsibG9jYWwtYnJhbmNoLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxZQUFZLEVBQUUsTUFBTSxvQkFBb0IsQ0FBQztBQUNsRCxPQUFPLEVBQUUsWUFBWSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ3ZDLE9BQU8sRUFBRSxVQUFVLEVBQVEsUUFBUSxFQUFFLE9BQU8sRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUNoRSxPQUFPLEVBQUUsYUFBYSxFQUFFLE1BQU0sVUFBVSxDQUFDO0FBQ3pDLE9BQU8sRUFBRSxVQUFVLEVBQUUsVUFBVSxJQUFJLFVBQVUsRUFBRSxNQUFNLHdDQUF3QyxDQUFDO0FBQzlGLE9BQU8sRUFBRSxlQUFlLEVBQUUsTUFBTSx3QkFBd0IsQ0FBQztBQUV6RCxPQUFPLEVBQUUsVUFBVSxFQUFFLFVBQVUsRUFBRSxDQUFDO0FBQ2xDLE9BQU8sRUFBRSxlQUFlLEVBQUUsQ0FBQztBQWlHM0IsU0FBUyxvQkFBb0IsQ0FBQyxLQUFhO0lBQ3pDLElBQUksR0FBRyxHQUFHLEtBQUssQ0FBQyxNQUFNLENBQUM7SUFDdkIsT0FBTyxHQUFHLEdBQUcsQ0FBQyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxHQUFHLENBQUMsQ0FBQyxLQUFLLEVBQUU7UUFBRSxHQUFHLElBQUksQ0FBQyxDQUFDO0lBQzdELE9BQU8sR0FBRyxLQUFLLEtBQUssQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDNUQsQ0FBQztBQUVELE1BQU0sVUFBVSxjQUFjLENBQUMsU0FBa0I7SUFDL0MsTUFBTSxPQUFPLEdBQUcsb0JBQW9CLENBQUMsTUFBTSxDQUFDLFNBQVMsSUFBSSxFQUFFLENBQUMsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ3JFLE1B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 \ No newline at end of file diff --git a/packages/loopover-mcp/lib/local-branch.ts b/packages/loopover-mcp/lib/local-branch.ts new file mode 100644 index 0000000000..7d5a6553a9 --- /dev/null +++ b/packages/loopover-mcp/lib/local-branch.ts @@ -0,0 +1,690 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isCodeFile, isTestPath as isTestFile } from "@loopover/engine/signals/test-evidence"; +import { redactLocalPath } from "./redact-local-path.js"; + +export { isCodeFile, isTestFile }; +export { redactLocalPath }; + +type WorkspaceRoot = { uri?: unknown }; +type NormalizedWorkspaceRoot = { path: string }; +type ChangedFile = { + path: string; + previousPath?: string | undefined; + additions: number; + deletions: number; + status: string; + binary?: boolean; +}; +type BranchInput = { + cwd?: unknown; + baseRef?: string; + login?: string; + workspaceRoots?: unknown; + repoFullName?: string; + branchName?: string; + headRef?: string; + pendingCommitCount?: number; + ciStatusHints?: string[]; + commitMessages?: string[]; + title?: string; + body?: string; + linkedIssues?: number[]; + labels?: unknown; + validation?: unknown; + pendingMergedPrCount?: unknown; + pendingClosedPrCount?: unknown; + approvedPrCount?: unknown; + expectedOpenPrCount?: unknown; + expectedOpenPrCountAfterMerge?: unknown; + projectedCredibility?: unknown; + scenarioNotes?: unknown; + branchEligibility?: unknown; + scorePreviewCommand?: unknown; +}; +type BranchMetadata = { + login?: string | undefined; + repoFullName: string; + baseRef: string; + headRef: string; + branchName: string; + baseSha?: string | undefined; + headSha?: string | undefined; + mergeBaseSha?: string | undefined; + remoteTrackingSha?: string | undefined; + commitMessages: string[]; + changedFiles: ChangedFile[]; + validation?: unknown; + linkedIssues: number[]; + labels?: unknown; + title?: string | undefined; + body?: string | undefined; + pendingMergedPrCount?: unknown; + pendingClosedPrCount?: unknown; + approvedPrCount?: unknown; + expectedOpenPrCountAfterMerge?: unknown; + projectedCredibility?: unknown; + scenarioNotes?: unknown; + pendingCommitCount: number; + ciStatusHints: string[]; + branchEligibility?: unknown; + repoRoot?: string; + cwd?: string; +}; +type ScorePreviewMetadata = Record & { + repoRoot?: string; + cwd?: string; +}; +type ScorerStatus = { + ok?: boolean; + payload?: ScorerPayload; + code?: string; + reason?: string; + stderr?: string; + scorerCommand?: string; + exitCode?: number; + [key: string]: unknown; +}; +type ScorerPayload = Record & { + source?: { tokenScore?: unknown; lines?: unknown }; + total?: { tokenScore?: unknown }; + tests?: { tokenScore?: unknown }; + nonCode?: { tokenScore?: unknown }; +}; +type ExecError = { + stdout?: unknown; + stderr?: unknown; + output?: unknown[]; + status?: unknown; + code?: unknown; + killed?: unknown; + signal?: unknown; +}; + +function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1; + return end === value.length ? value : value.slice(0, end); +} + +export function parseGitRemote(remoteUrl: unknown): string | undefined { + const trimmed = stripTrailingSlashes(String(remoteUrl ?? "").trim()); + const patterns = [ + /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/, + /^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, + ]; + for (const pattern of patterns) { + const match = trimmed.match(pattern); + if (match?.[1] && match[2]) return `${match[1]}/${match[2].replace(/\.git$/, "")}`; + } + return undefined; +} + +export function collectLocalDiff(cwd: string, baseRef: string, workspaceRoots: unknown) { + const metadata = collectLocalBranchMetadata({ cwd, baseRef, login: "local", workspaceRoots }); + return { + title: metadata.title ?? "Local diff preflight", + commitMessage: metadata.commitMessages.join("\n\n").trim(), + changedFiles: metadata.changedFiles.map((file) => file.path), + changedLineCount: metadata.changedFiles.reduce((sum, file) => sum + (file.additions ?? 0) + (file.deletions ?? 0), 0), + testFiles: metadata.changedFiles.map((file) => file.path).filter(isTestFile), + codeFiles: metadata.changedFiles.map((file) => file.path).filter(isCodeFile), + }; +} + +export function collectLocalBranchMetadata(input: BranchInput): BranchMetadata { + assertSourceUploadDisabled(); + const workspace = resolveWorkspaceCwd(input); + const cwd = workspace.cwd; + const baseRef = input.baseRef ?? defaultBaseRef(cwd); + const remoteUrl = gitLines(cwd, ["config", "--get", "remote.origin.url"])[0] ?? ""; + const repoFullName = input.repoFullName ?? parseGitRemote(remoteUrl); + if (!repoFullName) throw new Error("Could not infer repoFullName from git remote; pass --repo owner/repo."); + const branchName = input.branchName ?? gitLines(cwd, ["branch", "--show-current"])[0] ?? "local-branch"; + const headRef = input.headRef ?? gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])[0] ?? branchName; + const baseSha = gitLines(cwd, ["rev-parse", "--verify", baseRef])[0]; + const headSha = gitLines(cwd, ["rev-parse", "--verify", "HEAD"])[0]; + const mergeBaseSha = gitLines(cwd, ["merge-base", baseRef, "HEAD"])[0]; + const remoteTrackingSha = collectRemoteTrackingSha(cwd, baseRef); + const changedFiles = collectChangedFiles(cwd, baseRef); + const pendingCommitCount = input.pendingCommitCount ?? collectPendingCommitCount(cwd, baseRef); + const ciStatusHints = input.ciStatusHints ?? collectCiStatusHints(cwd, baseRef, changedFiles); + const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); + const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"))])].sort( + (left, right) => left - right, + ); + const payload = { + login: input.login, + repoFullName, + baseRef, + headRef, + branchName, + baseSha, + headSha, + mergeBaseSha, + remoteTrackingSha, + commitMessages, + changedFiles, + validation: input.validation, + linkedIssues, + labels: input.labels, + title, + body: input.body, + pendingMergedPrCount: input.pendingMergedPrCount, + pendingClosedPrCount: input.pendingClosedPrCount, + approvedPrCount: input.approvedPrCount, + expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, + projectedCredibility: input.projectedCredibility, + scenarioNotes: input.scenarioNotes, + pendingCommitCount, + ciStatusHints, + branchEligibility: input.branchEligibility, + }; + return stripUndefined(payload); +} + +export function collectPendingCommitCount(cwd: string, baseRef: string): number { + const count = gitLines(cwd, ["rev-list", "--count", `${baseRef}..HEAD`])[0]; + const parsed = Number(count); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0; +} + +export function collectCiStatusHints(cwd: string, baseRef: string, changedFiles: ChangedFile[] = []): string[] { + const hints = []; + const paths = changedFiles.map((file) => file.path).filter(Boolean); + if (paths.some((path) => /^\.github\/workflows\//i.test(path))) { + hints.push("Workflow files changed; CI required-check behavior may change after merge."); + } + if (paths.some((path) => /(^|\/)(Makefile|Dockerfile|package\.json|pyproject\.toml|go\.mod|Cargo\.toml)$/i.test(path))) { + hints.push("Build or dependency manifests changed; rerun the repo's standard validation commands."); + } + const pendingCommits = collectPendingCommitCount(cwd, baseRef); + if (pendingCommits > 0) { + hints.push(`${pendingCommits} local commit(s) ahead of ${baseRef}; push or rebase before reviewers rely on the latest diff.`); + } + return hints; +} + +export function buildBranchAnalysisPayload(input: BranchInput) { + const workspace = resolveWorkspaceCwd(input); + const metadata = collectLocalBranchMetadata({ ...input, cwd: workspace.cwd }); + const scorerMetadata = { ...metadata, repoRoot: workspace.cwd }; + const scorerCommand = resolveScorePreviewCommand(input); + const externalPreview = runExternalScorePreview(scorerMetadata, scorerCommand); + const localScorer = externalPreview.ok ? normalizeScorerOutput(externalPreview.payload as ScorerPayload) : metadataOnlyScorer(externalPreview); + return { + ...metadata, + localScorer, + localScorerStatus: sanitizeLocalScorerStatus(externalPreview), + }; +} + +export function resolveWorkspaceCwd(input: Pick = {}) { + const workspaceRoots = normalizeMcpWorkspaceRoots(input.workspaceRoots); + if (workspaceRoots.length === 0) { + return { + cwd: safeResolvedPath(input.cwd ?? process.cwd()), + rootsAvailable: false, + rootCount: 0, + }; + } + + const selectedRoot = workspaceRoots[0]!; + const requestedCwd = + input.cwd === undefined || input.cwd === null || input.cwd === "" + ? selectedRoot.path + : isAbsolute(String(input.cwd)) + ? String(input.cwd) + : resolve(selectedRoot.path, String(input.cwd)); + const cwd = safeResolvedPath(requestedCwd); + const containingRoot = workspaceRoots.find((root) => pathIsInside(cwd, root.path)); + if (!containingRoot) { + throw new Error("Selected workspace is outside the MCP roots exposed by the client."); + } + + return { + cwd, + rootsAvailable: true, + rootCount: workspaceRoots.length, + }; +} + +export function normalizeMcpWorkspaceRoots(roots: unknown): NormalizedWorkspaceRoot[] { + if (!Array.isArray(roots)) return []; + const normalized: NormalizedWorkspaceRoot[] = []; + const seen = new Set(); + for (const root of roots) { + const uri = typeof root?.uri === "string" ? root.uri : ""; + if (!uri.startsWith("file:")) continue; + try { + const path = safeResolvedPath(fileURLToPath(uri)); + if (seen.has(path)) continue; + seen.add(path); + normalized.push({ path }); + } catch { + // Ignore non-local or malformed root URIs. Clients without usable roots fall back to cwd. + } + } + return normalized; +} + +function safeResolvedPath(path: unknown): string { + const resolved = resolve(String(path)); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + +function pathIsInside(candidate: string, root: string): boolean { + const child = safeResolvedPath(candidate); + const parent = safeResolvedPath(root); + const childRelativeToParent = relative(parent, child); + return childRelativeToParent === "" || (!!childRelativeToParent && !childRelativeToParent.startsWith("..") && !isAbsolute(childRelativeToParent)); +} + +export function resolveScorePreviewCommand(input: Pick = {}): string | undefined { + const explicit = input.scorePreviewCommand ?? process.env.GITTENSOR_SCORE_PREVIEW_CMD; + if (typeof explicit === "string" && explicit.trim()) return explicit.trim(); + return undefined; +} + +export function referenceScorePreviewExample(kind: "metadata" | "gittensor" = "metadata"): string { + const script = kind === "gittensor" ? "gittensor-score-preview.py" : "gittensor-score-preview.mjs"; + const interpreter = kind === "gittensor" ? "python3" : "node"; + return `${interpreter} ./node_modules/@loopover/mcp/scripts/${script}`; +} + +export function redactScorerCommand(command: unknown): string { + const text = String(command ?? "").trim(); + if (!text) return text; + const parts = splitCommand(text); + const interpreter = parts[0]?.split(/[\\/]/).pop() ?? "command"; + const script = parts.at(-1)?.split(/[\\/]/).pop(); + if (script && /\.(mjs|js|cjs|py)$/i.test(script)) return `${interpreter} /${script}`; + return ""; +} + +export function sanitizeLocalScorerStatus(status: ScorerStatus): ScorerStatus; +export function sanitizeLocalScorerStatus(status: unknown): unknown; +export function sanitizeLocalScorerStatus(status: unknown): unknown { + if (!status || typeof status !== "object") return status; + const scorerStatus = status as ScorerStatus; + return stripUndefined({ + ...scorerStatus, + reason: scorerStatus.reason ? redactLocalPath(String(scorerStatus.reason)) : undefined, + stderr: scorerStatus.stderr ? redactLocalPath(String(scorerStatus.stderr)) : undefined, + scorerCommand: scorerStatus.scorerCommand ? redactScorerCommand(scorerStatus.scorerCommand) : undefined, + }); +} + +export function runExternalScorePreview(metadata: ScorePreviewMetadata, scorerCommand: string | undefined): ScorerStatus { + const timeoutMs = scorePreviewTimeoutMs(); + if (!scorerCommand) { + return scorerFailure("missing_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is not configured."); + } + const parts = splitCommand(scorerCommand); + const command = parts[0]; + const args = parts.slice(1); + if (!command) { + return scorerFailure("empty_scorer_command", "GITTENSOR_SCORE_PREVIEW_CMD is empty."); + } + + const startedAt = Date.now(); + try { + const output = execFileSync(command, args, { + input: JSON.stringify({ + ...metadata, + repoRoot: metadata.repoRoot ?? metadata.cwd, + gittensorRoot: process.env.GITTENSOR_ROOT, + }), + encoding: "utf8", + timeout: timeoutMs, + stdio: ["pipe", "pipe", "pipe"], + }); + const durationMs = Date.now() - startedAt; + let payload; + try { + payload = JSON.parse(output); + } catch { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(output), + fallbackMode: "metadata_only", + }); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return scorerFailure("malformed_json", "External scorer stdout must be a JSON object.", { + durationMs, + fallbackMode: "metadata_only", + }); + } + const normalized = normalizeScorerOutput(payload as ScorerPayload); + if (normalized.sourceTokenScore === undefined && normalized.totalTokenScore === undefined) { + return scorerFailure("malformed_json", "External scorer JSON must include sourceTokenScore or totalTokenScore.", { + durationMs, + fallbackMode: "metadata_only", + }); + } + return stripUndefined({ + ok: true, + code: "success", + reason: "external_scorer_succeeded", + durationMs, + payload, + fallbackMode: "external_command", + }); + } catch (error) { + return classifyScorerExecFailure(error, Date.now() - startedAt, scorerCommand); + } +} + +export function setupGuidanceForLocalScorer(status: ScorerStatus): string[] { + if (status.ok) return []; + const safeStatus = sanitizeLocalScorerStatus(status); + const code = safeStatus.code ?? inferScorerCode(safeStatus.reason); + const guidance = [ + "LoopOver used metadata-only analysis because no external scorer succeeded.", + ]; + switch (code) { + case "missing_scorer_command": + guidance.push(`Set GITTENSOR_SCORE_PREVIEW_CMD, for example: export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("metadata")}"`); + guidance.push(`For tree-sitter scoring with a local gittensor checkout: export GITTENSOR_ROOT= && export GITTENSOR_SCORE_PREVIEW_CMD="${referenceScorePreviewExample("gittensor")}"`); + break; + case "empty_scorer_command": + guidance.push("GITTENSOR_SCORE_PREVIEW_CMD is set but empty; provide a command that reads branch metadata JSON from stdin."); + break; + case "timeout": + guidance.push(`External scorer exceeded ${scorePreviewTimeoutMs()}ms; simplify the scorer or raise GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS.`); + break; + case "malformed_json": + guidance.push("External scorer must print one JSON object with sourceTokenScore/totalTokenScore fields to stdout."); + if (safeStatus.stderr) guidance.push(`Last scorer stdout snippet: ${truncateText(safeStatus.stderr, 160)}`); + break; + case "non_zero_exit": + guidance.push("External scorer exited with a non-zero status; inspect stderr and run loopover-mcp doctor."); + if (safeStatus.stderr) guidance.push(`Scorer stderr: ${truncateText(safeStatus.stderr, 160)}`); + if (typeof safeStatus.exitCode === "number") guidance.push(`Exit code: ${safeStatus.exitCode}`); + break; + default: + guidance.push("Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON."); + if (safeStatus.reason) guidance.push(`Last scorer error: ${safeStatus.reason}`); + break; + } + guidance.push("Local scorer output stays on your machine; LoopOver never uploads source contents."); + return guidance; +} + +export function probeLocalScorer(scorerCommand: string | undefined = resolveScorePreviewCommand()) { + return sanitizeLocalScorerStatus( + runExternalScorePreview( + { + repoFullName: "JSONbored/loopover", + branchName: "doctor-probe", + changedFiles: [{ path: "src/example.ts", additions: 12, deletions: 2, status: "modified" }], + repoRoot: process.cwd(), + }, + scorerCommand, + ), + ); +} + +function gitOutput(cwd: string, args: string[]): string { + try { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); + } catch { + return ""; + } +} + +export function gitLines(cwd: string, args: string[]): string[] { + return gitOutput(cwd, args) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function collectChangedFiles(cwd: string, baseRef: string): ChangedFile[] { + // Read both halves with `-z`: the human format quotes non-ASCII/control-char paths, so a quoted + // name-status key would never match the verbatim numstat key and the file's stats would be lost. + const numstat = new Map(parseNumstat(cwd, baseRef).map((entry) => [entry.path, entry])); + return parseNameStatus(cwd, baseRef).map((entry) => { + const stats = numstat.get(entry.path) ?? { additions: 0, deletions: 0, binary: false }; + return stripUndefined({ + path: entry.path, + previousPath: entry.previousPath, + additions: stats.additions, + deletions: stats.deletions, + status: statusFromCode(entry.code), + binary: stats.binary, + }); + }); +} + +function parseNameStatus(cwd: string, baseRef: string): Array<{ code: string; path: string; previousPath?: string | undefined }> { + // `-z`: the status code is its own field and paths are verbatim; a rename is followed by the old + // then the new path, any other status by a single path. + const records = gitOutput(cwd, ["diff", "--name-status", "-M", "-z", baseRef, "--"]).split("\0"); + const entries: Array<{ code: string; path: string; previousPath?: string | undefined }> = []; + for (let index = 0; index < records.length; index += 1) { + const code = records[index]; + if (!code) continue; + const isRename = code.startsWith("R"); + const previousPath = isRename ? records[index + 1] : undefined; + const path = records[index + (isRename ? 2 : 1)]; + index += isRename ? 2 : 1; + entries.push({ code, path: path as string, previousPath }); + } + return entries; +} + +function parseNumstat(cwd: string, baseRef: string): Array<{ path: string; additions: number; deletions: number; binary: boolean }> { + // `-z`: paths are verbatim and a rename emits old/new as separate fields, not the lossy + // "{a => b}" / "a => b" human form that left cross-directory renames keyed by an unmatchable string. + const records = gitOutput(cwd, ["diff", "--numstat", "-M", "-z", baseRef, "--"]).split("\0"); + const entries: Array<{ path: string; additions: number; deletions: number; binary: boolean }> = []; + for (let index = 0; index < records.length; index += 1) { + const stat = records[index]; + if (!stat) continue; + const [added, deleted, inlinePath] = splitNumstatStat(stat); + // An empty inline path marks a rename: the new path is the second of the two following fields. + let path = inlinePath; + if (inlinePath === "") { + path = records[index + 2] as string; + index += 2; + } + const binary = added === "-"; + entries.push({ path: path as string, additions: binary ? 0 : Number(added), deletions: binary ? 0 : Number(deleted), binary }); + } + return entries; +} + +function splitNumstatStat(stat: string): [string, string, string] { + // "\t\t" -- keep the path slice intact even if it contains tabs. + const firstTab = stat.indexOf("\t"); + const secondTab = stat.indexOf("\t", firstTab + 1); + return [stat.slice(0, firstTab), stat.slice(firstTab + 1, secondTab), stat.slice(secondTab + 1)]; +} + +function collectCommitMessages(cwd: string, baseRef: string): string[] { + const rangeMessages = gitLines(cwd, ["log", "--pretty=%B%x1e", `${baseRef}..HEAD`]).join("\n"); + const messages = rangeMessages + .split("\u001e") + .map((message) => message.trim()) + .filter(Boolean); + if (messages.length > 0) return messages.slice(0, 30); + const last = gitLines(cwd, ["log", "-1", "--pretty=%B"]).join("\n").trim(); + return last ? [last] : []; +} + +function defaultBaseRef(cwd: string): string { + const originHead = gitLines(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])[0]; + if (originHead) return originHead; + if (gitLines(cwd, ["rev-parse", "--verify", "origin/main"]).length > 0) return "origin/main"; + if (gitLines(cwd, ["rev-parse", "--verify", "origin/master"]).length > 0) return "origin/master"; + return "HEAD"; +} + +function collectRemoteTrackingSha(cwd: string, baseRef: string): string | undefined { + const match = String(baseRef ?? "").replace(/^refs\/remotes\//, "").match(/^origin\/(.+)$/); + const branch = match?.[1]; + if (!branch) return undefined; + const remoteRow = gitLines(cwd, ["ls-remote", "--heads", "origin", branch])[0]; + return remoteRow?.split(/\s+/)[0]; +} + +function normalizeScorerOutput(payload: ScorerPayload): ScorerPayload { + return stripUndefined({ + mode: "external_command", + activeModel: stringValue(payload.activeModel ?? payload.active_model), + sourceTokenScore: numberValue(payload.sourceTokenScore ?? payload.source_token_score ?? payload.source?.tokenScore), + totalTokenScore: numberValue(payload.totalTokenScore ?? payload.total_token_score ?? payload.total?.tokenScore), + sourceLines: numberValue(payload.sourceLines ?? payload.source_lines ?? payload.source?.lines), + testTokenScore: numberValue(payload.testTokenScore ?? payload.test_token_score ?? payload.tests?.tokenScore), + nonCodeTokenScore: numberValue(payload.nonCodeTokenScore ?? payload.non_code_token_score ?? payload.nonCode?.tokenScore), + warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : undefined, + }); +} + +function metadataOnlyScorer(status: ScorerStatus): ScorerPayload { + return { + mode: "metadata_only", + warnings: [status.reason ?? status.code ?? "external_scorer_unavailable"], + }; +} + +function scorerFailure(code: string, reason: string, extra: ScorerPayload = {}): ScorerStatus { + return stripUndefined({ + ok: false, + code, + reason, + fallbackMode: "metadata_only", + ...extra, + }); +} + +function classifyScorerExecFailure(error: unknown, durationMs: number, scorerCommand: string): ScorerStatus { + const execError = error && typeof error === "object" ? (error as ExecError) : undefined; + const stdout = String(execError?.stdout ?? execError?.output?.[1] ?? "").trim(); + const stderr = truncateText(execError?.stderr ?? execError?.output?.[2] ?? ""); + const exitCode = typeof execError?.status === "number" ? execError.status : undefined; + if (stdout && !looksLikeScorerJson(stdout)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(stdout), + scorerCommand: redactScorerCommand(scorerCommand), + fallbackMode: "metadata_only", + }); + } + if (execError?.code === "ETIMEDOUT" || (execError?.killed && execError?.signal === "SIGTERM")) { + return scorerFailure("timeout", `External scorer timed out after ${scorePreviewTimeoutMs()}ms.`, { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); + } + if (typeof exitCode === "number" && exitCode !== 0) { + return scorerFailure("non_zero_exit", `External scorer exited with status ${exitCode}.`, { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); + } + const message = error instanceof Error ? error.message : "external_scorer_failed"; + if (/JSON/i.test(message)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { durationMs, stderr, scorerCommand: redactScorerCommand(scorerCommand) }); + } + if (stderr && !looksLikeScorerJson(stderr)) { + return scorerFailure("malformed_json", "External scorer stdout was not valid JSON.", { + durationMs, + stderr: truncateText(stderr), + scorerCommand: redactScorerCommand(scorerCommand), + fallbackMode: "metadata_only", + }); + } + return scorerFailure("scorer_failed", redactLocalPath(message), { durationMs, stderr, exitCode, scorerCommand: redactScorerCommand(scorerCommand) }); +} + +function looksLikeScorerJson(output: string): boolean { + try { + const payload = JSON.parse(output); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const normalized = normalizeScorerOutput(payload as ScorerPayload); + return normalized.sourceTokenScore !== undefined || normalized.totalTokenScore !== undefined; + } catch { + return false; + } +} + +function inferScorerCode(reason: unknown): string { + const text = String(reason ?? ""); + if (text.includes("missing_scorer_command")) return "missing_scorer_command"; + if (text.includes("empty_scorer_command")) return "empty_scorer_command"; + if (/timed out|ETIMEDOUT/i.test(text)) return "timeout"; + if (/JSON/i.test(text)) return "malformed_json"; + if (/status \d+/i.test(text)) return "non_zero_exit"; + return "scorer_failed"; +} + +function scorePreviewTimeoutMs(): number { + const parsed = Number(process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS ?? 15000); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000; +} + +function truncateText(value: unknown, maxLength = 240): string | undefined { + const text = String(value ?? "").trim(); + if (!text) return undefined; + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`; +} + +function splitCommand(command: string): string[] { + return String(command).match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) ?? []; +} + +function assertSourceUploadDisabled() { + if (/^(1|true|yes)$/i.test(process.env.LOOPOVER_UPLOAD_SOURCE ?? "false")) { + throw new Error("LOOPOVER_UPLOAD_SOURCE=true is not supported in v1; local MCP sends metadata only."); + } +} + +// Word-boundary the closing keywords (as the server-side extractors in src/db/repositories.ts and +// src/signals/engine.ts already do) so a keyword embedded in a longer word does not spuriously link an +// issue: without \b, `hotfix 5` / `prefixes 12` matched the `fix`/`fixes` substring and captured the +// trailing number. The bare `#` branch stays boundary-free so `#123` still matches anywhere. +export function extractLinkedIssues(text: unknown): number[] { + const issues = []; + for (const match of String(text).matchAll(/(?:\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)|#)\s*#?(\d+)/gi)) issues.push(Number(match[1])); + return issues.filter((issue) => Number.isInteger(issue) && issue > 0); +} + +function statusFromCode(code: string): string { + if (code.startsWith("A")) return "added"; + if (code.startsWith("M")) return "modified"; + if (code.startsWith("D")) return "deleted"; + if (code.startsWith("R")) return "renamed"; + if (code.startsWith("C")) return "copied"; + return "unknown"; +} + +function titleFromBranch(branchName: string): string { + return String(branchName ?? "") + .replace(/^[-/_.\w]+\/(?=[^/]+$)/, "") + .replace(/[-_]+/g, " ") + .trim(); +} + +function firstCommitTitle(messages: string[]): string | undefined { + return messages.find((message) => message.trim().length > 0)?.split("\n")[0]?.trim(); +} + +function numberValue(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function stripUndefined(value: T): T { + if (Array.isArray(value)) return value.map(stripUndefined) as T; + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).map(([key, entry]) => [key, stripUndefined(entry)])) as T; +} diff --git a/packages/loopover-mcp/lib/redact-local-path.d.ts b/packages/loopover-mcp/lib/redact-local-path.d.ts new file mode 100644 index 0000000000..12b04fede9 --- /dev/null +++ b/packages/loopover-mcp/lib/redact-local-path.d.ts @@ -0,0 +1,19 @@ +/** + * Redact any absolute or home-anchored local path found in free text, replacing it with the + * `` placeholder. Heuristic (matches an unknown path by shape), so it never needs the + * concrete path in advance — the counterpart to the exact-match `redactKnownLocalPaths` below. + */ +export declare function redactLocalPath(value: unknown): string; +export type RedactKnownLocalPathsOptions = { + tokens?: unknown[]; + paths?: unknown[]; +}; +/** + * Redact KNOWN sensitive strings from free text by exact substring substitution: every entry of + * `tokens` becomes `[redacted]` and every entry of `paths` becomes `[local-path]`. Non-string / + * empty entries are ignored; a token must be non-empty and a path longer than one character (a bare + * `/` is not a "known path"). Paths are applied longest-first so a nested path (e.g. cwd under home) + * is redacted before a shorter prefix would swallow its tail. `undefined`/`null` pass through + * untouched so callers can hand diagnostics straight in. + */ +export declare function redactKnownLocalPaths(value: unknown, { tokens, paths }?: RedactKnownLocalPathsOptions): unknown; diff --git a/packages/loopover-mcp/lib/redact-local-path.js b/packages/loopover-mcp/lib/redact-local-path.js index 00379bd8a4..3684219900 100644 --- a/packages/loopover-mcp/lib/redact-local-path.js +++ b/packages/loopover-mcp/lib/redact-local-path.js @@ -11,31 +11,30 @@ // supplied by the caller, by exact substring substitution → `[redacted]` / // `[local-path]`. It cannot detect an arbitrary path; the heuristic cannot // redact a token it was never told about. Each solves a distinct problem. - /** * Redact any absolute or home-anchored local path found in free text, replacing it with the * `` placeholder. Heuristic (matches an unknown path by shape), so it never needs the * concrete path in advance — the counterpart to the exact-match `redactKnownLocalPaths` below. */ export function redactLocalPath(value) { - const text = String(value ?? ""); - if (!text) return text; - // Both `/g` patterns are rebuilt per call so no `lastIndex` state carries between invocations. - // Delimiter-anchored roots (`~/`, `~\`, `C:\`, `C:/`, `/`) whose interior segments may contain - // spaces, e.g. `/Users/Alice Smith/project` — the anchoring prefix is preserved, only the path swaps. - const pathSegment = "[^\\\\/\\s\"'`,;)\\]]+(?:\\s+[^\\\\/\\s\"'`,;)\\]]+)*(?=[\\\\/])"; - const pathTail = "[^\\\\/\\s\"'`,;)\\]]+"; - // Prefix delimiters a real path can immediately follow in pasted stack-trace/validation-output text. - // `(` is the Node.js stack-frame shape (`at fn (/abs/path:10:5)`); `[` and `:` cover the same "no space - // before the path" shape in bracketed log lines and colon-joined messages (e.g. `path:/abs/path`). - const rootedPath = new RegExp(`(^|[\\s"'\\\`=(\\[:])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); - return text - .replace(rootedPath, (_, prefix) => `${prefix}`) - // Home/Windows roots that appear mid-token with no leading delimiter (so the anchored pass skips - // them); run second so it only mops up what the anchored, space-aware pass could not claim. - .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, ""); + const text = String(value ?? ""); + if (!text) + return text; + // Both `/g` patterns are rebuilt per call so no `lastIndex` state carries between invocations. + // Delimiter-anchored roots (`~/`, `~\`, `C:\`, `C:/`, `/`) whose interior segments may contain + // spaces, e.g. `/Users/Alice Smith/project` — the anchoring prefix is preserved, only the path swaps. + const pathSegment = "[^\\\\/\\s\"'`,;)\\]]+(?:\\s+[^\\\\/\\s\"'`,;)\\]]+)*(?=[\\\\/])"; + const pathTail = "[^\\\\/\\s\"'`,;)\\]]+"; + // Prefix delimiters a real path can immediately follow in pasted stack-trace/validation-output text. + // `(` is the Node.js stack-frame shape (`at fn (/abs/path:10:5)`); `[` and `:` cover the same "no space + // before the path" shape in bracketed log lines and colon-joined messages (e.g. `path:/abs/path`). + const rootedPath = new RegExp(`(^|[\\s"'\\\`=(\\[:])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); + return text + .replace(rootedPath, (_, prefix) => `${prefix}`) + // Home/Windows roots that appear mid-token with no leading delimiter (so the anchored pass skips + // them); run second so it only mops up what the anchored, space-aware pass could not claim. + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, ""); } - /** * Redact KNOWN sensitive strings from free text by exact substring substitution: every entry of * `tokens` becomes `[redacted]` and every entry of `paths` becomes `[local-path]`. Non-string / @@ -45,14 +44,17 @@ export function redactLocalPath(value) { * untouched so callers can hand diagnostics straight in. */ export function redactKnownLocalPaths(value, { tokens = [], paths = [] } = {}) { - if (value === undefined || value === null) return value; - let text = String(value); - for (const token of tokens) { - if (typeof token === "string" && token.length > 0) text = text.split(token).join("[redacted]"); - } - const knownPaths = paths.filter((candidate) => typeof candidate === "string" && candidate.length > 1); - for (const localPath of knownPaths.sort((left, right) => right.length - left.length)) { - text = text.split(localPath).join("[local-path]"); - } - return text; + if (value === undefined || value === null) + return value; + let text = String(value); + for (const token of tokens) { + if (typeof token === "string" && token.length > 0) + text = text.split(token).join("[redacted]"); + } + const knownPaths = paths.filter((candidate) => typeof candidate === "string" && candidate.length > 1); + for (const localPath of knownPaths.sort((left, right) => right.length - left.length)) { + text = text.split(localPath).join("[local-path]"); + } + return text; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVkYWN0LWxvY2FsLXBhdGguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJyZWRhY3QtbG9jYWwtcGF0aC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxpR0FBaUc7QUFDakcsK0ZBQStGO0FBQy9GLG1HQUFtRztBQUNuRyx1RkFBdUY7QUFDdkYsRUFBRTtBQUNGLGtHQUFrRztBQUNsRyw4QkFBOEI7QUFDOUIsc0dBQXNHO0FBQ3RHLHVHQUF1RztBQUN2RyxzR0FBc0c7QUFDdEcsc0dBQXNHO0FBQ3RHLHNHQUFzRztBQUN0RyxxR0FBcUc7QUFFckc7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQUMsS0FBYztJQUM1QyxNQUFNLElBQUksR0FBRyxNQUFNLENBQUMsS0FBSyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ2pDLElBQUksQ0FBQyxJQUFJO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDdkIsK0ZBQStGO0lBQy9GLCtGQUErRjtJQUMvRixzR0FBc0c7SUFDdEcsTUFBTSxXQUFXLEdBQUcsa0VBQWtFLENBQUM7SUFDdkYsTUFBTSxRQUFRLEdBQUcsd0JBQXdCLENBQUM7SUFDMUMscUdBQXFHO0lBQ3JHLHdHQUF3RztJQUN4RyxtR0FBbUc7SUFDbkcsTUFBTSxVQUFVLEdBQUcsSUFBSSxNQUFNLENBQUMsMkRBQTJELFdBQVcsWUFBWSxRQUFRLEdBQUcsRUFBRSxHQUFHLENBQUMsQ0FBQztJQUNsSSxPQUFPLElBQUk7U0FDUixPQUFPLENBQUMsVUFBVSxFQUFFLENBQUMsQ0FBQyxFQUFFLE1BQWMsRUFBRSxFQUFFLENBQUMsR0FBRyxNQUFNLGNBQWMsQ0FBQztRQUNwRSxpR0FBaUc7UUFDakcsNEZBQTRGO1NBQzNGLE9BQU8sQ0FBQyxrQ0FBa0MsRUFBRSxjQUFjLENBQUMsQ0FBQztBQUNqRSxDQUFDO0FBT0Q7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxxQkFBcUIsQ0FDbkMsS0FBYyxFQUNkLEVBQUUsTUFBTSxHQUFHLEVBQUUsRUFBRSxLQUFLLEdBQUcsRUFBRSxLQUFtQyxFQUFFO0lBRTlELElBQUksS0FBSyxLQUFLLFNBQVMsSUFBSSxLQUFLLEtBQUssSUFBSTtRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3hELElBQUksSUFBSSxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUN6QixLQUFLLE1BQU0sS0FBSyxJQUFJLE1BQU0sRUFBRSxDQUFDO1FBQzNCLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxNQUFNLEdBQUcsQ0FBQztZQUFFLElBQUksR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQyxZQUFZLENBQUMsQ0FBQztJQUNqRyxDQUFDO0lBQ0QsTUFBTSxVQUFVLEdBQUcsS0FBSyxDQUFDLE1BQU0sQ0FBQyxDQUFDLFNBQVMsRUFBdUIsRUFBRSxDQUFDLE9BQU8sU0FBUyxLQUFLLFFBQVEsSUFBSSxTQUFTLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQyxDQUFDO0lBQzNILEtBQUssTUFBTSxTQUFTLElBQUksVUFBVSxDQUFDLElBQUksQ0FBQyxDQUFDLElBQUksRUFBRSxLQUFLLEVBQUUsRUFBRSxDQUFDLEtBQUssQ0FBQyxNQUFNLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7UUFDckYsSUFBSSxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsU0FBUyxDQUFDLENBQUMsSUFBSSxDQUFDLGNBQWMsQ0FBQyxDQUFDO0lBQ3BELENBQUM7SUFDRCxPQUFPLElBQUksQ0FBQztBQUNkLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/redact-local-path.ts b/packages/loopover-mcp/lib/redact-local-path.ts new file mode 100644 index 0000000000..01eccb7e5d --- /dev/null +++ b/packages/loopover-mcp/lib/redact-local-path.ts @@ -0,0 +1,66 @@ +// #6264: the one shared local-filesystem-path redactor for the MCP CLI. Three call sites used to +// carry their own copy of this logic (`redactLocalPath` here, `redactLocalValidationPaths` and +// `sanitizeDiagnosticText` in bin/loopover-mcp.js), so a single redaction fix had to be made — and +// kept in sync — three times. They are consolidated here so a future fix happens once. +// +// Two genuinely different mechanisms are needed, so both stay available as named functions rather +// than being forced into one: +// - `redactLocalPath` DETECTS an unknown absolute/home path in free text via a regex heuristic +// (stack traces, scorer stderr, pasted validation output) → ``. +// - `redactKnownLocalPaths` redacts KNOWN sensitive strings (session tokens, config dirs, cwd/home) +// supplied by the caller, by exact substring substitution → `[redacted]` / +// `[local-path]`. It cannot detect an arbitrary path; the heuristic cannot +// redact a token it was never told about. Each solves a distinct problem. + +/** + * Redact any absolute or home-anchored local path found in free text, replacing it with the + * `` placeholder. Heuristic (matches an unknown path by shape), so it never needs the + * concrete path in advance — the counterpart to the exact-match `redactKnownLocalPaths` below. + */ +export function redactLocalPath(value: unknown): string { + const text = String(value ?? ""); + if (!text) return text; + // Both `/g` patterns are rebuilt per call so no `lastIndex` state carries between invocations. + // Delimiter-anchored roots (`~/`, `~\`, `C:\`, `C:/`, `/`) whose interior segments may contain + // spaces, e.g. `/Users/Alice Smith/project` — the anchoring prefix is preserved, only the path swaps. + const pathSegment = "[^\\\\/\\s\"'`,;)\\]]+(?:\\s+[^\\\\/\\s\"'`,;)\\]]+)*(?=[\\\\/])"; + const pathTail = "[^\\\\/\\s\"'`,;)\\]]+"; + // Prefix delimiters a real path can immediately follow in pasted stack-trace/validation-output text. + // `(` is the Node.js stack-frame shape (`at fn (/abs/path:10:5)`); `[` and `:` cover the same "no space + // before the path" shape in bracketed log lines and colon-joined messages (e.g. `path:/abs/path`). + const rootedPath = new RegExp(`(^|[\\s"'\\\`=(\\[:])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); + return text + .replace(rootedPath, (_, prefix: string) => `${prefix}`) + // Home/Windows roots that appear mid-token with no leading delimiter (so the anchored pass skips + // them); run second so it only mops up what the anchored, space-aware pass could not claim. + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, ""); +} + +export type RedactKnownLocalPathsOptions = { + tokens?: unknown[]; + paths?: unknown[]; +}; + +/** + * Redact KNOWN sensitive strings from free text by exact substring substitution: every entry of + * `tokens` becomes `[redacted]` and every entry of `paths` becomes `[local-path]`. Non-string / + * empty entries are ignored; a token must be non-empty and a path longer than one character (a bare + * `/` is not a "known path"). Paths are applied longest-first so a nested path (e.g. cwd under home) + * is redacted before a shorter prefix would swallow its tail. `undefined`/`null` pass through + * untouched so callers can hand diagnostics straight in. + */ +export function redactKnownLocalPaths( + value: unknown, + { tokens = [], paths = [] }: RedactKnownLocalPathsOptions = {}, +): unknown { + if (value === undefined || value === null) return value; + let text = String(value); + for (const token of tokens) { + if (typeof token === "string" && token.length > 0) text = text.split(token).join("[redacted]"); + } + const knownPaths = paths.filter((candidate): candidate is string => typeof candidate === "string" && candidate.length > 1); + for (const localPath of knownPaths.sort((left, right) => right.length - left.length)) { + text = text.split(localPath).join("[local-path]"); + } + return text; +} diff --git a/packages/loopover-mcp/lib/telemetry.d.ts b/packages/loopover-mcp/lib/telemetry.d.ts new file mode 100644 index 0000000000..0f70e2706f --- /dev/null +++ b/packages/loopover-mcp/lib/telemetry.d.ts @@ -0,0 +1,15 @@ +export type RecordMcpToolCallOptions = { + telemetryEnabled?: boolean; +}; +export type RecordMcpToolCallEvent = { + tool: string; + callerType?: "local"; + ok: boolean; + durationMs: number; +}; +/** + * Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly + * `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND + * LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws. + */ +export declare function recordMcpToolCall(options: RecordMcpToolCallOptions | null | undefined, event: RecordMcpToolCallEvent): void; diff --git a/packages/loopover-mcp/lib/telemetry.js b/packages/loopover-mcp/lib/telemetry.js index 435b1eb395..e89cdca7e4 100644 --- a/packages/loopover-mcp/lib/telemetry.js +++ b/packages/loopover-mcp/lib/telemetry.js @@ -1,5 +1,4 @@ import { PostHog } from "posthog-node"; - // Local MCP telemetry wrapper (#6236, mirrors the remote wrapper from #6235). Same allowlisted event shape // and PostHog vendor as src/mcp/telemetry.ts, so the two servers report consistent data -- the only real // difference is the trust posture: this CLI runs on a user's own machine, so it is gated on an EXPLICIT, @@ -11,58 +10,52 @@ import { PostHog } from "posthog-node"; // this records nothing and behaves byte-identically to before this module existed -- true for every user // who has not run `loopover-mcp telemetry enable` (the default). It also never throws: a PostHog init/ // capture failure degrades to recording nothing, so it can never affect the CLI's actual command behavior. - /** PostHog US-cloud ingestion host -- the default when LOOPOVER_MCP_POSTHOG_HOST isn't set. */ const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; - /** The PostHog event name every MCP tool call is recorded under (matches the remote wrapper, #6235). */ const MCP_TOOL_CALL_EVENT = "mcp_tool_call"; - /** Anonymous, constant distinct id: this fleet telemetry carries NO per-actor identity by design (#6228), * so every event shares one handle and there is no per-user person to build up. */ const MCP_TELEMETRY_DISTINCT_ID = "loopover-mcp"; - /** * Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly * `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND * LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws. - * - * @param {{ telemetryEnabled: boolean }} options - * @param {{ tool: string, callerType?: "local", ok: boolean, durationMs: number }} event */ export function recordMcpToolCall(options, event) { - // Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an - // API key alone is not enough; the user must have explicitly enabled telemetry. - if (options?.telemetryEnabled !== true) return; - - const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY); - // Unconfigured -> record nothing, byte-identical to before this module existed. - if (!apiKey) return; - - const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; - try { - const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); - client.capture({ - distinctId: MCP_TELEMETRY_DISTINCT_ID, - event: MCP_TOOL_CALL_EVENT, - // Exactly the #6228 allowlist -- nothing more. - properties: { - tool: event.tool, - caller_type: event.callerType ?? "local", - ok: event.ok, - duration_ms: event.durationMs, - }, - // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. - disableGeoip: true, - }); - } catch { - // Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure - // degrades to recording nothing, identical to the unconfigured path above. - } + // Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an + // API key alone is not enough; the user must have explicitly enabled telemetry. + if (options?.telemetryEnabled !== true) + return; + const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY); + // Unconfigured -> record nothing, byte-identical to before this module existed. + if (!apiKey) + return; + const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; + try { + const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); + client.capture({ + distinctId: MCP_TELEMETRY_DISTINCT_ID, + event: MCP_TOOL_CALL_EVENT, + // Exactly the #6228 allowlist -- nothing more. + properties: { + tool: event.tool, + caller_type: event.callerType ?? "local", + ok: event.ok, + duration_ms: event.durationMs, + }, + // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. + disableGeoip: true, + }); + } + catch { + // Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure + // degrades to recording nothing, identical to the unconfigured path above. + } } - /** Trim a possibly-undefined env string, treating blank/whitespace as absent. */ function trimmedOrUndefined(value) { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVsZW1ldHJ5LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsidGVsZW1ldHJ5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxPQUFPLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFFdkMsMkdBQTJHO0FBQzNHLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcseUdBQXlHO0FBQ3pHLDJHQUEyRztBQUMzRyxnR0FBZ0c7QUFDaEcsRUFBRTtBQUNGLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcsdUdBQXVHO0FBQ3ZHLDJHQUEyRztBQUUzRywrRkFBK0Y7QUFDL0YsTUFBTSxvQkFBb0IsR0FBRywwQkFBMEIsQ0FBQztBQUV4RCx3R0FBd0c7QUFDeEcsTUFBTSxtQkFBbUIsR0FBRyxlQUFlLENBQUM7QUFFNUM7b0ZBQ29GO0FBQ3BGLE1BQU0seUJBQXlCLEdBQUcsY0FBYyxDQUFDO0FBYWpEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUsaUJBQWlCLENBQy9CLE9BQW9ELEVBQ3BELEtBQTZCO0lBRTdCLHdHQUF3RztJQUN4RyxnRkFBZ0Y7SUFDaEYsSUFBSSxPQUFPLEVBQUUsZ0JBQWdCLEtBQUssSUFBSTtRQUFFLE9BQU87SUFFL0MsTUFBTSxNQUFNLEdBQUcsa0JBQWtCLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyw0QkFBNEIsQ0FBQyxDQUFDO0lBQzVFLGdGQUFnRjtJQUNoRixJQUFJLENBQUMsTUFBTTtRQUFFLE9BQU87SUFFcEIsTUFBTSxJQUFJLEdBQUcsa0JBQWtCLENBQUMsT0FBTyxDQUFDLEdBQUcsQ0FBQyx5QkFBeUIsQ0FBQyxJQUFJLG9CQUFvQixDQUFDO0lBQy9GLElBQUksQ0FBQztRQUNILE1BQU0sTUFBTSxHQUFHLElBQUksT0FBTyxDQUFDLE1BQU0sRUFBRSxFQUFFLElBQUksRUFBRSxPQUFPLEVBQUUsQ0FBQyxFQUFFLGFBQWEsRUFBRSxDQUFDLEVBQUUsQ0FBQyxDQUFDO1FBQzNFLE1BQU0sQ0FBQyxPQUFPLENBQUM7WUFDYixVQUFVLEVBQUUseUJBQXlCO1lBQ3JDLEtBQUssRUFBRSxtQkFBbUI7WUFDMUIsK0NBQStDO1lBQy9DLFVBQVUsRUFBRTtnQkFDVixJQUFJLEVBQUUsS0FBSyxDQUFDLElBQUk7Z0JBQ2hCLFdBQVcsRUFBRSxLQUFLLENBQUMsVUFBVSxJQUFJLE9BQU87Z0JBQ3hDLEVBQUUsRUFBRSxLQUFLLENBQUMsRUFBRTtnQkFDWixXQUFXLEVBQUUsS0FBSyxDQUFDLFVBQVU7YUFDOUI7WUFDRCwyRkFBMkY7WUFDM0YsWUFBWSxFQUFFLElBQUk7U0FDbkIsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLG1HQUFtRztRQUNuRywyRUFBMkU7SUFDN0UsQ0FBQztBQUNILENBQUM7QUFFRCxpRkFBaUY7QUFDakYsU0FBUyxrQkFBa0IsQ0FBQyxLQUF5QjtJQUNuRCxNQUFNLE9BQU8sR0FBRyxLQUFLLEVBQUUsSUFBSSxFQUFFLENBQUM7SUFDOUIsT0FBTyxPQUFPLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsU0FBUyxDQUFDO0FBQ3ZDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/telemetry.ts b/packages/loopover-mcp/lib/telemetry.ts new file mode 100644 index 0000000000..6aee758f72 --- /dev/null +++ b/packages/loopover-mcp/lib/telemetry.ts @@ -0,0 +1,79 @@ +import { PostHog } from "posthog-node"; + +// Local MCP telemetry wrapper (#6236, mirrors the remote wrapper from #6235). Same allowlisted event shape +// and PostHog vendor as src/mcp/telemetry.ts, so the two servers report consistent data -- the only real +// difference is the trust posture: this CLI runs on a user's own machine, so it is gated on an EXPLICIT, +// persisted opt-in flag rather than mere env-var presence. This module stays a pure helper like its lib/ +// siblings (cli-error.js, format-table.js, ...) -- it never reads the CLI's config file itself. The caller +// (bin/loopover-mcp.js) resolves `telemetryEnabled` from the persisted config and passes it in. +// +// SAFE NO-OP: unless the caller passes `telemetryEnabled: true` AND LOOPOVER_MCP_POSTHOG_API_KEY is set, +// this records nothing and behaves byte-identically to before this module existed -- true for every user +// who has not run `loopover-mcp telemetry enable` (the default). It also never throws: a PostHog init/ +// capture failure degrades to recording nothing, so it can never affect the CLI's actual command behavior. + +/** PostHog US-cloud ingestion host -- the default when LOOPOVER_MCP_POSTHOG_HOST isn't set. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +/** The PostHog event name every MCP tool call is recorded under (matches the remote wrapper, #6235). */ +const MCP_TOOL_CALL_EVENT = "mcp_tool_call"; + +/** Anonymous, constant distinct id: this fleet telemetry carries NO per-actor identity by design (#6228), + * so every event shares one handle and there is no per-user person to build up. */ +const MCP_TELEMETRY_DISTINCT_ID = "loopover-mcp"; + +export type RecordMcpToolCallOptions = { + telemetryEnabled?: boolean; +}; + +export type RecordMcpToolCallEvent = { + tool: string; + callerType?: "local"; + ok: boolean; + durationMs: number; +}; + +/** + * Record a single local MCP tool call to PostHog. Safe no-op unless `telemetryEnabled` is explicitly + * `true` (the caller's resolved, persisted opt-in flag, default OFF -- #6236) AND + * LOOPOVER_MCP_POSTHOG_API_KEY is configured; never throws. + */ +export function recordMcpToolCall( + options: RecordMcpToolCallOptions | null | undefined, + event: RecordMcpToolCallEvent, +): void { + // Opt-in default OFF (#6236, per #6228's privacy decision) -- unlike the remote wrapper, presence of an + // API key alone is not enough; the user must have explicitly enabled telemetry. + if (options?.telemetryEnabled !== true) return; + + const apiKey = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_API_KEY); + // Unconfigured -> record nothing, byte-identical to before this module existed. + if (!apiKey) return; + + const host = trimmedOrUndefined(process.env.LOOPOVER_MCP_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; + try { + const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); + client.capture({ + distinctId: MCP_TELEMETRY_DISTINCT_ID, + event: MCP_TOOL_CALL_EVENT, + // Exactly the #6228 allowlist -- nothing more. + properties: { + tool: event.tool, + caller_type: event.callerType ?? "local", + ok: event.ok, + duration_ms: event.durationMs, + }, + // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. + disableGeoip: true, + }); + } catch { + // Telemetry is best-effort and MUST NOT throw into the CLI (#6236): a PostHog init/capture failure + // degrades to recording nothing, identical to the unconfigured path above. + } +} + +/** Trim a possibly-undefined env string, treating blank/whitespace as absent. */ +function trimmedOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/loopover-mcp/package.json b/packages/loopover-mcp/package.json index 66a8c24db2..9b77ab9d11 100644 --- a/packages/loopover-mcp/package.json +++ b/packages/loopover-mcp/package.json @@ -32,10 +32,17 @@ "bin", "lib", "scripts", - "CHANGELOG.md" + "CHANGELOG.md", + "!bin/**/*.ts", + "!lib/**/*.ts", + "!scripts/check-syntax.mjs", + "bin/**/*.d.ts", + "lib/**/*.d.ts" ], "scripts": { - "build": "node --check bin/loopover-mcp.js && node --check lib/cli-error.js && node --check lib/local-branch.js && node --check lib/format-table.js && node --check lib/redact-local-path.js && node --check lib/telemetry.js && node --check scripts/gittensor-score-preview.mjs" + "build": "npm run build:tsc && npm run build:verify", + "build:tsc": "tsc -p tsconfig.json", + "build:verify": "node scripts/check-syntax.mjs" }, "dependencies": { "@loopover/engine": "^3.4.0", @@ -43,6 +50,10 @@ "posthog-node": "^5.44.0", "zod": "^4.4.3" }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^5.9.3" + }, "engines": { "node": ">=22.0.0" } diff --git a/packages/loopover-mcp/scripts/check-syntax.mjs b/packages/loopover-mcp/scripts/check-syntax.mjs new file mode 100644 index 0000000000..2cd2657d3b --- /dev/null +++ b/packages/loopover-mcp/scripts/check-syntax.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Syntax-verifies every compiled/hand-written .js file in bin/ and lib/ via `node --check`. Replaces a +// previously hand-listed chain of individual `node --check ` commands in package.json's own +// "build" script -- that list had to be kept in sync by hand every time a file was added, removed, or +// migrated to TypeScript (#7291 / #7328). Glob-driven instead: covers every .js file in bin/lib +// automatically, migrated or not, with no list to fall out of date. +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +function listJsFiles(dir) { + return readdirSync(join(ROOT, dir), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) + .map((entry) => join(dir, entry.name)); +} + +const files = [...listJsFiles("bin"), ...listJsFiles("lib")].sort(); + +const failures = []; +for (const file of files) { + try { + execFileSync(process.execPath, ["--check", file], { cwd: ROOT, stdio: "pipe" }); + } catch (error) { + failures.push({ file, message: error.stderr?.toString().trim() || String(error) }); + } +} + +if (failures.length > 0) { + for (const { file, message } of failures) { + console.error(`${file}:\n${message}\n`); + } + console.error(`node --check failed for ${failures.length} of ${files.length} file(s).`); + process.exit(1); +} + +console.log(`node --check passed for all ${files.length} files in bin/ and lib/.`); diff --git a/packages/loopover-mcp/tsconfig.json b/packages/loopover-mcp/tsconfig.json new file mode 100644 index 0000000000..9d795cfa5d --- /dev/null +++ b/packages/loopover-mcp/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "declaration": true, + // Inline (not a separate .js.map) so the compiled output stays exactly the same file set the + // package has always shipped -- no new file type to teach the npm-pack allowlist about. Vitest's + // v8 coverage provider still remaps through it fine, attributing coverage to the .ts source. + "inlineSourceMap": true, + "noEmit": false, + // Overrides the root config's "dist" so each converted file compiles in place next to its .ts + // source (e.g. lib/foo.ts -> lib/foo.js) -- the package's published bin/lib layout never changes + // as more files convert, so no consumer (in this repo or published) ever needs a different import + // path depending on a given file's migration status. + "rootDir": ".", + "outDir": ".", + // Without this, the inherited root value ("./.tsbuildinfo") resolves relative to the ROOT config's + // location, not this one -- both packages would then read/write the exact same cache file at the + // repo root and corrupt each other's incremental state. + "tsBuildInfoFile": "./.tsbuildinfo" + }, + // Only files already converted to real TypeScript are included -- everything else in bin/lib stays + // plain .js until its own migration PR lands (#7291). No edits needed here as later phases convert + // more files: the glob picks them up automatically. + "include": ["bin/**/*.ts", "lib/**/*.ts"], + // Without this, tsc's default exclude list (which always adds outDir) resolves to "." -- the whole + // package root -- and silently excludes every include match, since outDir is "." for in-place emit. + "exclude": [] +} diff --git a/scripts/mcp-package-allowlist.mjs b/scripts/mcp-package-allowlist.mjs index c0bf12162d..30c798e9a2 100644 --- a/scripts/mcp-package-allowlist.mjs +++ b/scripts/mcp-package-allowlist.mjs @@ -4,11 +4,12 @@ export const MCP_PACKAGE_ALLOWED_FILE_PATTERNS = [ /^bin\/loopover-mcp\.js$/, - /^lib\/cli-error\.js$/, - /^lib\/local-branch\.js$/, - /^lib\/format-table\.js$/, - /^lib\/redact-local-path\.js$/, - /^lib\/telemetry\.js$/, + // Compiled in-place TypeScript emit ships sibling .d.ts next to each converted lib/*.js (#7328 / #7329). + /^lib\/cli-error\.(js|d\.ts)$/, + /^lib\/local-branch\.(js|d\.ts)$/, + /^lib\/format-table\.(js|d\.ts)$/, + /^lib\/redact-local-path\.(js|d\.ts)$/, + /^lib\/telemetry\.(js|d\.ts)$/, /^scripts\/gittensor-score-preview\.(mjs|py)$/, /^package\.json$/, /^README\.md$/, diff --git a/test/unit/format-table.test.ts b/test/unit/format-table.test.ts index cfcc1f78b4..b547863b33 100644 --- a/test/unit/format-table.test.ts +++ b/test/unit/format-table.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; // The helper ships in the MCP package's lib/ (the bin auto-runs on import, so it cannot be imported); // mirror the local-branch.test.ts pattern of dynamically importing the packaged .js module. async function loadFormatTable() { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. return (await import("../../packages/loopover-mcp/lib/format-table.js")).formatTable; } diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index e242c68a91..8e0d52595f 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -1728,7 +1728,6 @@ describe("local MCP git metadata collection", () => { }); it("counts pending commits, emits CI hints, and tracks deleted or renamed paths", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectCiStatusHints, collectLocalBranchMetadata, collectPendingCommitCount } = await import("../../packages/loopover-mcp/lib/local-branch.js"); tempDir = mkdtempSync(join(tmpdir(), "loopover-local-")); git(tempDir, "init"); @@ -1764,7 +1763,6 @@ describe("local MCP git metadata collection", () => { }); it("counts additions and deletions for cross-directory renames that share no prefix or suffix", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata } = await import("../../packages/loopover-mcp/lib/local-branch.js"); tempDir = mkdtempSync(join(tmpdir(), "loopover-local-")); git(tempDir, "init"); @@ -1804,13 +1802,11 @@ describe("local MCP git metadata collection", () => { }); it("returns no lines when the git command fails", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { gitLines } = await import("../../packages/loopover-mcp/lib/local-branch.js"); expect(gitLines(join(tmpdir(), "loopover-no-such-repo-d8f3"), ["rev-parse", "HEAD"])).toEqual([]); }); it("counts stats and keeps verbatim paths for non-ASCII filenames at the default core.quotePath", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata } = await import("../../packages/loopover-mcp/lib/local-branch.js"); tempDir = mkdtempSync(join(tmpdir(), "loopover-local-")); git(tempDir, "init"); @@ -1846,7 +1842,6 @@ describe("local MCP git metadata collection", () => { }); it("classifies Cypress/e2e and snapshot paths as test files, mirroring the server isTestPath", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { isTestFile, isCodeFile } = await import("../../packages/loopover-mcp/lib/local-branch.js"); // Existing forms still classify as tests. for (const file of ["test/foo.ts", "src/app.test.ts", "pkg/foo_test.go", "spec/foo_spec.rb", "src/__tests__/x.ts"]) { @@ -1903,7 +1898,6 @@ describe("local MCP git metadata collection", () => { }); it("extracts linked issues only from standalone closing keywords, not keyword substrings", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { extractLinkedIssues } = await import("../../packages/loopover-mcp/lib/local-branch.js"); // Standalone closing keywords (hash optional, as this client-side extractor allows) and bare #refs link. expect(extractLinkedIssues("fixes #5")).toEqual([5]); @@ -1918,7 +1912,6 @@ describe("local MCP git metadata collection", () => { }); it("parses remotes, changed-file stats, linked issues, and refuses source upload mode", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata, parseGitRemote } = await import("../../packages/loopover-mcp/lib/local-branch.js"); expect(parseGitRemote("git@github.com:entrius/allways-ui.git")).toBe("entrius/allways-ui"); expect(parseGitRemote("https://github.com/JSONbored/loopover.git")).toBe("JSONbored/loopover"); @@ -1962,7 +1955,6 @@ describe("local MCP git metadata collection", () => { }); it("selects and validates cwd from MCP roots without leaking local paths", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata, normalizeMcpWorkspaceRoots, resolveWorkspaceCwd } = await import("../../packages/loopover-mcp/lib/local-branch.js"); tempDir = mkdtempSync(join(tmpdir(), "loopover-local-")); const workspace = join(tempDir, "workspace"); diff --git a/test/unit/local-scorer-adapter.test.ts b/test/unit/local-scorer-adapter.test.ts index 973970c3ac..16c623a74c 100644 --- a/test/unit/local-scorer-adapter.test.ts +++ b/test/unit/local-scorer-adapter.test.ts @@ -39,7 +39,6 @@ describe("local scorer adapter", () => { }); it("returns structured success output from a working scorer command", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview } = await import("../../packages/loopover-mcp/lib/local-branch.js"); const result = runExternalScorePreview(metadata, fixtureCommand("scorer-success.mjs")); expect(result).toMatchObject({ @@ -52,7 +51,6 @@ describe("local scorer adapter", () => { }); it("reports missing scorer command with setup guidance", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview, setupGuidanceForLocalScorer } = await import("../../packages/loopover-mcp/lib/local-branch.js"); const result = runExternalScorePreview(metadata, undefined); expect(result).toMatchObject({ ok: false, code: "missing_scorer_command", fallbackMode: "metadata_only" }); @@ -62,7 +60,6 @@ describe("local scorer adapter", () => { }); it("handles scorer timeouts without crashing analysis", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview } = await import("../../packages/loopover-mcp/lib/local-branch.js"); previousTimeout = process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS; process.env.GITTENSOR_SCORE_PREVIEW_TIMEOUT_MS = "200"; @@ -73,7 +70,6 @@ describe("local scorer adapter", () => { }); it("handles malformed scorer JSON and non-zero exits", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview } = await import("../../packages/loopover-mcp/lib/local-branch.js"); const malformed = runExternalScorePreview(metadata, fixtureCommand("scorer-malformed.mjs")); expect(malformed).toMatchObject({ ok: false, code: "malformed_json", fallbackMode: "metadata_only" }); @@ -84,7 +80,6 @@ describe("local scorer adapter", () => { }); it("falls back to metadata-only scorer output and keeps source upload disabled", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { buildBranchAnalysisPayload, collectLocalBranchMetadata } = await import("../../packages/loopover-mcp/lib/local-branch.js"); const payload = buildBranchAnalysisPayload({ cwd: process.cwd(), @@ -104,7 +99,6 @@ describe("local scorer adapter", () => { }); it("runs the packaged reference scorer against metadata only", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { runExternalScorePreview } = await import("../../packages/loopover-mcp/lib/local-branch.js"); const result = runExternalScorePreview(metadata, packagedScorerCommand("metadata")); expect(result.ok).toBe(true); @@ -115,7 +109,6 @@ describe("local scorer adapter", () => { }); it("redacts local paths from scorer diagnostics and setup guidance", async () => { - // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { probeLocalScorer, redactLocalPath, redactScorerCommand, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer } = await import("../../packages/loopover-mcp/lib/local-branch.js"); previousGittensorRoot = process.env.GITTENSOR_ROOT; diff --git a/test/unit/mcp-cli-error.test.ts b/test/unit/mcp-cli-error.test.ts new file mode 100644 index 0000000000..39448dfc04 --- /dev/null +++ b/test/unit/mcp-cli-error.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadCliError() { + return await import("../../packages/loopover-mcp/lib/cli-error.js"); +} + +describe("mcp cli-error helpers (#7328)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("argsWantJson detects --json and --json= forms", async () => { + const { argsWantJson } = await loadCliError(); + expect(argsWantJson([])).toBe(false); + expect(argsWantJson(["--help"])).toBe(false); + expect(argsWantJson(["--json"])).toBe(true); + expect(argsWantJson(["--json=true"])).toBe(true); + expect(argsWantJson([undefined, "--json"])).toBe(true); + }); + + it("describeCliError prefers Error.message and stringifies other throws", async () => { + const { describeCliError } = await loadCliError(); + expect(describeCliError(new Error("boom"))).toBe("boom"); + expect(describeCliError("plain")).toBe("plain"); + expect(describeCliError(42)).toBe("42"); + }); + + it("reportCliFailure emits JSON on stdout when wantsJson is true", async () => { + const { reportCliFailure } = await loadCliError(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(reportCliFailure(true, "nope", 3)).toBe(3); + expect(log).toHaveBeenCalledWith(JSON.stringify({ ok: false, error: "nope" }, null, 2)); + expect(err).not.toHaveBeenCalled(); + }); + + it("reportCliFailure writes plain text to stderr otherwise (default exit 2)", async () => { + const { reportCliFailure } = await loadCliError(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(reportCliFailure(false, "plain failure")).toBe(2); + expect(err).toHaveBeenCalledWith("plain failure"); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/mcp-local-telemetry.test.ts b/test/unit/mcp-local-telemetry.test.ts index bf3779e164..5eb1149c89 100644 --- a/test/unit/mcp-local-telemetry.test.ts +++ b/test/unit/mcp-local-telemetry.test.ts @@ -22,7 +22,6 @@ vi.mock("posthog-node", () => ({ }, })); -// @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { recordMcpToolCall } = await import("../../packages/loopover-mcp/lib/telemetry.js"); type LocalToolCallEvent = { tool: string; callerType?: "local"; ok: boolean; durationMs: number }; diff --git a/test/unit/mcp-package-skeleton.test.ts b/test/unit/mcp-package-skeleton.test.ts new file mode 100644 index 0000000000..2150b7f987 --- /dev/null +++ b/test/unit/mcp-package-skeleton.test.ts @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const mcpRoot = join(process.cwd(), "packages/loopover-mcp"); + +type McpPackageJson = { + name: string; + scripts: { build: string; "build:tsc": string; "build:verify": string }; + files: string[]; +}; + +describe("loopover-mcp TypeScript build pipeline (#7328)", () => { + it("exposes a real tsc build split like loopover-miner", () => { + const pkg = JSON.parse(readFileSync(join(mcpRoot, "package.json"), "utf8")) as McpPackageJson; + expect(pkg.name).toBe("@loopover/mcp"); + expect(pkg.scripts.build).toBe("npm run build:tsc && npm run build:verify"); + expect(pkg.scripts["build:tsc"]).toBe("tsc -p tsconfig.json"); + expect(pkg.scripts["build:verify"]).toBe("node scripts/check-syntax.mjs"); + expect(pkg.files).toEqual( + expect.arrayContaining(["bin", "lib", "!bin/**/*.ts", "!lib/**/*.ts", "lib/**/*.d.ts"]), + ); + }); + + it("build:verify's syntax check covers bin and lib .js files", () => { + const result = spawnSync("node", ["scripts/check-syntax.mjs"], { cwd: mcpRoot, encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/node --check passed for all \d+ files in bin\/ and lib\//); + }); +}); diff --git a/test/unit/redact-local-path.test.ts b/test/unit/redact-local-path.test.ts index 6b9ef3601c..1d7b6af1fb 100644 --- a/test/unit/redact-local-path.test.ts +++ b/test/unit/redact-local-path.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; // #6264: the three former MCP redaction call sites now share packages/loopover-mcp/lib/redact-local-path.js. // This is the single home for the redaction contract, so it is tested once here; the call-site tests // (local-scorer-adapter.test.ts, mcp-cli-packets.test.ts) still assert the wired-up behavior end to end. -// @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { redactLocalPath, redactKnownLocalPaths } = await import("../../packages/loopover-mcp/lib/redact-local-path.js"); describe("redactLocalPath (heuristic: detect an unknown path in free text)", () => { diff --git a/turbo.json b/turbo.json index f1e2a25e99..19faa8da86 100644 --- a/turbo.json +++ b/turbo.json @@ -159,10 +159,19 @@ "@loopover/miner-extension#build": { "cache": false }, - "@loopover/mcp#build": { + "@loopover/mcp#build:tsc": { "dependsOn": ["^build"], + "cache": false + }, + "@loopover/mcp#build:verify": { + "dependsOn": ["@loopover/mcp#build:tsc"], + "inputs": ["bin/**/*.js", "lib/**/*.js"], "outputs": [] }, + "@loopover/mcp#build": { + "dependsOn": ["@loopover/mcp#build:tsc", "@loopover/mcp#build:verify"], + "cache": false + }, "@loopover/miner#build:tsc": { "dependsOn": ["^build"], "cache": false diff --git a/vitest.config.ts b/vitest.config.ts index dc1f7f12ea..43c3ff6a45 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -49,12 +49,9 @@ export default defineConfig({ "packages/loopover-miner/bin/**/*.js", "packages/loopover-miner/bin/**/*.ts", "packages/discovery-index/src/**/*.ts", - // packages/loopover-mcp/lib/*.js are plain JS today; issue #7291 migrates them to real TypeScript, - // keeping the same .js/.ts/.d.ts triplet shape packages/loopover-miner/lib/** already has (so - // coverage is wired BEFORE that PR lands, not as a follow-up fix). 4 of the 5 files - // (format-table/local-branch/redact-local-path/telemetry) are already imported in-process by - // test/unit/*.test.ts; lib/cli-error.js currently has no in-process test at all, so it will need - // one before a PR touching it can pass codecov/patch -- that's intended enforcement, not a bug. + // packages/loopover-mcp/lib/* migrate to real TypeScript under #7291; coverage is remapped via + // inline sourcemaps from the compiled .js back to .ts. cli-error gained an in-process unit test + // in test/unit/mcp-cli-error.test.ts so codecov/patch can grade its Phase-1 conversion. "packages/loopover-mcp/lib/**/*.js", "packages/loopover-mcp/lib/**/*.ts", // packages/loopover-mcp/bin/loopover-mcp.js (~6,600 of ~7,400 lines in the package) is tested