Problems: auto-fix & problem panel (#541)
Test cases: - [x] create-ts-errors - [x] with auto-fix - [x] without auto-fix - [x] create-unfixable-ts-errors - [x] manually edit file & click recheck - [x] fix all - [x] delete and rename case THINGS - [x] error handling for checkProblems isn't working as expected - [x] make sure it works for both default templates (add tests) - [x] fix bad animation - [x] change file context (prompt/files) IF everything passes in Windows AND defensive try catch... then enable by default - [x] enable auto-fix by default
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { CoreMessage, TextPart, ImagePart, streamText } from "ai";
|
||||
import {
|
||||
CoreMessage,
|
||||
TextPart,
|
||||
ImagePart,
|
||||
streamText,
|
||||
ToolSet,
|
||||
TextStreamPart,
|
||||
} from "ai";
|
||||
import { db } from "../../db";
|
||||
import { chats, messages } from "../../db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
@@ -14,11 +21,11 @@ import {
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { readSettings } from "../../main/settings";
|
||||
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
|
||||
import { extractCodebase } from "../../utils/codebase";
|
||||
import { extractCodebase, readFileWithCache } from "../../utils/codebase";
|
||||
import { processFullResponseActions } from "../processors/response_processor";
|
||||
import { streamTestResponse } from "./testing_chat_handlers";
|
||||
import { getTestResponse } from "./testing_chat_handlers";
|
||||
import { getModelClient } from "../utils/get_model_client";
|
||||
import { getModelClient, ModelClient } from "../utils/get_model_client";
|
||||
import log from "electron-log";
|
||||
import {
|
||||
getSupabaseContext,
|
||||
@@ -39,6 +46,12 @@ import { getExtraProviderOptions } from "../utils/thinking_utils";
|
||||
|
||||
import { safeSend } from "../utils/safe_sender";
|
||||
import { cleanFullResponse } from "../utils/cleanFullResponse";
|
||||
import { generateProblemReport } from "../processors/tsc";
|
||||
import { createProblemFixPrompt } from "@/shared/problem_prompt";
|
||||
import { AsyncVirtualFileSystem } from "@/utils/VirtualFilesystem";
|
||||
import { fileExists } from "../utils/file_utils";
|
||||
|
||||
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
||||
|
||||
const logger = log.scope("chat_stream_handlers");
|
||||
|
||||
@@ -68,11 +81,76 @@ async function isTextFile(filePath: string): Promise<boolean> {
|
||||
return TEXT_FILE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function escapeXml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Ensure the temp directory exists
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Helper function to process stream chunks
|
||||
async function processStreamChunks({
|
||||
fullStream,
|
||||
fullResponse,
|
||||
abortController,
|
||||
chatId,
|
||||
processResponseChunkUpdate,
|
||||
}: {
|
||||
fullStream: AsyncIterableStream<TextStreamPart<ToolSet>>;
|
||||
fullResponse: string;
|
||||
abortController: AbortController;
|
||||
chatId: number;
|
||||
processResponseChunkUpdate: (params: {
|
||||
fullResponse: string;
|
||||
}) => Promise<string>;
|
||||
}): Promise<{ fullResponse: string; incrementalResponse: string }> {
|
||||
let incrementalResponse = "";
|
||||
let inThinkingBlock = false;
|
||||
|
||||
for await (const part of fullStream) {
|
||||
let chunk = "";
|
||||
if (part.type === "text-delta") {
|
||||
if (inThinkingBlock) {
|
||||
chunk = "</think>";
|
||||
inThinkingBlock = false;
|
||||
}
|
||||
chunk += part.textDelta;
|
||||
} else if (part.type === "reasoning") {
|
||||
if (!inThinkingBlock) {
|
||||
chunk = "<think>";
|
||||
inThinkingBlock = true;
|
||||
}
|
||||
|
||||
chunk += escapeDyadTags(part.textDelta);
|
||||
}
|
||||
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fullResponse += chunk;
|
||||
incrementalResponse += chunk;
|
||||
fullResponse = cleanFullResponse(fullResponse);
|
||||
fullResponse = await processResponseChunkUpdate({
|
||||
fullResponse,
|
||||
});
|
||||
|
||||
// If the stream was aborted, exit early
|
||||
if (abortController.signal.aborted) {
|
||||
logger.log(`Stream for chat ${chatId} was aborted`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { fullResponse, incrementalResponse };
|
||||
}
|
||||
|
||||
export function registerChatStreamHandlers() {
|
||||
ipcMain.handle("chat:stream", async (event, req: ChatStreamParams) => {
|
||||
try {
|
||||
@@ -263,32 +341,24 @@ ${componentSnippet}
|
||||
// Normal AI processing for non-test prompts
|
||||
const settings = readSettings();
|
||||
|
||||
// Extract codebase information if app is associated with the chat
|
||||
let codebaseInfo = "";
|
||||
let files: { path: string; content: string }[] = [];
|
||||
if (updatedChat.app) {
|
||||
const appPath = getDyadAppPath(updatedChat.app.path);
|
||||
try {
|
||||
const out = await extractCodebase({
|
||||
appPath,
|
||||
chatContext: req.selectedComponent
|
||||
? {
|
||||
contextPaths: [
|
||||
{
|
||||
globPath: req.selectedComponent.relativePath,
|
||||
},
|
||||
],
|
||||
smartContextAutoIncludes: [],
|
||||
}
|
||||
: validateChatContext(updatedChat.app.chatContext),
|
||||
});
|
||||
codebaseInfo = out.formattedOutput;
|
||||
files = out.files;
|
||||
logger.log(`Extracted codebase information from ${appPath}`);
|
||||
} catch (error) {
|
||||
logger.error("Error extracting codebase:", error);
|
||||
}
|
||||
}
|
||||
const appPath = getDyadAppPath(updatedChat.app.path);
|
||||
const chatContext = req.selectedComponent
|
||||
? {
|
||||
contextPaths: [
|
||||
{
|
||||
globPath: req.selectedComponent.relativePath,
|
||||
},
|
||||
],
|
||||
smartContextAutoIncludes: [],
|
||||
}
|
||||
: validateChatContext(updatedChat.app.chatContext);
|
||||
|
||||
const { formattedOutput: codebaseInfo, files } = await extractCodebase({
|
||||
appPath,
|
||||
chatContext,
|
||||
});
|
||||
|
||||
logger.log(`Extracted codebase information from ${appPath}`);
|
||||
logger.log(
|
||||
"codebaseInfo: length",
|
||||
codebaseInfo.length,
|
||||
@@ -396,7 +466,7 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
: ([
|
||||
{
|
||||
role: "user",
|
||||
content: "This is my codebase. " + codebaseInfo,
|
||||
content: createCodebasePrompt(codebaseInfo),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -413,8 +483,8 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
// and eats up extra tokens.
|
||||
content:
|
||||
settings.selectedChatMode === "ask"
|
||||
? removeDyadTags(removeThinkingTags(msg.content))
|
||||
: removeThinkingTags(msg.content),
|
||||
? removeDyadTags(removeNonEssentialTags(msg.content))
|
||||
: removeNonEssentialTags(msg.content),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -453,8 +523,10 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
|
||||
const simpleStreamText = async ({
|
||||
chatMessages,
|
||||
modelClient,
|
||||
}: {
|
||||
chatMessages: CoreMessage[];
|
||||
modelClient: ModelClient;
|
||||
}) => {
|
||||
return streamText({
|
||||
maxTokens: await getMaxTokens(settings.selectedModel),
|
||||
@@ -531,51 +603,21 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
};
|
||||
|
||||
// When calling streamText, the messages need to be properly formatted for mixed content
|
||||
const { fullStream } = await simpleStreamText({ chatMessages });
|
||||
const { fullStream } = await simpleStreamText({
|
||||
chatMessages,
|
||||
modelClient,
|
||||
});
|
||||
|
||||
// Process the stream as before
|
||||
let inThinkingBlock = false;
|
||||
try {
|
||||
for await (const part of fullStream) {
|
||||
let chunk = "";
|
||||
if (part.type === "text-delta") {
|
||||
if (inThinkingBlock) {
|
||||
chunk = "</think>";
|
||||
inThinkingBlock = false;
|
||||
}
|
||||
chunk += part.textDelta;
|
||||
} else if (part.type === "reasoning") {
|
||||
if (!inThinkingBlock) {
|
||||
chunk = "<think>";
|
||||
inThinkingBlock = true;
|
||||
}
|
||||
// Escape dyad tags in reasoning content
|
||||
// We are replacing the opening tag with a look-alike character
|
||||
// to avoid issues where thinking content includes dyad tags
|
||||
// and are mishandled by:
|
||||
// 1. FE markdown parser
|
||||
// 2. Main process response processor
|
||||
chunk += part.textDelta
|
||||
.replace(/<dyad/g, "<dyad")
|
||||
.replace(/<\/dyad/g, "</dyad");
|
||||
}
|
||||
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fullResponse += chunk;
|
||||
fullResponse = cleanFullResponse(fullResponse);
|
||||
fullResponse = await processResponseChunkUpdate({
|
||||
fullResponse,
|
||||
});
|
||||
|
||||
// If the stream was aborted, exit early
|
||||
if (abortController.signal.aborted) {
|
||||
logger.log(`Stream for chat ${req.chatId} was aborted`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const result = await processStreamChunks({
|
||||
fullStream,
|
||||
fullResponse,
|
||||
abortController,
|
||||
chatId: req.chatId,
|
||||
processResponseChunkUpdate,
|
||||
});
|
||||
fullResponse = result.fullResponse;
|
||||
|
||||
if (
|
||||
!abortController.signal.aborted &&
|
||||
@@ -599,6 +641,7 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
...chatMessages,
|
||||
{ role: "assistant", content: fullResponse },
|
||||
],
|
||||
modelClient,
|
||||
});
|
||||
for await (const part of contStream) {
|
||||
// If the stream was aborted, exit early
|
||||
@@ -615,6 +658,117 @@ This conversation includes one or more image attachments. When the user uploads
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!abortController.signal.aborted &&
|
||||
settings.enableAutoFixProblems &&
|
||||
settings.selectedChatMode !== "ask"
|
||||
) {
|
||||
try {
|
||||
// IF auto-fix is enabled
|
||||
let problemReport = await generateProblemReport({
|
||||
fullResponse,
|
||||
appPath: getDyadAppPath(updatedChat.app.path),
|
||||
});
|
||||
|
||||
let autoFixAttempts = 0;
|
||||
const originalFullResponse = fullResponse;
|
||||
const previousAttempts: CoreMessage[] = [];
|
||||
while (
|
||||
problemReport.problems.length > 0 &&
|
||||
autoFixAttempts < 2 &&
|
||||
!abortController.signal.aborted
|
||||
) {
|
||||
fullResponse += `<dyad-problem-report summary="${problemReport.problems.length} problems">
|
||||
${problemReport.problems
|
||||
.map(
|
||||
(problem) =>
|
||||
`<problem file="${escapeXml(problem.file)}" line="${problem.line}" column="${problem.column}" code="${problem.code}">${escapeXml(problem.message)}</problem>`,
|
||||
)
|
||||
.join("\n")}
|
||||
</dyad-problem-report>`;
|
||||
|
||||
logger.info(
|
||||
`Attempting to auto-fix problems, attempt #${autoFixAttempts + 1}`,
|
||||
);
|
||||
autoFixAttempts++;
|
||||
const problemFixPrompt = createProblemFixPrompt(problemReport);
|
||||
|
||||
const virtualFileSystem = new AsyncVirtualFileSystem(
|
||||
getDyadAppPath(updatedChat.app.path),
|
||||
{
|
||||
fileExists: (fileName: string) => fileExists(fileName),
|
||||
readFile: (fileName: string) => readFileWithCache(fileName),
|
||||
},
|
||||
);
|
||||
virtualFileSystem.applyResponseChanges(fullResponse);
|
||||
|
||||
const { formattedOutput: codebaseInfo, files } =
|
||||
await extractCodebase({
|
||||
appPath,
|
||||
chatContext,
|
||||
virtualFileSystem,
|
||||
});
|
||||
const { modelClient } = await getModelClient(
|
||||
settings.selectedModel,
|
||||
settings,
|
||||
files,
|
||||
);
|
||||
|
||||
const { fullStream } = await simpleStreamText({
|
||||
modelClient,
|
||||
chatMessages: [
|
||||
...chatMessages.map((msg, index) => {
|
||||
if (
|
||||
index === 0 &&
|
||||
msg.role === "user" &&
|
||||
typeof msg.content === "string" &&
|
||||
msg.content.startsWith(CODEBASE_PROMPT_PREFIX)
|
||||
) {
|
||||
return {
|
||||
role: "user",
|
||||
content: createCodebasePrompt(codebaseInfo),
|
||||
} as const;
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
{
|
||||
role: "assistant",
|
||||
content: originalFullResponse,
|
||||
},
|
||||
...previousAttempts,
|
||||
{ role: "user", content: problemFixPrompt },
|
||||
],
|
||||
});
|
||||
previousAttempts.push({
|
||||
role: "user",
|
||||
content: problemFixPrompt,
|
||||
});
|
||||
const result = await processStreamChunks({
|
||||
fullStream,
|
||||
fullResponse,
|
||||
abortController,
|
||||
chatId: req.chatId,
|
||||
processResponseChunkUpdate,
|
||||
});
|
||||
fullResponse = result.fullResponse;
|
||||
previousAttempts.push({
|
||||
role: "assistant",
|
||||
content: result.incrementalResponse,
|
||||
});
|
||||
|
||||
problemReport = await generateProblemReport({
|
||||
fullResponse,
|
||||
appPath: getDyadAppPath(updatedChat.app.path),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error generating problem report or auto-fixing:",
|
||||
settings.enableAutoFixProblems,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
// Check if this was an abort error
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -901,11 +1055,21 @@ async function prepareMessageWithAttachments(
|
||||
};
|
||||
}
|
||||
|
||||
function removeNonEssentialTags(text: string): string {
|
||||
return removeProblemReportTags(removeThinkingTags(text));
|
||||
}
|
||||
|
||||
function removeThinkingTags(text: string): string {
|
||||
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
|
||||
return text.replace(thinkRegex, "").trim();
|
||||
}
|
||||
|
||||
export function removeProblemReportTags(text: string): string {
|
||||
const problemReportRegex =
|
||||
/<dyad-problem-report[^>]*>[\s\S]*?<\/dyad-problem-report>/g;
|
||||
return text.replace(problemReportRegex, "").trim();
|
||||
}
|
||||
|
||||
export function removeDyadTags(text: string): string {
|
||||
const dyadRegex = /<dyad-[^>]*>[\s\S]*?<\/dyad-[^>]*>/g;
|
||||
return text.replace(dyadRegex, "").trim();
|
||||
@@ -932,3 +1096,18 @@ export function hasUnclosedDyadWrite(text: string): boolean {
|
||||
|
||||
return !hasClosingTag;
|
||||
}
|
||||
|
||||
function escapeDyadTags(text: string): string {
|
||||
// Escape dyad tags in reasoning content
|
||||
// We are replacing the opening tag with a look-alike character
|
||||
// to avoid issues where thinking content includes dyad tags
|
||||
// and are mishandled by:
|
||||
// 1. FE markdown parser
|
||||
// 2. Main process response processor
|
||||
return text.replace(/<dyad/g, "<dyad").replace(/<\/dyad/g, "</dyad");
|
||||
}
|
||||
|
||||
const CODEBASE_PROMPT_PREFIX = "This is my codebase.";
|
||||
function createCodebasePrompt(codebaseInfo: string): string {
|
||||
return `${CODEBASE_PROMPT_PREFIX} ${codebaseInfo}`;
|
||||
}
|
||||
|
||||
36
src/ipc/handlers/problems_handlers.ts
Normal file
36
src/ipc/handlers/problems_handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { db } from "../../db";
|
||||
import { ipcMain } from "electron";
|
||||
import { apps } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateProblemReport } from "../processors/tsc";
|
||||
import { getDyadAppPath } from "@/paths/paths";
|
||||
import { logger } from "./app_upgrade_handlers";
|
||||
|
||||
export function registerProblemsHandlers() {
|
||||
// Handler to check problems using autofix with empty response
|
||||
ipcMain.handle("check-problems", async (event, params: { appId: number }) => {
|
||||
try {
|
||||
// Get the app to find its path
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, params.appId),
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App not found: ${params.appId}`);
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
|
||||
// Call autofix with empty full response to just run TypeScript checking
|
||||
const problemReport = await generateProblemReport({
|
||||
fullResponse: "",
|
||||
appPath,
|
||||
});
|
||||
|
||||
return problemReport;
|
||||
} catch (error) {
|
||||
logger.error("Error checking problems:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import { cleanFullResponse } from "../utils/cleanFullResponse";
|
||||
// e.g. [dyad-qa=add-dep]
|
||||
// Canned responses for test prompts
|
||||
const TEST_RESPONSES: Record<string, string> = {
|
||||
"ts-error": `This will get a TypeScript error.
|
||||
|
||||
<dyad-write path="src/bad-file.ts" description="This will get a TypeScript error.">
|
||||
import NonExistentClass from 'non-existent-class';
|
||||
|
||||
const x = new Object();
|
||||
x.nonExistentMethod();
|
||||
</dyad-write>
|
||||
|
||||
EOM`,
|
||||
"add-dep": `I'll add that dependency for you.
|
||||
|
||||
<dyad-add-dependency packages="deno"></dyad-add-dependency>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AppOutput,
|
||||
Chat,
|
||||
ChatResponseEnd,
|
||||
ChatProblemsEvent,
|
||||
CreateAppParams,
|
||||
CreateAppResult,
|
||||
ListAppsResponse,
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
App,
|
||||
ComponentSelection,
|
||||
AppUpgrade,
|
||||
ProblemReport,
|
||||
} from "./ipc_types";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
import { showError } from "@/lib/toast";
|
||||
@@ -233,6 +235,7 @@ export class IpcClient {
|
||||
onUpdate: (messages: Message[]) => void;
|
||||
onEnd: (response: ChatResponseEnd) => void;
|
||||
onError: (error: string) => void;
|
||||
onProblems?: (problems: ChatProblemsEvent) => void;
|
||||
},
|
||||
): void {
|
||||
const {
|
||||
@@ -934,4 +937,10 @@ export class IpcClient {
|
||||
public async openAndroid(params: { appId: number }): Promise<void> {
|
||||
return this.ipcRenderer.invoke("open-android", params);
|
||||
}
|
||||
|
||||
public async checkProblems(params: {
|
||||
appId: number;
|
||||
}): Promise<ProblemReport> {
|
||||
return this.ipcRenderer.invoke("check-problems", params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { registerProHandlers } from "./handlers/pro_handlers";
|
||||
import { registerContextPathsHandlers } from "./handlers/context_paths_handlers";
|
||||
import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
|
||||
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
|
||||
import { registerProblemsHandlers } from "./handlers/problems_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -33,6 +34,7 @@ export function registerIpcHandlers() {
|
||||
registerDependencyHandlers();
|
||||
registerGithubHandlers();
|
||||
registerNodeHandlers();
|
||||
registerProblemsHandlers();
|
||||
registerProposalHandlers();
|
||||
registerDebugHandlers();
|
||||
registerSupabaseHandlers();
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface ChatResponseEnd {
|
||||
extraFilesError?: string;
|
||||
}
|
||||
|
||||
export interface ChatProblemsEvent {
|
||||
chatId: number;
|
||||
appId: number;
|
||||
problems: ProblemReport;
|
||||
}
|
||||
|
||||
export interface CreateAppParams {
|
||||
name: string;
|
||||
}
|
||||
@@ -240,3 +246,15 @@ export interface AppUpgrade {
|
||||
manualUpgradeUrl: string;
|
||||
isNeeded: boolean;
|
||||
}
|
||||
|
||||
export interface Problem {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
message: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface ProblemReport {
|
||||
problems: Problem[];
|
||||
}
|
||||
|
||||
10
src/ipc/processors/normalizePath.ts
Normal file
10
src/ipc/processors/normalizePath.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Normalize the path to use forward slashes instead of backslashes.
|
||||
* This is important to prevent weird Git issues, particularly on Windows.
|
||||
* @param path Source path.
|
||||
* @returns Normalized path.
|
||||
*/
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
@@ -19,20 +19,11 @@ import { SqlQuery, UserSettings } from "../../lib/schemas";
|
||||
import { gitCommit } from "../utils/git_utils";
|
||||
import { readSettings } from "@/main/settings";
|
||||
import { writeMigrationFile } from "../utils/file_utils";
|
||||
import { normalizePath } from "./normalizePath";
|
||||
|
||||
const readFile = fs.promises.readFile;
|
||||
const logger = log.scope("response_processor");
|
||||
|
||||
/**
|
||||
* Normalize the path to use forward slashes instead of backslashes.
|
||||
* This is important to prevent weird Git issues, particularly on Windows.
|
||||
* @param path Source path.
|
||||
* @returns Normalized path.
|
||||
*/
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function getDyadWriteTags(fullResponse: string): {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
211
src/ipc/processors/tsc.ts
Normal file
211
src/ipc/processors/tsc.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { ProblemReport } from "../ipc_types";
|
||||
import { Problem } from "../ipc_types";
|
||||
|
||||
import { normalizePath } from "./normalizePath";
|
||||
import { SyncVirtualFileSystem } from "../../utils/VirtualFilesystem";
|
||||
import log from "electron-log";
|
||||
|
||||
const logger = log.scope("tsc");
|
||||
|
||||
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
|
||||
try {
|
||||
// Try to load TypeScript from the project's node_modules
|
||||
const requirePath = require.resolve("typescript", { paths: [appPath] });
|
||||
logger.info(`Loading TypeScript from ${requirePath} for app ${appPath}`);
|
||||
const ts = require(requirePath);
|
||||
return ts;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load TypeScript from ${appPath} because of ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateProblemReport({
|
||||
fullResponse,
|
||||
appPath,
|
||||
}: {
|
||||
fullResponse: string;
|
||||
appPath: string;
|
||||
}): Promise<ProblemReport> {
|
||||
// Load the local TypeScript version from the app's node_modules
|
||||
const ts = loadLocalTypeScript(appPath);
|
||||
|
||||
// Create virtual file system with TypeScript system delegate and apply changes from response
|
||||
const vfs = new SyncVirtualFileSystem(appPath, {
|
||||
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
|
||||
readFile: (fileName: string) => ts.sys.readFile(fileName),
|
||||
});
|
||||
vfs.applyResponseChanges(fullResponse);
|
||||
|
||||
// Find TypeScript config - throw error if not found
|
||||
const tsconfigPath = findTypeScriptConfig(appPath);
|
||||
|
||||
// Create TypeScript program with virtual file system
|
||||
const result = await runTypeScriptCheck(ts, appPath, tsconfigPath, vfs);
|
||||
return result;
|
||||
}
|
||||
|
||||
function findTypeScriptConfig(appPath: string): string {
|
||||
const possibleConfigs = [
|
||||
// For vite applications, we want to check tsconfig.app.json, since it's the
|
||||
// most important one (client-side app).
|
||||
// The tsconfig.json in vite apps is a project reference and doesn't
|
||||
// actually check anything unless you do "--build" which requires a complex
|
||||
// programmatic approach
|
||||
"tsconfig.app.json",
|
||||
// For Next.js applications, it typically has a single tsconfig.json file
|
||||
"tsconfig.json",
|
||||
];
|
||||
|
||||
for (const config of possibleConfigs) {
|
||||
const configPath = path.join(appPath, config);
|
||||
if (fs.existsSync(configPath)) {
|
||||
return configPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runTypeScriptCheck(
|
||||
ts: typeof import("typescript"),
|
||||
appPath: string,
|
||||
tsconfigPath: string,
|
||||
vfs: SyncVirtualFileSystem,
|
||||
): Promise<ProblemReport> {
|
||||
return runSingleProject(ts, appPath, tsconfigPath, vfs);
|
||||
}
|
||||
|
||||
async function runSingleProject(
|
||||
ts: typeof import("typescript"),
|
||||
appPath: string,
|
||||
tsconfigPath: string,
|
||||
vfs: SyncVirtualFileSystem,
|
||||
): Promise<ProblemReport> {
|
||||
// Use the idiomatic way to parse TypeScript config
|
||||
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
|
||||
tsconfigPath,
|
||||
undefined, // No additional options
|
||||
{
|
||||
// Custom system object that can handle our virtual files
|
||||
...ts.sys,
|
||||
fileExists: (fileName: string) => vfs.fileExists(fileName),
|
||||
readFile: (fileName: string) => vfs.readFile(fileName),
|
||||
onUnRecoverableConfigFileDiagnostic: (
|
||||
diagnostic: import("typescript").Diagnostic,
|
||||
) => {
|
||||
throw new Error(
|
||||
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!parsedCommandLine) {
|
||||
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
|
||||
}
|
||||
|
||||
let rootNames = parsedCommandLine.fileNames;
|
||||
|
||||
// Add any virtual files that aren't already included
|
||||
const virtualTsFiles = vfs
|
||||
.getVirtualFiles()
|
||||
.map((file) => path.resolve(appPath, file.path))
|
||||
.filter(isTypeScriptFile);
|
||||
|
||||
// Remove deleted files from rootNames
|
||||
const deletedFiles = vfs
|
||||
.getDeletedFiles()
|
||||
.map((file) => path.resolve(appPath, file));
|
||||
rootNames = rootNames.filter((fileName) => {
|
||||
const resolvedPath = path.resolve(fileName);
|
||||
return !deletedFiles.includes(resolvedPath);
|
||||
});
|
||||
|
||||
for (const virtualFile of virtualTsFiles) {
|
||||
if (!rootNames.includes(virtualFile)) {
|
||||
rootNames.push(virtualFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom compiler host
|
||||
const host = createVirtualCompilerHost(
|
||||
ts,
|
||||
appPath,
|
||||
vfs,
|
||||
parsedCommandLine.options,
|
||||
);
|
||||
|
||||
// Create TypeScript program - this is the idiomatic way
|
||||
const program = ts.createProgram(rootNames, parsedCommandLine.options, host);
|
||||
|
||||
// Get diagnostics
|
||||
const diagnostics = [
|
||||
...program.getSyntacticDiagnostics(),
|
||||
...program.getSemanticDiagnostics(),
|
||||
...program.getGlobalDiagnostics(),
|
||||
];
|
||||
|
||||
// Convert diagnostics to our format
|
||||
const problems: Problem[] = [];
|
||||
|
||||
for (const diagnostic of diagnostics) {
|
||||
if (!diagnostic.file) continue;
|
||||
|
||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
|
||||
diagnostic.start!,
|
||||
);
|
||||
const message = ts.flattenDiagnosticMessageText(
|
||||
diagnostic.messageText,
|
||||
"\n",
|
||||
);
|
||||
|
||||
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
problems.push({
|
||||
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
|
||||
line: line + 1, // Convert to 1-based
|
||||
column: character + 1, // Convert to 1-based
|
||||
message,
|
||||
code: diagnostic.code,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
problems,
|
||||
};
|
||||
}
|
||||
|
||||
function createVirtualCompilerHost(
|
||||
ts: typeof import("typescript"),
|
||||
appPath: string,
|
||||
vfs: SyncVirtualFileSystem,
|
||||
compilerOptions: import("typescript").CompilerOptions,
|
||||
): import("typescript").CompilerHost {
|
||||
const host = ts.createCompilerHost(compilerOptions);
|
||||
|
||||
// Override file reading to use virtual files
|
||||
host.readFile = (fileName: string) => {
|
||||
return vfs.readFile(fileName);
|
||||
};
|
||||
|
||||
// Override file existence check
|
||||
host.fileExists = (fileName: string) => {
|
||||
return vfs.fileExists(fileName);
|
||||
};
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
function isTypeScriptFile(fileName: string): boolean {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
|
||||
}
|
||||
@@ -92,3 +92,10 @@ export async function writeMigrationFile(
|
||||
|
||||
await fsExtra.writeFile(migrationFilePath, queryContent);
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
return fsPromises
|
||||
.access(filePath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user