diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c5b20746e..bb4d0f9a4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Changed + +- Changed tool argument validation to always normalize optional null values before type coercion, ensuring consistent handling of LLM-generated 'null' strings + +### Fixed + +- Fixed tool argument validation to properly handle string 'null' values from LLMs on optional fields by stripping them during normalization +- Improved type safety of `validateToolCall` and `validateToolArguments` functions by returning properly typed `ToolCall["arguments"]` instead of `any` ## [13.12.9] - 2026-03-17 ### Changed diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index 97a473846..f75ce4b47 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -43,7 +43,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai"); const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); - const isOpenRouterKimi = provider === "openrouter" && model.id.includes("moonshotai/kimi"); + const isKimiModel = model.id.includes("moonshotai/kimi"); const isAlibaba = provider === "alibaba-coding-plan" || baseUrl.includes("dashscope"); const isQwen = model.id.toLowerCase().includes("qwen"); @@ -91,8 +91,8 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved requiresMistralToolIds: isMistral, thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai", reasoningContentField: "reasoning_content", - requiresReasoningContentForToolCalls: isOpenRouterKimi, - requiresAssistantContentForToolCalls: isOpenRouterKimi, + requiresReasoningContentForToolCalls: isKimiModel, + requiresAssistantContentForToolCalls: isKimiModel, openRouterRouting: undefined, vercelGatewayRouting: undefined, supportsStrictMode: detectStrictModeSupport(provider, baseUrl), diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 702f80790..c56ea0977 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -172,7 +172,14 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: try { const parsed = JSON.parse(trimmed) as unknown; - // Only accept if the parsed type matches what the schema expects + // If the string was "null", we parsed it to actual null. + // Accept this even if null isn't in expectedTypes - the LLM meant "no value". + // normalizeOptionalNullsForSchema will strip it from optional fields, and + // AJV will correctly error on required fields. + if (parsed === null && trimmed === "null") { + return { value: null, changed: true }; + } + // For non-null values, only accept if the parsed type matches what the schema expects if (matchesExpectedType(parsed, expectedTypes)) { return { value: parsed, changed: true }; } @@ -378,7 +385,9 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val if (!(key in nextValue)) continue; const currentValue = nextValue[key]; - if (currentValue === null && !required.has(key)) { + // Strip null and the string "null" from optional fields. + // The LLM sometimes outputs string "null" to mean "no value". + if ((currentValue === null || currentValue === "null") && !required.has(key)) { if (!changed) { nextValue = { ...nextValue }; changed = true; @@ -386,7 +395,6 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val delete nextValue[key]; continue; } - const normalized = normalizeOptionalNullsForSchema(propertySchema, currentValue); if (!normalized.changed) continue; @@ -482,7 +490,7 @@ const MAX_TYPE_COERCION_PASSES = 5; * @returns The validated arguments * @throws Error if tool is not found or validation fails */ -export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { +export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["arguments"] { const tool = tools.find(t => t.name === toolCall.name); if (!tool) { throw new Error(`Tool "${toolCall.name}" not found`); @@ -497,26 +505,26 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { * @returns The validated arguments * @throws Error with formatted message if validation fails */ -export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { +export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] { const originalArgs = toolCall.arguments; const validate = compileSchema(tool.parameters); - // Validate the arguments - if (validate(originalArgs)) { - return originalArgs; - } - + // Always normalize first - strip null and string "null" from optional fields. + // This handles LLM outputting string "null" to mean "no value" even when + // validation would pass (e.g., optional string field where "null" is a valid string). let normalizedArgs: unknown = originalArgs; let changed = false; - const optionalNullNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs); - if (optionalNullNormalization.changed) { - normalizedArgs = optionalNullNormalization.value; + const initialNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs); + if (initialNormalization.changed) { + normalizedArgs = initialNormalization.value; changed = true; - if (validate(normalizedArgs)) { - return normalizedArgs; - } + } + + // Validate after normalization + if (validate(normalizedArgs)) { + return normalizedArgs as ToolCall["arguments"]; } for (let pass = 0; pass < MAX_TYPE_COERCION_PASSES; pass += 1) { @@ -532,7 +540,7 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { } if (validate(normalizedArgs)) { - return normalizedArgs; + return normalizedArgs as ToolCall["arguments"]; } } diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index c0f567316..8ba61c1ec 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -18,7 +18,7 @@ describe("Tool argument coercion", () => { arguments: { timeout: "300" }, }; - const result = validateToolArguments(tool, toolCall); + const result = validateToolArguments(tool, toolCall) as { timeout: number }; expect(result.timeout).toBe(300); expect(typeof result.timeout).toBe("number"); }); @@ -37,7 +37,7 @@ describe("Tool argument coercion", () => { arguments: { label: "300" }, }; - const result = validateToolArguments(tool, toolCall); + const result = validateToolArguments(tool, toolCall) as { label: string }; expect(result.label).toBe("300"); expect(typeof result.label).toBe("string"); }); @@ -56,7 +56,7 @@ describe("Tool argument coercion", () => { arguments: { items: "[1, 2, 3]" }, }; - const result = validateToolArguments(tool, toolCall); + const result = validateToolArguments(tool, toolCall) as { items: number[] }; expect(result.items).toEqual([1, 2, 3]); }); @@ -395,4 +395,89 @@ describe("Tool argument coercion", () => { const result = validateToolArguments(tool, toolCall); expect(result.tick_size).toBeUndefined(); }); + it("strips string 'null' on optional boolean field", () => { + const tool: Tool = { + name: "edit-tool", + description: "", + parameters: Type.Object({ + path: Type.String(), + delete: Type.Optional(Type.Boolean()), + }), + }; + + const toolCall: ToolCall = { + type: "toolCall", + id: "call-edit", + name: "edit-tool", + arguments: { path: "file.ts", delete: "null" }, + }; + + const result = validateToolArguments(tool, toolCall); + expect(result).toEqual({ path: "file.ts" }); + }); + + it("strips string 'null' on optional string field", () => { + const tool: Tool = { + name: "edit-tool", + description: "", + parameters: Type.Object({ + path: Type.String(), + move: Type.Optional(Type.String()), + }), + }; + + const toolCall: ToolCall = { + type: "toolCall", + id: "call-edit", + name: "edit-tool", + arguments: { path: "file.ts", move: "null" }, + }; + + const result = validateToolArguments(tool, toolCall); + expect(result).toEqual({ path: "file.ts" }); + }); + + it("errors on string 'null' for required field", () => { + const tool: Tool = { + name: "required-tool", + description: "", + parameters: Type.Object({ + path: Type.String(), + }), + }; + + const toolCall: ToolCall = { + type: "toolCall", + id: "call-required", + name: "required-tool", + arguments: { path: "null" }, + }; + + // Should NOT strip - path is required, so validation should pass + // (the string "null" is a valid string) + const result = validateToolArguments(tool, toolCall); + expect(result).toEqual({ path: "null" }); + }); + + it("strips string 'null' and actual null on multiple optional fields", () => { + const tool: Tool = { + name: "multi-optional", + description: "", + parameters: Type.Object({ + required: Type.String(), + optBool: Type.Optional(Type.Boolean()), + optString: Type.Optional(Type.String()), + }), + }; + + const toolCall: ToolCall = { + type: "toolCall", + id: "call-multi", + name: "multi-optional", + arguments: { required: "value", optBool: "null", optString: null }, + }; + + const result = validateToolArguments(tool, toolCall); + expect(result).toEqual({ required: "value" }); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f1ec9219..f17bec967 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,11 +1,18 @@ # Changelog ## [Unreleased] + ### Added +- Added `edit.blockAutoGenerated` setting to control whether auto-generated file detection is enforced (enabled by default) +- Improved auto-generated file detection to use language-specific comment parsing instead of broad regex patterns, reducing false positives - Added auto-generated file detection to prevent accidental modification of generated code (protoc, sqlc, buf, swagger, etc.) - Added validation in Edit and Write tools to block modifications to files with auto-generated markers or naming patterns +### Changed + +- Enhanced auto-generated marker detection to only scan leading header comments rather than entire file prefix, improving accuracy for files with generated markers in code + ## [13.12.10] - 2026-03-17 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index cd50bdcb5..bd4e02442 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -859,7 +859,16 @@ export const SETTINGS_SCHEMA = { }, }, - // Read tool + "edit.blockAutoGenerated": { + type: "boolean", + default: true, + ui: { + tab: "editing", + label: "Block Auto-Generated Files", + description: "Prevent editing of files that appear to be auto-generated (protoc, sqlc, swagger, etc.)", + }, + }, + readLineNumbers: { type: "boolean", default: false, diff --git a/packages/coding-agent/src/tools/auto-generated-guard.ts b/packages/coding-agent/src/tools/auto-generated-guard.ts index 73ef43829..4d6d6fcd3 100644 --- a/packages/coding-agent/src/tools/auto-generated-guard.ts +++ b/packages/coding-agent/src/tools/auto-generated-guard.ts @@ -5,19 +5,83 @@ * by code generation tools (protoc, sqlc, buf, swagger, etc.). */ import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { settings } from "../config/settings"; import { ToolError } from "./tool-errors"; /** * Number of bytes to read from the start of a file for auto-generated detection. */ const CHECK_BYTE_COUNT = 1024; +const HEADER_LINE_LIMIT = 40; + +const KNOWN_GENERATOR_PATTERN = + "(?:protoc(?:-gen-[\\w-]+)?|sqlc|buf|swagger(?:-codegen)?|openapi(?:-generator)?|grpc-gateway|mockery|stringer|easyjson|deepcopy-gen|defaulter-gen|conversion-gen|client-gen|lister-gen|informer-gen)"; /** - * Combined regex pattern to detect auto-generated file markers. - * Matches all common conventions in a single pass. + * Strong marker patterns for generated-file headers. + * + * Keep these strict: broad patterns like /auto-generated/ cause false positives + * in normal hand-written files (including this guard implementation itself). */ -const AUTO_GENERATED_MARKER_RE = - /(?:@generated|auto[-\s]?generat(?:ed|ion)|do(?:n'?t|\s+not)\s+(?:edit|modify)|code generated by|generated by\s+\w+|this file was automatically generated|protoc|sqlc|chisel|buf|swagger|openapi|grpc-gateway|mockery|stringer|easyjson|deepcopy-gen|defaulter-gen|conversion-gen|client-gen|lister-gen|informer-gen|zz_generated\.|\.pb\.|_pb2|\.gen\.|generated\.)/i; +const AUTO_GENERATED_HEADER_MARKERS: readonly RegExp[] = [ + /@generated\b/i, + /\bcode\s+generated\s+by\s+[a-z0-9_.-]+/i, + /\bthis\s+file\s+was\s+automatically\s+generated\b/i, + new RegExp(`\\bgenerated\\s+by\\s+${KNOWN_GENERATOR_PATTERN}\\b`, "i"), +]; + +type CommentStyle = "slash" | "hash" | "sql" | "html"; + +const COMMENT_STYLES_BY_EXTENSION = new Map([ + [".c", ["slash"]], + [".cc", ["slash"]], + [".cpp", ["slash"]], + [".cs", ["slash"]], + [".dart", ["slash"]], + [".go", ["slash"]], + [".h", ["slash"]], + [".hpp", ["slash"]], + [".java", ["slash"]], + [".js", ["slash"]], + [".jsx", ["slash"]], + [".kt", ["slash"]], + [".kts", ["slash"]], + [".mjs", ["slash"]], + [".cjs", ["slash"]], + [".php", ["slash"]], + [".rs", ["slash"]], + [".scala", ["slash"]], + [".swift", ["slash"]], + [".ts", ["slash"]], + [".tsx", ["slash"]], + [".py", ["hash"]], + [".rb", ["hash"]], + [".sh", ["hash"]], + [".bash", ["hash"]], + [".zsh", ["hash"]], + [".yml", ["hash"]], + [".yaml", ["hash"]], + [".toml", ["hash"]], + [".ini", ["hash"]], + [".cfg", ["hash"]], + [".conf", ["hash"]], + [".env", ["hash"]], + [".pl", ["hash"]], + [".r", ["hash"]], + [".sql", ["sql"]], + [".html", ["html"]], + [".htm", ["html"]], + [".xml", ["html"]], + [".svg", ["html"]], + [".xhtml", ["html"]], +]); + +const COMMENT_STYLES_BY_BASENAME = new Map([ + ["dockerfile", ["hash"]], + ["makefile", ["hash"]], + ["justfile", ["hash"]], +]); /** * File name patterns that strongly indicate auto-generated files. @@ -36,6 +100,104 @@ const AUTO_GENERATED_FILENAME_PATTERNS = [ /\.mocks?\.(go|ts|js)$/, ]; +function stripBom(content: string): string { + if (content.charCodeAt(0) === 0xfeff) { + return content.slice(1); + } + return content; +} + +function getCommentStylesForPath(filePath: string): readonly CommentStyle[] { + const normalizedPath = filePath.toLowerCase(); + const fileName = path.basename(normalizedPath); + const stylesByName = COMMENT_STYLES_BY_BASENAME.get(fileName); + if (stylesByName) return stylesByName; + + const ext = path.extname(fileName); + const stylesByExt = COMMENT_STYLES_BY_EXTENSION.get(ext); + return stylesByExt ?? []; +} + +function extractLeadingHeaderCommentText(content: string, commentStyles: readonly CommentStyle[]): string { + if (commentStyles.length === 0) return ""; + + const includeSlash = commentStyles.includes("slash"); + const includeHash = commentStyles.includes("hash"); + const includeSql = commentStyles.includes("sql"); + const includeHtml = commentStyles.includes("html"); + + const lines = stripBom(content).split(/\r?\n/); + const headerLines: string[] = []; + let started = false; + let inSlashBlock = false; + let inHtmlBlock = false; + + for (let lineIndex = 0; lineIndex < lines.length && lineIndex < HEADER_LINE_LIMIT; lineIndex += 1) { + const trimmed = lines[lineIndex]?.trim() ?? ""; + if (lineIndex === 0 && trimmed.startsWith("#!")) { + continue; + } + + if (inSlashBlock) { + headerLines.push(trimmed); + if (trimmed.includes("*/")) inSlashBlock = false; + continue; + } + + if (inHtmlBlock) { + headerLines.push(trimmed); + if (trimmed.includes("-->")) inHtmlBlock = false; + continue; + } + + if (trimmed.length === 0) { + if (started) headerLines.push(""); + continue; + } + + if (includeSlash && trimmed.startsWith("//")) { + started = true; + headerLines.push(trimmed); + continue; + } + + if (includeSlash && trimmed.startsWith("/*")) { + started = true; + headerLines.push(trimmed); + if (!trimmed.includes("*/")) { + inSlashBlock = true; + } + continue; + } + + if (includeHash && trimmed.startsWith("#")) { + started = true; + headerLines.push(trimmed); + continue; + } + + if (includeSql && trimmed.startsWith("--")) { + started = true; + headerLines.push(trimmed); + continue; + } + + if (includeHtml && trimmed.startsWith("")) { + inHtmlBlock = true; + } + continue; + } + + if (started) break; + break; + } + + return headerLines.join("\n"); +} + /** * Check if a file name indicates it might be auto-generated. * This is a quick pre-check before reading file content. @@ -46,14 +208,19 @@ function isAutoGeneratedFileName(filePath: string): boolean { } /** - * Check if content prefix contains auto-generated markers. + * Check if leading header comments contain auto-generated markers. * Returns the matched marker text if found, null otherwise. */ -function detectAutoGeneratedMarker(content: string): string | null { - const match = AUTO_GENERATED_MARKER_RE.exec(content); - if (match) { - return match[0]; +function detectAutoGeneratedMarker(content: string, filePath: string): string | null { + const commentStyles = getCommentStylesForPath(filePath); + const headerCommentText = extractLeadingHeaderCommentText(content, commentStyles); + if (!headerCommentText) return null; + + for (const markerPattern of AUTO_GENERATED_HEADER_MARKERS) { + const match = markerPattern.exec(headerCommentText); + if (match?.[0]) return match[0]; } + return null; } @@ -61,8 +228,8 @@ function detectAutoGeneratedMarker(content: string): string | null { * Read the first N bytes of a file as a UTF-8 string. * More efficient than reading the entire file. */ -async function readFilePrefix(path: string, bytes: number): Promise { - const handle = await fs.open(path, "r").catch(() => null); +async function readFilePrefix(filePath: string, bytes: number): Promise { + const handle = await fs.open(filePath, "r").catch(() => null); if (!handle) { return null; } @@ -100,22 +267,23 @@ function buildAutoGeneratedError(displayPath: string, detected: string): ToolErr * @param displayPath - Path to show in error messages (relative or as provided) */ export async function checkAutoGeneratedFile(absolutePath: string, displayPath?: string): Promise { + if (!settings.get("edit.blockAutoGenerated")) { + return; + } + const pathForDisplay = displayPath ?? absolutePath; - // First check file name as a quick filter if (isAutoGeneratedFileName(absolutePath)) { const fileName = absolutePath.split("/").pop() ?? ""; throw buildAutoGeneratedError(pathForDisplay, fileName); } - // Read only the first N bytes for content check const content = await readFilePrefix(absolutePath, CHECK_BYTE_COUNT); if (content === null) { - // If we can't read the file, let the caller handle it return; } - const marker = detectAutoGeneratedMarker(content); + const marker = detectAutoGeneratedMarker(content, absolutePath); if (marker) { throw buildAutoGeneratedError(pathForDisplay, marker); } @@ -130,9 +298,12 @@ export async function checkAutoGeneratedFile(absolutePath: string, displayPath?: * @param displayPath - Path to show in error messages */ export async function checkAutoGeneratedFileContent(content: string, displayPath: string): Promise { - // Only check the prefix for efficiency + if (!settings.get("edit.blockAutoGenerated")) { + return; + } + const prefix = content.slice(0, CHECK_BYTE_COUNT); - const marker = detectAutoGeneratedMarker(prefix); + const marker = detectAutoGeneratedMarker(prefix, displayPath); if (marker) { throw buildAutoGeneratedError(displayPath, marker); } diff --git a/packages/coding-agent/test/tools/auto-generated-guard.test.ts b/packages/coding-agent/test/tools/auto-generated-guard.test.ts new file mode 100644 index 000000000..dc03d7376 --- /dev/null +++ b/packages/coding-agent/test/tools/auto-generated-guard.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + checkAutoGeneratedFile, + checkAutoGeneratedFileContent, +} from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; +import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; + +let tempDir: string; + +beforeAll(async () => { + _resetSettingsForTest(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auto-gen-guard-")); + await Settings.init({ inMemory: true, cwd: tempDir }); +}); + +describe("checkAutoGeneratedFileContent", () => { + it("detects canonical TypeScript generated header", async () => { + const content = "// Code generated by sqlc. DO NOT EDIT.\n\nexport const foo = 1;"; + await expect(checkAutoGeneratedFileContent(content, "test.ts")).rejects.toBeInstanceOf(ToolError); + }); + + it("detects @generated marker", async () => { + const content = "// @generated\n\nexport const foo = 1;"; + await expect(checkAutoGeneratedFileContent(content, "test.ts")).rejects.toBeInstanceOf(ToolError); + }); + + it("detects generated-by marker for Python files", async () => { + const content = "# Generated by buf\n\nvalue = 1"; + await expect(checkAutoGeneratedFileContent(content, "test.py")).rejects.toBeInstanceOf(ToolError); + }); + + it("detects generated-by marker for SQL files", async () => { + const content = "-- generated by sqlc\n\nselect 1;"; + await expect(checkAutoGeneratedFileContent(content, "query.sql")).rejects.toBeInstanceOf(ToolError); + }); + + it("detects generated markers in leading block comments", async () => { + const content = "/*\n * Code generated by mockery. DO NOT EDIT.\n */\nexport const foo = 1;"; + await expect(checkAutoGeneratedFileContent(content, "test.ts")).rejects.toBeInstanceOf(ToolError); + }); + + it("does not block broad prose comment markers", async () => { + const content = "// auto generated dont edit bla bla\n// this is a hand-written file note\nexport const foo = 1;"; + await expect(checkAutoGeneratedFileContent(content, "test.ts")).resolves.toBeUndefined(); + }); + + it("does not match generated markers after code starts", async () => { + const content = "export const foo = 1;\n\n// Code generated by sqlc. DO NOT EDIT."; + await expect(checkAutoGeneratedFileContent(content, "test.ts")).resolves.toBeUndefined(); + }); + + it("uses language-specific comment styles", async () => { + const tsContent = "# Code generated by sqlc. DO NOT EDIT.\nexport const foo = 1;"; + await expect(checkAutoGeneratedFileContent(tsContent, "test.ts")).resolves.toBeUndefined(); + + const pyContent = "// Code generated by sqlc. DO NOT EDIT.\nvalue = 1"; + await expect(checkAutoGeneratedFileContent(pyContent, "test.py")).resolves.toBeUndefined(); + }); + + it("does not block editing the guard file itself", async () => { + const guardPath = path.join(import.meta.dir, "../../src/tools/auto-generated-guard.ts"); + const content = await Bun.file(guardPath).text(); + await expect( + checkAutoGeneratedFileContent(content, "packages/coding-agent/src/tools/auto-generated-guard.ts"), + ).resolves.toBeUndefined(); + }); + + it("checks only first 1024 bytes of content", async () => { + const prefix = "A".repeat(1024); + const content = `${prefix}\n// Code generated by sqlc. DO NOT EDIT.`; + await expect(checkAutoGeneratedFileContent(content, "test.ts")).resolves.toBeUndefined(); + }); +}); + +describe("checkAutoGeneratedFile", () => { + it("detects auto-generated filename patterns", async () => { + const filePath = path.join(tempDir, "zz_generated.deepcopy.go"); + await Bun.write(filePath, "package generated"); + await expect(checkAutoGeneratedFile(filePath)).rejects.toBeInstanceOf(ToolError); + }); + + it("detects content marker from file prefix", async () => { + const filePath = path.join(tempDir, "service.ts"); + await Bun.write(filePath, "// Code generated by sqlc. DO NOT EDIT.\nexport const foo = 1;"); + await expect(checkAutoGeneratedFile(filePath)).rejects.toBeInstanceOf(ToolError); + }); + + it("allows normal files", async () => { + const filePath = path.join(tempDir, "normal.ts"); + await Bun.write(filePath, "// Regular source file\nexport const foo = 1;"); + await expect(checkAutoGeneratedFile(filePath)).resolves.toBeUndefined(); + }); + + it("handles missing files gracefully", async () => { + const filePath = path.join(tempDir, "does-not-exist.ts"); + await expect(checkAutoGeneratedFile(filePath)).resolves.toBeUndefined(); + }); +});