Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion schema/repair/codex-result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@
"source_prs",
"repair_strategy",
"allow_no_pr",
"branch_update_blockers"
"branch_update_blockers",
"repair_contract"
],
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -356,6 +357,31 @@
"items": {
"type": "string"
}
},
"repair_contract": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"required": ["must_touch", "match"],
"additionalProperties": false,
"properties": {
"must_touch": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"match": {
"type": "string",
"enum": ["any", "all"]
}
}
}
]
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/repair/commit-finding-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ function writeSyntheticRun(context: LooseRecord) {
repair_strategy: "new_fix_pr",
allow_no_pr: true,
branch_update_blockers: [],
repair_contract: null,
},
};
fs.writeFileSync(
Expand Down
1 change: 1 addition & 0 deletions src/repair/deterministic-automerge-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function deterministicAutomergeResult({
repair_strategy: "repair_contributor_branch",
allow_no_pr: false,
branch_update_blockers: [],
repair_contract: null,
};

return {
Expand Down
27 changes: 22 additions & 5 deletions src/repair/execute-fix-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ import {
type TargetValidationOptions,
} from "./target-validation.js";
import { uniqueStrings } from "./validation-command-utils.js";
import {
changedFilesFromNameOnlyZ,
enforceRepairContract,
repairContract,
} from "./repair-contract.js";
import {
rebaseConflictEditDecision,
unresolvedRebaseConflictReason,
Expand Down Expand Up @@ -1996,6 +2001,8 @@ function editValidatePrepareMerge({
let producedChanges = allowExistingChanges;
let previousSummary = "";
const checkpointCommits: JsonValue[] = [];
const hasRepairContract = repairContract(fixArtifact) !== null;
const pushIntermediateCheckpoint = hasRepairContract ? null : pushCheckpoint;
if (
!producedChanges &&
canTreatRebaseAsCompleteRepair({
Expand Down Expand Up @@ -2210,7 +2217,7 @@ function editValidatePrepareMerge({
});
if (firstCheckpoint) {
checkpointCommits.push(firstCheckpoint);
pushCheckpoint?.();
pushIntermediateCheckpoint?.();
}

let codexReview = null;
Expand Down Expand Up @@ -2241,7 +2248,7 @@ function editValidatePrepareMerge({
});
if (checkpoint) {
checkpointCommits.push(checkpoint);
pushCheckpoint?.();
pushIntermediateCheckpoint?.();
}
},
});
Expand Down Expand Up @@ -2272,7 +2279,7 @@ function editValidatePrepareMerge({
});
if (checkpoint) {
checkpointCommits.push(checkpoint);
pushCheckpoint?.();
pushIntermediateCheckpoint?.();
}
if (attempt === maxFinalBaseSyncAttempts) {
codexReview.final_base_sync = {
Expand All @@ -2292,7 +2299,7 @@ function editValidatePrepareMerge({
});
if (finalCheckpoint) {
checkpointCommits.push(finalCheckpoint);
pushCheckpoint?.();
pushIntermediateCheckpoint?.();
}
const historyCompaction =
mode === "replacement"
Expand All @@ -2304,7 +2311,8 @@ function editValidatePrepareMerge({
checkpointCommits,
})
: null;
if (historyCompaction?.status === "compacted") {
enforceFinalRepairContract({ fixArtifact, targetDir, baseBranch });
if (hasRepairContract || historyCompaction?.status === "compacted") {
pushCheckpoint?.();
}
const commit = run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim();
Expand Down Expand Up @@ -3367,6 +3375,15 @@ function commitCheckpointIfNeeded({ targetDir, message, trailers = [] }: LooseRe
return run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim();
}

function enforceFinalRepairContract({ fixArtifact, targetDir, baseBranch }: LooseRecord) {
if (!repairContract(fixArtifact)) return;
const baseRef = `origin/${baseBranch}`;
const changedFiles = changedFilesFromNameOnlyZ(
run("git", ["diff", "--name-only", "-z", `${baseRef}..HEAD`], { cwd: targetDir }),
);
enforceRepairContract({ fixArtifact, changedFiles });
}

function pushRecoverableBranch({ targetDir, branch }: LooseRecord) {
assertIssueImplementationNotPaused();
const remoteSha = remoteBranchSha({ targetDir, branch });
Expand Down
3 changes: 3 additions & 0 deletions src/repair/execute-fix-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";

import type { JsonValue, LooseRecord } from "./json-types.js";
import { validateRepairContractShape } from "./repair-contract.js";
import { GITHUB_PR_TITLE_MAX_LENGTH } from "./pr-title.js";
import { slug } from "./text-utils.js";

Expand Down Expand Up @@ -51,6 +52,8 @@ export function validateFixArtifact(fixArtifact: LooseRecord): LooseRecord {
if (typeof fixArtifact.changelog_required !== "boolean") {
throw new Error("fix_artifact.changelog_required must be boolean");
}
const contractErrors = validateRepairContractShape(fixArtifact);
if (contractErrors.length > 0) throw new Error(contractErrors.join("; "));
if (!REPAIR_STRATEGIES.has(fixArtifact.repair_strategy)) {
throw new Error("fix_artifact.repair_strategy is not executable");
}
Expand Down
1 change: 1 addition & 0 deletions src/repair/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export function renderPrompt(
parts.push(
"## Required final output",
"Return JSON matching `schema/repair/codex-result.schema.json` and nothing else.",
"If the fix has explicit files that must differ from the latest base in the final repaired tree, set `fix_artifact.repair_contract` to an object with `must_touch` and `match` (`any` or `all`). The executor checks this once against the final branch delta after review fixes and base sync. Set `repair_contract` to null when the expected edit surface is uncertain, only represented by incomplete `likely_files`, or the work is a pure deterministic rebase.",
);

return parts.join("\n\n");
Expand Down
110 changes: 110 additions & 0 deletions src/repair/repair-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { JsonValue, LooseRecord } from "./json-types.js";

export interface RepairContract {
mustTouch: string[];
match: "any" | "all";
}

export function repairContract(fixArtifact: LooseRecord): RepairContract | null {
const raw = fixArtifact.repair_contract;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
if (validateRepairContractShape(fixArtifact).length > 0) return null;
const mustTouch = uniqueStrings(raw.must_touch.map(normalizeRepairContractPath));
return {
mustTouch,
match: raw.match,
};
}

export function enforceRepairContract({
fixArtifact,
changedFiles: rawChangedFiles,
}: {
fixArtifact: LooseRecord;
changedFiles: readonly string[];
}): void {
const contract = repairContract(fixArtifact);
if (!contract) return;

const changedFiles = uniqueStrings(
rawChangedFiles.map(normalizeRepairContractPath).filter(Boolean),
);
const matched = contract.mustTouch.filter((expected) =>
changedFiles.some((file) => changedFileMatchesContract(file, expected)),
);
const ok =
contract.match === "all" ? matched.length === contract.mustTouch.length : matched.length > 0;
if (ok) return;

const missing = contract.mustTouch.filter((expected) => !matched.includes(expected));
throw new Error(
[
"repair contract rejected final repair tree: required paths are missing from the final branch delta",
`match=${contract.match}`,
`must_touch=${contract.mustTouch.join(", ")}`,
`matched=${matched.join(", ") || "none"}`,
`missing=${missing.join(", ") || "none"}`,
`changed_files=${changedFiles.join(", ") || "none"}`,
].join("; "),
);
}

export function changedFilesFromNameOnlyZ(diff: string): string[] {
return uniqueStrings(diff.split("\0").map(normalizeRepairContractPath).filter(Boolean));
}

export function validateRepairContractShape(fixArtifact: LooseRecord): string[] {
const raw = fixArtifact.repair_contract;
if (raw === undefined || raw === null) return [];
if (typeof raw !== "object" || Array.isArray(raw)) {
return ["fix_artifact.repair_contract must be an object when present"];
}

const errors: string[] = [];
const allowedKeys = new Set(["must_touch", "match"]);
for (const key of Object.keys(raw)) {
if (!allowedKeys.has(key)) {
errors.push(`fix_artifact.repair_contract.${key} is not allowed`);
}
}
if (!Array.isArray(raw.must_touch) || raw.must_touch.length === 0) {
errors.push("fix_artifact.repair_contract.must_touch must be a non-empty list");
}
for (const value of Array.isArray(raw.must_touch) ? raw.must_touch : []) {
if (typeof value !== "string") {
errors.push("fix_artifact.repair_contract.must_touch entries must be strings");
continue;
}
if (!normalizeRepairContractPath(value)) {
errors.push(
`fix_artifact.repair_contract.must_touch contains an unsafe path: ${String(value)}`,
);
}
}
if (raw.match !== "any" && raw.match !== "all") {
errors.push("fix_artifact.repair_contract.match must be any or all");
}
if (fixArtifact.deterministic_rebase_only === true) {
errors.push(
"fix_artifact.repair_contract is incompatible with deterministic_rebase_only because a pure base sync has no repair delta",
);
}
return errors;
}

function changedFileMatchesContract(changedFile: string, expected: string): boolean {
const prefix = expected.replace(/\/$/, "");
return changedFile === expected || changedFile.startsWith(`${prefix}/`);
}

function normalizeRepairContractPath(value: JsonValue): string {
const pathValue = String(value ?? "").trim();
if (!pathValue || pathValue.startsWith("/") || pathValue.includes("\0")) return "";
if (/[`$;&|<>()[\]{}*?~]/.test(pathValue)) return "";
if (pathValue.split(/[\\/]/).includes("..")) return "";
return pathValue.replace(/^\.\//, "");
}

function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)];
}
2 changes: 2 additions & 0 deletions src/repair/review-results.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import type { JsonValue, LooseRecord } from "./json-types.js";
import { validateRepairContractShape } from "./repair-contract.js";
import fs from "node:fs";
import path from "node:path";
import { parseArgs, parseJob, repoRoot } from "./lib.js";
Expand Down Expand Up @@ -526,6 +527,7 @@ function validateFixArtifact(fixArtifact: LooseRecord, failures: LooseRecord[])
if (typeof fixArtifact.changelog_required !== "boolean") {
failures.push("fix_artifact.changelog_required must be boolean");
}
failures.push(...validateRepairContractShape(fixArtifact));
if (!FIX_REPAIR_STRATEGIES.has(fixArtifact.repair_strategy)) {
failures.push("fix_artifact.repair_strategy is required");
}
Expand Down
1 change: 1 addition & 0 deletions test/repair/deterministic-automerge-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ test("deterministic automerge result emits generic direct-Codex repair artifact"
]);
assert.deepEqual(result?.fix_artifact.affected_surfaces, ["extensions/memory-core"]);
assert.equal(result?.fix_artifact.changelog_required, false);
assert.equal(result?.fix_artifact.repair_contract, null);
assert.deepEqual(result?.fix_artifact.source_prs, [
"https://github.com/openclaw/openclaw/pull/71898",
]);
Expand Down
35 changes: 35 additions & 0 deletions test/repair/execute-fix-artifact-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,38 @@ test("issue implementation rechecks opt-out labels immediately before branch pus
assert.match(source.slice(helperStart, helperEnd), /repairPauseLabel\(issue\.labels\)/);
assert.match(source.slice(helperStart, helperEnd), /refusing to push or open a PR/);
});

test("repair contract gates the final cumulative tree, not individual checkpoints", () => {
const source = readText(path.join(process.cwd(), "src/repair/execute-fix-artifact.ts"));
assert.equal(
[...source.matchAll(/commitCheckpointIfNeeded\(/g)].length,
5,
"four checkpoint call sites plus the ordinary commit helper should remain",
);
assert.doesNotMatch(source, /commitRepairCheckpointIfNeeded|checkpointBaseHead/);
assert.match(source, /enforceFinalRepairContract\(\{ fixArtifact, targetDir, baseBranch \}\)/);
assert.equal(
[...source.matchAll(/pushIntermediateCheckpoint\?\.\(\)/g)].length,
4,
"contract jobs must defer all four recovery pushes until final validation",
);
assert.match(source, /if \(hasRepairContract \|\| historyCompaction\?\.status === "compacted"\)/);

const compact = source.indexOf("const historyCompaction =");
const enforce = source.indexOf("enforceFinalRepairContract(", compact);
const publish = source.indexOf("if (hasRepairContract", enforce);
const commit = source.indexOf('const commit = run("git", ["rev-parse", "HEAD"]', publish);
assert.ok(compact < enforce && enforce < publish && publish < commit);
});

test("final repair contract compares the repaired tree with the latest base", () => {
const source = readText(path.join(process.cwd(), "src/repair/execute-fix-artifact.ts"));
const start = source.indexOf("function enforceFinalRepairContract(");
const end = source.indexOf("function pushRecoverableBranch(", start);
const helper = source.slice(start, end);
assert.match(source, /\.\/repair-contract\.js/);
assert.match(helper, /const baseRef = `origin\/\$\{baseBranch\}`/);
assert.match(helper, /"diff", "--name-only", "-z", `\$\{baseRef\}\.\.HEAD`/);
assert.match(helper, /enforceRepairContract\(\{ fixArtifact, changedFiles \}\)/);
assert.doesNotMatch(helper, /--porcelain=v1|phase|checkpoint/);
});
Loading