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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.
- Fixed Fastify plugin callback route mapping for typed parameters and plugin aliases, thanks @rohitjavvadi.
- Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911.
- Fixed review-output schema to tolerate optional `reproduction` and `minimumFixScope` fields and zero-valued evidence line numbers (normalized to `null`), recovering 4 of 28 zod issue patterns observed in run `20260517T190759-3c9e9e` (78 errors over 1000 features) that previously dropped whole-feature output instead of the affected finding.

## 0.3.0 - 2026-05-18

Expand Down
12 changes: 11 additions & 1 deletion src/provider-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const revalidateJsonSchema = providerJsonSchema(revalidateOutputSchema);
export const fixPlanJsonSchema = providerJsonSchema(fixPlanOutputSchema);

export function providerJsonSchema(schema: z.ZodType): object {
return stripProviderUnsupportedSchemaKeywords(z.toJSONSchema(schema)) as object;
return stripProviderUnsupportedSchemaKeywords(
z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
) as object;
}

function stripProviderUnsupportedSchemaKeywords(value: unknown): unknown {
Expand All @@ -38,5 +40,13 @@ function stripProviderUnsupportedSchemaKeywords(value: unknown): unknown {
}
output[key] = stripProviderUnsupportedSchemaKeywords(item);
}
if (output["type"] === "object" && isRecord(output["properties"])) {
output["additionalProperties"] = false;
output["required"] = Object.keys(output["properties"]);
}
return output;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
113 changes: 112 additions & 1 deletion src/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { ClawpatchError } from "./errors.js";
import { __testing, extractJson, providerByName } from "./provider.js";
import { safeProviderPreview } from "./provider-json.js";
import { revalidateOutputSchema, reviewOutputSchema } from "./types.js";
import { evidenceRefSchema, revalidateOutputSchema, reviewOutputSchema } from "./types.js";

// eslint-disable-next-line no-underscore-dangle
const {
Expand Down Expand Up @@ -260,6 +260,20 @@ describe("providerJsonSchema", () => {
expect(enumNodes.every((node) => node["type"] === "string")).toBe(true);
}
});

it("keeps object schemas strict even when parser input fields are optional", () => {
const schema = providerJsonSchema(reviewOutputSchema) as Record<string, unknown>;
const findings = propertySchema(schema, "findings");
const finding = itemSchema(findings);
const inspected = propertySchema(schema, "inspected");

for (const objectSchema of [schema, finding, inspected]) {
expect(objectSchema["additionalProperties"]).toBe(false);
expect(objectSchema["required"]).toEqual(Object.keys(propertiesOf(objectSchema)));
}
expect(finding["required"]).toContain("reproduction");
expect(finding["required"]).toContain("minimumFixScope");
});
});

describe("piThinkingLevel", () => {
Expand Down Expand Up @@ -294,6 +308,30 @@ function enumSchemaNodes(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(node["enum"]) ? [node, ...nested] : nested;
}

function propertySchema(schema: Record<string, unknown>, name: string): Record<string, unknown> {
const value = propertiesOf(schema)[name];
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`missing schema property: ${name}`);
}
return value as Record<string, unknown>;
}

function itemSchema(schema: Record<string, unknown>): Record<string, unknown> {
const value = schema["items"];
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("missing item schema");
}
return value as Record<string, unknown>;
}

function propertiesOf(schema: Record<string, unknown>): Record<string, unknown> {
const value = schema["properties"];
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("missing schema properties");
}
return value as Record<string, unknown>;
}

describe("codexFailureMessage", () => {
it("adds scope guidance for missing Responses API write permission", () => {
const message = codexFailureMessage(
Expand Down Expand Up @@ -748,6 +786,79 @@ describe("providerByName", () => {
});
});

function buildToleranceFinding(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
title: "x",
category: "bug",
severity: "low",
confidence: "low",
evidence: [],
reasoning: "r",
reproduction: null,
recommendation: "rec",
whyTestsDoNotAlreadyCoverThis: "",
suggestedRegressionTest: null,
minimumFixScope: "",
...overrides,
};
}

function buildToleranceOutput(finding: Record<string, unknown>): Record<string, unknown> {
return {
findings: [finding],
inspected: { files: [], symbols: [], notes: [] },
};
}

describe("reviewOutputSchema tolerance", () => {
it("accepts findings with null reproduction", () => {
const parsed = reviewOutputSchema.parse(
buildToleranceOutput(buildToleranceFinding({ reproduction: null })),
);
expect(parsed.findings[0]!.reproduction).toBeNull();
});

it("accepts findings with omitted reproduction (becomes null)", () => {
const finding = buildToleranceFinding();
delete finding["reproduction"];
const parsed = reviewOutputSchema.parse(buildToleranceOutput(finding));
expect(parsed.findings[0]!.reproduction).toBeNull();
});

it("accepts findings with omitted minimumFixScope (becomes empty string)", () => {
const finding = buildToleranceFinding();
delete finding["minimumFixScope"];
const parsed = reviewOutputSchema.parse(buildToleranceOutput(finding));
expect(parsed.findings[0]!.minimumFixScope).toBe("");
});
});

describe("evidenceRefSchema tolerance", () => {
it("accepts startLine 0 and normalizes to null", () => {
const parsed = evidenceRefSchema.parse({
path: "src/index.ts",
startLine: 0,
endLine: 5,
symbol: null,
quote: null,
});
expect(parsed.startLine).toBeNull();
expect(parsed.endLine).toBeNull();
});

it("accepts endLine 0 and normalizes to null", () => {
const parsed = evidenceRefSchema.parse({
path: "src/index.ts",
startLine: 5,
endLine: 0,
symbol: null,
quote: null,
});
expect(parsed.startLine).toBeNull();
expect(parsed.endLine).toBeNull();
});
});

describe("acpxPromptRetries", () => {
afterEach(() => {
delete process.env["CLAWPATCH_ACPX_PROMPT_RETRIES"];
Expand Down
32 changes: 23 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,21 @@ export type FeatureRecord = z.infer<typeof featureRecordSchema>;
export type FeatureKind = FeatureRecord["kind"];
export type TrustBoundary = FeatureRecord["trustBoundaries"][number];

export const evidenceRefSchema = z.object({
path: z.string(),
startLine: z.number().int().positive().nullable(),
endLine: z.number().int().positive().nullable(),
symbol: z.string().nullable(),
quote: z.string().nullable(),
});
const evidenceLineSchema = z.number().int().min(0).nullable();

export const evidenceRefSchema = z
.object({
path: z.string(),
startLine: evidenceLineSchema,
endLine: evidenceLineSchema,
symbol: z.string().nullable(),
quote: z.string().nullable(),
})
.transform((evidence) =>
evidence.startLine === 0 || evidence.endLine === 0
? { ...evidence, startLine: null, endLine: null }
: evidence,
);

export const findingHistoryEntrySchema = z.object({
runId: z.string().nullable(),
Expand Down Expand Up @@ -358,11 +366,17 @@ export const reviewOutputSchema = z.object({
confidence: z.enum(["high", "medium", "low"]),
evidence: z.array(evidenceRefSchema),
reasoning: z.string(),
reproduction: z.string().nullable(),
reproduction: z
.string()
.nullish()
.transform((v) => v ?? null),
recommendation: z.string(),
whyTestsDoNotAlreadyCoverThis: z.string(),
suggestedRegressionTest: z.string().nullable(),
minimumFixScope: z.string(),
minimumFixScope: z
.string()
.nullish()
.transform((v) => v ?? ""),
}),
),
inspected: z.object({
Expand Down