@martinloop/mcp 0.8.7 → 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 +1 -1
- 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 +55 -8
- package/dist/vendor/contracts/index.d.ts +60 -2
- package/dist/vendor/contracts/index.js +34 -0
- package/dist/vendor/core/index.d.ts +38 -0
- package/dist/vendor/core/index.js +473 -79
- package/dist/vendor/core/policy.d.ts +18 -1
- package/dist/vendor/core/policy.js +113 -10
- package/dist/vendor/core/rollback.js +43 -13
- package/dist/vendor/core/test-integrity.js +36 -13
- package/dist/vendor/core/test-surface.js +4 -1
- package/dist/vendor/core/verifier-input.d.ts +3 -1
- package/dist/vendor/core/verifier-input.js +50 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchScore, PolicyPhase } from "../contracts/index.js";
|
|
1
|
+
import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, PolicyPhase } from "../contracts/index.js";
|
|
2
2
|
export interface FailureAssessment {
|
|
3
3
|
failureClass: FailureClass;
|
|
4
4
|
rationale: string;
|
|
@@ -106,6 +106,17 @@ export interface BudgetPreflightInput {
|
|
|
106
106
|
remainingTokens?: number;
|
|
107
107
|
/** Adapter-declared estimated floor required to admit a credible first turn. */
|
|
108
108
|
minimumViableTokens?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Remaining iterations allowed. Used to compute a budget-aware per-attempt cap
|
|
111
|
+
* that reserves capacity for verifier and at least one repair attempt.
|
|
112
|
+
* When omitted, the preflight uses remaining budget divided by a conservative
|
|
113
|
+
* factor to prevent over-allocation on the first attempt.
|
|
114
|
+
*/
|
|
115
|
+
remainingIterations?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Explicit per-attempt spending cap. When set, overrides the computed cap.
|
|
118
|
+
* Only use when the caller has specific rate-limiting requirements.
|
|
119
|
+
*/
|
|
109
120
|
perAttemptCapUsd?: number;
|
|
110
121
|
pricePerMTokenUsd?: number;
|
|
111
122
|
}
|
|
@@ -158,5 +169,11 @@ export interface EvaluatedPatchDecision extends PatchDecisionArtifact {
|
|
|
158
169
|
score: PatchScore;
|
|
159
170
|
}
|
|
160
171
|
export declare function selectRecoveryRecipe(evidence: EvidenceVector): RecoveryDecision;
|
|
172
|
+
/**
|
|
173
|
+
* Returns true ONLY when every reason code is in the explicit allowlist of
|
|
174
|
+
* retainable soft failures. Unknown or future reason codes default to UNSAFE
|
|
175
|
+
* (fail-closed), preventing silent candidate retention for unrecognized states.
|
|
176
|
+
*/
|
|
177
|
+
export declare function isSafeUnverifiedDiscard(reasonCodes: readonly PatchDecisionReasonCode[]): boolean;
|
|
161
178
|
export declare function evaluatePatchDecision(input: PatchDecisionInput): EvaluatedPatchDecision;
|
|
162
179
|
export declare function scorePatchDecision(input: PatchDecisionInput): PatchScore;
|
|
@@ -244,17 +244,42 @@ export function inferExit(input) {
|
|
|
244
244
|
reason: "Oscillating failure pattern detected — loop is cycling without measurable progress."
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
|
+
// C2 — progress-aware repetition check.
|
|
248
|
+
// Two consecutive attempts with the same failureClass are NOT necessarily
|
|
249
|
+
// the same state. If the verifierScore improved (e.g. fewer failing tests),
|
|
250
|
+
// that is measurable progress and we must not stop prematurely.
|
|
251
|
+
// Only stop when the failure class repeated AND there is no measurable improvement:
|
|
252
|
+
// - verifierScore did not increase, AND
|
|
253
|
+
// - candidate diff did not change (candidateRetained with same failure)
|
|
247
254
|
const lastTwo = input.loop.attempts.slice(-2);
|
|
248
|
-
const
|
|
255
|
+
const repeatedFailureClass = lastTwo.length === 2 &&
|
|
249
256
|
lastTwo.every((attempt) => attempt.failureClass &&
|
|
250
257
|
attempt.failureClass === lastTwo[0]?.failureClass &&
|
|
251
258
|
attempt.failureClass === input.lastFailure?.failureClass);
|
|
252
|
-
|
|
259
|
+
// Measure progress using REAL evidence from actual verifier step results.
|
|
260
|
+
// Multiple signals are checked; any one constituting real progress allows continuation.
|
|
261
|
+
const measurableProgressOccurred = (() => {
|
|
262
|
+
const [prev, curr] = lastTwo;
|
|
263
|
+
if (!prev || !curr)
|
|
264
|
+
return false;
|
|
265
|
+
// Signal 1: verifierScore improved (computed from real passing/total step ratio)
|
|
266
|
+
const prevScore = prev.verifierScore ?? 0;
|
|
267
|
+
const currScore = curr.verifierScore ?? 0;
|
|
268
|
+
if (currScore > prevScore)
|
|
269
|
+
return true;
|
|
270
|
+
// Signal 2: fewer failing verifier steps than previous attempt (monotonic improvement)
|
|
271
|
+
if (prev.failingVerifierStepCount !== undefined &&
|
|
272
|
+
curr.failingVerifierStepCount !== undefined &&
|
|
273
|
+
curr.failingVerifierStepCount < prev.failingVerifierStepCount)
|
|
274
|
+
return true;
|
|
275
|
+
return false;
|
|
276
|
+
})();
|
|
277
|
+
if (repeatedFailureClass && !measurableProgressOccurred && input.lastFailure) {
|
|
253
278
|
return {
|
|
254
279
|
shouldExit: true,
|
|
255
280
|
lifecycleState: "diminishing_returns",
|
|
256
281
|
status: "exited",
|
|
257
|
-
reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts.`
|
|
282
|
+
reason: `Martin exited because ${input.lastFailure.failureClass} repeated across consecutive attempts with no measurable progress (score, step count, or failure surface unchanged).`
|
|
258
283
|
};
|
|
259
284
|
}
|
|
260
285
|
if (input.lastFailure?.failureClass === "environment_mismatch" &&
|
|
@@ -398,11 +423,34 @@ export function evaluateBudgetPreflight(input) {
|
|
|
398
423
|
estimate
|
|
399
424
|
};
|
|
400
425
|
}
|
|
401
|
-
|
|
426
|
+
// D2 — Budget-aware per-attempt cap.
|
|
427
|
+
//
|
|
428
|
+
// Old behavior: rigid maxUsd * 0.25 — blocked viable attempts when total budget
|
|
429
|
+
// was large (e.g. $0.30 cost blocked on $1.00 budget because 0.30 > 0.25).
|
|
430
|
+
//
|
|
431
|
+
// New behavior: if an explicit cap is provided, honor it. Otherwise compute a
|
|
432
|
+
// cap that reserves budget for the verifier and at least one repair attempt beyond
|
|
433
|
+
// the current one, giving the provider meaningful runway.
|
|
434
|
+
//
|
|
435
|
+
// Formula: cap = remainingBudget / (remainingIterations + 1)
|
|
436
|
+
// +1 accounts for the current attempt being the "next" attempt.
|
|
437
|
+
// Minimum cap of $0.05 prevents degenerate near-zero rejections.
|
|
438
|
+
const perAttemptCap = (() => {
|
|
439
|
+
if (input.perAttemptCapUsd !== undefined)
|
|
440
|
+
return input.perAttemptCapUsd;
|
|
441
|
+
const iters = input.remainingIterations;
|
|
442
|
+
if (iters !== undefined && iters > 0) {
|
|
443
|
+
// Divide remaining budget evenly across remaining iterations.
|
|
444
|
+
return Math.max(roundUsd(input.remainingBudgetUsd / iters), 0.05);
|
|
445
|
+
}
|
|
446
|
+
// No iteration information: allow up to 80% of remaining budget per attempt
|
|
447
|
+
// (down from the old 25%-of-total which was frequently too low).
|
|
448
|
+
return Math.max(roundUsd(input.remainingBudgetUsd * 0.8), 0.05);
|
|
449
|
+
})();
|
|
402
450
|
if (estimatedAttemptCostUsd > perAttemptCap) {
|
|
403
451
|
return {
|
|
404
452
|
allowed: false,
|
|
405
|
-
reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)}.`,
|
|
453
|
+
reason: `Preflight: estimated attempt cost $${estimatedAttemptCostUsd} exceeds per-attempt cap $${roundUsd(perAttemptCap)} (remaining budget $${input.remainingBudgetUsd}, ${input.remainingIterations ?? "?"} iterations left).`,
|
|
406
454
|
estimate
|
|
407
455
|
};
|
|
408
456
|
}
|
|
@@ -517,6 +565,41 @@ export function selectRecoveryRecipe(evidence) {
|
|
|
517
565
|
intervention: "escalate_human"
|
|
518
566
|
};
|
|
519
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* Explicit allowlist of reason codes that are SAFE to retain as an unverified
|
|
570
|
+
* candidate for the next repair attempt.
|
|
571
|
+
*
|
|
572
|
+
* FAIL-CLOSED: any reason code NOT in this set causes an immediate rollback.
|
|
573
|
+
* This prevents future or unknown reason codes from silently becoming "safe."
|
|
574
|
+
*
|
|
575
|
+
* Retainable only when ALL of these are also true:
|
|
576
|
+
* - scope_violation is absent (agent stayed within allowed paths)
|
|
577
|
+
* - safety_violation is absent (no secret/tenancy/test-integrity violation)
|
|
578
|
+
* - workspace observation was authoritative (changed files were tracked)
|
|
579
|
+
* - provider did not crash/fail (status !== "failed")
|
|
580
|
+
*
|
|
581
|
+
* no_code_change is explicitly NOT retainable — there is nothing to repair.
|
|
582
|
+
* Use a different recovery strategy instead.
|
|
583
|
+
*/
|
|
584
|
+
const RETAINABLE_SOFT_REASON_CODES = new Set([
|
|
585
|
+
"verifier_not_improved",
|
|
586
|
+
"low_novelty_no_progress",
|
|
587
|
+
"large_diff_no_improvement",
|
|
588
|
+
"grounding_failure",
|
|
589
|
+
"verifier_regressed", // actual test regression — retry with different approach
|
|
590
|
+
]);
|
|
591
|
+
/**
|
|
592
|
+
* Returns true ONLY when every reason code is in the explicit allowlist of
|
|
593
|
+
* retainable soft failures. Unknown or future reason codes default to UNSAFE
|
|
594
|
+
* (fail-closed), preventing silent candidate retention for unrecognized states.
|
|
595
|
+
*/
|
|
596
|
+
export function isSafeUnverifiedDiscard(reasonCodes) {
|
|
597
|
+
if (reasonCodes.length === 0) {
|
|
598
|
+
// No specific signal — we don't know why it's being discarded; fail closed.
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
return reasonCodes.every((code) => RETAINABLE_SOFT_REASON_CODES.has(code));
|
|
602
|
+
}
|
|
520
603
|
export function evaluatePatchDecision(input) {
|
|
521
604
|
const score = scorePatchDecision(input);
|
|
522
605
|
const reasonCodes = [...score.reasonCodes];
|
|
@@ -624,21 +707,41 @@ function tokenizeDiff(diff) {
|
|
|
624
707
|
return tokens;
|
|
625
708
|
}
|
|
626
709
|
function decidePatchOutcome(input, reasonCodes) {
|
|
710
|
+
// 1. Explicit human approval required — always escalate regardless of verifier.
|
|
711
|
+
// 2. Safety violation — always escalate.
|
|
627
712
|
if (input.humanApprovalRequired || reasonCodes.includes("safety_violation")) {
|
|
628
713
|
return "ESCALATE";
|
|
629
714
|
}
|
|
715
|
+
// 3. Scope violation — hard boundary: out-of-scope writes are never kept,
|
|
716
|
+
// even if the verifier passes, because the change violated the task contract.
|
|
717
|
+
if (reasonCodes.includes("scope_violation")) {
|
|
718
|
+
return "DISCARD";
|
|
719
|
+
}
|
|
720
|
+
// 4. Required mutation is absent — a green generic test suite is not proof
|
|
721
|
+
// that the requested work happened. Discard if:
|
|
722
|
+
// - a code change was required (mutation_required = edit mode)
|
|
723
|
+
// - no files were actually changed
|
|
724
|
+
// - the task was not pre-satisfied by the existing codebase
|
|
725
|
+
// Note: the scorer already suppresses no_code_change when
|
|
726
|
+
// definitionOfDonePreSatisfied is true, so that legitimate case passes through.
|
|
727
|
+
if (reasonCodes.includes("no_code_change")) {
|
|
728
|
+
return "DISCARD";
|
|
729
|
+
}
|
|
730
|
+
// 5. Passing verifier is the authoritative signal and overrides lower-confidence
|
|
731
|
+
// heuristics such as grounding failure. A grounding heuristic (symbol
|
|
732
|
+
// resolution, repo anatomy check) is weaker evidence than an actual passing
|
|
733
|
+
// host-run test exit code.
|
|
734
|
+
if (input.verificationPassed) {
|
|
735
|
+
return "KEEP";
|
|
736
|
+
}
|
|
737
|
+
// 6. Non-passing verifier: discard on any remaining negative heuristic signal.
|
|
630
738
|
if (reasonCodes.includes("grounding_failure") ||
|
|
631
|
-
reasonCodes.includes("scope_violation") ||
|
|
632
|
-
reasonCodes.includes("no_code_change") ||
|
|
633
739
|
reasonCodes.includes("verifier_regressed") ||
|
|
634
740
|
reasonCodes.includes("large_diff_no_improvement") ||
|
|
635
741
|
reasonCodes.includes("low_novelty_no_progress") ||
|
|
636
742
|
reasonCodes.includes("verifier_not_improved")) {
|
|
637
743
|
return "DISCARD";
|
|
638
744
|
}
|
|
639
|
-
if (input.verificationPassed) {
|
|
640
|
-
return "KEEP";
|
|
641
|
-
}
|
|
642
745
|
return "DISCARD";
|
|
643
746
|
}
|
|
644
747
|
function buildPatchDecisionSummary(decision, reasonCodes, summary) {
|
|
@@ -6,8 +6,11 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
|
6
6
|
import { DEPENDENCY_AND_BUILD_DIRECTORIES, isInsideDependencyOrBuildDirectory } from "./output-directories.js";
|
|
7
7
|
/** Past this many ignored files, further ignored directories stay collapsed. */
|
|
8
8
|
const MAX_EXPANDED_IGNORED_FILES = 20_000;
|
|
9
|
+
const MAX_HASHED_IGNORED_FILE_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_HASHED_IGNORED_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
9
11
|
const MAX_IGNORED_SNAPSHOT_BYTES = 64 * 1024 * 1024;
|
|
10
12
|
const ignoredContentSnapshots = new WeakMap();
|
|
13
|
+
const ignoredObservations = new WeakMap();
|
|
11
14
|
/** Git listings of large untracked or ignored trees exceed spawnSync's 1 MiB default. */
|
|
12
15
|
const GIT_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
13
16
|
export class RollbackStateUnavailableError extends Error {
|
|
@@ -50,7 +53,12 @@ export async function captureRollbackBoundary(input) {
|
|
|
50
53
|
for (const filePath of snapshotPaths) {
|
|
51
54
|
snapshots.push(await readRollbackSnapshot(input.repoRoot, filePath));
|
|
52
55
|
}
|
|
53
|
-
const
|
|
56
|
+
const observedIgnoredFiles = readIgnoredInventory(input.repoRoot);
|
|
57
|
+
const ignoredFiles = observedIgnoredFiles.map(({ path, size, mtimeMs }) => ({
|
|
58
|
+
path,
|
|
59
|
+
size,
|
|
60
|
+
mtimeMs
|
|
61
|
+
}));
|
|
54
62
|
const headRef = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
|
|
55
63
|
const headBranch = readHeadBranch(input.repoRoot);
|
|
56
64
|
const boundary = {
|
|
@@ -63,6 +71,7 @@ export async function captureRollbackBoundary(input) {
|
|
|
63
71
|
snapshots,
|
|
64
72
|
ignoredFiles
|
|
65
73
|
};
|
|
74
|
+
ignoredObservations.set(boundary, new Map(observedIgnoredFiles.map((entry) => [entry.path, entry])));
|
|
66
75
|
ignoredContentSnapshots.set(boundary, captureIgnoredContent(input.repoRoot, ignoredFiles));
|
|
67
76
|
return boundary;
|
|
68
77
|
}
|
|
@@ -75,6 +84,7 @@ export function listIgnoredFileChangesSinceBoundary(input) {
|
|
|
75
84
|
return makeIgnoredFileChanges([], [], []);
|
|
76
85
|
}
|
|
77
86
|
const baseline = new Map(input.boundary.ignoredFiles.map((entry) => [entry.path, entry]));
|
|
87
|
+
const baselineObservations = ignoredObservations.get(input.boundary);
|
|
78
88
|
const created = [];
|
|
79
89
|
const modified = [];
|
|
80
90
|
const currentEntries = readIgnoredInventory(input.repoRoot);
|
|
@@ -87,15 +97,23 @@ export function listIgnoredFileChangesSinceBoundary(input) {
|
|
|
87
97
|
if (!previous) {
|
|
88
98
|
created.push(entry.path);
|
|
89
99
|
}
|
|
90
|
-
else if (!entry.path.endsWith("/") &&
|
|
91
|
-
(previous.sha256 !== undefined && entry.sha256 !== undefined
|
|
92
|
-
? previous.sha256 !== entry.sha256
|
|
93
|
-
: previous.size !== entry.size || previous.mtimeMs !== entry.mtimeMs)) {
|
|
100
|
+
else if (!entry.path.endsWith("/") && ignoredObservationChanged(previous, baselineObservations?.get(entry.path), entry)) {
|
|
94
101
|
modified.push(entry.path);
|
|
95
102
|
}
|
|
96
103
|
}
|
|
97
104
|
return makeIgnoredFileChanges(created.sort(), modified.sort(), deleted.sort());
|
|
98
105
|
}
|
|
106
|
+
function ignoredObservationChanged(previous, previousObservation, current) {
|
|
107
|
+
if (previousObservation?.sha256 !== undefined && current.sha256 !== undefined) {
|
|
108
|
+
return previousObservation.sha256 !== current.sha256;
|
|
109
|
+
}
|
|
110
|
+
if (previousObservation !== undefined) {
|
|
111
|
+
return previous.size !== current.size ||
|
|
112
|
+
previous.mtimeMs !== current.mtimeMs ||
|
|
113
|
+
previousObservation.ctimeMs !== current.ctimeMs;
|
|
114
|
+
}
|
|
115
|
+
return previous.size !== current.size || previous.mtimeMs !== current.mtimeMs;
|
|
116
|
+
}
|
|
99
117
|
function makeIgnoredFileChanges(created, modified, deleted) {
|
|
100
118
|
const changes = { created, modified };
|
|
101
119
|
// Preserve the historical enumerable shape when there are no deletions while
|
|
@@ -345,25 +363,31 @@ function readIgnoredInventory(repoRoot) {
|
|
|
345
363
|
]));
|
|
346
364
|
const inventory = [];
|
|
347
365
|
let expanded = 0;
|
|
366
|
+
const hashBudget = { remainingBytes: MAX_HASHED_IGNORED_TOTAL_BYTES };
|
|
348
367
|
for (const entry of collapsed) {
|
|
349
368
|
const isDirectory = entry.endsWith("/");
|
|
350
369
|
const leaf = entry.replace(/\/$/u, "").split("/").at(-1) ?? entry;
|
|
351
|
-
if (isDirectory &&
|
|
352
|
-
|
|
353
|
-
inventory.push({ path: entry, size: 0, mtimeMs: 0 });
|
|
370
|
+
if (isDirectory && DEPENDENCY_AND_BUILD_DIRECTORIES.has(leaf)) {
|
|
371
|
+
inventory.push({ path: entry, size: 0, mtimeMs: 0, ctimeMs: 0 });
|
|
354
372
|
continue;
|
|
355
373
|
}
|
|
374
|
+
if (isDirectory && expanded >= MAX_EXPANDED_IGNORED_FILES) {
|
|
375
|
+
throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
|
|
376
|
+
}
|
|
356
377
|
const files = isDirectory
|
|
357
378
|
? collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", entry]))
|
|
358
379
|
: [entry];
|
|
359
380
|
for (const filePath of files) {
|
|
360
|
-
|
|
381
|
+
if (expanded >= MAX_EXPANDED_IGNORED_FILES) {
|
|
382
|
+
throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
|
|
383
|
+
}
|
|
384
|
+
inventory.push(stampIgnoredFile(repoRoot, filePath, hashBudget));
|
|
361
385
|
expanded += 1;
|
|
362
386
|
}
|
|
363
387
|
}
|
|
364
388
|
return inventory.sort((left, right) => left.path.localeCompare(right.path));
|
|
365
389
|
}
|
|
366
|
-
function stampIgnoredFile(repoRoot, filePath) {
|
|
390
|
+
function stampIgnoredFile(repoRoot, filePath, hashBudget) {
|
|
367
391
|
try {
|
|
368
392
|
const absolutePath = resolveRepoPath(repoRoot, filePath, { allowLeafLink: true });
|
|
369
393
|
const link = isLinkPath(repoRoot, filePath);
|
|
@@ -371,10 +395,16 @@ function stampIgnoredFile(repoRoot, filePath) {
|
|
|
371
395
|
const stamp = {
|
|
372
396
|
path: normalizeRepoPath(filePath),
|
|
373
397
|
size: stats.size,
|
|
374
|
-
mtimeMs: Math.trunc(stats.mtimeMs)
|
|
398
|
+
mtimeMs: Math.trunc(stats.mtimeMs),
|
|
399
|
+
ctimeMs: Math.trunc(stats.ctimeMs)
|
|
375
400
|
};
|
|
376
|
-
if (!link &&
|
|
401
|
+
if (!link &&
|
|
402
|
+
stats.isFile() &&
|
|
403
|
+
stats.size <= MAX_HASHED_IGNORED_FILE_BYTES &&
|
|
404
|
+
stats.size <= hashBudget.remainingBytes &&
|
|
405
|
+
!isInsideDependencyOrBuildDirectory(filePath)) {
|
|
377
406
|
stamp.sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
|
|
407
|
+
hashBudget.remainingBytes -= stats.size;
|
|
378
408
|
}
|
|
379
409
|
return stamp;
|
|
380
410
|
}
|
|
@@ -383,7 +413,7 @@ function stampIgnoredFile(repoRoot, filePath) {
|
|
|
383
413
|
throw new RollbackStateUnavailableError(`Unable to authenticate ignored rollback path ${normalizeRepoPath(filePath)}: ${toErrorMessage(error)}`);
|
|
384
414
|
}
|
|
385
415
|
// Vanished between listing and stat; record presence only so later comparison fails closed.
|
|
386
|
-
return { path: normalizeRepoPath(filePath), size: -1, mtimeMs: -1 };
|
|
416
|
+
return { path: normalizeRepoPath(filePath), size: -1, mtimeMs: -1, ctimeMs: -1 };
|
|
387
417
|
}
|
|
388
418
|
}
|
|
389
419
|
function captureIgnoredContent(repoRoot, ignoredFiles) {
|
|
@@ -226,43 +226,66 @@ export function findWeakenedTestFiles(input) {
|
|
|
226
226
|
if (candidates.length === 0) {
|
|
227
227
|
return [];
|
|
228
228
|
}
|
|
229
|
-
const
|
|
230
|
-
|
|
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();
|
|
231
243
|
}
|
|
232
|
-
/**
|
|
233
|
-
function
|
|
244
|
+
/** Modified files among `candidates`, with whether they lost a line (or are binary). */
|
|
245
|
+
function listChangedTestFiles(repoRoot, baseRef, candidates) {
|
|
234
246
|
const result = spawnSync("git", ["diff", "--numstat", "--no-renames", "-z", baseRef, "--", ...candidates], { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
235
247
|
if (result.status !== 0 || typeof result.stdout !== "string") {
|
|
236
248
|
const detail = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
237
249
|
throw new Error(`Test-change inspection failed: ${detail || `git exited with ${String(result.status)}`}`);
|
|
238
250
|
}
|
|
239
|
-
const
|
|
251
|
+
const changed = new Map();
|
|
240
252
|
for (const record of result.stdout.split("\0")) {
|
|
241
|
-
const [, deletions, filePath] = record.split("\t");
|
|
242
|
-
if (filePath &&
|
|
243
|
-
|
|
253
|
+
const [additions, deletions, filePath] = record.split("\t");
|
|
254
|
+
if (filePath && additions !== undefined && deletions !== undefined) {
|
|
255
|
+
changed.set(filePath.trim(), deletions === "-" || Number(deletions) > 0);
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
|
-
return
|
|
258
|
+
return changed;
|
|
247
259
|
}
|
|
248
260
|
/** Cap on file size the surface analysis reads; larger files use the strict rule. */
|
|
249
261
|
const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
|
|
250
|
-
|
|
262
|
+
const BASE_CONTENT_UNINSPECTABLE = Symbol("base-content-uninspectable");
|
|
263
|
+
function isWeakened(input, filePath, removedLines) {
|
|
251
264
|
const before = readBaseContent(input.repoRoot, input.baseRef, filePath);
|
|
252
|
-
if (before ===
|
|
265
|
+
if (before === BASE_CONTENT_UNINSPECTABLE) {
|
|
253
266
|
return true;
|
|
254
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
|
+
}
|
|
255
274
|
const after = readWorkingContent(input.repoRoot, filePath);
|
|
256
275
|
if (after === undefined) {
|
|
257
276
|
// Deleted, replaced by a link, unreadable or oversized: cannot prove it still asserts.
|
|
258
277
|
return true;
|
|
259
278
|
}
|
|
260
279
|
if (!hasTestSurface(extractTestSurface(before, filePath))) {
|
|
261
|
-
return
|
|
280
|
+
return removedLines || testSurfaceWeakened(before, after, filePath);
|
|
262
281
|
}
|
|
263
282
|
return testSurfaceWeakened(before, after, filePath);
|
|
264
283
|
}
|
|
265
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
|
+
}
|
|
266
289
|
const result = spawnSync("git", ["show", `${baseRef}:${filePath}`], {
|
|
267
290
|
cwd: repoRoot,
|
|
268
291
|
encoding: "utf8",
|
|
@@ -270,7 +293,7 @@ function readBaseContent(repoRoot, baseRef, filePath) {
|
|
|
270
293
|
});
|
|
271
294
|
return result.status === 0 && typeof result.stdout === "string" && result.stdout.length <= MAX_SURFACE_BYTES
|
|
272
295
|
? result.stdout
|
|
273
|
-
:
|
|
296
|
+
: BASE_CONTENT_UNINSPECTABLE;
|
|
274
297
|
}
|
|
275
298
|
function readWorkingContent(repoRoot, filePath) {
|
|
276
299
|
const absolutePath = join(repoRoot, filePath);
|
|
@@ -28,8 +28,11 @@ const CASE_PATTERNS = [
|
|
|
28
28
|
];
|
|
29
29
|
const SKIP_PATTERNS = [
|
|
30
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,
|
|
31
34
|
/\bx(?:it|describe|test|context)\s*\(/gu,
|
|
32
|
-
/@pytest\.mark\.skip\w
|
|
35
|
+
/@pytest\.mark\.(?:skip\w*|xfail)|\bpytest\.(?:skip|xfail)\s*\(|@unittest\.(?:skip\w*|expectedFailure)|\bself\.skipTest\s*\(/gu,
|
|
33
36
|
/\bt\.Skip\w*\s*\(/gu,
|
|
34
37
|
/#\[ignore\b/gu,
|
|
35
38
|
/@Disabled\b|@Ignore\b/gu
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** Returns a reason when a verifier would be parsed differently from what the operator intended. */
|
|
2
|
-
export declare function describeMalformedVerifier(command: string
|
|
2
|
+
export declare function describeMalformedVerifier(command: string, options?: {
|
|
3
|
+
detectShellFold?: boolean;
|
|
4
|
+
}): string | undefined;
|
|
@@ -7,6 +7,10 @@ const MARTIN_OWNED_OPTIONS = new Set([
|
|
|
7
7
|
]);
|
|
8
8
|
const MARTIN_ENGINES = new Set(["auto", "claude", "codex", "gemini", "openai"]);
|
|
9
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"]);
|
|
10
14
|
function foldedMartinOptions(tokens) {
|
|
11
15
|
const folded = [];
|
|
12
16
|
tokens.forEach((token, index) => {
|
|
@@ -24,12 +28,15 @@ function foldedMartinOptions(tokens) {
|
|
|
24
28
|
return folded;
|
|
25
29
|
}
|
|
26
30
|
/** Returns a reason when a verifier would be parsed differently from what the operator intended. */
|
|
27
|
-
export function describeMalformedVerifier(command) {
|
|
31
|
+
export function describeMalformedVerifier(command, options = {}) {
|
|
28
32
|
const quote = findUnterminatedQuote(command);
|
|
29
33
|
if (quote !== undefined)
|
|
30
34
|
return `--verify value has an unterminated quote (${quote}): ${command}`;
|
|
35
|
+
if (options.detectShellFold === false)
|
|
36
|
+
return undefined;
|
|
31
37
|
const tokens = command.trim().split(/\s+/u);
|
|
32
|
-
const
|
|
38
|
+
const executableIndex = resolveLaunchedExecutableIndex(tokens);
|
|
39
|
+
const executable = normalizeExecutableToken(tokens[executableIndex] ?? "");
|
|
33
40
|
if (MARTIN_EXECUTABLES.has(executable))
|
|
34
41
|
return undefined;
|
|
35
42
|
const swallowed = foldedMartinOptions(tokens);
|
|
@@ -39,6 +46,47 @@ export function describeMalformedVerifier(command) {
|
|
|
39
46
|
}
|
|
40
47
|
return undefined;
|
|
41
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
|
+
}
|
|
42
90
|
function findUnterminatedQuote(command) {
|
|
43
91
|
let quote;
|
|
44
92
|
for (let index = 0; index < command.length; index += 1) {
|
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
|
}
|