@martinloop/mcp 0.8.6 → 0.8.8
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/tools/run-loop.js +7 -1
- package/dist/vendor/adapters/claude-cli.d.ts +4 -0
- package/dist/vendor/adapters/claude-cli.js +81 -13
- package/dist/vendor/contracts/index.d.ts +79 -0
- package/dist/vendor/contracts/index.js +34 -0
- package/dist/vendor/contracts/swarm.js +1 -1
- package/dist/vendor/core/index.d.ts +43 -4
- package/dist/vendor/core/index.js +529 -74
- 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/policy.d.ts +18 -1
- package/dist/vendor/core/policy.js +113 -10
- package/dist/vendor/core/rollback.d.ts +24 -0
- package/dist/vendor/core/rollback.js +402 -19
- package/dist/vendor/core/test-integrity.d.ts +18 -0
- package/dist/vendor/core/test-integrity.js +114 -1
- package/dist/vendor/core/test-surface.d.ts +27 -0
- package/dist/vendor/core/test-surface.js +231 -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 +4 -0
- package/dist/vendor/core/verifier-input.js +107 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
* This module provides the analysis primitives. The verification pipeline
|
|
17
17
|
* calls these during the post-run verification step.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { lstatSync, readFileSync, readdirSync, existsSync } from "node:fs";
|
|
20
21
|
import { join, relative } from "node:path";
|
|
22
|
+
import { extractTestSurface, hasTestSurface, testSurfaceWeakened } from "./test-surface.js";
|
|
21
23
|
const TEST_FILE_PATTERNS = [
|
|
22
24
|
/\.test\.[tj]sx?$/,
|
|
23
25
|
/\.spec\.[tj]sx?$/,
|
|
@@ -195,3 +197,114 @@ export function validateTestIntegrity(rootDir, preRunTestSnapshot, preRunSourceF
|
|
|
195
197
|
summary
|
|
196
198
|
};
|
|
197
199
|
}
|
|
200
|
+
const TEST_PATH_PATTERNS = [
|
|
201
|
+
/\.(?:test|spec)\.[cm]?[jt]sx?$/u,
|
|
202
|
+
/(?:^|\/)(?:__tests__|tests?)\//u,
|
|
203
|
+
/(?:^|\/)test_[^/]+\.py$/u,
|
|
204
|
+
/_test\.(?:py|go)$/u
|
|
205
|
+
];
|
|
206
|
+
/**
|
|
207
|
+
* Repo-relative path check for test sources, covering JS/TS naming, test
|
|
208
|
+
* directories at any depth (including the repo root), Python, and Go.
|
|
209
|
+
*/
|
|
210
|
+
export function isTestPath(filePath) {
|
|
211
|
+
const normalized = filePath.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
212
|
+
return TEST_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Existing test files whose assertions the attempt removed or altered, whose cases it
|
|
216
|
+
* dropped or skipped, or which it deleted, compared with the boundary commit. New test
|
|
217
|
+
* files, appended cases, and edits to imports, setup or formatting are not reported.
|
|
218
|
+
* Files with no recognizable test surface (data, golden and snapshot files, unknown
|
|
219
|
+
* frameworks) fall back to the strict rule: any removed line counts. Changes committed
|
|
220
|
+
* by the attempt are included because the comparison is against `baseRef`, not HEAD.
|
|
221
|
+
*/
|
|
222
|
+
export function findWeakenedTestFiles(input) {
|
|
223
|
+
const candidates = [...new Set(input.changedFiles.map((file) => file.replace(/\\/gu, "/")))]
|
|
224
|
+
.filter(isTestPath)
|
|
225
|
+
.sort();
|
|
226
|
+
if (candidates.length === 0) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const changedLines = listChangedTestFiles(input.repoRoot, input.baseRef, candidates);
|
|
230
|
+
for (const filePath of candidates) {
|
|
231
|
+
if (!changedLines.has(filePath) &&
|
|
232
|
+
readBaseContent(input.repoRoot, input.baseRef, filePath) === undefined &&
|
|
233
|
+
readWorkingContent(input.repoRoot, filePath) !== undefined) {
|
|
234
|
+
// Untracked files are absent from `git diff --numstat` but still belong
|
|
235
|
+
// to the caller's observed changed-file set.
|
|
236
|
+
changedLines.set(filePath, false);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return [...changedLines.entries()]
|
|
240
|
+
.filter(([filePath, removedLines]) => isWeakened(input, filePath, removedLines))
|
|
241
|
+
.map(([filePath]) => filePath)
|
|
242
|
+
.sort();
|
|
243
|
+
}
|
|
244
|
+
/** Modified files among `candidates`, with whether they lost a line (or are binary). */
|
|
245
|
+
function listChangedTestFiles(repoRoot, baseRef, candidates) {
|
|
246
|
+
const result = spawnSync("git", ["diff", "--numstat", "--no-renames", "-z", baseRef, "--", ...candidates], { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
247
|
+
if (result.status !== 0 || typeof result.stdout !== "string") {
|
|
248
|
+
const detail = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
249
|
+
throw new Error(`Test-change inspection failed: ${detail || `git exited with ${String(result.status)}`}`);
|
|
250
|
+
}
|
|
251
|
+
const changed = new Map();
|
|
252
|
+
for (const record of result.stdout.split("\0")) {
|
|
253
|
+
const [additions, deletions, filePath] = record.split("\t");
|
|
254
|
+
if (filePath && additions !== undefined && deletions !== undefined) {
|
|
255
|
+
changed.set(filePath.trim(), deletions === "-" || Number(deletions) > 0);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return changed;
|
|
259
|
+
}
|
|
260
|
+
/** Cap on file size the surface analysis reads; larger files use the strict rule. */
|
|
261
|
+
const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
|
|
262
|
+
const BASE_CONTENT_UNINSPECTABLE = Symbol("base-content-uninspectable");
|
|
263
|
+
function isWeakened(input, filePath, removedLines) {
|
|
264
|
+
const before = readBaseContent(input.repoRoot, input.baseRef, filePath);
|
|
265
|
+
if (before === BASE_CONTENT_UNINSPECTABLE) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (before === undefined) {
|
|
269
|
+
const after = readWorkingContent(input.repoRoot, filePath);
|
|
270
|
+
// New tests are welcome, but focus/skip/expected-failure markers can still
|
|
271
|
+
// suppress the authoritative suite and therefore require approval.
|
|
272
|
+
return after === undefined || testSurfaceWeakened("", after, filePath);
|
|
273
|
+
}
|
|
274
|
+
const after = readWorkingContent(input.repoRoot, filePath);
|
|
275
|
+
if (after === undefined) {
|
|
276
|
+
// Deleted, replaced by a link, unreadable or oversized: cannot prove it still asserts.
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (!hasTestSurface(extractTestSurface(before, filePath))) {
|
|
280
|
+
return removedLines || testSurfaceWeakened(before, after, filePath);
|
|
281
|
+
}
|
|
282
|
+
return testSurfaceWeakened(before, after, filePath);
|
|
283
|
+
}
|
|
284
|
+
function readBaseContent(repoRoot, baseRef, filePath) {
|
|
285
|
+
const exists = spawnSync("git", ["cat-file", "-e", `${baseRef}:${filePath}`], { cwd: repoRoot });
|
|
286
|
+
if (exists.status !== 0) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const result = spawnSync("git", ["show", `${baseRef}:${filePath}`], {
|
|
290
|
+
cwd: repoRoot,
|
|
291
|
+
encoding: "utf8",
|
|
292
|
+
maxBuffer: MAX_SURFACE_BYTES * 2
|
|
293
|
+
});
|
|
294
|
+
return result.status === 0 && typeof result.stdout === "string" && result.stdout.length <= MAX_SURFACE_BYTES
|
|
295
|
+
? result.stdout
|
|
296
|
+
: BASE_CONTENT_UNINSPECTABLE;
|
|
297
|
+
}
|
|
298
|
+
function readWorkingContent(repoRoot, filePath) {
|
|
299
|
+
const absolutePath = join(repoRoot, filePath);
|
|
300
|
+
try {
|
|
301
|
+
const stats = lstatSync(absolutePath);
|
|
302
|
+
if (!stats.isFile() || stats.size > MAX_SURFACE_BYTES) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
return readFileSync(absolutePath, "utf8");
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export interface TestSurface {
|
|
14
|
+
/** Whitespace-normalized assertion expressions, in source order. */
|
|
15
|
+
assertions: string[];
|
|
16
|
+
/** Declared test cases. */
|
|
17
|
+
cases: number;
|
|
18
|
+
/** Skip, todo or disable markers. */
|
|
19
|
+
skips: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function hasTestSurface(surface: TestSurface): boolean;
|
|
22
|
+
export declare function extractTestSurface(content: string, path?: string): TestSurface;
|
|
23
|
+
/**
|
|
24
|
+
* True when `after` removes or alters an assertion, declares fewer cases, or skips more
|
|
25
|
+
* cases than `before`. Added assertions and cases never count as weakening.
|
|
26
|
+
*/
|
|
27
|
+
export declare function testSurfaceWeakened(before: string, after: string, path?: string): boolean;
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
/\b(?:test|it)\s*\.\s*failing\b/gu,
|
|
32
|
+
/\b(?:test|it|describe|context|suite|specify)\s*\.\s*only\b/gu,
|
|
33
|
+
/\bf(?:it|describe)\s*\(/gu,
|
|
34
|
+
/\bx(?:it|describe|test|context)\s*\(/gu,
|
|
35
|
+
/@pytest\.mark\.(?:skip\w*|xfail)|\bpytest\.(?:skip|xfail)\s*\(|@unittest\.(?:skip\w*|expectedFailure)|\bself\.skipTest\s*\(/gu,
|
|
36
|
+
/\bt\.Skip\w*\s*\(/gu,
|
|
37
|
+
/#\[ignore\b/gu,
|
|
38
|
+
/@Disabled\b|@Ignore\b/gu
|
|
39
|
+
];
|
|
40
|
+
export function hasTestSurface(surface) {
|
|
41
|
+
return surface.assertions.length > 0 || surface.cases > 0;
|
|
42
|
+
}
|
|
43
|
+
export function extractTestSurface(content, path) {
|
|
44
|
+
const code = stripComments(content, commentStyleFor(path));
|
|
45
|
+
return {
|
|
46
|
+
assertions: [...extractCallAssertions(code), ...extractBareAssertions(code)],
|
|
47
|
+
cases: CASE_PATTERNS.reduce((total, pattern) => total + countMatches(code, pattern), 0),
|
|
48
|
+
skips: SKIP_PATTERNS.reduce((total, pattern) => total + countMatches(code, pattern), 0)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True when `after` removes or alters an assertion, declares fewer cases, or skips more
|
|
53
|
+
* cases than `before`. Added assertions and cases never count as weakening.
|
|
54
|
+
*/
|
|
55
|
+
export function testSurfaceWeakened(before, after, path) {
|
|
56
|
+
const earlier = extractTestSurface(before, path);
|
|
57
|
+
const later = extractTestSurface(after, path);
|
|
58
|
+
if (later.cases < earlier.cases || later.skips > earlier.skips) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const remaining = new Map();
|
|
62
|
+
for (const assertion of later.assertions) {
|
|
63
|
+
remaining.set(assertion, (remaining.get(assertion) ?? 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
for (const assertion of earlier.assertions) {
|
|
66
|
+
const left = remaining.get(assertion) ?? 0;
|
|
67
|
+
if (left === 0) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
remaining.set(assertion, left - 1);
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
function commentStyleFor(path) {
|
|
75
|
+
const extension = path?.split(".").at(-1)?.toLowerCase() ?? "";
|
|
76
|
+
if (HASH_COMMENT_EXTENSIONS.has(extension)) {
|
|
77
|
+
return { slash: false, hash: true };
|
|
78
|
+
}
|
|
79
|
+
if (SLASH_ONLY_EXTENSIONS.has(extension)) {
|
|
80
|
+
return { slash: true, hash: extension === "php" };
|
|
81
|
+
}
|
|
82
|
+
return { slash: true, hash: true };
|
|
83
|
+
}
|
|
84
|
+
/** Replaces comments with spaces (newlines kept) while leaving string literals untouched. */
|
|
85
|
+
function stripComments(content, style) {
|
|
86
|
+
let output = "";
|
|
87
|
+
let index = 0;
|
|
88
|
+
while (index < content.length) {
|
|
89
|
+
const char = content[index];
|
|
90
|
+
const next = content[index + 1];
|
|
91
|
+
if (isQuote(char)) {
|
|
92
|
+
const end = skipString(content, index);
|
|
93
|
+
output += content.slice(index, end);
|
|
94
|
+
index = end;
|
|
95
|
+
}
|
|
96
|
+
else if (style.slash && char === "/" && next === "/") {
|
|
97
|
+
const end = lineEnd(content, index);
|
|
98
|
+
output += " ".repeat(end - index);
|
|
99
|
+
index = end;
|
|
100
|
+
}
|
|
101
|
+
else if (style.slash && char === "/" && next === "*") {
|
|
102
|
+
const close = content.indexOf("*/", index + 2);
|
|
103
|
+
const end = close === -1 ? content.length : close + 2;
|
|
104
|
+
output += content.slice(index, end).replace(/[^\n]/gu, " ");
|
|
105
|
+
index = end;
|
|
106
|
+
}
|
|
107
|
+
else if (style.hash && char === "#" && next !== "[" && next !== "!") {
|
|
108
|
+
const end = lineEnd(content, index);
|
|
109
|
+
output += " ".repeat(end - index);
|
|
110
|
+
index = end;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
output += char;
|
|
114
|
+
index += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return output;
|
|
118
|
+
}
|
|
119
|
+
function extractCallAssertions(code) {
|
|
120
|
+
const assertions = [];
|
|
121
|
+
const pattern = new RegExp(ASSERTION_START.source, ASSERTION_START.flags);
|
|
122
|
+
let cursor = 0;
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
125
|
+
if (match.index < cursor) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let end = balancedEnd(code, match.index + match[0].length - 1);
|
|
129
|
+
for (;;) {
|
|
130
|
+
const continuation = new RegExp(CHAIN_CONTINUATION.source, CHAIN_CONTINUATION.flags);
|
|
131
|
+
continuation.lastIndex = end;
|
|
132
|
+
const found = continuation.exec(code);
|
|
133
|
+
if (!found) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
end = balancedEnd(code, end + found[0].length - 1);
|
|
137
|
+
}
|
|
138
|
+
assertions.push(normalize(code.slice(match.index, end)));
|
|
139
|
+
cursor = end;
|
|
140
|
+
pattern.lastIndex = end;
|
|
141
|
+
}
|
|
142
|
+
return assertions;
|
|
143
|
+
}
|
|
144
|
+
function extractBareAssertions(code) {
|
|
145
|
+
return (code.match(PYTHON_BARE_ASSERT) ?? []).map((line) => normalize(line));
|
|
146
|
+
}
|
|
147
|
+
/** Index just past the parenthesis that closes the one at `openIndex`; end of text if unbalanced. */
|
|
148
|
+
function balancedEnd(code, openIndex) {
|
|
149
|
+
let depth = 0;
|
|
150
|
+
let index = openIndex;
|
|
151
|
+
while (index < code.length) {
|
|
152
|
+
const char = code[index];
|
|
153
|
+
if (isQuote(char)) {
|
|
154
|
+
index = skipString(code, index);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (char === "(") {
|
|
158
|
+
depth += 1;
|
|
159
|
+
}
|
|
160
|
+
else if (char === ")") {
|
|
161
|
+
depth -= 1;
|
|
162
|
+
if (depth === 0) {
|
|
163
|
+
return index + 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
index += 1;
|
|
167
|
+
}
|
|
168
|
+
return code.length;
|
|
169
|
+
}
|
|
170
|
+
/** Drops whitespace outside string literals, keeping one space between two word characters. */
|
|
171
|
+
function normalize(text) {
|
|
172
|
+
let output = "";
|
|
173
|
+
let index = 0;
|
|
174
|
+
let pendingSpace = false;
|
|
175
|
+
while (index < text.length) {
|
|
176
|
+
const char = text[index];
|
|
177
|
+
if (isQuote(char)) {
|
|
178
|
+
const end = skipString(text, index);
|
|
179
|
+
output += text.slice(index, end);
|
|
180
|
+
index = end;
|
|
181
|
+
pendingSpace = false;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (/\s/u.test(char)) {
|
|
185
|
+
pendingSpace = true;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
if (pendingSpace && /\w/u.test(output.at(-1) ?? "") && /\w/u.test(char)) {
|
|
189
|
+
output += " ";
|
|
190
|
+
}
|
|
191
|
+
output += char;
|
|
192
|
+
pendingSpace = false;
|
|
193
|
+
}
|
|
194
|
+
index += 1;
|
|
195
|
+
}
|
|
196
|
+
return output;
|
|
197
|
+
}
|
|
198
|
+
function isQuote(char) {
|
|
199
|
+
return char === '"' || char === "'" || char === "`";
|
|
200
|
+
}
|
|
201
|
+
/** Index just past the string literal that starts at `start`. */
|
|
202
|
+
function skipString(text, start) {
|
|
203
|
+
const quote = text[start];
|
|
204
|
+
const triple = quote !== "`" && text.startsWith(quote.repeat(3), start);
|
|
205
|
+
const delimiter = triple ? quote.repeat(3) : quote;
|
|
206
|
+
const multiline = triple || quote === "`";
|
|
207
|
+
let index = start + delimiter.length;
|
|
208
|
+
while (index < text.length) {
|
|
209
|
+
const char = text[index];
|
|
210
|
+
if (char === "\\") {
|
|
211
|
+
index += 2;
|
|
212
|
+
}
|
|
213
|
+
else if (text.startsWith(delimiter, index)) {
|
|
214
|
+
return index + delimiter.length;
|
|
215
|
+
}
|
|
216
|
+
else if (!multiline && char === "\n") {
|
|
217
|
+
return index;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
index += 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return text.length;
|
|
224
|
+
}
|
|
225
|
+
function lineEnd(text, from) {
|
|
226
|
+
const newline = text.indexOf("\n", from);
|
|
227
|
+
return newline === -1 ? text.length : newline;
|
|
228
|
+
}
|
|
229
|
+
function countMatches(code, pattern) {
|
|
230
|
+
return (code.match(new RegExp(pattern.source, pattern.flags)) ?? []).length;
|
|
231
|
+
}
|
|
@@ -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,107 @@
|
|
|
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
|
+
const LAUNCHER_BOOLEAN_OPTIONS = new Set([
|
|
11
|
+
"-s", "--silent", "-y", "--yes", "--no-install", "--offline", "--prefer-offline", "--no-color"
|
|
12
|
+
]);
|
|
13
|
+
const LAUNCHER_VALUE_OPTIONS = new Set(["--cwd", "--dir", "--filter", "--package", "-p", "--workspace", "-w"]);
|
|
14
|
+
function foldedMartinOptions(tokens) {
|
|
15
|
+
const folded = [];
|
|
16
|
+
tokens.forEach((token, index) => {
|
|
17
|
+
const [name, inlineValue] = token.split("=", 2);
|
|
18
|
+
if (name && MARTIN_OWNED_OPTIONS.has(name)) {
|
|
19
|
+
folded.push(name);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (name === "--engine") {
|
|
23
|
+
const value = (inlineValue ?? tokens[index + 1] ?? "").toLowerCase();
|
|
24
|
+
if (MARTIN_ENGINES.has(value))
|
|
25
|
+
folded.push(`--engine ${value}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return folded;
|
|
29
|
+
}
|
|
30
|
+
/** Returns a reason when a verifier would be parsed differently from what the operator intended. */
|
|
31
|
+
export function describeMalformedVerifier(command, options = {}) {
|
|
32
|
+
const quote = findUnterminatedQuote(command);
|
|
33
|
+
if (quote !== undefined)
|
|
34
|
+
return `--verify value has an unterminated quote (${quote}): ${command}`;
|
|
35
|
+
if (options.detectShellFold === false)
|
|
36
|
+
return undefined;
|
|
37
|
+
const tokens = command.trim().split(/\s+/u);
|
|
38
|
+
const executableIndex = resolveLaunchedExecutableIndex(tokens);
|
|
39
|
+
const executable = normalizeExecutableToken(tokens[executableIndex] ?? "");
|
|
40
|
+
if (MARTIN_EXECUTABLES.has(executable))
|
|
41
|
+
return undefined;
|
|
42
|
+
const swallowed = foldedMartinOptions(tokens);
|
|
43
|
+
if (swallowed.length > 0) {
|
|
44
|
+
return `--verify value contains MartinLoop option(s) ${swallowed.join(", ")}: ${command}. `
|
|
45
|
+
+ "The shell merged later flags into the verifier, usually because of a stray quote.";
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function resolveLaunchedExecutableIndex(tokens) {
|
|
50
|
+
const launcher = normalizeExecutableToken(tokens[0] ?? "");
|
|
51
|
+
if (launcher === "node")
|
|
52
|
+
return 1;
|
|
53
|
+
if (launcher === "npx" || launcher === "pnpx") {
|
|
54
|
+
return skipLauncherOptions(tokens, 1);
|
|
55
|
+
}
|
|
56
|
+
if (launcher === "pnpm" || launcher === "npm" || launcher === "yarn") {
|
|
57
|
+
const actionIndex = skipLauncherOptions(tokens, 1);
|
|
58
|
+
const action = normalizeExecutableToken(tokens[actionIndex] ?? "");
|
|
59
|
+
if (action !== "exec" && action !== "dlx")
|
|
60
|
+
return 0;
|
|
61
|
+
return skipLauncherOptions(tokens, actionIndex + 1);
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
function skipLauncherOptions(tokens, start) {
|
|
66
|
+
let index = start;
|
|
67
|
+
while (index < tokens.length) {
|
|
68
|
+
const token = tokens[index] ?? "";
|
|
69
|
+
if (token === "--" || LAUNCHER_BOOLEAN_OPTIONS.has(token) || token.startsWith("--color=")) {
|
|
70
|
+
index += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (LAUNCHER_VALUE_OPTIONS.has(token)) {
|
|
74
|
+
index += 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if ([...LAUNCHER_VALUE_OPTIONS].some((option) => token.startsWith(`${option}=`))) {
|
|
78
|
+
index += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
return index;
|
|
84
|
+
}
|
|
85
|
+
function normalizeExecutableToken(token) {
|
|
86
|
+
const unquoted = token.replace(/^["']|["']$/gu, "");
|
|
87
|
+
const basename = unquoted.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
|
|
88
|
+
return basename.endsWith(".js") ? basename.slice(0, -3) : basename;
|
|
89
|
+
}
|
|
90
|
+
function findUnterminatedQuote(command) {
|
|
91
|
+
let quote;
|
|
92
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
93
|
+
const char = command[index];
|
|
94
|
+
const next = command[index + 1];
|
|
95
|
+
if (char === "\\" && quote !== "'" && (next === quote || next === "\\")) {
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (char === '"' || char === "'") {
|
|
100
|
+
if (quote === undefined)
|
|
101
|
+
quote = char;
|
|
102
|
+
else if (quote === char)
|
|
103
|
+
quote = undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return quote;
|
|
107
|
+
}
|
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.8",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.8.
|
|
15
|
+
"version": "0.8.8",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|