@cassiomc1/forgeloop 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/project-loop.mdc +6 -3
- package/.github/copilot-instructions.md +5 -0
- package/AGENTS.md +6 -0
- package/CLAUDE.md +6 -0
- package/DOCS_INDEX.md +5 -0
- package/ENG/accessibility-eng.md +12 -2
- package/ENG/design-code-eng.md +22 -1
- package/LOOP_ENGINEERING.md +28 -0
- package/PROTOCOL_INTEGRATION.md +26 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +11 -0
- package/THREAT_MODEL.md +24 -0
- package/completions/_forgeloop +4 -1
- package/completions/forgeloop.bash +7 -1
- package/completions/forgeloop.fish +19 -1
- package/docs/AGENT_PROTOCOL_SUMMARY.md +6 -1
- package/docs/ARTIFACT_REFERENCE.md +128 -0
- package/docs/CLI_REFERENCE.md +84 -1
- package/docs/KNOWLEDGE_SOURCES.md +161 -0
- package/docs/MCP.md +1 -1
- package/docs/RECIPES.md +31 -0
- package/docs/STRUCTURAL_QUALITY.md +350 -0
- package/docs/TROUBLESHOOTING.md +107 -0
- package/package.json +3 -1
- package/schemas/config.schema.json +46 -0
- package/schemas/preflight.schema.json +2 -1
- package/schemas/structural-quality.schema.json +175 -0
- package/src/cli.js +18 -0
- package/src/commands/quality-baseline.js +28 -0
- package/src/commands/quality-status.js +34 -0
- package/src/commands/quality-verify.js +30 -0
- package/src/core/artifact-registry.js +12 -0
- package/src/core/audit.js +38 -0
- package/src/core/bundles.js +134 -1
- package/src/core/cli-command-definitions.js +45 -0
- package/src/core/command-executors.js +16 -0
- package/src/core/command-input.js +12 -0
- package/src/core/completion-artifacts.js +2 -0
- package/src/core/completion.js +42 -0
- package/src/core/config.js +3 -0
- package/src/core/error-codes.js +73 -0
- package/src/core/filesystem.js +18 -3
- package/src/core/inspect.js +64 -0
- package/src/core/integration-invocation-policy.js +15 -0
- package/src/core/integration-resources.js +17 -0
- package/src/core/next-action-model.js +11 -1
- package/src/core/next-action-phases.js +84 -5
- package/src/core/phase.js +9 -1
- package/src/core/preflight.js +33 -0
- package/src/core/protocol-info.js +15 -0
- package/src/core/runtime-context.js +27 -0
- package/src/core/schema-validation.js +1 -0
- package/src/core/structural-quality/artifacts.js +329 -0
- package/src/core/structural-quality/constants.js +67 -0
- package/src/core/structural-quality/policy.js +227 -0
- package/src/core/structural-quality/provider.js +287 -0
- package/src/core/structural-quality/sentrux-mcp.js +477 -0
- package/src/core/structural-quality/service.js +1138 -0
- package/src/core/structural-quality/source-fingerprint.js +112 -0
- package/src/core/structural-quality/status.js +3 -0
- package/src/core/task-paths.js +24 -0
- package/src/core/templates.js +1 -0
- package/src/integration.d.ts +25 -0
- package/src/integration.js +14 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
import { assertSafePath, ensureWithin, fileExists, readBytes } from "../filesystem.js";
|
|
5
|
+
import { sha256 } from "../manifest.js";
|
|
6
|
+
import {
|
|
7
|
+
E_STRUCTURAL_QUALITY_OUTPUT_LIMIT,
|
|
8
|
+
E_STRUCTURAL_QUALITY_PROVIDER_INVALID,
|
|
9
|
+
E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID,
|
|
10
|
+
E_STRUCTURAL_QUALITY_PROVIDER_TOOL_CONTRACT_INVALID,
|
|
11
|
+
E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE,
|
|
12
|
+
E_STRUCTURAL_QUALITY_PROVIDER_VERSION_UNSUPPORTED,
|
|
13
|
+
E_STRUCTURAL_QUALITY_SCAN_FAILED,
|
|
14
|
+
E_STRUCTURAL_QUALITY_SOURCE_FINGERPRINT_UNAVAILABLE,
|
|
15
|
+
E_STRUCTURAL_QUALITY_TIMEOUT,
|
|
16
|
+
} from "../error-codes.js";
|
|
17
|
+
import {
|
|
18
|
+
STRUCTURAL_QUALITY_DEFAULT_TIMEOUT_MS,
|
|
19
|
+
STRUCTURAL_QUALITY_MAX_OUTPUT_BYTES,
|
|
20
|
+
STRUCTURAL_QUALITY_MAX_TIMEOUT_MS,
|
|
21
|
+
STRUCTURAL_QUALITY_MEASUREMENT_MODEL,
|
|
22
|
+
STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
23
|
+
STRUCTURAL_QUALITY_SENTRUX_MIN_VERSION,
|
|
24
|
+
STRUCTURAL_QUALITY_SENTRUX_VERIFIED_VERSIONS,
|
|
25
|
+
structuralQualityError,
|
|
26
|
+
} from "./constants.js";
|
|
27
|
+
import { assertStructuralQualityProvider, normalizeStructuralQualityDetection, normalizeStructuralQualitySnapshot } from "./provider.js";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_EXECUTABLE = "sentrux";
|
|
30
|
+
const DEFAULT_ARGS = Object.freeze(["--mcp"]);
|
|
31
|
+
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
32
|
+
const SENTRUX_RULES_PATH = ".sentrux/rules.toml";
|
|
33
|
+
|
|
34
|
+
function mcpError(code, message) {
|
|
35
|
+
return structuralQualityError(code, message);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function versionParts(value) {
|
|
43
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(String(value ?? ""));
|
|
44
|
+
return match ? match.slice(1).map(Number) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function versionAtLeast(actual, minimum) {
|
|
48
|
+
const left = versionParts(actual);
|
|
49
|
+
const right = versionParts(minimum);
|
|
50
|
+
if (!left || !right) return false;
|
|
51
|
+
for (let index = 0; index < 3; index += 1) {
|
|
52
|
+
if (left[index] !== right[index]) return left[index] > right[index];
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sentruxCompatibilityForVersion(version) {
|
|
58
|
+
if (!STRUCTURAL_QUALITY_SENTRUX_VERIFIED_VERSIONS.includes(version)) {
|
|
59
|
+
return { supported: false, reasonCode: E_STRUCTURAL_QUALITY_PROVIDER_VERSION_UNSUPPORTED };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
supported: true,
|
|
63
|
+
measurementModel: STRUCTURAL_QUALITY_MEASUREMENT_MODEL,
|
|
64
|
+
compatibilityKey: STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseJsonLine(line) {
|
|
69
|
+
try {
|
|
70
|
+
const value = JSON.parse(line);
|
|
71
|
+
if (!isRecord(value)) throw new Error("JSON-RPC message must be an object");
|
|
72
|
+
if (value.jsonrpc !== "2.0") throw new Error("JSON-RPC message must declare jsonrpc 2.0");
|
|
73
|
+
return value;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, `Sentrux emitted malformed JSON-RPC: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function responseResult(message, requestId) {
|
|
80
|
+
if (message.id !== requestId) {
|
|
81
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux JSON-RPC response ID does not match the request");
|
|
82
|
+
}
|
|
83
|
+
if (Object.prototype.hasOwnProperty.call(message, "result") && Object.prototype.hasOwnProperty.call(message, "error")) {
|
|
84
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux JSON-RPC response contains both result and error");
|
|
85
|
+
}
|
|
86
|
+
if (Object.prototype.hasOwnProperty.call(message, "error")) {
|
|
87
|
+
const detail = isRecord(message.error) ? message.error.message ?? "provider returned an error" : "provider returned an invalid error";
|
|
88
|
+
const error = mcpError(E_STRUCTURAL_QUALITY_SCAN_FAILED, `Sentrux request failed: ${detail}`);
|
|
89
|
+
error.providerError = message.error;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
if (!Object.prototype.hasOwnProperty.call(message, "result")) {
|
|
93
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux JSON-RPC response has no result");
|
|
94
|
+
}
|
|
95
|
+
return message.result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class McpStdioSession {
|
|
99
|
+
constructor({ projectPath, timeoutMs, maxOutputBytes, executable, args, spawnImpl, env }) {
|
|
100
|
+
this.projectPath = projectPath;
|
|
101
|
+
this.timeoutMs = Math.min(timeoutMs ?? STRUCTURAL_QUALITY_DEFAULT_TIMEOUT_MS, STRUCTURAL_QUALITY_MAX_TIMEOUT_MS);
|
|
102
|
+
this.maxOutputBytes = maxOutputBytes ?? STRUCTURAL_QUALITY_MAX_OUTPUT_BYTES;
|
|
103
|
+
this.executable = executable ?? DEFAULT_EXECUTABLE;
|
|
104
|
+
this.args = [...(args ?? DEFAULT_ARGS)];
|
|
105
|
+
this.spawnImpl = spawnImpl ?? nodeSpawn;
|
|
106
|
+
this.env = env;
|
|
107
|
+
this.child = null;
|
|
108
|
+
this.buffer = "";
|
|
109
|
+
this.nextId = 1;
|
|
110
|
+
this.pending = new Map();
|
|
111
|
+
this.outputBytes = 0;
|
|
112
|
+
this.stderrText = "";
|
|
113
|
+
this.stdoutEnded = false;
|
|
114
|
+
this.closed = false;
|
|
115
|
+
this.timeout = null;
|
|
116
|
+
this.protocolError = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async start() {
|
|
120
|
+
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 0 || this.timeoutMs > STRUCTURAL_QUALITY_MAX_TIMEOUT_MS) {
|
|
121
|
+
throw mcpError(E_STRUCTURAL_QUALITY_TIMEOUT, "Sentrux timeout must be a non-negative integer no greater than 300000ms");
|
|
122
|
+
}
|
|
123
|
+
if (!Number.isInteger(this.maxOutputBytes) || this.maxOutputBytes < 1 || this.maxOutputBytes > STRUCTURAL_QUALITY_MAX_OUTPUT_BYTES) {
|
|
124
|
+
throw mcpError(E_STRUCTURAL_QUALITY_OUTPUT_LIMIT, "Sentrux maxOutputBytes must be between 1 and 2097152");
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
this.child = this.spawnImpl(this.executable, this.args, {
|
|
128
|
+
cwd: this.projectPath,
|
|
129
|
+
shell: false,
|
|
130
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
131
|
+
...(this.env ? { env: { ...process.env, ...this.env } } : {}),
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw this.spawnFailure(error);
|
|
135
|
+
}
|
|
136
|
+
if (!this.child || !this.child.stdout || !this.child.stdin || !this.child.stderr) {
|
|
137
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux process did not expose piped stdio");
|
|
138
|
+
}
|
|
139
|
+
this.child.stdout.on("data", (chunk) => this.consumeOutput(chunk, false));
|
|
140
|
+
this.child.stderr.on("data", (chunk) => this.consumeOutput(chunk, true));
|
|
141
|
+
this.child.stdout.on("end", () => {
|
|
142
|
+
this.stdoutEnded = true;
|
|
143
|
+
if (this.buffer.trim()) this.fail(mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux ended with an incomplete JSON-RPC line"));
|
|
144
|
+
});
|
|
145
|
+
this.child.on("error", (error) => this.fail(this.spawnFailure(error)));
|
|
146
|
+
this.child.on("close", (code, signal) => {
|
|
147
|
+
this.closed = true;
|
|
148
|
+
if (this.pending.size > 0 && !this.protocolError) {
|
|
149
|
+
this.fail(mcpError(E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE, `Sentrux exited before completing its JSON-RPC response (${code ?? "null"}/${signal ?? "none"})`));
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
this.timeout = setTimeout(() => this.fail(mcpError(E_STRUCTURAL_QUALITY_TIMEOUT, `Sentrux exceeded the ${this.timeoutMs}ms timeout`)), this.timeoutMs);
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
spawnFailure(error) {
|
|
157
|
+
if (error?.code === "ENOENT") return mcpError(E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE, "Sentrux executable is unavailable on PATH");
|
|
158
|
+
return mcpError(E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE, `Unable to start Sentrux: ${error?.message ?? String(error)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
consumeOutput(chunk, stderr) {
|
|
162
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
163
|
+
this.outputBytes += bytes.byteLength;
|
|
164
|
+
if (this.outputBytes > this.maxOutputBytes) {
|
|
165
|
+
this.fail(mcpError(E_STRUCTURAL_QUALITY_OUTPUT_LIMIT, `Sentrux combined stdout/stderr exceeded ${this.maxOutputBytes} bytes`));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (stderr) {
|
|
169
|
+
this.stderrText = `${this.stderrText}${bytes.toString("utf8")}`.slice(-4096);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.buffer += bytes.toString("utf8");
|
|
173
|
+
while (true) {
|
|
174
|
+
const newline = this.buffer.indexOf("\n");
|
|
175
|
+
if (newline < 0) break;
|
|
176
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/u, "");
|
|
177
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
178
|
+
if (!line.trim()) continue;
|
|
179
|
+
let message;
|
|
180
|
+
try {
|
|
181
|
+
message = parseJsonLine(line);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.fail(error);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!Object.prototype.hasOwnProperty.call(message, "id")) continue;
|
|
187
|
+
const pending = this.pending.get(message.id);
|
|
188
|
+
if (!pending) {
|
|
189
|
+
this.fail(mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux returned an unexpected or duplicate JSON-RPC response"));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.pending.delete(message.id);
|
|
193
|
+
try {
|
|
194
|
+
pending.resolve(responseResult(message, message.id));
|
|
195
|
+
} catch (error) {
|
|
196
|
+
pending.reject(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fail(error) {
|
|
202
|
+
if (this.protocolError) return;
|
|
203
|
+
this.protocolError = error;
|
|
204
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
205
|
+
this.pending.clear();
|
|
206
|
+
this.terminate();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
request(method, params = {}) {
|
|
210
|
+
if (this.protocolError || this.closed) return Promise.reject(this.protocolError ?? mcpError(E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE, "Sentrux process is not running"));
|
|
211
|
+
const id = this.nextId;
|
|
212
|
+
this.nextId += 1;
|
|
213
|
+
const message = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
this.pending.set(id, { resolve, reject });
|
|
216
|
+
try {
|
|
217
|
+
this.child.stdin.write(message);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.pending.delete(id);
|
|
220
|
+
reject(mcpError(E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE, `Unable to write to Sentrux stdin: ${error.message}`));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
notify(method, params = {}) {
|
|
226
|
+
if (this.protocolError || this.closed) return;
|
|
227
|
+
try {
|
|
228
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
229
|
+
} catch {
|
|
230
|
+
// The corresponding request will fail through the process close/error
|
|
231
|
+
// event; notifications have no response to reject.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async stop() {
|
|
236
|
+
if (!this.child) return;
|
|
237
|
+
if (this.timeout) clearTimeout(this.timeout);
|
|
238
|
+
try { this.child.stdin.end(); } catch { /* process may already be gone */ }
|
|
239
|
+
await delay(20).catch(() => {});
|
|
240
|
+
if (!this.closed) {
|
|
241
|
+
try { this.child.kill("SIGTERM"); } catch { /* preserve provider result */ }
|
|
242
|
+
await delay(100).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
if (!this.closed) {
|
|
245
|
+
try { this.child.kill("SIGKILL"); } catch { /* preserve provider result */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
terminate() {
|
|
250
|
+
try { this.child?.kill("SIGTERM"); } catch { /* preserve original error */ }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function inspectSentruxToolContract(tools) {
|
|
255
|
+
if (!Array.isArray(tools)) {
|
|
256
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux tools/list response has no tools array");
|
|
257
|
+
}
|
|
258
|
+
const scan = tools.find((t) => t?.name === "scan");
|
|
259
|
+
const health = tools.find((t) => t?.name === "health");
|
|
260
|
+
if (!scan || !health) {
|
|
261
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux must expose both scan and health MCP tools");
|
|
262
|
+
}
|
|
263
|
+
const scanSchema = scan.inputSchema;
|
|
264
|
+
if (!isRecord(scanSchema)
|
|
265
|
+
|| scanSchema.type !== "object"
|
|
266
|
+
|| !Array.isArray(scanSchema.required)
|
|
267
|
+
|| !scanSchema.required.includes("path")
|
|
268
|
+
|| !isRecord(scanSchema.properties)
|
|
269
|
+
|| scanSchema.properties.path?.type !== "string") {
|
|
270
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_TOOL_CONTRACT_INVALID, "Sentrux scan tool contract must be an object schema requiring a string path property");
|
|
271
|
+
}
|
|
272
|
+
const healthSchema = health.inputSchema;
|
|
273
|
+
if (healthSchema !== undefined && healthSchema !== null
|
|
274
|
+
&& (!isRecord(healthSchema) || healthSchema.type !== "object")) {
|
|
275
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_TOOL_CONTRACT_INVALID, "Sentrux health tool contract must be an object schema");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function openHandshake(options) {
|
|
280
|
+
const session = await new McpStdioSession(options).start();
|
|
281
|
+
try {
|
|
282
|
+
const initialized = await session.request("initialize", {
|
|
283
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
284
|
+
capabilities: {},
|
|
285
|
+
clientInfo: { name: "forgeloop", version: "1" },
|
|
286
|
+
});
|
|
287
|
+
const serverInfo = initialized?.serverInfo ?? initialized?.server_info;
|
|
288
|
+
if (!isRecord(serverInfo) || serverInfo.name !== "sentrux" || typeof serverInfo.version !== "string") {
|
|
289
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, "Sentrux initialize response has an invalid serverInfo identity");
|
|
290
|
+
}
|
|
291
|
+
if (!versionAtLeast(serverInfo.version, STRUCTURAL_QUALITY_SENTRUX_MIN_VERSION)
|
|
292
|
+
|| !sentruxCompatibilityForVersion(serverInfo.version).supported) {
|
|
293
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_VERSION_UNSUPPORTED, `Sentrux ${serverInfo.version} is older than the supported ${STRUCTURAL_QUALITY_SENTRUX_MIN_VERSION}`);
|
|
294
|
+
}
|
|
295
|
+
session.notify("notifications/initialized");
|
|
296
|
+
const listed = await session.request("tools/list", {});
|
|
297
|
+
inspectSentruxToolContract(listed?.tools);
|
|
298
|
+
return { session, serverInfo };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await session.stop();
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function callTool(session, name, args = {}) {
|
|
306
|
+
const response = await session.request("tools/call", {
|
|
307
|
+
name,
|
|
308
|
+
arguments: args,
|
|
309
|
+
});
|
|
310
|
+
if (!isRecord(response) || response.isError === true || !Array.isArray(response.content)) {
|
|
311
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, `Sentrux ${name} tool returned an invalid MCP result`);
|
|
312
|
+
}
|
|
313
|
+
const textItems = response.content.filter((item) => item?.type === "text");
|
|
314
|
+
if (textItems.length !== 1 || typeof textItems[0].text !== "string") {
|
|
315
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, `Sentrux ${name} tool must return exactly one text content item`);
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(textItems[0].text);
|
|
319
|
+
if (!isRecord(parsed)) throw new Error("tool text must contain a JSON object");
|
|
320
|
+
return parsed;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
throw mcpError(E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID, `Sentrux ${name} tool returned malformed JSON text: ${error.message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function mergedSnapshot(scan, health, projectPath) {
|
|
327
|
+
const snapshot = {
|
|
328
|
+
...scan,
|
|
329
|
+
...(scan.snapshot && isRecord(scan.snapshot) ? scan.snapshot : {}),
|
|
330
|
+
...(health.snapshot && isRecord(health.snapshot) ? health.snapshot : {}),
|
|
331
|
+
qualitySignal: scan.qualitySignal ?? scan.quality_signal ?? scan.snapshot?.qualitySignal ?? scan.snapshot?.quality_signal
|
|
332
|
+
?? health.qualitySignal ?? health.quality_signal ?? health.snapshot?.qualitySignal ?? health.snapshot?.quality_signal,
|
|
333
|
+
rootCauses: scan.rootCauses ?? scan.root_causes ?? scan.snapshot?.rootCauses ?? scan.snapshot?.root_causes
|
|
334
|
+
?? health.rootCauses ?? health.root_causes ?? health.snapshot?.rootCauses ?? health.snapshot?.root_causes,
|
|
335
|
+
statistics: scan.statistics ?? scan.snapshot?.statistics ?? health.statistics ?? health.snapshot?.statistics,
|
|
336
|
+
diagnostics: health.diagnostics ?? scan.diagnostics ?? scan.snapshot?.diagnostics ?? null,
|
|
337
|
+
scan: scan.scan ?? scan,
|
|
338
|
+
};
|
|
339
|
+
if (health.crossModuleEdges !== undefined || health.cross_module_edges !== undefined) {
|
|
340
|
+
snapshot.statistics = {
|
|
341
|
+
...(snapshot.statistics ?? {}),
|
|
342
|
+
crossModuleEdges: health.crossModuleEdges ?? health.cross_module_edges,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return normalizeStructuralQualitySnapshot(snapshot, { projectPath });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function sentruxScopeBinding(projectPath) {
|
|
349
|
+
let architectureRulesFingerprint = null;
|
|
350
|
+
if (projectPath) {
|
|
351
|
+
try {
|
|
352
|
+
await assertSafePath(projectPath, SENTRUX_RULES_PATH);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
throw mcpError(E_STRUCTURAL_QUALITY_SOURCE_FINGERPRINT_UNAVAILABLE, `Unable to fingerprint Sentrux configuration: ${error.message}`);
|
|
355
|
+
}
|
|
356
|
+
const rulesAbsolute = ensureWithin(projectPath, SENTRUX_RULES_PATH);
|
|
357
|
+
if (await fileExists(rulesAbsolute)) {
|
|
358
|
+
try {
|
|
359
|
+
architectureRulesFingerprint = sha256(await readBytes(rulesAbsolute));
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw mcpError(E_STRUCTURAL_QUALITY_SOURCE_FINGERPRINT_UNAVAILABLE, `Unable to fingerprint Sentrux configuration: ${error.message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
architectureRulesFingerprint,
|
|
367
|
+
measurementCompatibilityKey: STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function createSentruxStructuralQualityProvider({
|
|
372
|
+
projectPath,
|
|
373
|
+
timeoutMs = STRUCTURAL_QUALITY_DEFAULT_TIMEOUT_MS,
|
|
374
|
+
maxOutputBytes = STRUCTURAL_QUALITY_MAX_OUTPUT_BYTES,
|
|
375
|
+
executable = DEFAULT_EXECUTABLE,
|
|
376
|
+
args = DEFAULT_ARGS,
|
|
377
|
+
spawnImpl = nodeSpawn,
|
|
378
|
+
env,
|
|
379
|
+
} = {}) {
|
|
380
|
+
const provider = {
|
|
381
|
+
id: "sentrux",
|
|
382
|
+
async detect(input = {}) {
|
|
383
|
+
const options = {
|
|
384
|
+
projectPath: input.projectPath ?? projectPath,
|
|
385
|
+
timeoutMs: input.timeoutMs ?? timeoutMs,
|
|
386
|
+
maxOutputBytes: input.maxOutputBytes ?? maxOutputBytes,
|
|
387
|
+
executable,
|
|
388
|
+
args,
|
|
389
|
+
spawnImpl,
|
|
390
|
+
env,
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
const { session, serverInfo } = await openHandshake(options);
|
|
394
|
+
await session.stop();
|
|
395
|
+
return normalizeStructuralQualityDetection({
|
|
396
|
+
available: true,
|
|
397
|
+
providerId: "sentrux",
|
|
398
|
+
providerVersion: serverInfo.version,
|
|
399
|
+
transport: "mcp-stdio",
|
|
400
|
+
measurementModel: STRUCTURAL_QUALITY_MEASUREMENT_MODEL,
|
|
401
|
+
compatibilityKey: STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
402
|
+
reasonCode: null,
|
|
403
|
+
});
|
|
404
|
+
} catch (error) {
|
|
405
|
+
return normalizeStructuralQualityDetection({
|
|
406
|
+
available: false,
|
|
407
|
+
providerId: "sentrux",
|
|
408
|
+
providerVersion: error.providerVersion ?? null,
|
|
409
|
+
transport: "mcp-stdio",
|
|
410
|
+
measurementModel: STRUCTURAL_QUALITY_MEASUREMENT_MODEL,
|
|
411
|
+
compatibilityKey: STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
412
|
+
reasonCode: error.code ?? E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
async scopeBinding(input = {}) {
|
|
417
|
+
const resolvedProjectPath = input.projectPath ?? projectPath;
|
|
418
|
+
return sentruxScopeBinding(resolvedProjectPath);
|
|
419
|
+
},
|
|
420
|
+
async scan(input = {}) {
|
|
421
|
+
const resolvedProjectPath = input.projectPath ?? projectPath;
|
|
422
|
+
const options = {
|
|
423
|
+
projectPath: resolvedProjectPath,
|
|
424
|
+
timeoutMs: input.timeoutMs ?? timeoutMs,
|
|
425
|
+
maxOutputBytes: input.maxOutputBytes ?? maxOutputBytes,
|
|
426
|
+
executable,
|
|
427
|
+
args,
|
|
428
|
+
spawnImpl,
|
|
429
|
+
env,
|
|
430
|
+
};
|
|
431
|
+
const { session, serverInfo } = await openHandshake(options);
|
|
432
|
+
try {
|
|
433
|
+
const scanResult = await callTool(session, "scan", { path: resolvedProjectPath });
|
|
434
|
+
const healthResult = await callTool(session, "health", {});
|
|
435
|
+
const providerScopeBinding = await sentruxScopeBinding(resolvedProjectPath);
|
|
436
|
+
return {
|
|
437
|
+
snapshot: mergedSnapshot(scanResult, healthResult, resolvedProjectPath),
|
|
438
|
+
provider: {
|
|
439
|
+
id: "sentrux",
|
|
440
|
+
version: serverInfo.version,
|
|
441
|
+
transport: "mcp-stdio",
|
|
442
|
+
executionMode: "trusted-path-mcp-stdio",
|
|
443
|
+
measurementModel: STRUCTURAL_QUALITY_MEASUREMENT_MODEL,
|
|
444
|
+
compatibilityKey: STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
445
|
+
},
|
|
446
|
+
detection: normalizeStructuralQualityDetection({
|
|
447
|
+
available: true,
|
|
448
|
+
providerId: "sentrux",
|
|
449
|
+
providerVersion: serverInfo.version,
|
|
450
|
+
transport: "mcp-stdio",
|
|
451
|
+
measurementModel: STRUCTURAL_QUALITY_MEASUREMENT_MODEL,
|
|
452
|
+
compatibilityKey: STRUCTURAL_QUALITY_SENTRUX_COMPATIBILITY_KEY,
|
|
453
|
+
reasonCode: null,
|
|
454
|
+
}),
|
|
455
|
+
providerScopeBinding,
|
|
456
|
+
};
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error.code === E_STRUCTURAL_QUALITY_TIMEOUT
|
|
459
|
+
|| error.code === E_STRUCTURAL_QUALITY_OUTPUT_LIMIT
|
|
460
|
+
|| error.code === E_STRUCTURAL_QUALITY_PROVIDER_PROTOCOL_INVALID
|
|
461
|
+
|| error.code === E_STRUCTURAL_QUALITY_PROVIDER_TOOL_CONTRACT_INVALID
|
|
462
|
+
|| error.code === E_STRUCTURAL_QUALITY_PROVIDER_INVALID
|
|
463
|
+
|| error.code === E_STRUCTURAL_QUALITY_PROVIDER_VERSION_UNSUPPORTED
|
|
464
|
+
|| error.code === E_STRUCTURAL_QUALITY_PROVIDER_UNAVAILABLE) throw error;
|
|
465
|
+
throw mcpError(E_STRUCTURAL_QUALITY_SCAN_FAILED, error.message);
|
|
466
|
+
} finally {
|
|
467
|
+
await session.stop();
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
async observe(input = {}) {
|
|
471
|
+
return this.scan(input);
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
return assertStructuralQualityProvider(provider);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export { DEFAULT_EXECUTABLE as SENTRUX_EXECUTABLE, DEFAULT_ARGS as SENTRUX_ARGS, SENTRUX_RULES_PATH };
|