@martinloop/mcp 0.8.6 → 0.8.7
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/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/tools/preflight.js +8 -4
- package/dist/vendor/adapters/claude-cli.js +26 -5
- package/dist/vendor/contracts/index.d.ts +21 -0
- package/dist/vendor/contracts/swarm.js +1 -1
- package/dist/vendor/core/index.d.ts +5 -4
- package/dist/vendor/core/index.js +80 -19
- package/dist/vendor/core/leash.d.ts +11 -1
- package/dist/vendor/core/leash.js +77 -3
- package/dist/vendor/core/output-directories.d.ts +7 -0
- package/dist/vendor/core/output-directories.js +36 -0
- package/dist/vendor/core/rollback.d.ts +24 -0
- package/dist/vendor/core/rollback.js +372 -19
- package/dist/vendor/core/test-integrity.d.ts +18 -0
- package/dist/vendor/core/test-integrity.js +91 -1
- package/dist/vendor/core/test-surface.d.ts +27 -0
- package/dist/vendor/core/test-surface.js +228 -0
- package/dist/vendor/core/verified-handoff.d.ts +1 -0
- package/dist/vendor/core/verified-handoff.js +6 -0
- package/dist/vendor/core/verifier-input.d.ts +2 -0
- package/dist/vendor/core/verifier-input.js +59 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test surface: what a test file asserts, how many cases it declares and how many it skips.
|
|
3
|
+
*
|
|
4
|
+
* Weakening is judged on this surface rather than on raw removed lines, so ordinary edits
|
|
5
|
+
* (growing an import list, reformatting, appending cases) are allowed while a removed or
|
|
6
|
+
* altered assertion, a dropped case or a newly skipped case is not.
|
|
7
|
+
*
|
|
8
|
+
* The extractor is language-agnostic and heuristic. It is comment- and string-aware so a
|
|
9
|
+
* commented-out assertion does not count as present. Files with no recognizable surface
|
|
10
|
+
* (data, golden and snapshot files, unknown frameworks) are reported as empty so callers
|
|
11
|
+
* can fall back to a stricter rule.
|
|
12
|
+
*/
|
|
13
|
+
const HASH_COMMENT_EXTENSIONS = new Set(["py", "rb", "sh", "bash", "yaml", "yml", "toml", "pl", "r"]);
|
|
14
|
+
const SLASH_ONLY_EXTENSIONS = new Set([
|
|
15
|
+
"js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts", "go", "rs", "java", "kt", "kts", "cs",
|
|
16
|
+
"swift", "scala", "c", "cc", "cpp", "h", "hpp", "dart", "php"
|
|
17
|
+
]);
|
|
18
|
+
const ASSERTION_START = /\b(?:assert\w*|expect|should\w*)\b[\w.!]*\s*\(|\bt\.(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\s*\(|\brequire\.[A-Z]\w*\s*\(/giu;
|
|
19
|
+
const PYTHON_BARE_ASSERT = /^[ \t]*assert[ \t]+(?!\()[^\n]*/gmu;
|
|
20
|
+
const CHAIN_CONTINUATION = /\s*\.\s*[\w$]+(?:\s*\.\s*[\w$]+)*\s*\(/uy;
|
|
21
|
+
const CASE_PATTERNS = [
|
|
22
|
+
/\b(?:test|it|specify)(?:\s*\.\s*(?:each|only|concurrent|serial|failing|sequential))*\s*\(/gu,
|
|
23
|
+
/\b(?:it|specify|scenario)[ \t]+['"]/gu,
|
|
24
|
+
/^[ \t]*(?:async[ \t]+)?def[ \t]+test\w*[ \t]*\(/gmu,
|
|
25
|
+
/^func[ \t]+Test\w*[ \t]*\(/gmu,
|
|
26
|
+
/#\[(?:tokio::)?test\b/gu,
|
|
27
|
+
/@Test\b|\[Test\]|\[Fact\]|\[Theory\]/gu
|
|
28
|
+
];
|
|
29
|
+
const SKIP_PATTERNS = [
|
|
30
|
+
/\b(?:test|it|describe|context|suite)\s*\.\s*(?:skip|todo)\b/gu,
|
|
31
|
+
/\bx(?:it|describe|test|context)\s*\(/gu,
|
|
32
|
+
/@pytest\.mark\.skip\w*|\bpytest\.skip\s*\(|@unittest\.skip\w*|\bself\.skipTest\s*\(/gu,
|
|
33
|
+
/\bt\.Skip\w*\s*\(/gu,
|
|
34
|
+
/#\[ignore\b/gu,
|
|
35
|
+
/@Disabled\b|@Ignore\b/gu
|
|
36
|
+
];
|
|
37
|
+
export function hasTestSurface(surface) {
|
|
38
|
+
return surface.assertions.length > 0 || surface.cases > 0;
|
|
39
|
+
}
|
|
40
|
+
export function extractTestSurface(content, path) {
|
|
41
|
+
const code = stripComments(content, commentStyleFor(path));
|
|
42
|
+
return {
|
|
43
|
+
assertions: [...extractCallAssertions(code), ...extractBareAssertions(code)],
|
|
44
|
+
cases: CASE_PATTERNS.reduce((total, pattern) => total + countMatches(code, pattern), 0),
|
|
45
|
+
skips: SKIP_PATTERNS.reduce((total, pattern) => total + countMatches(code, pattern), 0)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* True when `after` removes or alters an assertion, declares fewer cases, or skips more
|
|
50
|
+
* cases than `before`. Added assertions and cases never count as weakening.
|
|
51
|
+
*/
|
|
52
|
+
export function testSurfaceWeakened(before, after, path) {
|
|
53
|
+
const earlier = extractTestSurface(before, path);
|
|
54
|
+
const later = extractTestSurface(after, path);
|
|
55
|
+
if (later.cases < earlier.cases || later.skips > earlier.skips) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const remaining = new Map();
|
|
59
|
+
for (const assertion of later.assertions) {
|
|
60
|
+
remaining.set(assertion, (remaining.get(assertion) ?? 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
for (const assertion of earlier.assertions) {
|
|
63
|
+
const left = remaining.get(assertion) ?? 0;
|
|
64
|
+
if (left === 0) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
remaining.set(assertion, left - 1);
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
function commentStyleFor(path) {
|
|
72
|
+
const extension = path?.split(".").at(-1)?.toLowerCase() ?? "";
|
|
73
|
+
if (HASH_COMMENT_EXTENSIONS.has(extension)) {
|
|
74
|
+
return { slash: false, hash: true };
|
|
75
|
+
}
|
|
76
|
+
if (SLASH_ONLY_EXTENSIONS.has(extension)) {
|
|
77
|
+
return { slash: true, hash: extension === "php" };
|
|
78
|
+
}
|
|
79
|
+
return { slash: true, hash: true };
|
|
80
|
+
}
|
|
81
|
+
/** Replaces comments with spaces (newlines kept) while leaving string literals untouched. */
|
|
82
|
+
function stripComments(content, style) {
|
|
83
|
+
let output = "";
|
|
84
|
+
let index = 0;
|
|
85
|
+
while (index < content.length) {
|
|
86
|
+
const char = content[index];
|
|
87
|
+
const next = content[index + 1];
|
|
88
|
+
if (isQuote(char)) {
|
|
89
|
+
const end = skipString(content, index);
|
|
90
|
+
output += content.slice(index, end);
|
|
91
|
+
index = end;
|
|
92
|
+
}
|
|
93
|
+
else if (style.slash && char === "/" && next === "/") {
|
|
94
|
+
const end = lineEnd(content, index);
|
|
95
|
+
output += " ".repeat(end - index);
|
|
96
|
+
index = end;
|
|
97
|
+
}
|
|
98
|
+
else if (style.slash && char === "/" && next === "*") {
|
|
99
|
+
const close = content.indexOf("*/", index + 2);
|
|
100
|
+
const end = close === -1 ? content.length : close + 2;
|
|
101
|
+
output += content.slice(index, end).replace(/[^\n]/gu, " ");
|
|
102
|
+
index = end;
|
|
103
|
+
}
|
|
104
|
+
else if (style.hash && char === "#" && next !== "[" && next !== "!") {
|
|
105
|
+
const end = lineEnd(content, index);
|
|
106
|
+
output += " ".repeat(end - index);
|
|
107
|
+
index = end;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
output += char;
|
|
111
|
+
index += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
function extractCallAssertions(code) {
|
|
117
|
+
const assertions = [];
|
|
118
|
+
const pattern = new RegExp(ASSERTION_START.source, ASSERTION_START.flags);
|
|
119
|
+
let cursor = 0;
|
|
120
|
+
let match;
|
|
121
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
122
|
+
if (match.index < cursor) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let end = balancedEnd(code, match.index + match[0].length - 1);
|
|
126
|
+
for (;;) {
|
|
127
|
+
const continuation = new RegExp(CHAIN_CONTINUATION.source, CHAIN_CONTINUATION.flags);
|
|
128
|
+
continuation.lastIndex = end;
|
|
129
|
+
const found = continuation.exec(code);
|
|
130
|
+
if (!found) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
end = balancedEnd(code, end + found[0].length - 1);
|
|
134
|
+
}
|
|
135
|
+
assertions.push(normalize(code.slice(match.index, end)));
|
|
136
|
+
cursor = end;
|
|
137
|
+
pattern.lastIndex = end;
|
|
138
|
+
}
|
|
139
|
+
return assertions;
|
|
140
|
+
}
|
|
141
|
+
function extractBareAssertions(code) {
|
|
142
|
+
return (code.match(PYTHON_BARE_ASSERT) ?? []).map((line) => normalize(line));
|
|
143
|
+
}
|
|
144
|
+
/** Index just past the parenthesis that closes the one at `openIndex`; end of text if unbalanced. */
|
|
145
|
+
function balancedEnd(code, openIndex) {
|
|
146
|
+
let depth = 0;
|
|
147
|
+
let index = openIndex;
|
|
148
|
+
while (index < code.length) {
|
|
149
|
+
const char = code[index];
|
|
150
|
+
if (isQuote(char)) {
|
|
151
|
+
index = skipString(code, index);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (char === "(") {
|
|
155
|
+
depth += 1;
|
|
156
|
+
}
|
|
157
|
+
else if (char === ")") {
|
|
158
|
+
depth -= 1;
|
|
159
|
+
if (depth === 0) {
|
|
160
|
+
return index + 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
index += 1;
|
|
164
|
+
}
|
|
165
|
+
return code.length;
|
|
166
|
+
}
|
|
167
|
+
/** Drops whitespace outside string literals, keeping one space between two word characters. */
|
|
168
|
+
function normalize(text) {
|
|
169
|
+
let output = "";
|
|
170
|
+
let index = 0;
|
|
171
|
+
let pendingSpace = false;
|
|
172
|
+
while (index < text.length) {
|
|
173
|
+
const char = text[index];
|
|
174
|
+
if (isQuote(char)) {
|
|
175
|
+
const end = skipString(text, index);
|
|
176
|
+
output += text.slice(index, end);
|
|
177
|
+
index = end;
|
|
178
|
+
pendingSpace = false;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (/\s/u.test(char)) {
|
|
182
|
+
pendingSpace = true;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
if (pendingSpace && /\w/u.test(output.at(-1) ?? "") && /\w/u.test(char)) {
|
|
186
|
+
output += " ";
|
|
187
|
+
}
|
|
188
|
+
output += char;
|
|
189
|
+
pendingSpace = false;
|
|
190
|
+
}
|
|
191
|
+
index += 1;
|
|
192
|
+
}
|
|
193
|
+
return output;
|
|
194
|
+
}
|
|
195
|
+
function isQuote(char) {
|
|
196
|
+
return char === '"' || char === "'" || char === "`";
|
|
197
|
+
}
|
|
198
|
+
/** Index just past the string literal that starts at `start`. */
|
|
199
|
+
function skipString(text, start) {
|
|
200
|
+
const quote = text[start];
|
|
201
|
+
const triple = quote !== "`" && text.startsWith(quote.repeat(3), start);
|
|
202
|
+
const delimiter = triple ? quote.repeat(3) : quote;
|
|
203
|
+
const multiline = triple || quote === "`";
|
|
204
|
+
let index = start + delimiter.length;
|
|
205
|
+
while (index < text.length) {
|
|
206
|
+
const char = text[index];
|
|
207
|
+
if (char === "\\") {
|
|
208
|
+
index += 2;
|
|
209
|
+
}
|
|
210
|
+
else if (text.startsWith(delimiter, index)) {
|
|
211
|
+
return index + delimiter.length;
|
|
212
|
+
}
|
|
213
|
+
else if (!multiline && char === "\n") {
|
|
214
|
+
return index;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
index += 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return text.length;
|
|
221
|
+
}
|
|
222
|
+
function lineEnd(text, from) {
|
|
223
|
+
const newline = text.indexOf("\n", from);
|
|
224
|
+
return newline === -1 ? text.length : newline;
|
|
225
|
+
}
|
|
226
|
+
function countMatches(code, pattern) {
|
|
227
|
+
return (code.match(new RegExp(pattern.source, pattern.flags)) ?? []).length;
|
|
228
|
+
}
|
|
@@ -78,4 +78,5 @@ export interface BuildVerifiedHandoffInput {
|
|
|
78
78
|
recovery?: Partial<VerifiedHandoffRecoveryV1>;
|
|
79
79
|
nextAction: string;
|
|
80
80
|
}
|
|
81
|
+
export declare const NO_FILE_CHANGES_WARNING = "No file changes were observed for this run; the verifier result reflects the workspace as it already was.";
|
|
81
82
|
export declare function buildVerifiedHandoff(input: BuildVerifiedHandoffInput): VerifiedHandoffV1;
|
|
@@ -207,6 +207,7 @@ function toCheck(step) {
|
|
|
207
207
|
// ---------------------------------------------------------------------------
|
|
208
208
|
// Main builder
|
|
209
209
|
// ---------------------------------------------------------------------------
|
|
210
|
+
export const NO_FILE_CHANGES_WARNING = "No file changes were observed for this run; the verifier result reflects the workspace as it already was.";
|
|
210
211
|
export function buildVerifiedHandoff(input) {
|
|
211
212
|
const generatedAt = input.generatedAt ?? new Date().toISOString();
|
|
212
213
|
const changedFiles = input.changedFiles ?? [];
|
|
@@ -280,6 +281,11 @@ export function buildVerifiedHandoff(input) {
|
|
|
280
281
|
...(effectiveVerificationStatus !== input.verification.status
|
|
281
282
|
? ["A VERIFIED claim requires at least one launched, completed, exit-zero verifier step."]
|
|
282
283
|
: []),
|
|
284
|
+
...(fileEffects.observationStatus === "observed" &&
|
|
285
|
+
fileEffects.netChangedAtCompletion.length === 0 &&
|
|
286
|
+
input.loop.task.mutationMode !== "read_only"
|
|
287
|
+
? [NO_FILE_CHANGES_WARNING]
|
|
288
|
+
: []),
|
|
283
289
|
];
|
|
284
290
|
const outcome = resolveVerifiedHandoffOutcome({
|
|
285
291
|
lifecycleState: input.loop.lifecycleState,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const MARTIN_OWNED_OPTIONS = new Set([
|
|
2
|
+
"--allow-network-domain", "--allow-path", "--approve-config-changes",
|
|
3
|
+
"--approve-dependency-changes", "--approve-external-writes", "--approve-migrations",
|
|
4
|
+
"--approve-test-changes", "--baseline-usd", "--budget-usd", "--deny-path",
|
|
5
|
+
"--execution-profile", "--provider-execution-timeout-ms", "--runs-dir",
|
|
6
|
+
"--soft-limit-usd", "--unsafe-allow-unguarded-run", "--verify-only", "--verify-timeout-ms"
|
|
7
|
+
]);
|
|
8
|
+
const MARTIN_ENGINES = new Set(["auto", "claude", "codex", "gemini", "openai"]);
|
|
9
|
+
const MARTIN_EXECUTABLES = new Set(["martin", "martin-loop", "martin.cmd", "martin-loop.cmd"]);
|
|
10
|
+
function foldedMartinOptions(tokens) {
|
|
11
|
+
const folded = [];
|
|
12
|
+
tokens.forEach((token, index) => {
|
|
13
|
+
const [name, inlineValue] = token.split("=", 2);
|
|
14
|
+
if (name && MARTIN_OWNED_OPTIONS.has(name)) {
|
|
15
|
+
folded.push(name);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (name === "--engine") {
|
|
19
|
+
const value = (inlineValue ?? tokens[index + 1] ?? "").toLowerCase();
|
|
20
|
+
if (MARTIN_ENGINES.has(value))
|
|
21
|
+
folded.push(`--engine ${value}`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
return folded;
|
|
25
|
+
}
|
|
26
|
+
/** Returns a reason when a verifier would be parsed differently from what the operator intended. */
|
|
27
|
+
export function describeMalformedVerifier(command) {
|
|
28
|
+
const quote = findUnterminatedQuote(command);
|
|
29
|
+
if (quote !== undefined)
|
|
30
|
+
return `--verify value has an unterminated quote (${quote}): ${command}`;
|
|
31
|
+
const tokens = command.trim().split(/\s+/u);
|
|
32
|
+
const executable = tokens[0]?.replace(/^["']|["']$/gu, "").toLowerCase() ?? "";
|
|
33
|
+
if (MARTIN_EXECUTABLES.has(executable))
|
|
34
|
+
return undefined;
|
|
35
|
+
const swallowed = foldedMartinOptions(tokens);
|
|
36
|
+
if (swallowed.length > 0) {
|
|
37
|
+
return `--verify value contains MartinLoop option(s) ${swallowed.join(", ")}: ${command}. `
|
|
38
|
+
+ "The shell merged later flags into the verifier, usually because of a stray quote.";
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
function findUnterminatedQuote(command) {
|
|
43
|
+
let quote;
|
|
44
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
45
|
+
const char = command[index];
|
|
46
|
+
const next = command[index + 1];
|
|
47
|
+
if (char === "\\" && quote !== "'" && (next === quote || next === "\\")) {
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (char === '"' || char === "'") {
|
|
52
|
+
if (quote === undefined)
|
|
53
|
+
quote = char;
|
|
54
|
+
else if (quote === char)
|
|
55
|
+
quote = undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return quote;
|
|
59
|
+
}
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/Keesan12/martin-loop",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.8.
|
|
10
|
+
"version": "0.8.7",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.8.
|
|
15
|
+
"version": "0.8.7",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|