@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.
@@ -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 { readFileSync, readdirSync, existsSync } from "node:fs";
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,4 @@
1
+ /** Returns a reason when a verifier would be parsed differently from what the operator intended. */
2
+ export declare function describeMalformedVerifier(command: string, options?: {
3
+ detectShellFold?: boolean;
4
+ }): string | undefined;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martinloop/mcp",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "mcpName": "io.github.Keesan12/martin-loop",
5
5
  "private": false,
6
6
  "type": "module",
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.6",
10
+ "version": "0.8.8",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@martinloop/mcp",
15
- "version": "0.8.6",
15
+ "version": "0.8.8",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }